Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/navbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const unauthContext = {
displayName: null,
avatarUrl: null,
tosAcceptedAt: null,
hasUsablePassword: false,
consents: null,
login: () => {},
logout: async () => {},
Expand Down
7 changes: 7 additions & 0 deletions src/lib/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
Expand All @@ -46,6 +47,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [displayName, setDisplayName] = useState<string | null>(null)
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
const [tosAcceptedAt, setTosAcceptedAt] = useState<string | null>(null)
const [hasUsablePassword, setHasUsablePassword] = useState(false)
const [consents, setConsentsState] = useState<ConsentsResponse | null>(null)

const setConsents = useCallback((next: ConsentsResponse) => {
Expand All @@ -61,6 +63,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setDisplayName(null)
setTosAcceptedAt(null)
setAvatarUrl(null)
setHasUsablePassword(false)
setConsentsState(null)
clearCachedConsents()
resetAnalytics()
Expand Down Expand Up @@ -90,13 +93,15 @@ 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)
setUserId(user.id)
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) {
Expand Down Expand Up @@ -140,6 +145,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
displayName,
avatarUrl,
tosAcceptedAt,
hasUsablePassword,
consents,
login,
logout,
Expand All @@ -154,6 +160,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
displayName,
avatarUrl,
tosAcceptedAt,
hasUsablePassword,
consents,
login,
logout,
Expand Down
1 change: 1 addition & 0 deletions src/pages/accept-terms/accept-terms-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function makeRouterAuth(overrides: {
displayName: null,
avatarUrl: null,
tosAcceptedAt: overrides.tosAcceptedAt ?? null,
hasUsablePassword: true,
consents: null,
login: () => {},
logout: async () => {},
Expand Down
1 change: 1 addition & 0 deletions src/pages/callback/google-callback-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const unauthContext = {
displayName: null,
avatarUrl: null,
tosAcceptedAt: null,
hasUsablePassword: false,
consents: null,
login: () => {},
logout: async () => {},
Expand Down
1 change: 1 addition & 0 deletions src/pages/callback/steam-callback-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const unauthContext = {
displayName: null,
avatarUrl: null,
tosAcceptedAt: null,
hasUsablePassword: false,
consents: null,
login: () => {},
logout: async () => {},
Expand Down
43 changes: 43 additions & 0 deletions src/pages/forgot-password/forgot-password-page.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>("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<HTMLInputElement>("Email")
expect(input.value).toBe("player@example.com")
})
})
5 changes: 3 additions & 2 deletions src/pages/forgot-password/forgot-password-page.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)

Expand Down
1 change: 1 addition & 0 deletions src/pages/login/login-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const unauthContext = {
displayName: null,
avatarUrl: null,
tosAcceptedAt: null,
hasUsablePassword: false,
consents: null,
login: () => {},
logout: async () => {},
Expand Down
165 changes: 165 additions & 0 deletions src/pages/profile/profile-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () =>
Expand Down Expand Up @@ -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()
})
})
Loading
Loading