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
66 changes: 63 additions & 3 deletions app/(auth)/auth/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useEffect, Suspense, useState } from "react"
import { useSearchParams } from "next/navigation"
import { useRouter } from "next/navigation"
import { useTranslation } from "react-i18next"
import { LoginForm, type LoginMethod } from "@/components/auth/login-form"
import { LoginForm, type LoginMethod, type SecondFactorStep } from "@/components/auth/login-form"
import { AppLoadingShell } from "@/components/app-loading-shell"
import { useAuth } from "@/contexts/auth-context"
import { useMessage } from "@/lib/feedback/message"
Expand All @@ -24,7 +24,7 @@ function LoginPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const message = useMessage()
const { login, isAuthenticated } = useAuth()
const { login, completeLoginWithSecondFactor, isAuthenticated } = useAuth()
const { t } = useTranslation()

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

useEffect(() => {
if (!isAuthenticated) return
Expand Down Expand Up @@ -92,14 +98,67 @@ function LoginPageContent() {

try {
const currentConfig = await configManager.loadConfig()
await login(credentials, currentConfig)
const outcome = await login(credentials, currentConfig)

if (outcome.status === "mfa-required") {
// Not a failure: the password was accepted and the account simply has a
// second factor. Saying "login failed" here would send the user to reset
// a password that is working.
setPendingMfa({ challenge: outcome.challenge })
setMfaCode("")
setMfaError("")
return
}

message.success(t("Login Success"))
} catch {
message.error(t("Login Failed"))
}
}

const handleSecondFactor = async (e: React.FormEvent) => {
e.preventDefault()
if (!pendingMfa || mfaSubmitting) return

setMfaSubmitting(true)
setMfaError("")
try {
const currentConfig = await configManager.loadConfig()
await completeLoginWithSecondFactor(
method === "accessKeyAndSecretKey" ? accessKeyAndSecretKey : sts,
{ code: mfaCode, challenge: pendingMfa.challenge },
currentConfig,
)
message.success(t("Login Success"))
} catch (error) {
// Inline, next to the input: a toast alone leaves no durable explanation
// of why the code was refused.
setMfaError((error as Error)?.message || t("Invalid verification code"))
setMfaCode("")
} finally {
setMfaSubmitting(false)
}
}

const secondFactor: SecondFactorStep | undefined = pendingMfa
? {
code: mfaCode,
setCode: (value) => {
setMfaCode(value)
setMfaError("")
},
error: mfaError,
submitting: mfaSubmitting,
onSubmit: handleSecondFactor,
onCancel: () => {
setPendingMfa(null)
setMfaCode("")
setMfaError("")
},
accountName: method === "accessKeyAndSecretKey" ? accessKeyAndSecretKey.accessKeyId : sts.accessKeyId,
}
: undefined

const handleOidcLogin = async (providerId: string) => {
const config = await configManager.loadConfig()
initiateOidcLogin(config.serverHost, providerId)
Expand All @@ -116,6 +175,7 @@ function LoginPageContent() {
handleLogin={handleLogin}
oidcProviders={oidcProviders}
onOidcLogin={handleOidcLogin}
secondFactor={secondFactor}
/>
)
}
170 changes: 170 additions & 0 deletions app/(dashboard)/account/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"use client"

import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { useTranslation } from "react-i18next"
import { RiRefreshLine, RiShieldKeyholeLine } from "@remixicon/react"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Page } from "@/components/page"
import { PageHeader } from "@/components/page-header"
import { useAccount, type AccountInfo } from "@/hooks/use-account"
import { buildRoute } from "@/lib/routes"

export default function AccountPage() {
const { t } = useTranslation()
const { getAccountInfo } = useAccount()

const [info, setInfo] = useState<AccountInfo | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)

const load = useCallback(async () => {
setLoading(true)
setLoadError(null)
try {
const result = await getAccountInfo()
if (!result) {
setLoadError(t("API not ready"))
return
}
setInfo(result)
} catch (error) {
// A failed read must never render as an empty profile: the previous value
// stays on screen (if any) and the failure is stated explicitly.
setLoadError((error as Error)?.message || t("Failed to get data"))
} finally {
setLoading(false)
}
}, [getAccountInfo, t])

useEffect(() => {
void load()
}, [load])

const identityTypeLabel = (value: AccountInfo["identity_type"]) => {
switch (value) {
case "root":
return t("Root credential")
case "iam":
return t("IAM user")
case "sts":
return t("Temporary session")
case "service-account":
return t("Service account")
}
}

return (
<Page>
<PageHeader
description={t("The identity you are signed in as.")}
actions={
<>
<Button variant="outline" onClick={() => void load()} disabled={loading}>
<RiRefreshLine className="size-4" aria-hidden />
<span>{t("Refresh")}</span>
</Button>
<Button
render={
<Link href={buildRoute("/account/security")}>
<RiShieldKeyholeLine className="size-4" aria-hidden />
<span>{t("Security")}</span>
</Link>
}
/>
</>
}
>
<h1>{t("Profile")}</h1>
</PageHeader>

{loadError && (
<Alert variant="destructive">
<AlertDescription className="flex flex-wrap items-center justify-between gap-3">
<span>{loadError}</span>
<Button variant="outline" size="sm" onClick={() => void load()}>
{t("Retry")}
</Button>
</AlertDescription>
</Alert>
)}

{loading && !info ? (
<div className="space-y-3" aria-live="polite">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-5 w-64" />
<Skeleton className="h-5 w-52" />
</div>
) : info ? (
<>
{/* Passive metadata as a definition list, not a grid of cards: these
are facts to read, not objects to select. */}
<dl className="grid gap-x-8 gap-y-4 sm:grid-cols-[max-content_1fr]">
<dt className="text-sm text-muted-foreground">{t("Username")}</dt>
<dd className="font-mono break-all">{info.access_key}</dd>

<dt className="text-sm text-muted-foreground">{t("Role")}</dt>
<dd className="flex flex-wrap items-center gap-2">
<span>{info.is_admin ? t("Administrator") : t("User")}</span>
<Badge variant="secondary">{identityTypeLabel(info.identity_type)}</Badge>
</dd>

<dt className="text-sm text-muted-foreground">{t("Status")}</dt>
<dd>{info.status === "enabled" ? t("Enabled") : t("Disabled")}</dd>

<dt className="text-sm text-muted-foreground">{t("Two-factor authentication")}</dt>
<dd>{info.mfa.enabled ? t("On") : t("Off")}</dd>

{info.policies.length > 0 && (
<>
<dt className="text-sm text-muted-foreground">{t("Policies")}</dt>
<dd className="flex flex-wrap gap-1">
{info.policies.map((policy) => (
<Badge key={policy} variant="outline">
{policy}
</Badge>
))}
</dd>
</>
)}

{info.member_of.length > 0 && (
<>
<dt className="text-sm text-muted-foreground">{t("Groups")}</dt>
<dd className="flex flex-wrap gap-1">
{info.member_of.map((group) => (
<Badge key={group} variant="outline">
{group}
</Badge>
))}
</dd>
</>
)}

{info.session_access_key && (
<>
<dt className="text-sm text-muted-foreground">{t("Session access key")}</dt>
<dd className="font-mono text-sm break-all text-muted-foreground">{info.session_access_key}</dd>
</>
)}
</dl>

{/* Root cannot be edited here, and saying so beats a disabled control
with no explanation. */}
{info.credentials_source === "env" && (
<Alert>
<AlertDescription>
{t(
"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.",
)}
</AlertDescription>
</Alert>
)}
</>
) : null}
</Page>
)
}
Loading