Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ tsconfig.tsbuildinfo

# env
.env
.env*
.env.local
.env*.local

Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,11 @@ nextkit house rules (see the imported guide): tabs/width-4/88 via oxfmt, no
subsystem knowledge goes in `docs/`.

<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,13 @@ bun install
bun run dev # PGlite (no Docker) + migrations + next dev on :3000
```

Google sign-in gates every page, so `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`
are required to use the app at all; `ANTHROPIC_API_KEY` is needed only for the
agent chat. Get both from `vercel env pull`. Nothing else needs configuring
locally — `bun run dev` boots its own database.
Google sign-in gates every page by default, so `GOOGLE_CLIENT_ID`/
`GOOGLE_CLIENT_SECRET` are required to use the app at all; `ANTHROPIC_API_KEY`
is needed only for the agent chat. For local work without Google OAuth, set
`DEV_EMAIL_PASSWORD_SIGN_IN=true` before `bun run dev` and create an account on
the login page. This option is ignored outside development. Get the remaining
secrets from `vercel env pull`. Nothing else needs configuring locally — `bun
run dev` boots its own database.

## Env

Expand All @@ -43,5 +46,6 @@ locally — `bun run dev` boots its own database.
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Shared Ingram Google OAuth client (from the infra `platform` stack) |
| `BETTER_AUTH_SECRET` | Session signing secret (dev falls back to a placeholder) |
| `BETTER_AUTH_URL` | Canonical origin, `https://sheets.ingram.tech` in prod |
| `DEV_EMAIL_PASSWORD_SIGN_IN` | Set to `true` to enable Better Auth email/password sign-in and local account creation in `NODE_ENV=development` only |
| `NEXT_PUBLIC_GOOGLE_PICKER_API_KEY` | Optional — API key (same Google project as the OAuth client) enabling "Browse Google Drive" via the Google Picker |
| `NEXT_PUBLIC_GOOGLE_PICKER_APP_ID` | Optional — that Google project's number (Picker `setAppId`) |
75 changes: 38 additions & 37 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"sonner": "^2.0.7",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"use-stick-to-bottom": "^1.1.6",
"zod": "^4.4.3"
},
"devDependencies": {
Expand Down
48 changes: 48 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,37 @@
}

@layer base {
/*
* Reserve the scrollbar gutter on pages that opt in with
* `data-stable-gutter`.
*
* Without it the viewport WIDTH depends on the page HEIGHT, which makes a
* responsive breakpoint bistable. The workbook list is the case that
* exposed it: below `sm` every row carries a second meta line and the
* footer wraps, so the page is ~100px taller and needs a scrollbar; above
* `sm` it doesn't. Each mode is then self-consistent over a narrow band of
* widths — tall enough to need the scrollbar that keeps it narrow, or short
* enough to drop the scrollbar that keeps it wide — so within that band the
* layout holds whichever mode it happened to reach, and a one-pixel resize
* flips it. Chrome compounds this by matching `min-width` against the
* viewport INCLUDING the classic scrollbar while laying the content out in
* what's left, so at the breakpoint the query and the layout disagree by
* the scrollbar's width.
*
* A reserved gutter breaks the loop: height can no longer change width, so
* the breakpoint is one step instead of a band.
*
* `overflow-y: auto` is load-bearing — `scrollbar-gutter` only applies to a
* real scroll container, and on a root with the default `visible` it is
* silently ignored. `:has()` keeps both off every other surface: the
* workbook is `h-dvh` with its own inner scrollers and must not lose 15px
* of grid to a gutter it never uses.
*/
html:has([data-stable-gutter]) {
overflow-y: auto;
scrollbar-gutter: stable;
}

/*
* Default border colour. Tailwind v4 dropped v3's preflight that reset every
* element's border to gray-200, so a bare `border` / `border-b` now paints in
Expand All @@ -261,6 +292,23 @@
border-color: var(--color-border);
}

/*
* Chrome strips that carry their own scroll affordance.
*
* Only for the toolbar and the sheet-tab strip (see ScrollStrip), where the
* row is 36–40px tall and a classic scrollbar takes a third of it while
* still reading as a rendering defect. Those strips replace it with an edge
* fade plus a chevron, so the scroll stays both visible and operable — this
* is never a licence to hide a scrollbar on a real scrolling surface.
*/
.scrollbar-none {
scrollbar-width: none;
}

.scrollbar-none::-webkit-scrollbar {
display: none;
}

/*
* Spreadsheet chrome is dense and keyboard-first: a visible focus ring is
* the difference between usable and unusable. Base UI primitives ship
Expand Down
101 changes: 99 additions & 2 deletions src/app/login/login-form.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
"use client";

import { Loader2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type FormEvent } from "react";

import { SheetsMark } from "@/components/brand/sheets-mark";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";

// If the handoff to Google hasn't navigated away by now, something is wrong
// (blocked popup, offline, bfcache restore). Give the button back rather than
// leaving "Redirecting…" disabled forever.
const REDIRECT_TIMEOUT_MS = 8000;

export function LoginForm({ next }: { next: string }) {
export function LoginForm({
next,
enableDevEmailPassword,
}: {
next: string;
enableDevEmailPassword: boolean;
}) {
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isCreatingDevAccount, setIsCreatingDevAccount] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
Expand Down Expand Up @@ -53,6 +61,27 @@ export function LoginForm({ next }: { next: string }) {
}
};

const signInWithEmailPassword = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const email = String(form.get("email") ?? "");
const password = String(form.get("password") ?? "");
const name = String(form.get("name") ?? "");

setPending(true);
setError(null);
try {
const result = isCreatingDevAccount
? await authClient.signUp.email({ name, email, password })
: await authClient.signIn.email({ email, password });
if (result.error) throw new Error(result.error.message ?? "Sign-in failed");
window.location.assign(next);
} catch (caught) {
setPending(false);
setError(caught instanceof Error ? caught.message : "Sign-in failed");
}
};

return (
<main className="flex min-h-dvh items-center justify-center px-6">
<div className="w-full max-w-sm space-y-8">
Expand Down Expand Up @@ -80,6 +109,74 @@ export function LoginForm({ next }: { next: string }) {
)}
{pending ? "Redirecting…" : "Continue with Google"}
</Button>
{enableDevEmailPassword ? (
<>
<div className="flex items-center gap-3 text-xs text-muted-foreground before:h-px before:flex-1 before:bg-border after:h-px after:flex-1 after:bg-border">
Local development
</div>
<form
className="space-y-3"
onSubmit={signInWithEmailPassword}
>
{isCreatingDevAccount ? (
<Input
name="name"
placeholder="Name"
required
disabled={pending}
/>
) : null}
<Input
name="email"
type="email"
placeholder="Email"
autoComplete="email"
required
disabled={pending}
/>
<Input
name="password"
type="password"
placeholder="Password"
autoComplete={
isCreatingDevAccount
? "new-password"
: "current-password"
}
minLength={8}
required
disabled={pending}
/>
<Button
type="submit"
variant="secondary"
className="w-full"
disabled={pending}
aria-busy={pending}
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : null}
{isCreatingDevAccount
? "Create local account"
: "Sign in locally"}
</Button>
</form>
<Button
variant="link"
className="w-full text-xs"
disabled={pending}
onClick={() => {
setIsCreatingDevAccount((current) => !current);
setError(null);
}}
>
{isCreatingDevAccount
? "Already have a local account? Sign in"
: "Need a local account? Create one"}
</Button>
</>
) : null}
{error ? (
<p
className="text-center text-sm text-destructive-ink"
Expand Down
8 changes: 7 additions & 1 deletion src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { safeNext } from "@ingram-tech/nk-auth/server";

import { redirectIfAuthenticated } from "@/lib/session";
import { isDevEmailPasswordSignInEnabled } from "@/lib/dev-auth";

import { LoginForm } from "./login-form";

Expand All @@ -13,5 +14,10 @@ export default async function LoginPage({
}) {
const next = safeNext((await searchParams).next) ?? "/spreadsheets";
await redirectIfAuthenticated(next);
return <LoginForm next={next} />;
return (
<LoginForm
next={next}
enableDevEmailPassword={isDevEmailPasswordSignInEnabled}
/>
);
}
Loading