feat(auth): PocketBase login, logout and signed-out redirect

This commit is contained in:
Dave 2026-09-16 09:43:18 +02:00
parent 30c1405f72
commit 91e53f150a
8 changed files with 371 additions and 12 deletions

View file

@ -2030,35 +2030,83 @@ export function Board({
}
```
- [ ] **Step 3: Verify manually as the owner**
- [ ] **Step 3: Create the board route**
Sign in as `owner@example.com`, open the WFDash board, and drag a card between columns.
`src/app/p/[clientSlug]/[projectSlug]/page.tsx`:
```tsx
import { notFound, redirect } from "next/navigation";
import { AppShell } from "@/components/shell/app-shell";
import { TopBar } from "@/components/shell/top-bar";
import { Board } from "@/components/board/board";
import { getSession } from "@/lib/auth/session";
import { loadBoard } from "@/lib/data/board";
export default async function BoardPage({
params,
}: {
params: Promise<{ clientSlug: string; projectSlug: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const { clientSlug, projectSlug } = await params;
const board = await loadBoard(clientSlug, projectSlug);
// loadBoard returns null both for "does not exist" and "not yours". Rendering
// the same 404 for both is deliberate: a distinct 403 would confirm to one
// client that another client's project is real.
if (!board) notFound();
return (
<AppShell
role={session.role}
currentPath={`/p/${clientSlug}/${projectSlug}`}
>
<TopBar title={board.project.name} subtitle={board.client.name} />
<Board
role={session.role}
projectId={board.project.id}
columns={board.columns}
initialCards={board.cards}
/>
</AppShell>
);
}
```
- [ ] **Step 4: Verify manually as the owner**
```bash
npm run build
```
Expected: build succeeds. Then sign in as `owner@example.com`, open the WFDash board, and drag a card between columns.
Expected: the card follows the pointer with a raised shadow, lands where dropped, and stays there after a reload. Drag a card to the middle of a populated column and confirm it lands between its neighbours rather than snapping to the end.
- [ ] **Step 4: Verify manually as a client manager**
- [ ] **Step 5: Verify manually as a client manager**
Sign in as `manager@wf.example.com` and open the same board.
Expected: cards do not drag at all, and the cursor stays a normal pointer.
Expected: cards do not drag at all, and the cursor stays a normal pointer. Change a card's priority and set a target date; both persist across a reload. Set a date in the past and confirm the date pill turns accent-coloured. In the PocketBase admin UI, confirm two new `activity` rows exist with the correct old and new values.
- [ ] **Step 5: Commit**
- [ ] **Step 6: Commit**
```bash
git add src/components/board/board.tsx package.json package-lock.json
git add src/components/board/board.tsx src/app/p package.json package-lock.json
git commit -m "feat(board): owner drag and drop with optimistic persistence"
```
---
## Task 11: Client priority and date controls
## Task 11b: REMOVED — superseded
The client manager's two editable fields, posting to the routes built in Plan 1a.
The client control component moved into Task 9, where it is created alongside the other presentation components so that Task 11's board compiles. The code block below is the same file and is retained only so a reader following an older copy of this plan does not think a step is missing. **Do not create this file twice.**
**Files:**
- Create: `src/components/board/client-card-controls.tsx`
- [ ] **Step 1: Write the controls**
<details>
<summary>Duplicate of the Task 9 Step 3 file — skip</summary>
`src/components/board/client-card-controls.tsx`:

View file

@ -0,0 +1,47 @@
import PocketBase from "pocketbase";
import { AUTH_COOKIE } from "@/lib/auth/cookie";
import { jsonError, jsonOk } from "@/lib/http/json";
const PB_URL = process.env.NEXT_PUBLIC_PB_URL ?? "http://127.0.0.1:8090";
export async function POST(request: Request): Promise<Response> {
let email: unknown;
let password: unknown;
try {
const body = await request.json();
email = body?.email;
password = body?.password;
} catch {
return jsonError("Invalid request body", 400);
}
if (typeof email !== "string" || typeof password !== "string") {
return jsonError("Email and password are required", 400);
}
const pb = new PocketBase(PB_URL);
try {
await pb.collection("users").authWithPassword(email, password);
} catch {
// One message for both "no such user" and "wrong password", so the
// response cannot be used to enumerate accounts.
return jsonError("Invalid email or password", 401);
}
const cookie = pb.authStore.exportToCookie(
{
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
},
AUTH_COOKIE
);
const response = jsonOk({ ok: true });
response.headers.append("set-cookie", cookie);
return response;
}

View file

@ -0,0 +1,11 @@
import { AUTH_COOKIE } from "@/lib/auth/cookie";
import { jsonOk } from "@/lib/http/json";
export async function POST(): Promise<Response> {
const response = jsonOk({ ok: true });
response.headers.append(
"set-cookie",
`${AUTH_COOKIE}=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax`
);
return response;
}

78
src/app/login/page.tsx Normal file
View file

@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
setPending(true);
setError(null);
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
setPending(false);
if (!response.ok) {
const body = await response.json().catch(() => ({}));
setError(body.error ?? "Sign in failed");
return;
}
router.push("/");
router.refresh();
}
return (
<main className="flex min-h-screen items-center justify-center p-6">
<form
onSubmit={onSubmit}
className="w-full max-w-sm space-y-4 rounded-xl border border-neutral-800 bg-neutral-900 p-6"
>
<h1 className="text-lg font-semibold">Sign in</h1>
<label className="block space-y-1">
<span className="text-sm text-neutral-400">Email</span>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 outline-none focus:border-neutral-500"
/>
</label>
<label className="block space-y-1">
<span className="text-sm text-neutral-400">Password</span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 outline-none focus:border-neutral-500"
/>
</label>
{error && <p className="text-sm text-red-400">{error}</p>}
<button
type="submit"
disabled={pending}
className="w-full rounded-lg bg-neutral-100 px-3 py-2 font-medium text-neutral-900 disabled:opacity-50"
>
{pending ? "Signing in…" : "Sign in"}
</button>
</form>
</main>
);
}

23
src/lib/auth/session.ts Normal file
View file

@ -0,0 +1,23 @@
import "server-only";
import { getCurrentUser } from "@/lib/pb/server";
import { AccessError, type Session } from "@/lib/auth/access";
/** The current session, or null when signed out. */
export async function getSession(): Promise<Session | null> {
const user = await getCurrentUser();
if (!user) return null;
return {
userId: user.id,
role: user.role,
clientId: user.client || null,
};
}
/** The current session, or throw a 401. For route handlers. */
export async function requireSession(): Promise<Session> {
const session = await getSession();
if (!session) throw new AccessError("Not signed in", 401);
return session;
}

View file

@ -0,0 +1,62 @@
import { describe, it, expect } from "vitest";
import {
DEFAULT_COLUMNS,
buildColumnRecords,
assertColumnInvariants,
} from "@/lib/columns/seed";
describe("DEFAULT_COLUMNS", () => {
it("is the six-lane board in display order", () => {
expect(DEFAULT_COLUMNS.map((c) => c.name)).toEqual([
"Inbox",
"Backlog",
"To Do",
"In Progress",
"Review",
"Done",
]);
});
it("flags exactly one inbox and one done column", () => {
expect(DEFAULT_COLUMNS.filter((c) => c.is_inbox)).toHaveLength(1);
expect(DEFAULT_COLUMNS.filter((c) => c.is_done)).toHaveLength(1);
});
it("puts the inbox first and done last", () => {
expect(DEFAULT_COLUMNS[0].is_inbox).toBe(true);
expect(DEFAULT_COLUMNS[DEFAULT_COLUMNS.length - 1].is_done).toBe(true);
});
});
describe("buildColumnRecords", () => {
it("attaches the project id and spaced positions", () => {
const records = buildColumnRecords("proj_1");
expect(records).toHaveLength(6);
expect(records.every((r) => r.project === "proj_1")).toBe(true);
expect(records.map((r) => r.position)).toEqual([
1000, 2000, 3000, 4000, 5000, 6000,
]);
});
});
describe("assertColumnInvariants", () => {
it("passes for the default set", () => {
expect(() => assertColumnInvariants(buildColumnRecords("p"))).not.toThrow();
});
it("throws when no column is the inbox", () => {
const cols = buildColumnRecords("p").map((c) => ({ ...c, is_inbox: false }));
expect(() => assertColumnInvariants(cols)).toThrow(/exactly one inbox/i);
});
it("throws when two columns claim to be the inbox", () => {
const cols = buildColumnRecords("p");
cols[1].is_inbox = true;
expect(() => assertColumnInvariants(cols)).toThrow(/exactly one inbox/i);
});
it("throws when no column is done", () => {
const cols = buildColumnRecords("p").map((c) => ({ ...c, is_done: false }));
expect(() => assertColumnInvariants(cols)).toThrow(/exactly one done/i);
});
});

61
src/lib/columns/seed.ts Normal file
View file

@ -0,0 +1,61 @@
/**
* The default board layout, and the invariants a board must always satisfy.
*
* `is_inbox` and `is_done` carry behaviour, not styling: client-created cards
* land in the inbox, and the done lane is what later phases will treat as
* completion. A project missing either flag has no valid destination for a
* client's card, so the invariant is checked wherever columns are written.
*/
import { SPACING } from "@/lib/ordering/fractional";
export interface ColumnSeed {
name: string;
is_inbox: boolean;
is_done: boolean;
}
export interface ColumnInput extends ColumnSeed {
project: string;
position: number;
}
export const DEFAULT_COLUMNS: readonly ColumnSeed[] = [
{ name: "Inbox", is_inbox: true, is_done: false },
{ name: "Backlog", is_inbox: false, is_done: false },
{ name: "To Do", is_inbox: false, is_done: false },
{ name: "In Progress", is_inbox: false, is_done: false },
{ name: "Review", is_inbox: false, is_done: false },
{ name: "Done", is_inbox: false, is_done: true },
];
/** The records to create for a new project, in display order. */
export function buildColumnRecords(projectId: string): ColumnInput[] {
return DEFAULT_COLUMNS.map((seed, index) => ({
...seed,
project: projectId,
position: (index + 1) * SPACING,
}));
}
/**
* Throw unless the set has exactly one inbox and exactly one done column.
* Call this before writing any change to a project's columns.
*/
export function assertColumnInvariants(
columns: Array<Pick<ColumnSeed, "is_inbox" | "is_done">>
): void {
const inboxes = columns.filter((c) => c.is_inbox).length;
if (inboxes !== 1) {
throw new Error(
`A project must have exactly one inbox column, found ${inboxes}`
);
}
const dones = columns.filter((c) => c.is_done).length;
if (dones !== 1) {
throw new Error(
`A project must have exactly one done column, found ${dones}`
);
}
}

29
src/middleware.ts Normal file
View file

@ -0,0 +1,29 @@
import { NextResponse, type NextRequest } from "next/server";
// From @/lib/auth/cookie, NOT @/lib/pb/server. Middleware runs on the Edge
// runtime, where `server-only` and `next/headers` do not exist.
import { AUTH_COOKIE } from "@/lib/auth/cookie";
/**
* A coarse redirect for signed-out visitors, nothing more.
*
* It checks only that a cookie is present — it does not validate the token and
* must never be relied on for authorization. Every route handler and page
* re-checks the session itself. Middleware runs before the request reaches
* them and is the wrong place to make security decisions.
*/
export function middleware(request: NextRequest) {
const hasAuth = request.cookies.has(AUTH_COOKIE);
if (!hasAuth) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/", "/p/:path*", "/admin/:path*"],
};