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:
parent
469cc82e15
commit
1a2421e134
8 changed files with 6249 additions and 1 deletions
|
|
@ -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>
|
||||
|
|
|
|||
10
app/root.tsx
10
app/root.tsx
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
95
app/routes/onboarding.tsx
Normal 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 3–30 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">
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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[] }>(),
|
||||
|
|
|
|||
1
drizzle/0106_chilly_chimera.sql
Normal file
1
drizzle/0106_chilly_chimera.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "users" ADD CONSTRAINT "users_username_unique" UNIQUE("username");
|
||||
6127
drizzle/meta/0106_snapshot.json
Normal file
6127
drizzle/meta/0106_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -743,6 +743,13 @@
|
|||
"when": 1778862483243,
|
||||
"tag": "0105_mighty_talisman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 106,
|
||||
"version": "7",
|
||||
"when": 1778867381171,
|
||||
"tag": "0106_chilly_chimera",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue