* 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>
110 lines
4 KiB
TypeScript
110 lines
4 KiB
TypeScript
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 3–30 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">
|
||
3–30 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>
|
||
);
|
||
}
|