Skip to content

Commit 0d0cbab

Browse files
authored
feat(profile): add display name input wired to PATCH /auth/me (#37)
Closes #35. Email/password users had no way to set a display name — this exposes the existing backend field via a labeled input on the profile page. Submit trims the value and PATCHes /auth/me, then re-syncs auth state from /auth/me so the navbar updates without a reload. Empty input clears the name (matches backend normalisation).
1 parent d79e542 commit 0d0cbab

2 files changed

Lines changed: 283 additions & 3 deletions

File tree

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { screen, waitFor } from "@testing-library/react"
2+
import userEvent from "@testing-library/user-event"
3+
import { http, HttpResponse } from "msw"
4+
import { describe, expect, it, vi } from "vitest"
5+
import { renderWithFileRoutes } from "@/test/renderers"
6+
import { server } from "@/test/setup"
7+
8+
vi.mock("sonner", async (importOriginal) => {
9+
const actual = await importOriginal<typeof import("sonner")>()
10+
return {
11+
...actual,
12+
toast: {
13+
...actual.toast,
14+
success: vi.fn(),
15+
error: vi.fn(),
16+
},
17+
}
18+
})
19+
20+
import { toast } from "sonner"
21+
22+
type AuthMeBody = {
23+
id: string
24+
email: string
25+
display_name: string | null
26+
avatar_url: string | null
27+
tos_accepted_at: string | null
28+
}
29+
30+
const consentsHandler = http.get("*/user/consents", () =>
31+
HttpResponse.json({ current_policy_version: "test", consents: {} })
32+
)
33+
34+
function authMeHandler(initial: AuthMeBody) {
35+
let current = initial
36+
return {
37+
setBody(next: AuthMeBody) {
38+
current = next
39+
},
40+
handler: http.get("*/auth/me", () => HttpResponse.json(current)),
41+
}
42+
}
43+
44+
describe("ProfilePage display name", () => {
45+
it("pre-fills the input with the current display_name", async () => {
46+
const me = authMeHandler({
47+
id: "u-1",
48+
email: "player@example.com",
49+
display_name: "ZeroEmpires",
50+
avatar_url: null,
51+
tos_accepted_at: "2026-01-01T00:00:00Z",
52+
})
53+
server.use(me.handler, consentsHandler)
54+
55+
await renderWithFileRoutes(<></>, { initialLocation: "/profile" })
56+
57+
const input = await screen.findByLabelText<HTMLInputElement>("Display name")
58+
await waitFor(() => expect(input.value).toBe("ZeroEmpires"))
59+
})
60+
61+
it("renders an empty input when display_name is unset", async () => {
62+
const me = authMeHandler({
63+
id: "u-1",
64+
email: "player@example.com",
65+
display_name: null,
66+
avatar_url: null,
67+
tos_accepted_at: "2026-01-01T00:00:00Z",
68+
})
69+
server.use(me.handler, consentsHandler)
70+
71+
await renderWithFileRoutes(<></>, { initialLocation: "/profile" })
72+
73+
const input = await screen.findByLabelText<HTMLInputElement>("Display name")
74+
await waitFor(() => expect(input.value).toBe(""))
75+
})
76+
77+
it("PATCHes /auth/me with the trimmed value and toasts on success", async () => {
78+
const me = authMeHandler({
79+
id: "u-1",
80+
email: "player@example.com",
81+
display_name: null,
82+
avatar_url: null,
83+
tos_accepted_at: "2026-01-01T00:00:00Z",
84+
})
85+
let captured: { display_name?: unknown } | null = null
86+
server.use(
87+
me.handler,
88+
consentsHandler,
89+
http.patch("*/auth/me", async ({ request }) => {
90+
captured = (await request.json()) as { display_name?: unknown }
91+
me.setBody({
92+
id: "u-1",
93+
email: "player@example.com",
94+
display_name: "ZeroEmpires",
95+
avatar_url: null,
96+
tos_accepted_at: "2026-01-01T00:00:00Z",
97+
})
98+
return HttpResponse.json({ ok: true })
99+
})
100+
)
101+
102+
await renderWithFileRoutes(<></>, { initialLocation: "/profile" })
103+
104+
const user = userEvent.setup()
105+
const input = await screen.findByLabelText<HTMLInputElement>("Display name")
106+
await user.type(input, " ZeroEmpires ")
107+
await user.click(screen.getByRole("button", { name: /save display name/i }))
108+
109+
await waitFor(() => expect(captured).not.toBeNull())
110+
expect(captured).toEqual({ display_name: "ZeroEmpires" })
111+
await waitFor(() =>
112+
expect(toast.success).toHaveBeenCalledWith("Display name updated.")
113+
)
114+
})
115+
116+
it("sends an empty string when the user clears the field", async () => {
117+
const me = authMeHandler({
118+
id: "u-1",
119+
email: "player@example.com",
120+
display_name: "ZeroEmpires",
121+
avatar_url: null,
122+
tos_accepted_at: "2026-01-01T00:00:00Z",
123+
})
124+
let captured: { display_name?: unknown } | null = null
125+
server.use(
126+
me.handler,
127+
consentsHandler,
128+
http.patch("*/auth/me", async ({ request }) => {
129+
captured = (await request.json()) as { display_name?: unknown }
130+
me.setBody({
131+
id: "u-1",
132+
email: "player@example.com",
133+
display_name: null,
134+
avatar_url: null,
135+
tos_accepted_at: "2026-01-01T00:00:00Z",
136+
})
137+
return HttpResponse.json({ ok: true })
138+
})
139+
)
140+
141+
await renderWithFileRoutes(<></>, { initialLocation: "/profile" })
142+
143+
const user = userEvent.setup()
144+
const input = await screen.findByLabelText<HTMLInputElement>("Display name")
145+
await waitFor(() => expect(input.value).toBe("ZeroEmpires"))
146+
147+
await user.clear(input)
148+
await user.click(
149+
screen.getByRole("button", { name: /clear display name/i })
150+
)
151+
152+
await waitFor(() => expect(captured).not.toBeNull())
153+
expect(captured).toEqual({ display_name: "" })
154+
await waitFor(() =>
155+
expect(toast.success).toHaveBeenCalledWith("Display name cleared.")
156+
)
157+
})
158+
159+
it("surfaces backend 422 messages as an error toast", async () => {
160+
const me = authMeHandler({
161+
id: "u-1",
162+
email: "player@example.com",
163+
display_name: null,
164+
avatar_url: null,
165+
tos_accepted_at: "2026-01-01T00:00:00Z",
166+
})
167+
server.use(
168+
me.handler,
169+
consentsHandler,
170+
http.patch("*/auth/me", () =>
171+
HttpResponse.json(
172+
{ detail: "Display name is too long." },
173+
{ status: 422 }
174+
)
175+
)
176+
)
177+
178+
await renderWithFileRoutes(<></>, { initialLocation: "/profile" })
179+
180+
const user = userEvent.setup()
181+
const input = await screen.findByLabelText<HTMLInputElement>("Display name")
182+
await user.type(input, "anything")
183+
await user.click(screen.getByRole("button", { name: /save display name/i }))
184+
185+
await waitFor(() =>
186+
expect(toast.error).toHaveBeenCalledWith("Display name is too long.")
187+
)
188+
})
189+
190+
it("disables the save button when the trimmed value equals the current name", async () => {
191+
const me = authMeHandler({
192+
id: "u-1",
193+
email: "player@example.com",
194+
display_name: "ZeroEmpires",
195+
avatar_url: null,
196+
tos_accepted_at: "2026-01-01T00:00:00Z",
197+
})
198+
server.use(me.handler, consentsHandler)
199+
200+
await renderWithFileRoutes(<></>, { initialLocation: "/profile" })
201+
202+
const input = await screen.findByLabelText<HTMLInputElement>("Display name")
203+
await waitFor(() => expect(input.value).toBe("ZeroEmpires"))
204+
205+
const button = screen.getByRole("button", { name: /save display name/i })
206+
expect(button).toBeDisabled()
207+
208+
const user = userEvent.setup()
209+
await user.type(input, " ")
210+
expect(button).toBeDisabled()
211+
})
212+
})

src/pages/profile/profile-page.tsx

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const CONSENT_COPY: Record<ConsentType, ConsentToggleCopy> = {
3636
},
3737
}
3838

39+
const DISPLAY_NAME_MAX_LENGTH = 100
40+
3941
export function ProfilePage() {
4042
const auth = useAuth()
4143
const search = ProfileRoute.useSearch()
@@ -45,10 +47,20 @@ export function ProfilePage() {
4547
const [isDeleting, setIsDeleting] = useState(false)
4648
const [savingConsentType, setSavingConsentType] =
4749
useState<ConsentType | null>(null)
50+
const [displayName, setDisplayName] = useState(auth.displayName ?? "")
51+
const [isSavingDisplayName, setIsSavingDisplayName] = useState(false)
52+
53+
useEffect(() => {
54+
setDisplayName(auth.displayName ?? "")
55+
}, [auth.displayName])
4856

4957
const stale = hasStaleConsent(auth.consents)
5058
const showStaleBanner = stale || search.reason === "consent-stale"
5159

60+
const trimmedDisplayName = displayName.trim()
61+
const currentDisplayName = auth.displayName ?? ""
62+
const displayNameChanged = trimmedDisplayName !== currentDisplayName
63+
5264
useEffect(() => {
5365
if (showStaleBanner && privacySectionRef.current) {
5466
privacySectionRef.current.scrollIntoView({
@@ -58,6 +70,28 @@ export function ProfilePage() {
5870
}
5971
}, [showStaleBanner])
6072

73+
async function handleDisplayNameSubmit(e: React.FormEvent) {
74+
e.preventDefault()
75+
if (!displayNameChanged || isSavingDisplayName) return
76+
77+
setIsSavingDisplayName(true)
78+
try {
79+
await api.patch("auth/me", { json: { display_name: trimmedDisplayName } })
80+
await auth.checkAuth()
81+
toast.success(
82+
trimmedDisplayName ? "Display name updated." : "Display name cleared."
83+
)
84+
} catch (error) {
85+
const message = await getErrorMessage(
86+
error,
87+
"Failed to update display name"
88+
)
89+
toast.error(message)
90+
} finally {
91+
setIsSavingDisplayName(false)
92+
}
93+
}
94+
6195
async function handleDelete(e: React.FormEvent) {
6296
e.preventDefault()
6397
if (confirmEmail !== "DELETE") return
@@ -113,9 +147,43 @@ export function ProfilePage() {
113147
</CardTitle>
114148
</CardHeader>
115149
<CardContent className="grid gap-6">
116-
<div className="grid gap-1 text-sm">
117-
<span className="text-muted-foreground">Email</span>
118-
<span>{auth.email}</span>
150+
<div className="grid gap-4">
151+
<div className="grid gap-1 text-sm">
152+
<span className="text-muted-foreground">Email</span>
153+
<span>{auth.email}</span>
154+
</div>
155+
<form
156+
onSubmit={handleDisplayNameSubmit}
157+
className="grid gap-2"
158+
data-testid="display-name-form"
159+
>
160+
<Label htmlFor="display-name" className="text-muted-foreground">
161+
Display name
162+
</Label>
163+
<Input
164+
id="display-name"
165+
type="text"
166+
value={displayName}
167+
onChange={(e) => setDisplayName(e.target.value)}
168+
maxLength={DISPLAY_NAME_MAX_LENGTH}
169+
placeholder="How you'll appear across criticalbit.gg"
170+
autoComplete="nickname"
171+
disabled={isSavingDisplayName}
172+
/>
173+
<Button
174+
type="submit"
175+
size="sm"
176+
className="w-full"
177+
disabled={!displayNameChanged || isSavingDisplayName}
178+
>
179+
{trimmedDisplayName || !currentDisplayName
180+
? "Save display name"
181+
: "Clear display name"}
182+
{isSavingDisplayName && (
183+
<LoaderCircle className="animate-spin" />
184+
)}
185+
</Button>
186+
</form>
119187
</div>
120188

121189
<div

0 commit comments

Comments
 (0)