diff --git a/AGENTS.md b/AGENTS.md index fe01a1a..1ce5337 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,7 +146,7 @@ Fetch or cache catalog metadata as needed. Do not import the entire TMDB catalog ### User account -Supabase Auth owns identity. Add a separate profile table only if actual UI requirements need fields beyond the authentication account. Do not create public profile routes. +Supabase Auth owns identity. Add a separate profile table only if actual UI requirements need fields beyond the authentication account. Do not create public profile routes. The one such field is the username ([prompts/account-username.md](prompts/account-username.md)): `profiles` holds it, unique ignoring case and changeable once per rolling week, both enforced in the database. Only its owner can see it. `public.username_available(text)` is the one `security definer` function and the one `anon` can call, a named exception in `140-security-sweep`. ### Movie state diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts index 59d3138..3a19c78 100644 --- a/app/(auth)/actions.ts +++ b/app/(auth)/actions.ts @@ -7,12 +7,14 @@ import { failure, fromZodError, keepEmail, + keepUsername, passwordFieldFor, success, } from "@/lib/auth/action-state"; import { isRecoverySession } from "@/lib/auth/identity"; import { AUTH_EVENT, logAuthEvent } from "@/lib/auth/log"; import { + ACCOUNT_NOTICE, AUTH_OUTCOME, RESEND_REQUESTED_MESSAGE, RESET_REQUESTED_MESSAGE, @@ -25,7 +27,10 @@ import { signInSchema, signUpSchema, } from "@/lib/auth/schemas"; -import { classifyAuthError } from "@/lib/auth/supabase-error"; +import { + classifyAuthError, + classifyProfileWriteError, +} from "@/lib/auth/supabase-error"; import { getAuthEmailDelivery, getPublicEnv } from "@/lib/env"; import { createClient } from "@/lib/supabase/server"; @@ -82,6 +87,57 @@ async function padTo(startedAt: number, floorMs: number): Promise { } } +/** + * Keeps both typed values on a refused sign up, so neither field empties. + */ +function keepTyped( + state: AuthActionState, + formData: FormData, +): AuthActionState { + return keepUsername(keepEmail(state, formData), formData); +} + +/** + * Stores the new account's username and says where to go next + * (prompts/account-username.md, Q9, Q16). + * + * Runs as the new user, under the session `signUp` just created, so row level + * security applies like any other write. The availability check before + * `signUp` makes a refusal here rare: it means someone claimed the same name + * in between. The account exists by then and cannot be undone from here, so + * the person is sent to `/account` to choose another, with a notice when the + * name was taken and without one for any other failure, where the empty field + * says enough. + * + * @returns The path to redirect to. Never throws, so the caller's `redirect()` + * stays outside any `try`. + */ +async function claimUsername( + supabase: Awaited>, + userId: string | undefined, + username: string, + nextPath: string, +): Promise { + if (!userId) { + logAuthEvent(AUTH_EVENT.signUp, "error", AUTH_OUTCOME.unexpected); + return "/account"; + } + + const { error } = await supabase + .from("profiles") + .insert({ user_id: userId, username }); + + if (!error) return nextPath; + + if (classifyProfileWriteError(error) === AUTH_OUTCOME.usernameTaken) { + logAuthEvent(AUTH_EVENT.signUp, "refused", AUTH_OUTCOME.usernameTaken); + return `/account?notice=${ACCOUNT_NOTICE.usernameTaken}`; + } + + logAuthEvent(AUTH_EVENT.signUp, "error", AUTH_OUTCOME.unexpected); + return "/account"; +} + /** * Creates an account, then signs in or asks for confirmation (spec 0005 AC-1, * AC-2; spec 0018 AC-11, AC-12). @@ -99,6 +155,7 @@ export async function signUpAction( const startedAt = Date.now(); const parsed = signUpSchema.safeParse({ + username: formData.get("username"), email: formData.get("email"), password: formData.get("password"), next: formData.get("next") ?? undefined, @@ -106,14 +163,36 @@ export async function signUpAction( if (!parsed.success) { logAuthEvent(AUTH_EVENT.signUp, "refused", AUTH_OUTCOME.invalidInput); - return keepEmail(fromZodError(parsed.error), formData); + return keepTyped(fromZodError(parsed.error), formData); } - const { email, password, next } = parsed.data; + const { username, email, password, next } = parsed.data; const nextPath = safeNextPath(next); const emailDelivery = getAuthEmailDelivery(); const supabase = await createClient(); + + // Asked before the account exists, so a taken name never leaves behind an + // account without one. Unpadded: that a name is in use is not a secret the + // plan keeps (prompts/account-username.md, Q10, Q17). An error is never + // read as "available". + const { data: available, error: availabilityError } = await supabase.rpc( + "username_available", + { p_username: username }, + ); + + if (availabilityError) { + logAuthEvent(AUTH_EVENT.signUp, "error", AUTH_OUTCOME.signUpUnavailable); + return keepTyped(failure(AUTH_OUTCOME.signUpUnavailable), formData); + } + + if (!available) { + logAuthEvent(AUTH_EVENT.signUp, "refused", AUTH_OUTCOME.usernameTaken); + return keepTyped( + failure(AUTH_OUTCOME.usernameTaken, ["username"]), + formData, + ); + } const { data, error } = await supabase.auth.signUp({ email, password, @@ -137,7 +216,7 @@ export async function signUpAction( if (error && !addressAlreadyRegistered) { const outcome = classifyAuthError(error); logAuthEvent(AUTH_EVENT.signUp, "refused", outcome); - return keepEmail(failure(outcome, passwordFieldFor(outcome)), formData); + return keepTyped(failure(outcome, passwordFieldFor(outcome)), formData); } if (emailDelivery === "off") { @@ -150,7 +229,7 @@ export async function signUpAction( "refused", AUTH_OUTCOME.alreadyRegistered, ); - return keepEmail( + return keepTyped( { ...failure(AUTH_OUTCOME.alreadyRegistered), link: { @@ -167,17 +246,23 @@ export async function signUpAction( // sends, so refuse plainly and leave the mismatch in the log. if (!data.session) { logAuthEvent(AUTH_EVENT.signUp, "error", AUTH_OUTCOME.signUpUnavailable); - return keepEmail(failure(AUTH_OUTCOME.signUpUnavailable), formData); + return keepTyped(failure(AUTH_OUTCOME.signUpUnavailable), formData); } - redirect(nextPath); + redirect(await claimUsername(supabase, data.user?.id, username, nextPath)); } // Both branches, never one. See SIGN_UP_PAD_MS: padding only the refusal // makes the refusal the slow one and leaks the answer just as loudly. await padTo(startedAt, SIGN_UP_PAD_MS); - if (data.session) redirect(nextPath); + if (data.session) { + redirect(await claimUsername(supabase, data.user?.id, username, nextPath)); + } + + // No session means confirmation is on, and nobody can write the profile + // until there is one. The name is then chosen on `/account`; unreachable + // with today's config (`enable_confirmations = false`). redirect( `/check-email?email=${encodeURIComponent(email)}&next=${encodeURIComponent(nextPath)}`, diff --git a/app/(auth)/email-delivery.test.ts b/app/(auth)/email-delivery.test.ts index 33b8c80..9bf481a 100644 --- a/app/(auth)/email-delivery.test.ts +++ b/app/(auth)/email-delivery.test.ts @@ -19,6 +19,11 @@ const auth = { signInWithPassword: vi.fn(), }; +/** The username check and the profile insert that sign up adds. */ +const rpc = vi.fn(); +const insert = vi.fn(); +const from = vi.fn(); + const createClient = vi.fn(); vi.mock("@/lib/supabase/server", () => ({ @@ -49,6 +54,8 @@ const PASSWORD = "a-long-enough-password"; function form(fields: Record): FormData { const data = new FormData(); + // Sign up needs a username; the other actions ignore the extra field. + data.set("username", "tester_one"); for (const [name, value] of Object.entries(fields)) data.set(name, value); return data; } @@ -68,7 +75,10 @@ const NEW_USER = { id: "user-a", identities: [{ id: "identity" }] }; let warn: ReturnType; beforeEach(() => { - createClient.mockResolvedValue({ auth }); + createClient.mockResolvedValue({ auth, rpc, from }); + rpc.mockResolvedValue({ data: true, error: null }); + insert.mockResolvedValue({ error: null }); + from.mockReturnValue({ insert }); vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "http://127.0.0.1:54321"); vi.stubEnv("NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY", "sb_publishable_test"); vi.stubEnv("NEXT_PUBLIC_SITE_URL", "http://localhost:3000"); @@ -381,3 +391,86 @@ describe("the email actions with email delivery on", () => { expect(state.message).toBe(AUTH_MESSAGES[AUTH_OUTCOME.emailNotConfirmed]); }); }); + +/** covers: prompts/account-username.md, R5 (Q9, Q16) */ +describe("signUpAction and the username", () => { + beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_AUTH_EMAIL_DELIVERY", "off"); + auth.signUp.mockResolvedValue({ + data: { user: NEW_USER, session: SESSION }, + error: null, + }); + }); + + it("stores the username as the new user, then goes on", async () => { + const url = await redirectOf( + signUpAction(IDLE_STATE, form({ email: ADDRESS, password: PASSWORD })), + ); + + expect(url).toBe("/shows"); + expect(rpc).toHaveBeenCalledWith("username_available", { + p_username: "tester_one", + }); + expect(from).toHaveBeenCalledWith("profiles"); + expect(insert).toHaveBeenCalledWith({ + user_id: "user-a", + username: "tester_one", + }); + }); + + it("refuses a taken name on its field before any account exists", async () => { + rpc.mockResolvedValue({ data: false, error: null }); + + const state = await signUpAction( + IDLE_STATE, + form({ email: ADDRESS, password: PASSWORD }), + ); + + expect(state.outcome).toBe(AUTH_OUTCOME.usernameTaken); + expect(state.fieldErrors?.username).toBe("That username is taken."); + expect(state.values).toEqual({ email: ADDRESS, username: "tester_one" }); + expect(auth.signUp).not.toHaveBeenCalled(); + }); + + it("never reads a failed check as available", async () => { + rpc.mockResolvedValue({ data: null, error: { code: "08006" } }); + + const state = await signUpAction( + IDLE_STATE, + form({ email: ADDRESS, password: PASSWORD }), + ); + + expect(state.outcome).toBe(AUTH_OUTCOME.signUpUnavailable); + expect(auth.signUp).not.toHaveBeenCalled(); + }); + + it("sends a name lost to a race to /account with the notice", async () => { + insert.mockResolvedValue({ error: { code: "23505" } }); + + const url = await redirectOf( + signUpAction(IDLE_STATE, form({ email: ADDRESS, password: PASSWORD })), + ); + + expect(url).toBe("/account?notice=username-taken"); + }); + + it("sends any other failed insert to /account without one", async () => { + insert.mockResolvedValue({ error: { code: "08006" } }); + + const url = await redirectOf( + signUpAction(IDLE_STATE, form({ email: ADDRESS, password: PASSWORD })), + ); + + expect(url).toBe("/account"); + }); + + it("requires a username", async () => { + const data = form({ email: ADDRESS, password: PASSWORD }); + data.delete("username"); + + const state = await signUpAction(IDLE_STATE, data); + + expect(state.fieldErrors?.username).toBeDefined(); + expect(rpc).not.toHaveBeenCalled(); + }); +}); diff --git a/app/(auth)/sign-up/sign-up-form.test.tsx b/app/(auth)/sign-up/sign-up-form.test.tsx index 2b507c4..6c5fa37 100644 --- a/app/(auth)/sign-up/sign-up-form.test.tsx +++ b/app/(auth)/sign-up/sign-up-form.test.tsx @@ -48,3 +48,40 @@ describe("SignUpForm's already registered refusal", () => { ).not.toBeInTheDocument(); }); }); + +/** covers: prompts/account-username.md, R5 */ +describe("SignUpForm's username field", () => { + it("asks for a required username and states the rules up front", () => { + render(); + const field = screen.getByLabelText("Username"); + expect(field).toBeRequired(); + expect(field).toHaveAttribute("maxlength", "30"); + expect(field).toHaveAccessibleDescription( + "2 to 30 characters: letters, numbers, _ . -", + ); + }); + + it("marks a taken name on the field and keeps what was typed", async () => { + vi.mocked(signUpAction).mockResolvedValue({ + status: "error", + message: AUTH_MESSAGES[AUTH_OUTCOME.usernameTaken], + outcome: AUTH_OUTCOME.usernameTaken, + fieldErrors: { username: "That username is taken." }, + values: { email: "someone@example.com", username: "tester_one" }, + }); + + render(); + const form = screen + .getByRole("button", { name: "Create account" }) + .closest("form"); + if (!form) throw new Error("the submit button is outside a form"); + fireEvent.submit(form); + + const field = await screen.findByDisplayValue("tester_one"); + expect(field).toHaveAttribute("aria-invalid", "true"); + expect(field).toHaveAccessibleDescription( + expect.stringContaining("That username is taken."), + ); + expect(screen.getByLabelText("Email")).toHaveValue("someone@example.com"); + }); +}); diff --git a/app/(auth)/sign-up/sign-up-form.tsx b/app/(auth)/sign-up/sign-up-form.tsx index 9f24c79..efd68e9 100644 --- a/app/(auth)/sign-up/sign-up-form.tsx +++ b/app/(auth)/sign-up/sign-up-form.tsx @@ -8,6 +8,7 @@ import { } from "@/components/auth/auth-feedback"; import { AuthField } from "@/components/auth/auth-field"; import { type AuthActionState, IDLE_STATE } from "@/lib/auth/action-state"; +import { USERNAME_HINT, USERNAME_MAX_LENGTH } from "@/lib/auth/username"; import { signUpAction } from "../actions"; /** @@ -24,6 +25,10 @@ import { signUpAction } from "../actions"; * as you type. With email delivery off, a submitted taken address is reported * with a link back to sign in (spec 0018, AC-12); with it on, the action masks * that case as AC-2 asks. + * + * The username comes first and is required (prompts/account-username.md). Its + * rules are shown up front for the same reason as the password's, and a taken + * one is reported on submit only, never as you type. */ function SignUpForm({ next }: { next?: string }) { const [state, formAction] = useActionState( @@ -46,6 +51,16 @@ function SignUpForm({ next }: { next?: string }) { ) : null} + + { + const parsed = changeUsernameSchema.safeParse({ + username: formData.get("username"), + }); + + if (!parsed.success) { + logAuthEvent( + AUTH_EVENT.changeUsername, + "refused", + AUTH_OUTCOME.invalidInput, + ); + // A reserved name fails here with the taken message, so it is reported + // as taken, not as a highlighted field to fix. + return keepUsername(fromZodError(parsed.error), formData); + } + + const user = await getOptionalUser(); + + if (!user) { + logAuthEvent( + AUTH_EVENT.changeUsername, + "refused", + AUTH_OUTCOME.sessionExpired, + ); + return failure(AUTH_OUTCOME.sessionExpired); + } + + // The identical name is no change, and the trigger would keep the clock + // where it was. Saying "updated" for it would be a small lie. + const current = await getOwnProfile(); + if (current?.username === parsed.data.username) { + return keepUsername(success(USERNAME_UNCHANGED_MESSAGE), formData); + } + + const supabase = await createClient(); + const { error } = await supabase + .from("profiles") + .upsert( + { user_id: user.id, username: parsed.data.username }, + { onConflict: "user_id" }, + ); + + if (error) { + const outcome = classifyProfileWriteError(error); + logAuthEvent(AUTH_EVENT.changeUsername, "refused", outcome); + return keepUsername( + failure( + outcome, + outcome === AUTH_OUTCOME.usernameTaken ? ["username"] : [], + ), + formData, + ); + } + + refresh(); + return success(USERNAME_UPDATED_MESSAGE); +} diff --git a/app/account/change-password-form.tsx b/app/account/change-password-form.tsx index 3e5e74f..8a99a1d 100644 --- a/app/account/change-password-form.tsx +++ b/app/account/change-password-form.tsx @@ -1,6 +1,5 @@ "use client"; -import Link from "next/link"; import { useActionState } from "react"; import { AuthFeedback, @@ -8,8 +7,8 @@ import { } from "@/components/auth/auth-feedback"; import { AuthField } from "@/components/auth/auth-field"; import { type AuthActionState, IDLE_STATE } from "@/lib/auth/action-state"; -import { AUTH_OUTCOME } from "@/lib/auth/messages"; import { changePasswordAction } from "./actions"; +import { SignInAgainLink } from "./sign-in-again-link"; /** * The change password form on `/account` (spec 0005, AC-16, AC-17). @@ -29,22 +28,10 @@ function ChangePasswordForm() { ); return ( -
+ - {/* - * AC-17: a write that did not happen must never look like one that did, - * and the message must come with a way forward. `next` carries this page, - * so signing in again lands back here rather than on the catalog. - */} - {state.outcome === AUTH_OUTCOME.sessionExpired ? ( - - Sign in again - - ) : null} + - - Change password - + + + {/* + * Pinned to the bottom, so in the two column layout it lines up with + * Save username; with no spare height `mt-auto` is zero. + */} +
+ + Change password + +
); } diff --git a/app/account/change-username-action.test.ts b/app/account/change-username-action.test.ts new file mode 100644 index 0000000..8953379 --- /dev/null +++ b/app/account/change-username-action.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { IDLE_STATE } from "@/lib/auth/action-state"; +import { + AUTH_OUTCOME, + USERNAME_UNCHANGED_MESSAGE, + USERNAME_UPDATED_MESSAGE, +} from "@/lib/auth/messages"; + +/** + * The database is the judge of the lock and of uniqueness, so the client is + * replaced and each refusal it can send is played back. + */ +const upsert = vi.fn(); +const from = vi.fn(); +const getOptionalUser = vi.fn(); +const getOwnProfile = vi.fn(); +const refresh = vi.fn(); + +vi.mock("@/lib/supabase/server", () => ({ + createClient: async () => ({ from }), +})); +vi.mock("@/lib/auth/user", () => ({ getOptionalUser })); +vi.mock("@/lib/auth/profile", () => ({ getOwnProfile })); +vi.mock("next/cache", () => ({ refresh })); + +const { changeUsernameAction } = await import("./actions"); + +function usernameForm(username: string): FormData { + const form = new FormData(); + form.set("username", username); + return form; +} + +let warn: ReturnType; + +beforeEach(() => { + from.mockReturnValue({ upsert }); + upsert.mockResolvedValue({ error: null }); + getOptionalUser.mockResolvedValue({ id: "user-a", email: "a@example.com" }); + getOwnProfile.mockResolvedValue(null); + warn = vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.resetAllMocks(); + warn.mockRestore(); +}); + +/** covers: prompts/account-username.md, R2, R6 */ +describe("changeUsernameAction", () => { + it("saves a valid name for the session's user and refreshes the page", async () => { + const state = await changeUsernameAction( + IDLE_STATE, + usernameForm(" tester_one "), + ); + + expect(state).toMatchObject({ + status: "success", + message: USERNAME_UPDATED_MESSAGE, + }); + expect(from).toHaveBeenCalledWith("profiles"); + expect(upsert).toHaveBeenCalledWith( + { user_id: "user-a", username: "tester_one" }, + { onConflict: "user_id" }, + ); + expect(refresh).toHaveBeenCalled(); + }); + + it.each([ + ["a", "at least 2"], + ["has space", "spaces"], + ["émile", "only letters"], + ["-dash", "start or end"], + ])("refuses %s on the field before any write", async (value, fragment) => { + const state = await changeUsernameAction(IDLE_STATE, usernameForm(value)); + + expect(state.status).toBe("error"); + expect(state.fieldErrors?.username).toContain(fragment); + expect(state.values?.username).toBe(value); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("calls a reserved name taken, so the list is not revealed", async () => { + const state = await changeUsernameAction(IDLE_STATE, usernameForm("Admin")); + + expect(state.fieldErrors?.username).toBe("That username is taken."); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("says nothing changed for the name already set, without writing", async () => { + getOwnProfile.mockResolvedValue({ + username: "tester_one", + usernameChangedAt: new Date(), + }); + + const state = await changeUsernameAction( + IDLE_STATE, + usernameForm("tester_one"), + ); + + expect(state.message).toBe(USERNAME_UNCHANGED_MESSAGE); + expect(upsert).not.toHaveBeenCalled(); + }); + + it.each([ + ["the unique index", { code: "23505" }, AUTH_OUTCOME.usernameTaken], + ["a check", { code: "23514" }, AUTH_OUTCOME.usernameTaken], + ["the weekly lock", { code: "BS002" }, AUTH_OUTCOME.usernameLocked], + ["anything else", { code: "08006" }, AUTH_OUTCOME.unexpected], + ])("reports a refusal from %s", async (_, error, outcome) => { + upsert.mockResolvedValue({ error }); + + const state = await changeUsernameAction( + IDLE_STATE, + usernameForm("tester_one"), + ); + + expect(state.outcome).toBe(outcome); + expect(refresh).not.toHaveBeenCalled(); + }); + + it("writes nothing without a session", async () => { + getOptionalUser.mockResolvedValue(null); + + const state = await changeUsernameAction( + IDLE_STATE, + usernameForm("tester_one"), + ); + + expect(state.outcome).toBe(AUTH_OUTCOME.sessionExpired); + expect(from).not.toHaveBeenCalled(); + }); +}); diff --git a/app/account/change-username-form.test.tsx b/app/account/change-username-form.test.tsx new file mode 100644 index 0000000..d1a72f9 --- /dev/null +++ b/app/account/change-username-form.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ChangeUsernameForm } from "./change-username-form"; + +vi.mock("./actions", () => ({ changeUsernameAction: vi.fn() })); + +/** covers: prompts/account-username.md, R6 (Q13) */ +describe("ChangeUsernameForm", () => { + it("offers an empty field when no username is set", () => { + render(); + expect(screen.getByLabelText("Username")).toHaveValue(""); + expect(screen.getByLabelText("Username")).toBeEnabled(); + expect(screen.getByRole("button", { name: "Save username" })).toBeEnabled(); + }); + + it("prefills the current name while unlocked", () => { + render(); + expect(screen.getByLabelText("Username")).toHaveValue("tester_one"); + expect(screen.getByLabelText("Username")).toBeEnabled(); + }); + + it("disables the field and says when it unlocks while locked", () => { + render( + , + ); + expect(screen.getByLabelText("Username")).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Save username" }), + ).toBeDisabled(); + expect(screen.getByText(/You can change it again on/)).toBeInTheDocument(); + expect(document.querySelector("time")).toHaveAttribute( + "datetime", + "2026-10-18T12:00:00.000Z", + ); + }); +}); diff --git a/app/account/change-username-form.tsx b/app/account/change-username-form.tsx new file mode 100644 index 0000000..7493362 --- /dev/null +++ b/app/account/change-username-form.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useActionState } from "react"; +import { + AuthFeedback, + AuthSubmitButton, +} from "@/components/auth/auth-feedback"; +import { AuthField } from "@/components/auth/auth-field"; +import { LocalDateTime } from "@/components/local-date-time"; +import { type AuthActionState, IDLE_STATE } from "@/lib/auth/action-state"; +import { USERNAME_HINT, USERNAME_MAX_LENGTH } from "@/lib/auth/username"; +import { changeUsernameAction } from "./actions"; +import { SignInAgainLink } from "./sign-in-again-link"; + +/** + * The username form on `/account` (prompts/account-username.md, Q13). + * + * Three states, all decided by the page from the stored profile: no username + * yet (an empty field), unlocked (the current name, editable), and locked (the + * current name, disabled, with when it unlocks). The lock shown here is only a + * courtesy; the trigger on `profiles` is what refuses an early change, and its + * refusal still reaches this form if the two ever disagree. + * + * @param username The current username, or null when none is set. + * @param unlocksAt When the weekly lock ends, as an ISO string, or null when + * it does not hold now. + */ +function ChangeUsernameForm({ + username, + unlocksAt, +}: { + username: string | null; + unlocksAt: string | null; +}) { + const [state, formAction] = useActionState( + changeUsernameAction, + IDLE_STATE, + ); + + const locked = unlocksAt !== null; + + return ( +
+ + + + + + + {locked ? ( +

+ You can change it again on . +

+ ) : null} + + {/* Bottom of the card, level with Change password beside it. */} +
+ + Save username + +
+ + ); +} + +export { ChangeUsernameForm }; diff --git a/app/account/page.tsx b/app/account/page.tsx index 777894b..aae26e2 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -1,11 +1,21 @@ +import { cn } from "cn"; import type { Metadata } from "next"; import { signOutAction } from "@/app/(auth)/actions"; import { Button, ButtonLink } from "@/components/ui/button"; import { hasPasswordIdentity } from "@/lib/auth/identity"; +import { + ACCOUNT_NOTICE, + ACCOUNT_NOTICES, + AUTH_OUTCOME, + authMessage, +} from "@/lib/auth/messages"; +import { getOwnProfile } from "@/lib/auth/profile"; import { requireUser } from "@/lib/auth/user"; +import { isUsernameLocked, usernameUnlocksAt } from "@/lib/auth/username"; import { createClient } from "@/lib/supabase/server"; import { ChangePasswordForm } from "./change-password-form"; +import { ChangeUsernameForm } from "./change-username-form"; /** * This route opts out of prerendering (root `AGENTS.md`, `cacheComponents`). @@ -28,6 +38,13 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, }; +/** The glass card every `/account` section sits on, `AuthPanel`'s look. */ +const PANEL = + "glass glass-rim glass-plate-panel glass-shadow flex flex-col rounded-panel px-5 py-8 md:px-10 md:py-10"; + +/** From lg, the password card takes the right column beside the other two. */ +const RIGHT_COLUMN = "lg:col-start-2 lg:row-span-2 lg:row-start-1"; + /** * `/account`, the first private route in the app (spec 0005, AC-12, AC-16). * @@ -40,8 +57,26 @@ export const metadata: Metadata = { * and spacing unchanged rather than inventing a second look, which is the * approach `AGENTS.md` section 3 asks for and spec 0005 records. */ -export default async function AccountPage() { +export default async function AccountPage({ + searchParams, +}: { + searchParams: Promise<{ notice?: string | string[] }>; +}) { const user = await requireUser(); + const profile = await getOwnProfile(); + const { notice } = await searchParams; + + // Only a known key shows anything, so the URL cannot put its own text here. + const noticeText = + notice === ACCOUNT_NOTICE.usernameTaken + ? ACCOUNT_NOTICES[ACCOUNT_NOTICE.usernameTaken] + : null; + + // Read once per request; the page is dynamic (`instant = false`). + const unlocksAt = + profile && isUsernameLocked(profile.usernameChangedAt, new Date()) + ? usernameUnlocksAt(profile.usernameChangedAt).toISOString() + : null; // The one network call on this page. `getClaims()` has already established // who this is, but the identities list is not in the JWT and AC-16 needs it. @@ -60,16 +95,39 @@ export default async function AccountPage() { const canChangePassword = !identitiesUnavailable && hasPasswordIdentity(data.user?.identities); + const twoColumns = canChangePassword || identitiesUnavailable; + return ( -
-
+
+

Account

You are signed in as{" "} - {user.email}. + {profile ? ( + <> + + {profile.username} + {" "} + ({user.email}) + + ) : ( + + {user.email} + + )} + .

@@ -80,8 +138,32 @@ export default async function AccountPage() {
+
+
+

+ Username +

+

+ {profile + ? authMessage(AUTH_OUTCOME.usernameLocked) + : "Choose a username. You can change it once a week."} +

+
+ + {noticeText && !profile ? ( +

+ {noticeText} +

+ ) : null} + + +
+ {canChangePassword ? ( -
+

Change password @@ -96,7 +178,7 @@ export default async function AccountPage() { ) : null} {identitiesUnavailable ? ( -
+

+ Sign in again + + ); +} diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index a2b5267..092e720 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -55,8 +55,9 @@ export default function PrivacyPage() {

  • Account data: your - email address, and your password, which Supabase Auth stores only as - a hash. + email address, the username you choose, and your password, which + Supabase Auth stores only as a hash. Your username is shown only to + you.
  • Tracking data: your diff --git a/components/auth/auth-feedback.tsx b/components/auth/auth-feedback.tsx index c7fc70c..154346d 100644 --- a/components/auth/auth-feedback.tsx +++ b/components/auth/auth-feedback.tsx @@ -48,9 +48,12 @@ function AuthFeedback({ state }: { state: AuthActionState }) { function AuthSubmitButton({ children, pendingLabel, + disabled = false, }: { children: React.ReactNode; pendingLabel: string; + /** Off for a reason other than a pending submit, such as a locked field. */ + disabled?: boolean; }) { const { pending } = useFormStatus(); @@ -59,7 +62,7 @@ function AuthSubmitButton({ type="submit" variant="selected" size="lg" - disabled={pending} + disabled={pending || disabled} className="w-full" > {pending ? pendingLabel : children} diff --git a/components/auth/auth-field.tsx b/components/auth/auth-field.tsx index d473510..50e3457 100644 --- a/components/auth/auth-field.tsx +++ b/components/auth/auth-field.tsx @@ -26,6 +26,9 @@ function AuthField({ required = true, error, defaultValue, + hint, + disabled, + maxLength, }: { label: string; name: string; @@ -35,9 +38,17 @@ function AuthField({ required?: boolean; error?: string; defaultValue?: string; + /** A standing rule shown under the field, bound like the error. */ + hint?: string; + disabled?: boolean; + maxLength?: number; }) { const id = useId(); const errorId = `${id}-error`; + const hintId = `${id}-hint`; + const describedBy = + [hint ? hintId : null, error ? errorId : null].filter(Boolean).join(" ") || + undefined; const [revealed, setRevealed] = useState(false); const isPassword = type === "password"; @@ -82,9 +93,15 @@ function AuthField({ autoComplete={autoComplete} placeholder={placeholder} required={required} + disabled={disabled} + maxLength={maxLength} + // A username or address is never a sentence: no capital first + // letter and no spell check underline on a phone keyboard. + autoCapitalize={isPassword ? undefined : "none"} + spellCheck={isPassword ? undefined : false} {...valueProps} aria-invalid={error ? true : undefined} - aria-describedby={error ? errorId : undefined} + aria-describedby={describedBy} className={isPassword ? "pr-14" : undefined} /> @@ -110,6 +127,12 @@ function AuthField({ ) : null}
  • + {hint ? ( +

    + {hint} +

    + ) : null} + {error ? (

    {error} diff --git a/components/layout/account-slot-signed-in.test.tsx b/components/layout/account-slot-signed-in.test.tsx index 929984d..ccdb39b 100644 --- a/components/layout/account-slot-signed-in.test.tsx +++ b/components/layout/account-slot-signed-in.test.tsx @@ -15,6 +15,8 @@ vi.mock("@/lib/auth/user", () => ({ email: "kris1027@example.test", })), })); +const getOwnProfile = vi.fn(); +vi.mock("@/lib/auth/profile", () => ({ getOwnProfile })); vi.mock("next/navigation", () => ({ usePathname: () => "/watched", useSearchParams: () => new URLSearchParams("type=movie"), @@ -23,6 +25,7 @@ vi.mock("next/navigation", () => ({ const { AccountSlot } = await import("./account-slot"); beforeEach(() => { + getOwnProfile.mockResolvedValue(null); vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "http://127.0.0.1:54321"); vi.stubEnv("NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY", "sb_publishable_test"); vi.stubEnv("NEXT_PUBLIC_SITE_URL", "http://localhost:3000"); @@ -95,3 +98,22 @@ describe("AccountSlot, signed in on mobile (AC-15)", () => { ); }); }); + +/** covers: prompts/account-username.md, R7 */ +describe("AccountSlot with a username", () => { + it("names the account by its username, and its avatar by the first letter", async () => { + getOwnProfile.mockResolvedValue({ + username: "tester_one", + usernameChangedAt: new Date("2026-10-11T10:00:00Z"), + }); + render(await AccountSlot({ variant: "desktop" })); + const link = screen.getByRole("link", { name: "tester_one" }); + expect(link).toHaveAttribute("href", "/account"); + expect(link).toHaveTextContent("T"); + }); + + it("falls back to the email name when there is no profile", async () => { + render(await AccountSlot({ variant: "mobile" })); + expect(screen.getByRole("link", { name: "kris1027" })).toBeInTheDocument(); + }); +}); diff --git a/components/layout/account-slot.test.tsx b/components/layout/account-slot.test.tsx index aac31d6..219d488 100644 --- a/components/layout/account-slot.test.tsx +++ b/components/layout/account-slot.test.tsx @@ -5,6 +5,9 @@ import { getOptionalUser } from "@/lib/auth/user"; import { AccountSlot } from "./account-slot"; vi.mock("@/app/(auth)/actions", () => ({ signOutAction: vi.fn() })); +vi.mock("@/lib/auth/profile", () => ({ + getOwnProfile: vi.fn(async () => null), +})); // Reading the session with no configuration throws, exactly as the real // server client does, so a test that reaches it fails the way the page did. diff --git a/components/layout/account-slot.tsx b/components/layout/account-slot.tsx index 66d787a..390d141 100644 --- a/components/layout/account-slot.tsx +++ b/components/layout/account-slot.tsx @@ -5,6 +5,7 @@ import { LibraryNav } from "@/components/layout/library-nav"; import { MobileMenuSheet } from "@/components/layout/mobile-menu-sheet"; import { Button, ButtonLink } from "@/components/ui/button"; import { avatarLetter, displayName } from "@/lib/auth/identity"; +import { getOwnProfile } from "@/lib/auth/profile"; import { getOptionalUser } from "@/lib/auth/user"; import { publicEnvProblems } from "@/lib/env"; @@ -51,8 +52,11 @@ async function AccountSlot({ variant }: { variant: "desktop" | "mobile" }) { ); } - const name = displayName(user.email); - const letter = avatarLetter(user.email); + // The username, when the account has chosen one. A failed read falls back + // to the email name rather than failing the slot on every page. + const profile = await getOwnProfile(); + const name = displayName(user.email, profile?.username); + const letter = avatarLetter(user.email, profile?.username); if (variant === "mobile") { return ( diff --git a/components/local-date-time.tsx b/components/local-date-time.tsx new file mode 100644 index 0000000..f5d193c --- /dev/null +++ b/components/local-date-time.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** Month, day and time: enough to say when a lock ends, no year or seconds. */ +const FORMAT: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", +}; + +/** + * A moment shown in the viewer's own time zone (prompts/account-username.md, + * Q13). + * + * The server does not know the viewer's zone, and a date alone can be hours + * off for a lock that ends mid day. So the server renders the moment in UTC, + * labelled as such, and the browser replaces it with local time after mount. + * The `