Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5d439de
feat(supabase): add profiles with a username and its weekly lock
kris1027 Oct 11, 2026
e45a983
docs(prompts): plan the account username
kris1027 Oct 11, 2026
d9fc946
feat(auth): ask for a username on sign up
kris1027 Oct 11, 2026
b52d965
feat(account): set and change the username once a week
kris1027 Oct 11, 2026
ea7ab88
feat(layout): name the account by its username in the navbar
kris1027 Oct 11, 2026
6c7f839
docs(legal): list the username in the privacy policy
kris1027 Oct 11, 2026
1fe64cd
docs(agents): record the profiles table and its one definer function
kris1027 Oct 11, 2026
69ab519
feat(account): lay the settings out in two columns from lg
kris1027 Oct 11, 2026
070aecd
feat(account): confirm the new password and align the card buttons
kris1027 Oct 11, 2026
127d041
refactor(auth): classify profile write errors beside auth errors
kris1027 Oct 11, 2026
be2b371
refactor(auth): take the username taken and locked copy from messages.ts
kris1027 Oct 11, 2026
0ee5e28
test(auth): compare the full username format rule with the SQL check
kris1027 Oct 11, 2026
0e20a11
docs(auth): say which session check Server Actions use
kris1027 Oct 11, 2026
2dbfc48
docs(ui): put the LocalDateTime doc block on the component
kris1027 Oct 11, 2026
81a228e
refactor(auth): list the form fields once
kris1027 Oct 11, 2026
198caa6
refactor(account): share the Sign in again link between both forms
kris1027 Oct 11, 2026
ad586d1
refactor(account): name the card classes once on the account page
kris1027 Oct 11, 2026
97ccf6c
docs(prompts): record the username plan as built after review
kris1027 Oct 11, 2026
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
101 changes: 93 additions & 8 deletions app/(auth)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";

Expand Down Expand Up @@ -82,6 +87,57 @@ async function padTo(startedAt: number, floorMs: number): Promise<void> {
}
}

/**
* 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<ReturnType<typeof createClient>>,
userId: string | undefined,
username: string,
nextPath: string,
): Promise<string> {
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).
Expand All @@ -99,21 +155,44 @@ 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,
});

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,
Expand All @@ -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") {
Expand All @@ -150,7 +229,7 @@ export async function signUpAction(
"refused",
AUTH_OUTCOME.alreadyRegistered,
);
return keepEmail(
return keepTyped(
{
...failure(AUTH_OUTCOME.alreadyRegistered),
link: {
Expand All @@ -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)}`,
Expand Down
95 changes: 94 additions & 1 deletion app/(auth)/email-delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -49,6 +54,8 @@ const PASSWORD = "a-long-enough-password";

function form(fields: Record<string, string>): 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;
}
Expand All @@ -68,7 +75,10 @@ const NEW_USER = { id: "user-a", identities: [{ id: "identity" }] };
let warn: ReturnType<typeof vi.spyOn>;

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");
Expand Down Expand Up @@ -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();
});
});
37 changes: 37 additions & 0 deletions app/(auth)/sign-up/sign-up-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SignUpForm />);
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(<SignUpForm />);
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");
});
});
15 changes: 15 additions & 0 deletions app/(auth)/sign-up/sign-up-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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<AuthActionState, FormData>(
Expand All @@ -46,6 +51,16 @@ function SignUpForm({ next }: { next?: string }) {
</Link>
) : null}

<AuthField
label="Username"
name="username"
autoComplete="nickname"
maxLength={USERNAME_MAX_LENGTH}
hint={USERNAME_HINT}
defaultValue={state.values?.username}
error={state.fieldErrors?.username}
/>

<AuthField
label="Email"
name="email"
Expand Down
1 change: 1 addition & 0 deletions app/account/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function changeForm(): FormData {
const form = new FormData();
form.set("currentPassword", "the-current-Passw0rd");
form.set("password", "a-fresh-Passw0rd-for-tests");
form.set("confirmPassword", "a-fresh-Passw0rd-for-tests");
return form;
}

Expand Down
Loading
Loading