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
This commit is contained in:
Claude 2026-05-15 17:52:12 +00:00
parent 469cc82e15
commit 1a2421e134
No known key found for this signature in database
8 changed files with 6249 additions and 1 deletions

View file

@ -38,6 +38,13 @@ export async function findUsersByIds(ids: string[]): Promise<User[]> {
.where(inArray(schema.users.id, ids));
}
export async function findUserByUsername(username: string): Promise<User | undefined> {
const db = database();
return await db.query.users.findFirst({
where: (users, { sql }) => sql`lower(${users.username}) = lower(${username})`,
});
}
export async function updateUser(
id: string,
data: Partial<NewUser>

View file

@ -6,6 +6,7 @@ import {
Scripts,
ScrollRestoration,
useLocation,
redirect,
} from "react-router";
import type { Route } from "./+types/root";
@ -20,10 +21,19 @@ import { findUserById } from "~/models/user";
import { AlertCircle, FileQuestion, Lock, ShieldOff, ServerCrash } from "lucide-react";
import logoUrl from "../public/logo.svg?url";
const ONBOARDING_EXEMPT = ["/onboarding", "/api/", "/login", "/register", "/check-email", "/forgot-password", "/reset-password"];
export async function loader({ request }: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: request.headers });
const currentUser = session ? await findUserById(session.user.id) : null;
const isAdmin = currentUser?.isAdmin ?? false;
if (currentUser && !currentUser.username) {
const { pathname } = new URL(request.url);
const exempt = ONBOARDING_EXEMPT.some((p) => pathname === p || pathname.startsWith(p));
if (!exempt) return redirect("/onboarding");
}
return { isAdmin, currentUser };
}

View file

@ -60,6 +60,7 @@ export default [
route("check-email", "routes/check-email.tsx"),
route("forgot-password", "routes/forgot-password.tsx"),
route("reset-password", "routes/reset-password.tsx"),
route("onboarding", "routes/onboarding.tsx"),
route("settings", "routes/settings.tsx"),
route("user-profile", "routes/user-profile-redirect.tsx"),
route("how-to-play", "routes/how-to-play.tsx"),

95
app/routes/onboarding.tsx Normal file
View file

@ -0,0 +1,95 @@
import { redirect, Form } from "react-router";
import { auth } from "~/lib/auth.server";
import { findUserById, findUserByUsername, updateUser } 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" }];
}
function suggestUsername(displayName: string | null): string {
if (!displayName) return "";
return displayName
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "")
.slice(0, 20);
}
const USERNAME_RE = /^[a-zA-Z0-9_-]{3,30}$/;
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("/");
return { suggestion: suggestUsername(user.displayName) };
}
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() ?? "";
if (!USERNAME_RE.test(username)) {
return {
error: "Username must be 330 characters and contain only letters, numbers, underscores, or hyphens.",
};
}
const existing = await findUserByUsername(username);
if (existing && existing.id !== session.user.id) {
return { error: "That username is already taken." };
}
await updateUser(session.user.id, { username });
return redirect("/");
}
export default function OnboardingPage({ loaderData, actionData }: Route.ComponentProps) {
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">
<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>
);
}

View file

@ -6,7 +6,7 @@ export const users = pgTable("users", {
clerkId: varchar("clerk_id", { length: 255 }).unique(), // legacy: only populated for users migrated from Clerk; new users are null
email: varchar("email", { length: 255 }).notNull(),
emailVerified: boolean("email_verified").notNull().default(false),
username: varchar("username", { length: 255 }),
username: varchar("username", { length: 255 }).unique(),
displayName: varchar("display_name", { length: 255 }), // written by BetterAuth on OAuth login ("name" field); not user-editable
imageUrl: varchar("image_url", { length: 512 }), // mapped as BetterAuth's "image" field
flagConfig: jsonb("flag_config").$type<{ pattern: string; colors: string[] }>(),

View file

@ -0,0 +1 @@
ALTER TABLE "users" ADD CONSTRAINT "users_username_unique" UNIQUE("username");

File diff suppressed because it is too large Load diff

View file

@ -743,6 +743,13 @@
"when": 1778862483243,
"tag": "0105_mighty_talisman",
"breakpoints": true
},
{
"idx": 106,
"version": "7",
"when": 1778867381171,
"tag": "0106_chilly_chimera",
"breakpoints": true
}
]
}