From 6b2898c3711b21aa050780e30c4eab4d2edebba5 Mon Sep 17 00:00:00 2001 From: Amr Gaber Date: Tue, 26 May 2026 17:02:08 -0500 Subject: [PATCH] feat(connections): disconnect, stranding guard, and Set-a-password CTA PR-B of two for #39. Builds on the Connect flow from #41 to close out the issue: removal of links, the safety rule, and the OAuth-first password on-ramp. - Plumb has_usable_password from /auth/me into the auth context. Default to false on missing/stale responses so the UI stays conservative. - Disconnect button per linked provider. Disabled with an inline hint ("You'd have no way to sign in. Set a password first or link another provider.") when removing it would strand the user. Mirrors the server-side rule so users don't have to click to learn it's blocked: canUnlink = otherConnections.length > 0 || (hasUsablePassword && email) - DELETE /auth/me/connections/{provider} on click. 204 refreshes the list + auth state and toasts success. The 409 unlink_would_strand_user response renders the message + remediation list inline; remediation strings matching /set a password/ get a "Start now" Link pointing at /forgot-password?email=. The 404 connection_not_found response refreshes the list and shows a generic toast. - "Set a password" CTA on /profile when has_usable_password=false, linking to /forgot-password?email=. The forgot-password route now reads ?email= via validateSearch and prefills the input; the authenticated-user guard relaxes when ?email= is present so the CTA works without forcing a logout. Closes the frontend portion of #39 once both PRs are on main. --- src/components/navbar.test.tsx | 1 + src/lib/auth.tsx | 7 + .../accept-terms/accept-terms-page.test.tsx | 1 + .../callback/google-callback-page.test.tsx | 1 + .../callback/steam-callback-page.test.tsx | 1 + .../forgot-password-page.test.tsx | 43 +++ .../forgot-password/forgot-password-page.tsx | 5 +- src/pages/login/login-page.test.tsx | 1 + src/pages/profile/profile-page.test.tsx | 165 +++++++++++ src/pages/profile/profile-page.tsx | 259 ++++++++++++++---- .../verify-email/verify-email-page.test.tsx | 1 + src/routes/__root.test.tsx | 1 + src/routes/forgot-password.tsx | 15 +- src/test/renderers.tsx | 2 + 14 files changed, 444 insertions(+), 59 deletions(-) create mode 100644 src/pages/forgot-password/forgot-password-page.test.tsx diff --git a/src/components/navbar.test.tsx b/src/components/navbar.test.tsx index e0df1ee..7c6aa9a 100644 --- a/src/components/navbar.test.tsx +++ b/src/components/navbar.test.tsx @@ -24,6 +24,7 @@ const unauthContext = { displayName: null, avatarUrl: null, tosAcceptedAt: null, + hasUsablePassword: false, consents: null, login: () => {}, logout: async () => {}, diff --git a/src/lib/auth.tsx b/src/lib/auth.tsx index fb7a700..36a498c 100644 --- a/src/lib/auth.tsx +++ b/src/lib/auth.tsx @@ -26,6 +26,7 @@ interface AuthContextValue { displayName: string | null avatarUrl: string | null tosAcceptedAt: string | null + hasUsablePassword: boolean consents: ConsentsResponse | null login: (email: string) => void logout: () => Promise @@ -46,6 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [displayName, setDisplayName] = useState(null) const [avatarUrl, setAvatarUrl] = useState(null) const [tosAcceptedAt, setTosAcceptedAt] = useState(null) + const [hasUsablePassword, setHasUsablePassword] = useState(false) const [consents, setConsentsState] = useState(null) const setConsents = useCallback((next: ConsentsResponse) => { @@ -61,6 +63,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setDisplayName(null) setTosAcceptedAt(null) setAvatarUrl(null) + setHasUsablePassword(false) setConsentsState(null) clearCachedConsents() resetAnalytics() @@ -90,6 +93,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { display_name: string | null avatar_url: string | null tos_accepted_at: string | null + has_usable_password?: boolean }>() setIsAuthenticated(true) setEmail(user.email) @@ -97,6 +101,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setDisplayName(user.display_name) setAvatarUrl(user.avatar_url) setTosAcceptedAt(user.tos_accepted_at) + setHasUsablePassword(user.has_usable_password ?? false) // Steam OAuth users start with no email until they pass /accept-terms; // don't cache "null" as a string and don't leave a stale value behind. if (user.email) { @@ -140,6 +145,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { displayName, avatarUrl, tosAcceptedAt, + hasUsablePassword, consents, login, logout, @@ -154,6 +160,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { displayName, avatarUrl, tosAcceptedAt, + hasUsablePassword, consents, login, logout, diff --git a/src/pages/accept-terms/accept-terms-page.test.tsx b/src/pages/accept-terms/accept-terms-page.test.tsx index af9d3e3..f1ec5db 100644 --- a/src/pages/accept-terms/accept-terms-page.test.tsx +++ b/src/pages/accept-terms/accept-terms-page.test.tsx @@ -48,6 +48,7 @@ function makeRouterAuth(overrides: { displayName: null, avatarUrl: null, tosAcceptedAt: overrides.tosAcceptedAt ?? null, + hasUsablePassword: true, consents: null, login: () => {}, logout: async () => {}, diff --git a/src/pages/callback/google-callback-page.test.tsx b/src/pages/callback/google-callback-page.test.tsx index bf894e4..a3c3f0c 100644 --- a/src/pages/callback/google-callback-page.test.tsx +++ b/src/pages/callback/google-callback-page.test.tsx @@ -13,6 +13,7 @@ const unauthContext = { displayName: null, avatarUrl: null, tosAcceptedAt: null, + hasUsablePassword: false, consents: null, login: () => {}, logout: async () => {}, diff --git a/src/pages/callback/steam-callback-page.test.tsx b/src/pages/callback/steam-callback-page.test.tsx index 3a21711..a3e2b26 100644 --- a/src/pages/callback/steam-callback-page.test.tsx +++ b/src/pages/callback/steam-callback-page.test.tsx @@ -13,6 +13,7 @@ const unauthContext = { displayName: null, avatarUrl: null, tosAcceptedAt: null, + hasUsablePassword: false, consents: null, login: () => {}, logout: async () => {}, diff --git a/src/pages/forgot-password/forgot-password-page.test.tsx b/src/pages/forgot-password/forgot-password-page.test.tsx new file mode 100644 index 0000000..89ae0cf --- /dev/null +++ b/src/pages/forgot-password/forgot-password-page.test.tsx @@ -0,0 +1,43 @@ +import { screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import { renderWithFileRoutes } from "@/test/renderers" + +const unauthContext = { + auth: { + isAuthenticated: false, + isLoading: false, + email: null, + userId: null, + displayName: null, + avatarUrl: null, + tosAcceptedAt: null, + hasUsablePassword: false, + consents: null, + login: () => {}, + logout: async () => {}, + checkAuth: async () => {}, + setConsents: () => {}, + }, +} + +describe("ForgotPasswordPage", () => { + it("starts with an empty email when no ?email= is supplied", async () => { + await renderWithFileRoutes(<>, { + initialLocation: "/forgot-password", + routerContext: unauthContext, + }) + + const input = await screen.findByLabelText("Email") + expect(input.value).toBe("") + }) + + it("prefills email from ?email= for the Set-a-password flow", async () => { + await renderWithFileRoutes(<>, { + initialLocation: "/forgot-password?email=player%40example.com", + routerContext: unauthContext, + }) + + const input = await screen.findByLabelText("Email") + expect(input.value).toBe("player@example.com") + }) +}) diff --git a/src/pages/forgot-password/forgot-password-page.tsx b/src/pages/forgot-password/forgot-password-page.tsx index a7c7fde..d6f55d8 100644 --- a/src/pages/forgot-password/forgot-password-page.tsx +++ b/src/pages/forgot-password/forgot-password-page.tsx @@ -1,5 +1,5 @@ import { useState } from "react" -import { Link } from "@tanstack/react-router" +import { Link, useSearch } from "@tanstack/react-router" import { LoaderCircle } from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -17,7 +17,8 @@ import { api } from "@/api/api" import { getErrorMessage } from "@/lib/api-errors" export function ForgotPasswordPage() { - const [email, setEmail] = useState("") + const search = useSearch({ from: "/forgot-password" }) + const [email, setEmail] = useState((search as { email?: string }).email ?? "") const [isSubmitting, setIsSubmitting] = useState(false) const [submitted, setSubmitted] = useState(false) diff --git a/src/pages/login/login-page.test.tsx b/src/pages/login/login-page.test.tsx index 49ce0bd..5050ee2 100644 --- a/src/pages/login/login-page.test.tsx +++ b/src/pages/login/login-page.test.tsx @@ -11,6 +11,7 @@ const unauthContext = { displayName: null, avatarUrl: null, tosAcceptedAt: null, + hasUsablePassword: false, consents: null, login: () => {}, logout: async () => {}, diff --git a/src/pages/profile/profile-page.test.tsx b/src/pages/profile/profile-page.test.tsx index 6c38828..62f63de 100644 --- a/src/pages/profile/profile-page.test.tsx +++ b/src/pages/profile/profile-page.test.tsx @@ -25,6 +25,7 @@ type AuthMeBody = { display_name: string | null avatar_url: string | null tos_accepted_at: string | null + has_usable_password?: boolean } const consentsHandler = http.get("*/user/consents", () => @@ -298,4 +299,168 @@ describe("ProfilePage connected accounts", () => { expect(toast.success).toHaveBeenCalledWith("Linked your Google account.") ) }) + + it("disables Disconnect with a helper hint when removing it would strand the user", async () => { + const me = authMeHandler({ + id: "u-1", + email: "player@example.com", + display_name: null, + avatar_url: null, + tos_accepted_at: "2026-01-01T00:00:00Z", + has_usable_password: false, + }) + server.use( + me.handler, + consentsHandler, + http.get("*/auth/me/connections", () => + HttpResponse.json([ + { + provider: "google", + account_id: "g-1", + account_email: "player@gmail.com", + }, + ]) + ) + ) + + await renderWithFileRoutes(<>, { initialLocation: "/profile" }) + + const disconnect = await screen.findByRole("button", { + name: /disconnect/i, + }) + expect(disconnect).toBeDisabled() + expect(screen.getByText(/no way to sign in/i)).toBeInTheDocument() + }) + + it("DELETEs the connection on click and refreshes the list", async () => { + const me = authMeHandler({ + id: "u-1", + email: "player@example.com", + display_name: null, + avatar_url: null, + tos_accepted_at: "2026-01-01T00:00:00Z", + has_usable_password: true, + }) + + const linkedOnce = [ + { + provider: "google", + account_id: "g-1", + account_email: "player@gmail.com", + }, + ] + let connectionsState = linkedOnce + let deleteHit = false + + server.use( + me.handler, + consentsHandler, + http.get("*/auth/me/connections", () => + HttpResponse.json(connectionsState) + ), + http.delete("*/auth/me/connections/google", () => { + deleteHit = true + connectionsState = [] + return new HttpResponse(null, { status: 204 }) + }) + ) + + await renderWithFileRoutes(<>, { initialLocation: "/profile" }) + + const disconnect = await screen.findByRole("button", { + name: /disconnect/i, + }) + expect(disconnect).not.toBeDisabled() + + const user = userEvent.setup() + await user.click(disconnect) + + await waitFor(() => expect(deleteHit).toBe(true)) + await waitFor(() => + expect(toast.success).toHaveBeenCalledWith("Disconnected Google.") + ) + }) + + it("renders the remediation list when the server returns unlink_would_strand_user", async () => { + const me = authMeHandler({ + id: "u-1", + email: "player@example.com", + display_name: null, + avatar_url: null, + tos_accepted_at: "2026-01-01T00:00:00Z", + has_usable_password: true, + }) + server.use( + me.handler, + consentsHandler, + http.get("*/auth/me/connections", () => + HttpResponse.json([ + { + provider: "google", + account_id: "g-1", + account_email: "player@gmail.com", + }, + ]) + ), + http.delete("*/auth/me/connections/google", () => + HttpResponse.json( + { + detail: { + code: "unlink_would_strand_user", + message: + "Disconnecting this account would leave you with no way to sign in.", + provider: "google", + remediation: [ + "set a password via /auth/forgot-password first", + "link another provider that provides an email", + ], + }, + }, + { status: 409 } + ) + ) + ) + + await renderWithFileRoutes(<>, { initialLocation: "/profile" }) + + const disconnect = await screen.findByRole("button", { + name: /disconnect/i, + }) + const user = userEvent.setup() + await user.click(disconnect) + + expect(await screen.findByTestId("stranding-issue")).toBeInTheDocument() + expect(screen.getByText(/set a password/i)).toBeInTheDocument() + expect(screen.getByRole("link", { name: /start now/i })).toBeInTheDocument() + }) + + it("renders the set-a-password CTA when the user has no usable password", async () => { + const me = authMeHandler({ + id: "u-1", + email: "player@example.com", + display_name: null, + avatar_url: null, + tos_accepted_at: "2026-01-01T00:00:00Z", + has_usable_password: false, + }) + server.use( + me.handler, + consentsHandler, + http.get("*/auth/me/connections", () => + HttpResponse.json([ + { + provider: "google", + account_id: "g-1", + account_email: "player@gmail.com", + }, + ]) + ) + ) + + await renderWithFileRoutes(<>, { initialLocation: "/profile" }) + + expect( + await screen.findByRole("link", { name: /set a password/i }) + ).toBeInTheDocument() + }) }) diff --git a/src/pages/profile/profile-page.tsx b/src/pages/profile/profile-page.tsx index c2ee1bb..973e7c9 100644 --- a/src/pages/profile/profile-page.tsx +++ b/src/pages/profile/profile-page.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from "react" -import { useNavigate } from "@tanstack/react-router" +import { useCallback, useEffect, useRef, useState } from "react" +import { Link, useNavigate } from "@tanstack/react-router" import { LoaderCircle } from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -27,6 +27,17 @@ const PROVIDERS: { id: ProviderId; label: string }[] = [ { id: "steam", label: "Steam" }, ] +async function readErrorDetail(error: unknown): Promise { + const response = (error as { response?: Response })?.response + if (!response) return null + try { + const body = await response.clone().json() + return body?.detail ?? null + } catch { + return null + } +} + interface ConsentToggleCopy { label: string helper: string @@ -64,29 +75,34 @@ export function ProfilePage() { ) const [connectingProvider, setConnectingProvider] = useState(null) + const [disconnectingProvider, setDisconnectingProvider] = + useState(null) + const [strandingIssue, setStrandingIssue] = useState<{ + message: string + remediation: string[] + } | null>(null) useEffect(() => { setDisplayName(auth.displayName ?? "") }, [auth.displayName]) - useEffect(() => { - let cancelled = false - api - .get("auth/me/connections") - .json() - .then((list) => { - if (!cancelled) setConnections(list) - }) - .catch(() => { - // Show an empty list rather than blocking the rest of the page; the - // user can refresh to retry. Avoids a noisy toast on transient errors. - if (!cancelled) setConnections([]) - }) - return () => { - cancelled = true + const refreshConnections = useCallback(async () => { + try { + const list = await api + .get("auth/me/connections") + .json() + setConnections(list) + } catch { + // Show an empty list rather than blocking the rest of the page; the + // user can refresh to retry. Avoids a noisy toast on transient errors. + setConnections([]) } }, []) + useEffect(() => { + void refreshConnections() + }, [refreshConnections]) + useEffect(() => { if (!search.linked) return const label = search.linked === "google" ? "Google" : "Steam" @@ -172,6 +188,59 @@ export function ProfilePage() { } } + async function handleDisconnect(provider: ProviderId) { + setDisconnectingProvider(provider) + setStrandingIssue(null) + try { + await api.delete(`auth/me/connections/${provider}`) + await refreshConnections() + // /auth/me changes too (has_usable_password unaffected, but cookies may + // refresh on the same response). Re-check so the rest of the page is + // accurate. + await auth.checkAuth() + const label = PROVIDERS.find((p) => p.id === provider)?.label ?? provider + toast.success(`Disconnected ${label}.`) + } catch (error) { + const detail = await readErrorDetail(error) + if ( + detail && + typeof detail === "object" && + (detail as { code?: unknown }).code === "unlink_would_strand_user" + ) { + const d = detail as { + message?: string + remediation?: unknown + } + const remediation = Array.isArray(d.remediation) + ? (d.remediation.filter((r) => typeof r === "string") as string[]) + : [] + setStrandingIssue({ + message: + d.message ?? + "Disconnecting this account would leave you with no way to sign in.", + remediation, + }) + // Server saw a different reality than we did; refresh so the UI + // reflects the truth. + await refreshConnections() + await auth.checkAuth() + } else if ( + detail && + typeof detail === "object" && + (detail as { code?: unknown }).code === "connection_not_found" + ) { + // Stale UI — the connection is already gone. Resync and tell the user. + await refreshConnections() + toast.info("That connection is already disconnected.") + } else { + const message = await getErrorMessage(error, "Failed to disconnect") + toast.error(message) + } + } finally { + setDisconnectingProvider(null) + } + } + async function handleConsentChange(type: ConsentType, consented: boolean) { setSavingConsentType(type) try { @@ -323,51 +392,131 @@ export function ProfilePage() { Loading…

) : ( - PROVIDERS.map(({ id, label }) => { - const linked = connections.find((c) => c.provider === id) - const isConnecting = connectingProvider === id - return ( -
-
- {label} + <> + {PROVIDERS.map(({ id, label }) => { + const linked = connections.find((c) => c.provider === id) + const isConnecting = connectingProvider === id + const isDisconnecting = disconnectingProvider === id + // Stranding guard: removing this connection is only safe if + // either another connection remains, or the user has a + // password they can fall back to. Mirrors the server-side + // rule so the user doesn't have to click Disconnect to learn + // it's blocked. + const otherConnections = connections.filter( + (c) => c.provider !== id + ) + const hasPasswordFallback = + auth.hasUsablePassword && !!auth.email + const canUnlink = + !!linked && + (otherConnections.length > 0 || hasPasswordFallback) + return ( +
+
+ {label} + {linked ? ( + + Connected + {(linked.account_email ?? linked.account_id) && ( + <> + {" as "} + + {linked.account_email ?? linked.account_id} + + + )} + + ) : ( + + Not connected + + )} + {linked && !canUnlink && ( + + You'd have no way to sign in. Set a password first + or link another provider. + + )} +
{linked ? ( - - Connected - {(linked.account_email ?? linked.account_id) && ( - <> - {" as "} - - {linked.account_email ?? linked.account_id} - - + ) : ( - - Not connected - + )}
- {!linked && ( - + ) + })} + {strandingIssue && ( +
+

+ {strandingIssue.message} +

+ {strandingIssue.remediation.length > 0 && ( +
    + {strandingIssue.remediation.map((hint, idx) => ( +
  • + {/set a password/i.test(hint) && auth.email ? ( + <> + {hint}{" "} + + Start now. + + + ) : ( + hint + )} +
  • + ))} +
)}
- ) - }) + )} + {!auth.hasUsablePassword && auth.email && ( +

+ You don't have a password set.{" "} + + Set a password + {" "} + to also sign in with your email. +

+ )} + )}
diff --git a/src/pages/verify-email/verify-email-page.test.tsx b/src/pages/verify-email/verify-email-page.test.tsx index 8f5f67f..7542e65 100644 --- a/src/pages/verify-email/verify-email-page.test.tsx +++ b/src/pages/verify-email/verify-email-page.test.tsx @@ -13,6 +13,7 @@ const unauthContext = { displayName: null, avatarUrl: null, tosAcceptedAt: null, + hasUsablePassword: false, consents: null, login: () => {}, logout: async () => {}, diff --git a/src/routes/__root.test.tsx b/src/routes/__root.test.tsx index 8378eff..f01fe93 100644 --- a/src/routes/__root.test.tsx +++ b/src/routes/__root.test.tsx @@ -20,6 +20,7 @@ function makeAuthContext(overrides: { displayName: "ZeroEmpires", avatarUrl: null, tosAcceptedAt: overrides.tosAcceptedAt ?? null, + hasUsablePassword: true, consents: null, login: () => {}, logout: async () => {}, diff --git a/src/routes/forgot-password.tsx b/src/routes/forgot-password.tsx index 77f8098..18bf7bf 100644 --- a/src/routes/forgot-password.tsx +++ b/src/routes/forgot-password.tsx @@ -1,9 +1,20 @@ import { createFileRoute, redirect } from "@tanstack/react-router" import { ForgotPasswordPage } from "@/pages/forgot-password/forgot-password-page" +type ForgotPasswordSearch = { + email?: string +} + export const Route = createFileRoute("/forgot-password")({ - beforeLoad: ({ context }) => { - if (context.auth.isAuthenticated) { + validateSearch: (search: Record): ForgotPasswordSearch => + typeof search.email === "string" && search.email + ? { email: search.email } + : {}, + beforeLoad: ({ context, search }) => { + // Authenticated users coming from the "Set a password" CTA need this page + // to mint a token — don't bounce them when they explicitly opted in with + // ?email=...; otherwise keep the existing "you're already signed in" guard. + if (context.auth.isAuthenticated && !search.email) { throw redirect({ to: "/profile" }) } }, diff --git a/src/test/renderers.tsx b/src/test/renderers.tsx index 773f1f1..6188cb3 100644 --- a/src/test/renderers.tsx +++ b/src/test/renderers.tsx @@ -25,6 +25,7 @@ interface RenderWithFileRoutesOptions extends Omit { displayName: string | null avatarUrl: string | null tosAcceptedAt: string | null + hasUsablePassword: boolean consents: ConsentsResponse | null login: (email: string) => void logout: () => Promise @@ -42,6 +43,7 @@ const defaultAuth = { displayName: null, avatarUrl: null, tosAcceptedAt: null, + hasUsablePassword: true, consents: null, login: () => {}, logout: async () => {},