feat(web-next): minimal authenticated shell home
Shell home for the platform-retrofit floor: a sign-in form (dev-seed credentials) and a signed-in placeholder with sign-out. Both flows run through the composed tRPC appRouter via server actions that own the session cookie on the app side; the auth feature is untouched. Adds the auth sign-in Playwright spec as the surviving e2e baseline and drops the last demo-template metadata. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
34
apps/web-next/e2e/auth-sign-in.spec.ts
Normal file
34
apps/web-next/e2e/auth-sign-in.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
// Surviving e2e baseline (platform-retrofit PRD): the dev-seed shell must
|
||||||
|
// sign in with seeded credentials (see packages/auth/src/__seeds__/dev.ts)
|
||||||
|
// and sign back out. Runs against `pnpm dev` in dev-seed mode — no Payload.
|
||||||
|
test.describe("auth sign-in", () => {
|
||||||
|
test("signs in with dev-seed credentials and reaches the signed-in shell", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await page.getByLabel("Username").fill("alice");
|
||||||
|
await page.getByLabel("Password").fill("secret_alice");
|
||||||
|
await page.getByRole("button", { name: "Sign in" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("You are signed in.")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Sign out" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid credentials and keeps the sign-in form", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await page.getByLabel("Username").fill("alice");
|
||||||
|
await page.getByLabel("Password").fill("not-the-password");
|
||||||
|
await page.getByRole("button", { name: "Sign in" }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Invalid username or password.")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,8 +5,8 @@ import { bindAll } from "../server/bind-production";
|
|||||||
import { Providers } from "./providers";
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Template — Next.js",
|
title: "Veect",
|
||||||
description: "Clean Architecture Monorepo Template",
|
description: "Veect",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
|
|||||||
@@ -1,11 +1,90 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { SESSION_COOKIE } from "@repo/auth";
|
||||||
import { bindAll } from "../server/bind-production";
|
import { bindAll } from "../server/bind-production";
|
||||||
|
import { signInAction, signOutAction } from "../server/auth-actions";
|
||||||
|
|
||||||
export default async function Home() {
|
/**
|
||||||
|
* Shell home: the sign-in surface plus a minimal authenticated placeholder.
|
||||||
|
* The workspaces UI that replaces the placeholder arrives with the
|
||||||
|
* walking-skeleton PRD. Signed-in state is cookie presence only — session
|
||||||
|
* validation stays inside the auth feature and is exercised on sign-out.
|
||||||
|
*/
|
||||||
|
export default async function Home({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ error?: string }>;
|
||||||
|
}) {
|
||||||
await bindAll();
|
await bindAll();
|
||||||
|
const [cookieStore, params] = await Promise.all([cookies(), searchParams]);
|
||||||
|
const signedIn = cookieStore.has(SESSION_COOKIE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
<main className="mx-auto flex min-h-screen w-full max-w-sm flex-col justify-center gap-6 px-6 py-8">
|
||||||
<h1 className="mb-4 text-2xl font-bold text-foreground">Veect</h1>
|
<h1 className="text-2xl font-bold text-foreground">Veect</h1>
|
||||||
|
|
||||||
|
{signedIn ? (
|
||||||
|
<section aria-label="Signed in" className="flex flex-col gap-4">
|
||||||
|
<p className="text-foreground">You are signed in.</p>
|
||||||
|
<form action={signOutAction}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
action={signInAction}
|
||||||
|
aria-label="Sign in"
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-2 text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-2 text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{params.error === "invalid-credentials" ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
Invalid username or password.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
161
apps/web-next/src/server/auth-actions.test.ts
Normal file
161
apps/web-next/src/server/auth-actions.test.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// redirect() in Next.js throws a control-flow error; the mock mirrors that so
|
||||||
|
// action code after redirect() is provably unreachable in tests too.
|
||||||
|
const redirectSentinel = vi.hoisted(() => {
|
||||||
|
class RedirectError extends Error {
|
||||||
|
constructor(public readonly url: string) {
|
||||||
|
super(`NEXT_REDIRECT:${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { RedirectError };
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookieMocks = vi.hoisted(() => {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
set: vi.fn(
|
||||||
|
(name: string, value: string, _attributes?: Record<string, unknown>) => {
|
||||||
|
store.set(name, value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
get: vi.fn((name: string) => {
|
||||||
|
const value = store.get(name);
|
||||||
|
return value === undefined ? undefined : { name, value };
|
||||||
|
}),
|
||||||
|
delete: vi.fn((name: string) => {
|
||||||
|
store.delete(name);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const callerMocks = vi.hoisted(() => ({
|
||||||
|
signIn: vi.fn(),
|
||||||
|
signOut: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/headers", () => ({
|
||||||
|
cookies: vi.fn(async () => cookieMocks),
|
||||||
|
}));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: vi.fn((url: string): never => {
|
||||||
|
throw new redirectSentinel.RedirectError(url);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("./bind-production", () => ({ bindAll: vi.fn(async () => {}) }));
|
||||||
|
vi.mock("@repo/core-api", () => ({
|
||||||
|
appRouter: {
|
||||||
|
createCaller: vi.fn(() => ({
|
||||||
|
auth: { signIn: callerMocks.signIn, signOut: callerMocks.signOut },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { signInAction, signOutAction } from "./auth-actions";
|
||||||
|
import { bindAll } from "./bind-production";
|
||||||
|
|
||||||
|
const { RedirectError } = redirectSentinel;
|
||||||
|
|
||||||
|
function signInForm(username: string, password: string): FormData {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("username", username);
|
||||||
|
form.set("password", password);
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function redirectTargetOf(action: Promise<void>): Promise<string> {
|
||||||
|
try {
|
||||||
|
await action;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RedirectError) return err.url;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new Error("expected the action to redirect");
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cookieMocks.store.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("signInAction", () => {
|
||||||
|
it("binds, signs in through the app router, sets the returned cookie, and redirects home", async () => {
|
||||||
|
callerMocks.signIn.mockResolvedValue({
|
||||||
|
name: "session",
|
||||||
|
value: "session_alice",
|
||||||
|
attributes: { httpOnly: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = await redirectTargetOf(
|
||||||
|
signInAction(signInForm("alice", "secret_alice")),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(target).toBe("/");
|
||||||
|
expect(bindAll).toHaveBeenCalled();
|
||||||
|
expect(callerMocks.signIn).toHaveBeenCalledExactlyOnceWith({
|
||||||
|
username: "alice",
|
||||||
|
password: "secret_alice",
|
||||||
|
});
|
||||||
|
expect(cookieMocks.set).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
"session",
|
||||||
|
"session_alice",
|
||||||
|
{ httpOnly: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to the error state without setting a cookie when sign-in fails", async () => {
|
||||||
|
callerMocks.signIn.mockRejectedValue(new Error("UNAUTHORIZED"));
|
||||||
|
|
||||||
|
const target = await redirectTargetOf(
|
||||||
|
signInAction(signInForm("alice", "wrong-password")),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(target).toBe("/?error=invalid-credentials");
|
||||||
|
expect(cookieMocks.set).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits missing form fields as empty strings (rejected by the input schema server-side)", async () => {
|
||||||
|
callerMocks.signIn.mockRejectedValue(new Error("BAD_REQUEST"));
|
||||||
|
|
||||||
|
const target = await redirectTargetOf(signInAction(new FormData()));
|
||||||
|
|
||||||
|
expect(target).toBe("/?error=invalid-credentials");
|
||||||
|
expect(callerMocks.signIn).toHaveBeenCalledExactlyOnceWith({
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("signOutAction", () => {
|
||||||
|
it("invalidates the session from the cookie, clears it, and redirects home", async () => {
|
||||||
|
cookieMocks.store.set("session", "session_alice");
|
||||||
|
callerMocks.signOut.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const target = await redirectTargetOf(signOutAction());
|
||||||
|
|
||||||
|
expect(target).toBe("/");
|
||||||
|
expect(callerMocks.signOut).toHaveBeenCalledExactlyOnceWith({
|
||||||
|
sessionId: "session_alice",
|
||||||
|
});
|
||||||
|
expect(cookieMocks.delete).toHaveBeenCalledExactlyOnceWith("session");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still clears a stale cookie when the server-side session is already gone", async () => {
|
||||||
|
cookieMocks.store.set("session", "session_stale");
|
||||||
|
callerMocks.signOut.mockRejectedValue(new Error("UNAUTHORIZED"));
|
||||||
|
|
||||||
|
const target = await redirectTargetOf(signOutAction());
|
||||||
|
|
||||||
|
expect(target).toBe("/");
|
||||||
|
expect(cookieMocks.delete).toHaveBeenCalledExactlyOnceWith("session");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op redirect when no session cookie is present", async () => {
|
||||||
|
const target = await redirectTargetOf(signOutAction());
|
||||||
|
|
||||||
|
expect(target).toBe("/");
|
||||||
|
expect(callerMocks.signOut).not.toHaveBeenCalled();
|
||||||
|
expect(cookieMocks.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
64
apps/web-next/src/server/auth-actions.ts
Normal file
64
apps/web-next/src/server/auth-actions.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// apps/web-next/src/server/auth-actions.ts
|
||||||
|
// SERVER-ONLY: Next.js server actions for the auth shell. Both actions go
|
||||||
|
// through the composed tRPC appRouter (createCaller — the sanctioned server
|
||||||
|
// entry point) so the auth feature's controllers, error mapping, and
|
||||||
|
// conformance wrappers all run. The app owns the session cookie: the sign-in
|
||||||
|
// controller *returns* the cookie and the action writes it via next/headers,
|
||||||
|
// which keeps httpOnly-capable attributes server-side.
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { appRouter } from "@repo/core-api";
|
||||||
|
import { SESSION_COOKIE } from "@repo/auth";
|
||||||
|
import { bindAll } from "./bind-production";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign in with username + password from the shell home form.
|
||||||
|
*
|
||||||
|
* Success: sets the session cookie and redirects to `/`.
|
||||||
|
* Failure (bad input or wrong credentials): redirects to
|
||||||
|
* `/?error=invalid-credentials` — the shell home renders the error inline.
|
||||||
|
*/
|
||||||
|
export async function signInAction(formData: FormData): Promise<void> {
|
||||||
|
await bindAll();
|
||||||
|
const caller = appRouter.createCaller({});
|
||||||
|
|
||||||
|
let cookie;
|
||||||
|
try {
|
||||||
|
cookie = await caller.auth.signIn({
|
||||||
|
username: String(formData.get("username") ?? ""),
|
||||||
|
password: String(formData.get("password") ?? ""),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// BAD_REQUEST (input) and UNAUTHORIZED (credentials) collapse into one
|
||||||
|
// user-facing error on the placeholder shell.
|
||||||
|
redirect("/?error=invalid-credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = await cookies();
|
||||||
|
store.set(cookie.name, cookie.value, cookie.attributes);
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign out from the shell home. Invalidates the server-side session, then
|
||||||
|
* clears the cookie even when the session is already gone (e.g. the dev
|
||||||
|
* server restarted and the in-memory session store was lost).
|
||||||
|
*/
|
||||||
|
export async function signOutAction(): Promise<void> {
|
||||||
|
await bindAll();
|
||||||
|
const store = await cookies();
|
||||||
|
const sessionId = store.get(SESSION_COOKIE)?.value;
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
try {
|
||||||
|
await appRouter.createCaller({}).auth.signOut({ sessionId });
|
||||||
|
} catch {
|
||||||
|
// Stale session id — still clear the cookie below.
|
||||||
|
}
|
||||||
|
store.delete(SESSION_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user