Skip to content

Commit a9ee0ae

Browse files
sineldclaude
andcommitted
feat(account): add profile, security and two-factor login UI
The account menu previously opened on an avatar with no name and hid password management from administrators, because the backend rejected the call it made. Both are fixed: the menu now names the signed-in identity and its authority, and links to Profile and Security. Adds `/account` and `/account/security`, dialogs for password change, two-factor setup, disable and recovery-code regeneration, and the second-factor step in the login flow. Design notes: * Setup is one dialog with internal steps, not a chain of dialogs: it is one decision, and a parent closing underneath the recovery codes would orphan the only copy that will ever exist. * Recovery codes cannot be dismissed until copied or downloaded. The server keeps only hashes and cannot show them again. * Turning the factor off asks for the code *and* the password. A code alone would let a session someone walked away from strip the protection. * Controls are gated on what the server reports as possible, and an unavailable feature explains itself instead of showing a dead button — a root identity says its credentials come from the server environment. * A failed read never renders as an empty profile; passive metadata is a definition list rather than a grid of cards. The long-term credentials needed for the second call stay in component state; only the resulting STS session is persisted, and no TOTP secret reaches browser storage. Replaces `components/user/change-password.tsx`, which called `add-user` and so rewrote the target's status and dropped its policy field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgvAaMmAmq9Sg5BrSc8T5d
1 parent f9b1c8e commit a9ee0ae

29 files changed

Lines changed: 2952 additions & 414 deletions

‎app/(auth)/auth/login/page.tsx‎

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useEffect, Suspense, useState } from "react"
44
import { useSearchParams } from "next/navigation"
55
import { useRouter } from "next/navigation"
66
import { useTranslation } from "react-i18next"
7-
import { LoginForm, type LoginMethod } from "@/components/auth/login-form"
7+
import { LoginForm, type LoginMethod, type SecondFactorStep } from "@/components/auth/login-form"
88
import { AppLoadingShell } from "@/components/app-loading-shell"
99
import { useAuth } from "@/contexts/auth-context"
1010
import { useMessage } from "@/lib/feedback/message"
@@ -24,7 +24,7 @@ function LoginPageContent() {
2424
const router = useRouter()
2525
const searchParams = useSearchParams()
2626
const message = useMessage()
27-
const { login, isAuthenticated } = useAuth()
27+
const { login, completeLoginWithSecondFactor, isAuthenticated } = useAuth()
2828
const { t } = useTranslation()
2929

3030
const [method, setMethod] = useState<LoginMethod>("accessKeyAndSecretKey")
@@ -38,6 +38,12 @@ function LoginPageContent() {
3838
sessionToken: "",
3939
})
4040
const [oidcProviders, setOidcProviders] = useState<OidcProvider[]>([])
41+
// The pending second-factor exchange. The long-term credentials it needs stay
42+
// in this component's state for the duration and are never persisted.
43+
const [pendingMfa, setPendingMfa] = useState<{ challenge?: string } | null>(null)
44+
const [mfaCode, setMfaCode] = useState("")
45+
const [mfaError, setMfaError] = useState("")
46+
const [mfaSubmitting, setMfaSubmitting] = useState(false)
4147

4248
useEffect(() => {
4349
if (!isAuthenticated) return
@@ -92,14 +98,67 @@ function LoginPageContent() {
9298

9399
try {
94100
const currentConfig = await configManager.loadConfig()
95-
await login(credentials, currentConfig)
101+
const outcome = await login(credentials, currentConfig)
102+
103+
if (outcome.status === "mfa-required") {
104+
// Not a failure: the password was accepted and the account simply has a
105+
// second factor. Saying "login failed" here would send the user to reset
106+
// a password that is working.
107+
setPendingMfa({ challenge: outcome.challenge })
108+
setMfaCode("")
109+
setMfaError("")
110+
return
111+
}
96112

97113
message.success(t("Login Success"))
98114
} catch {
99115
message.error(t("Login Failed"))
100116
}
101117
}
102118

119+
const handleSecondFactor = async (e: React.FormEvent) => {
120+
e.preventDefault()
121+
if (!pendingMfa || mfaSubmitting) return
122+
123+
setMfaSubmitting(true)
124+
setMfaError("")
125+
try {
126+
const currentConfig = await configManager.loadConfig()
127+
await completeLoginWithSecondFactor(
128+
method === "accessKeyAndSecretKey" ? accessKeyAndSecretKey : sts,
129+
{ code: mfaCode, challenge: pendingMfa.challenge },
130+
currentConfig,
131+
)
132+
message.success(t("Login Success"))
133+
} catch (error) {
134+
// Inline, next to the input: a toast alone leaves no durable explanation
135+
// of why the code was refused.
136+
setMfaError((error as Error)?.message || t("Invalid verification code"))
137+
setMfaCode("")
138+
} finally {
139+
setMfaSubmitting(false)
140+
}
141+
}
142+
143+
const secondFactor: SecondFactorStep | undefined = pendingMfa
144+
? {
145+
code: mfaCode,
146+
setCode: (value) => {
147+
setMfaCode(value)
148+
setMfaError("")
149+
},
150+
error: mfaError,
151+
submitting: mfaSubmitting,
152+
onSubmit: handleSecondFactor,
153+
onCancel: () => {
154+
setPendingMfa(null)
155+
setMfaCode("")
156+
setMfaError("")
157+
},
158+
accountName: method === "accessKeyAndSecretKey" ? accessKeyAndSecretKey.accessKeyId : sts.accessKeyId,
159+
}
160+
: undefined
161+
103162
const handleOidcLogin = async (providerId: string) => {
104163
const config = await configManager.loadConfig()
105164
initiateOidcLogin(config.serverHost, providerId)
@@ -116,6 +175,7 @@ function LoginPageContent() {
116175
handleLogin={handleLogin}
117176
oidcProviders={oidcProviders}
118177
onOidcLogin={handleOidcLogin}
178+
secondFactor={secondFactor}
119179
/>
120180
)
121181
}

‎app/(dashboard)/account/page.tsx‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"use client"
2+
3+
import { useCallback, useEffect, useState } from "react"
4+
import Link from "next/link"
5+
import { useTranslation } from "react-i18next"
6+
import { RiRefreshLine, RiShieldKeyholeLine } from "@remixicon/react"
7+
import { Alert, AlertDescription } from "@/components/ui/alert"
8+
import { Badge } from "@/components/ui/badge"
9+
import { Button } from "@/components/ui/button"
10+
import { Skeleton } from "@/components/ui/skeleton"
11+
import { Page } from "@/components/page"
12+
import { PageHeader } from "@/components/page-header"
13+
import { useAccount, type AccountInfo } from "@/hooks/use-account"
14+
import { buildRoute } from "@/lib/routes"
15+
16+
export default function AccountPage() {
17+
const { t } = useTranslation()
18+
const { getAccountInfo } = useAccount()
19+
20+
const [info, setInfo] = useState<AccountInfo | null>(null)
21+
const [loading, setLoading] = useState(true)
22+
const [loadError, setLoadError] = useState<string | null>(null)
23+
24+
const load = useCallback(async () => {
25+
setLoading(true)
26+
setLoadError(null)
27+
try {
28+
const result = await getAccountInfo()
29+
if (!result) {
30+
setLoadError(t("API not ready"))
31+
return
32+
}
33+
setInfo(result)
34+
} catch (error) {
35+
// A failed read must never render as an empty profile: the previous value
36+
// stays on screen (if any) and the failure is stated explicitly.
37+
setLoadError((error as Error)?.message || t("Failed to get data"))
38+
} finally {
39+
setLoading(false)
40+
}
41+
}, [getAccountInfo, t])
42+
43+
useEffect(() => {
44+
void load()
45+
}, [load])
46+
47+
const identityTypeLabel = (value: AccountInfo["identity_type"]) => {
48+
switch (value) {
49+
case "root":
50+
return t("Root credential")
51+
case "iam":
52+
return t("IAM user")
53+
case "sts":
54+
return t("Temporary session")
55+
case "service-account":
56+
return t("Service account")
57+
}
58+
}
59+
60+
return (
61+
<Page>
62+
<PageHeader
63+
description={t("The identity you are signed in as.")}
64+
actions={
65+
<>
66+
<Button variant="outline" onClick={() => void load()} disabled={loading}>
67+
<RiRefreshLine className="size-4" aria-hidden />
68+
<span>{t("Refresh")}</span>
69+
</Button>
70+
<Button
71+
render={
72+
<Link href={buildRoute("/account/security")}>
73+
<RiShieldKeyholeLine className="size-4" aria-hidden />
74+
<span>{t("Security")}</span>
75+
</Link>
76+
}
77+
/>
78+
</>
79+
}
80+
>
81+
<h1>{t("Profile")}</h1>
82+
</PageHeader>
83+
84+
{loadError && (
85+
<Alert variant="destructive">
86+
<AlertDescription className="flex flex-wrap items-center justify-between gap-3">
87+
<span>{loadError}</span>
88+
<Button variant="outline" size="sm" onClick={() => void load()}>
89+
{t("Retry")}
90+
</Button>
91+
</AlertDescription>
92+
</Alert>
93+
)}
94+
95+
{loading && !info ? (
96+
<div className="space-y-3" aria-live="polite">
97+
<Skeleton className="h-5 w-40" />
98+
<Skeleton className="h-5 w-64" />
99+
<Skeleton className="h-5 w-52" />
100+
</div>
101+
) : info ? (
102+
<>
103+
{/* Passive metadata as a definition list, not a grid of cards: these
104+
are facts to read, not objects to select. */}
105+
<dl className="grid gap-x-8 gap-y-4 sm:grid-cols-[max-content_1fr]">
106+
<dt className="text-sm text-muted-foreground">{t("Username")}</dt>
107+
<dd className="font-mono break-all">{info.access_key}</dd>
108+
109+
<dt className="text-sm text-muted-foreground">{t("Role")}</dt>
110+
<dd className="flex flex-wrap items-center gap-2">
111+
<span>{info.is_admin ? t("Administrator") : t("User")}</span>
112+
<Badge variant="secondary">{identityTypeLabel(info.identity_type)}</Badge>
113+
</dd>
114+
115+
<dt className="text-sm text-muted-foreground">{t("Status")}</dt>
116+
<dd>{info.status === "enabled" ? t("Enabled") : t("Disabled")}</dd>
117+
118+
<dt className="text-sm text-muted-foreground">{t("Two-factor authentication")}</dt>
119+
<dd>{info.mfa.enabled ? t("On") : t("Off")}</dd>
120+
121+
{info.policies.length > 0 && (
122+
<>
123+
<dt className="text-sm text-muted-foreground">{t("Policies")}</dt>
124+
<dd className="flex flex-wrap gap-1">
125+
{info.policies.map((policy) => (
126+
<Badge key={policy} variant="outline">
127+
{policy}
128+
</Badge>
129+
))}
130+
</dd>
131+
</>
132+
)}
133+
134+
{info.member_of.length > 0 && (
135+
<>
136+
<dt className="text-sm text-muted-foreground">{t("Groups")}</dt>
137+
<dd className="flex flex-wrap gap-1">
138+
{info.member_of.map((group) => (
139+
<Badge key={group} variant="outline">
140+
{group}
141+
</Badge>
142+
))}
143+
</dd>
144+
</>
145+
)}
146+
147+
{info.session_access_key && (
148+
<>
149+
<dt className="text-sm text-muted-foreground">{t("Session access key")}</dt>
150+
<dd className="font-mono text-sm break-all text-muted-foreground">{info.session_access_key}</dd>
151+
</>
152+
)}
153+
</dl>
154+
155+
{/* Root cannot be edited here, and saying so beats a disabled control
156+
with no explanation. */}
157+
{info.credentials_source === "env" && (
158+
<Alert>
159+
<AlertDescription>
160+
{t(
161+
"This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Its username and password are changed by restarting the server with new values, not from the console.",
162+
)}
163+
</AlertDescription>
164+
</Alert>
165+
)}
166+
</>
167+
) : null}
168+
</Page>
169+
)
170+
}

0 commit comments

Comments
 (0)