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}
-
- >
+ handleDisconnect(id)}
+ disabled={!canUnlink || isDisconnecting}
+ >
+ Disconnect
+ {isDisconnecting && (
+
)}
-
+
) : (
-
- Not connected
-
+
handleConnect(id)}
+ disabled={isConnecting}
+ >
+ Connect
+ {isConnecting && (
+
+ )}
+
)}
- {!linked && (
-
handleConnect(id)}
- disabled={isConnecting}
- >
- Connect
- {isConnecting && (
-
- )}
-
+ )
+ })}
+ {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 () => {},