brackt/app/routes/onboarding.tsx
Chris Parsons 215b0cf6b5
Add user onboarding flow with username selection (#430)
* Add username onboarding prompt for new signups

New users (especially via OAuth/Google) are redirected to /onboarding
to choose a display username before accessing the app. Adds unique
constraint on users.username and case-insensitive uniqueness validation.

https://claude.ai/code/session_01UinBMAy9d6dQzzbcUAdq8J

* Address all code review issues on username onboarding

- Replace case-sensitive unique constraint with functional unique index on
  lower(username) so DB-level uniqueness is case-insensitive (fix #1)
- Share USERNAME_RE from models/user.ts; add same format + uniqueness
  validation to the settings update-profile action (fix #2)
- Wrap updateUser calls in try/catch to handle race-condition DB errors
  gracefully in both onboarding and settings (fix #3)
- Add unit tests for suggestUsername, safeRedirectTo, USERNAME_RE, and
  isOnboardingExempt (fix #4)
- Guard suggestUsername against returning strings shorter than 3 chars (fix #5)
- Preserve the user's intended destination via redirectTo query param
  through the onboarding flow (fix #6)
- Treat empty username in settings as a validation error instead of a
  silent no-op (fix #7)
- Use exact/sub-path matching in isOnboardingExempt to prevent
  false-positive prefix matches like /loginpage (fix #8)

https://claude.ai/code/session_01UinBMAy9d6dQzzbcUAdq8J

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-15 11:17:15 -07:00

110 lines
4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { redirect, Form } from "react-router";
import { auth } from "~/lib/auth.server";
import { findUserById, findUserByUsername, updateUser, USERNAME_RE } from "~/models/user";
import type { Route } from "./+types/onboarding";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
export function meta(): Route.MetaDescriptors {
return [{ title: "Choose a Username - Brackt" }];
}
export function suggestUsername(displayName: string | null): string {
if (!displayName) return "";
const cleaned = displayName
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "")
.slice(0, 20);
return cleaned.length >= 3 ? cleaned : "";
}
export function safeRedirectTo(value: string | null): string {
if (!value) return "/";
return value.startsWith("/") && !value.startsWith("//") ? value : "/";
}
export async function loader({ request }: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) return redirect("/login");
const user = await findUserById(session.user.id);
if (!user) return redirect("/login");
if (user.username) return redirect("/");
const redirectTo = safeRedirectTo(new URL(request.url).searchParams.get("redirectTo"));
return { suggestion: suggestUsername(user.displayName), redirectTo };
}
export async function action({ request }: Route.ActionArgs) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) return redirect("/login");
const formData = await request.formData();
const username = (formData.get("username") as string | null)?.trim() ?? "";
const redirectTo = safeRedirectTo(formData.get("redirectTo") as string | null);
if (!USERNAME_RE.test(username)) {
return {
error: "Username must be 330 characters and contain only letters, numbers, underscores, or hyphens.",
redirectTo,
};
}
const existing = await findUserByUsername(username);
if (existing && existing.id !== session.user.id) {
return { error: "That username is already taken.", redirectTo };
}
try {
await updateUser(session.user.id, { username });
} catch {
return { error: "That username is already taken.", redirectTo };
}
return redirect(redirectTo);
}
export default function OnboardingPage({ loaderData, actionData }: Route.ComponentProps) {
const redirectTo = actionData && "redirectTo" in actionData ? actionData.redirectTo : loaderData.redirectTo;
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl">Choose a username</CardTitle>
<CardDescription>
Pick a username that will be shown to other players. You can change it later in Settings.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="redirectTo" value={redirectTo} />
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
type="text"
defaultValue={loaderData.suggestion}
placeholder="e.g. fantasy_king"
minLength={3}
maxLength={30}
autoFocus
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
330 characters. Letters, numbers, underscores, and hyphens only.
</p>
</div>
{actionData && "error" in actionData && (
<p className="text-sm text-destructive">{actionData.error}</p>
)}
<Button type="submit" className="w-full">
Continue
</Button>
</Form>
</CardContent>
</Card>
</div>
);
}