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";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Template — Next.js",
|
||||
description: "Clean Architecture Monorepo Template",
|
||||
title: "Veect",
|
||||
description: "Veect",
|
||||
};
|
||||
|
||||
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 { 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();
|
||||
const [cookieStore, params] = await Promise.all([cookies(), searchParams]);
|
||||
const signedIn = cookieStore.has(SESSION_COOKIE);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
<h1 className="mb-4 text-2xl font-bold text-foreground">Veect</h1>
|
||||
<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="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>
|
||||
);
|
||||
}
|
||||
|
||||
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