diff --git a/app/(auth)/auth/login/page.tsx b/app/(auth)/auth/login/page.tsx index 91724c64..d9ac169e 100644 --- a/app/(auth)/auth/login/page.tsx +++ b/app/(auth)/auth/login/page.tsx @@ -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" @@ -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("accessKeyAndSecretKey") @@ -38,6 +38,12 @@ function LoginPageContent() { sessionToken: "", }) const [oidcProviders, setOidcProviders] = useState([]) + // 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 @@ -92,7 +98,17 @@ 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 { @@ -100,6 +116,49 @@ function LoginPageContent() { } } + 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) @@ -116,6 +175,7 @@ function LoginPageContent() { handleLogin={handleLogin} oidcProviders={oidcProviders} onOidcLogin={handleOidcLogin} + secondFactor={secondFactor} /> ) } diff --git a/app/(dashboard)/account/page.tsx b/app/(dashboard)/account/page.tsx new file mode 100644 index 00000000..bbe80904 --- /dev/null +++ b/app/(dashboard)/account/page.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(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 ( + + + + + + + )} + + {loading && !info ? ( +
+ + + +
+ ) : info ? ( + <> + {/* Passive metadata as a definition list, not a grid of cards: these + are facts to read, not objects to select. */} +
+
{t("Username")}
+
{info.access_key}
+ +
{t("Role")}
+
+ {info.is_admin ? t("Administrator") : t("User")} + {identityTypeLabel(info.identity_type)} +
+ +
{t("Status")}
+
{info.status === "enabled" ? t("Enabled") : t("Disabled")}
+ +
{t("Two-factor authentication")}
+
{info.mfa.enabled ? t("On") : t("Off")}
+ + {info.policies.length > 0 && ( + <> +
{t("Policies")}
+
+ {info.policies.map((policy) => ( + + {policy} + + ))} +
+ + )} + + {info.member_of.length > 0 && ( + <> +
{t("Groups")}
+
+ {info.member_of.map((group) => ( + + {group} + + ))} +
+ + )} + + {info.session_access_key && ( + <> +
{t("Session access key")}
+
{info.session_access_key}
+ + )} +
+ + {/* Root cannot be edited here, and saying so beats a disabled control + with no explanation. */} + {info.credentials_source === "env" && ( + + + {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.", + )} + + + )} + + ) : null} +
+ ) +} diff --git a/app/(dashboard)/account/security/page.tsx b/app/(dashboard)/account/security/page.tsx new file mode 100644 index 00000000..eed0b513 --- /dev/null +++ b/app/(dashboard)/account/security/page.tsx @@ -0,0 +1,225 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { useTranslation } from "react-i18next" +import { RiArrowLeftLine, RiRefreshLine } from "@remixicon/react" +import Link from "next/link" +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 { ChangePasswordDialog } from "@/components/account/change-password-dialog" +import { MfaDisableDialog } from "@/components/account/mfa-disable-dialog" +import { MfaRecoveryCodesDialog } from "@/components/account/mfa-recovery-codes-dialog" +import { MfaSetupDialog } from "@/components/account/mfa-setup-dialog" +import { useAccount, type AccountInfo } from "@/hooks/use-account" +import { recoveryCodesRunningLow } from "@/lib/mfa" +import { buildRoute } from "@/lib/routes" +import { formatDateTime } from "@/lib/functions" + +export default function AccountSecurityPage() { + const { t } = useTranslation() + const { getAccountInfo } = useAccount() + + const [info, setInfo] = useState(null) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + + const [passwordOpen, setPasswordOpen] = useState(false) + const [setupOpen, setSetupOpen] = useState(false) + const [disableOpen, setDisableOpen] = useState(false) + const [recoveryOpen, setRecoveryOpen] = useState(false) + + 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) { + setLoadError((error as Error)?.message || t("Failed to get data")) + } finally { + setLoading(false) + } + }, [getAccountInfo, t]) + + useEffect(() => { + void load() + }, [load]) + + const mfa = info?.mfa + const canManagePassword = info?.mutable.password ?? false + // Enrollment needs both a mutable credential and server-side at-rest + // protection for the shared secret; the server reports why when it refuses. + const canManageMfa = mfa?.enrollment_available ?? false + + return ( + + + + + } + > +

{t("Security")}

+
+ + {loadError && ( + + + {loadError} + + + + )} + + {loading && !info ? ( +
+ + +
+ ) : info ? ( +
+ {/* Peer sections separated by one divider system, no nested frames. */} +
+
+

+ {t("Password")} +

+

+ {canManagePassword + ? t("Change your password. This is also your S3 secret key.") + : t("This identity's password is managed outside the console.")} +

+
+ {canManagePassword ? ( + + ) : ( + + + {info.credentials_source === "env" + ? t( + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.", + ) + : t("This identity's password is managed outside the console.")} + + + )} +
+ +
+
+
+

+ {t("Two-factor authentication")} +

+

{t("Protect your account with an authenticator app")}

+
+ {mfa?.enabled ? t("On") : t("Off")} +
+ + {mfa?.enabled ? ( + <> +
+ {mfa.activated_at && ( + <> +
{t("Enabled on")}
+
{formatDateTime(mfa.activated_at)}
+ + )} + {mfa.last_verified_at && ( + <> +
{t("Last used")}
+
{formatDateTime(mfa.last_verified_at)}
+ + )} +
{t("Recovery codes remaining")}
+
{mfa.recovery_codes_remaining}
+
+ + {mfa.recovery_codes_remaining === 0 && ( + + + {t( + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.", + )} + + + )} + {recoveryCodesRunningLow(mfa.recovery_codes_remaining) && ( + + + {t("You are running low on recovery codes. Generate a new set to be safe.")} + + + )} + +
+ + + +
+ {mfa.pending && ( +

+ {t("A new authenticator is waiting to be confirmed. Your current one keeps working until then.")} +

+ )} + + ) : ( + <> + {/* An unavailable feature explains itself instead of showing a + dead button. */} + {!canManageMfa && mfa?.enrollment_blocked_reason && ( + + {mfa.enrollment_blocked_reason} + + )} + + + )} +
+
+ ) : null} + + void load()} /> + void load()} /> + void load()} + /> + void load()} /> +
+ ) +} diff --git a/components/account/change-password-dialog.tsx b/components/account/change-password-dialog.tsx new file mode 100644 index 00000000..72080ae7 --- /dev/null +++ b/components/account/change-password-dialog.tsx @@ -0,0 +1,218 @@ +"use client" + +import { useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Spinner } from "@/components/ui/spinner" +import { Field, FieldContent, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { useAccount } from "@/hooks/use-account" +import { useMessage } from "@/lib/feedback/message" + +/** Mirrors the server's `SECRET_KEY_MIN_LEN`. */ +const PASSWORD_MIN_LENGTH = 8 + +interface ChangePasswordDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onChanged?: () => void +} + +/** + * Rotates the caller's own secret key. + * + * Asks for the current secret even though the request is already signed: the + * console signs with a short-lived session, so a signature proves the session is + * live, not that the person at the keyboard knows the password. Without this a + * borrowed browser tab could change the account's credentials. + */ +export function ChangePasswordDialog({ open, onOpenChange, onChanged }: ChangePasswordDialogProps) { + const { t } = useTranslation() + const message = useMessage() + const { changePassword } = useAccount() + + const currentRef = useRef(null) + const nextRef = useRef(null) + const confirmRef = useRef(null) + + const [current, setCurrent] = useState("") + const [next, setNext] = useState("") + const [confirm, setConfirm] = useState("") + const [errors, setErrors] = useState({ current: "", next: "", confirm: "" }) + const [submitError, setSubmitError] = useState("") + const [submitting, setSubmitting] = useState(false) + + const reset = () => { + setCurrent("") + setNext("") + setConfirm("") + setErrors({ current: "", next: "", confirm: "" }) + setSubmitError("") + setSubmitting(false) + } + + const handleOpenChange = (nextOpen: boolean) => { + // Dismissal is blocked mid-flight so a half-applied rotation cannot be + // hidden by an accidental click. + if (submitting && !nextOpen) return + onOpenChange(nextOpen) + if (!nextOpen) reset() + } + + const validate = () => { + const nextErrors = { + current: current ? "" : t("Please enter your current password"), + next: !next + ? t("Please enter new password") + : next.length < PASSWORD_MIN_LENGTH + ? t("Password must be at least 8 characters") + : next === current + ? t("The new password must be different from the current one") + : "", + confirm: !confirm + ? t("Please enter new password again") + : confirm !== next + ? t("The two passwords are inconsistent") + : "", + } + setErrors(nextErrors) + + // Focus the first invalid field rather than leaving the user to hunt for it. + if (nextErrors.current) currentRef.current?.focus() + else if (nextErrors.next) nextRef.current?.focus() + else if (nextErrors.confirm) confirmRef.current?.focus() + + return !nextErrors.current && !nextErrors.next && !nextErrors.confirm + } + + const submit = async () => { + if (submitting || !validate()) return + + setSubmitting(true) + setSubmitError("") + try { + const result = await changePassword(current, next) + const revoked = result?.sessions_revoked ?? 0 + message.success( + revoked > 0 ? t("Password updated. Other sessions have been signed out.") : t("Password updated."), + ) + onChanged?.() + handleOpenChange(false) + } catch (error) { + // Kept inline, not only as a toast: the user needs the reason next to the + // form they must correct, and their input is preserved so they can. + setSubmitError((error as Error)?.message || t("Update failed")) + } finally { + setSubmitting(false) + } + } + + return ( + + + + {t("Change Password")} + + +
{ + event.preventDefault() + void submit() + }} + > +
+ {submitError && ( + + {submitError} + + )} + + + {t("Current Password")} + + setCurrent(event.target.value)} + type="password" + autoComplete="current-password" + spellCheck={false} + required + disabled={submitting} + aria-invalid={Boolean(errors.current)} + aria-describedby={errors.current ? "account-password-current-error" : undefined} + /> + + {errors.current} + + + + {t("New Password")} + + setNext(event.target.value)} + type="password" + autoComplete="new-password" + spellCheck={false} + minLength={PASSWORD_MIN_LENGTH} + required + disabled={submitting} + aria-invalid={Boolean(errors.next)} + aria-describedby={errors.next ? "account-password-new-error" : "account-password-new-hint"} + /> + + + {t("At least 8 characters. This is also your S3 secret key.")} + + {errors.next} + + + + {t("Confirm New Password")} + + setConfirm(event.target.value)} + type="password" + autoComplete="new-password" + spellCheck={false} + required + disabled={submitting} + aria-invalid={Boolean(errors.confirm)} + aria-describedby={errors.confirm ? "account-password-confirm-error" : undefined} + /> + + {errors.confirm} + + +

+ {t("Changing your password signs out your other sessions and invalidates the old secret key.")} +

+
+ + + + + +
+
+
+ ) +} diff --git a/components/account/mfa-disable-dialog.tsx b/components/account/mfa-disable-dialog.tsx new file mode 100644 index 00000000..ed30f983 --- /dev/null +++ b/components/account/mfa-disable-dialog.tsx @@ -0,0 +1,198 @@ +"use client" + +import { useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Field, FieldContent, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field" +import { Spinner } from "@/components/ui/spinner" +import { useAccount } from "@/hooks/use-account" +import { isSubmittableCode } from "@/lib/mfa" +import { useMessage } from "@/lib/feedback/message" + +interface MfaDisableDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + /** The identity being unprotected, named so the consequence is unambiguous. */ + accessKey: string + onDisabled: () => void +} + +/** + * Turns two-factor authentication off. + * + * Destructive: it removes a protection, and the recovery codes go with it. So it + * names the account, states the consequence, and asks for both factors — the + * authenticator code *and* the account password. Requiring only the code would + * let a session that someone walked away from strip the protection with a single + * shoulder-surfed number. + */ +export function MfaDisableDialog({ open, onOpenChange, accessKey, onDisabled }: MfaDisableDialogProps) { + const { t } = useTranslation() + const message = useMessage() + const { disableMfa } = useAccount() + + const codeRef = useRef(null) + const passwordRef = useRef(null) + + const [code, setCode] = useState("") + const [password, setPassword] = useState("") + const [errors, setErrors] = useState({ code: "", password: "" }) + const [submitError, setSubmitError] = useState("") + const [submitting, setSubmitting] = useState(false) + + const reset = () => { + setCode("") + setPassword("") + setErrors({ code: "", password: "" }) + setSubmitError("") + setSubmitting(false) + } + + const handleOpenChange = (nextOpen: boolean) => { + if (submitting && !nextOpen) return + onOpenChange(nextOpen) + if (!nextOpen) reset() + } + + const submit = async () => { + if (submitting) return + + const nextErrors = { + code: !code + ? t("Enter a code from your authenticator app or a recovery code") + : isSubmittableCode(code) + ? "" + : t("That does not look like a valid code"), + password: password ? "" : t("Please enter your current password"), + } + setErrors(nextErrors) + if (nextErrors.code) { + codeRef.current?.focus() + return + } + if (nextErrors.password) { + passwordRef.current?.focus() + return + } + + setSubmitting(true) + setSubmitError("") + try { + await disableMfa(code, password) + message.success(t("Two-factor authentication is off.")) + onDisabled() + handleOpenChange(false) + } catch (error) { + setSubmitError((error as Error)?.message || t("Update failed")) + setCode("") + codeRef.current?.focus() + } finally { + setSubmitting(false) + } + } + + return ( + + + + {t("Turn off two-factor authentication")} + + {t("{account} will be protected by its password alone, and the recovery codes will stop working.", { + account: accessKey, + })} + + + +
{ + event.preventDefault() + void submit() + }} + > +
+ {submitError && ( + + {submitError} + + )} + + + {t("Authentication code")} + + { + setCode(event.target.value) + setErrors((current) => ({ ...current, code: "" })) + }} + autoComplete="one-time-code" + inputMode="text" + spellCheck={false} + dir="ltr" + className="font-mono" + required + disabled={submitting} + aria-invalid={Boolean(errors.code)} + aria-describedby={errors.code ? "mfa-disable-code-error" : "mfa-disable-code-hint"} + /> + + + {t("A 6-digit code, or one of your recovery codes.")} + + {errors.code} + + + + {t("Current Password")} + + { + setPassword(event.target.value) + setErrors((current) => ({ ...current, password: "" })) + }} + type="password" + autoComplete="current-password" + spellCheck={false} + required + disabled={submitting} + aria-invalid={Boolean(errors.password)} + aria-describedby={errors.password ? "mfa-disable-password-error" : undefined} + /> + + {errors.password} + +
+ + + + + +
+
+
+ ) +} diff --git a/components/account/mfa-recovery-codes-dialog.tsx b/components/account/mfa-recovery-codes-dialog.tsx new file mode 100644 index 00000000..dc279ba9 --- /dev/null +++ b/components/account/mfa-recovery-codes-dialog.tsx @@ -0,0 +1,177 @@ +"use client" + +import { useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Field, FieldContent, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field" +import { Spinner } from "@/components/ui/spinner" +import { RecoveryCodesPanel } from "@/components/account/recovery-codes-panel" +import { useAccount } from "@/hooks/use-account" +import { isSubmittableCode } from "@/lib/mfa" + +interface MfaRecoveryCodesDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onRegenerated: () => void +} + +/** + * Replaces the recovery code set. + * + * Requires a current second factor first: regenerating is equivalent to minting + * ten new bypasses for the account, so it must not be reachable from a session + * alone. Generating also invalidates the previous set, which the copy says + * plainly because a user who keeps the old printout would otherwise be locked + * out believing they were safe. + */ +export function MfaRecoveryCodesDialog({ open, onOpenChange, onRegenerated }: MfaRecoveryCodesDialogProps) { + const { t } = useTranslation() + const { regenerateRecoveryCodes } = useAccount() + const codeRef = useRef(null) + + const [code, setCode] = useState("") + const [codeError, setCodeError] = useState("") + const [submitError, setSubmitError] = useState("") + const [submitting, setSubmitting] = useState(false) + const [codes, setCodes] = useState(null) + + const reset = () => { + setCode("") + setCodeError("") + setSubmitError("") + setSubmitting(false) + setCodes(null) + } + + const handleOpenChange = (nextOpen: boolean) => { + // Same rule as setup: while the new codes are on screen they exist nowhere + // else, so the panel owns dismissal. + if (!nextOpen && codes) return + if (!nextOpen && submitting) return + onOpenChange(nextOpen) + if (!nextOpen) reset() + } + + const submit = async () => { + if (submitting) return + if (!isSubmittableCode(code)) { + setCodeError(code ? t("That does not look like a valid code") : t("Enter a code from your authenticator app")) + codeRef.current?.focus() + return + } + + setSubmitting(true) + setSubmitError("") + try { + const result = await regenerateRecoveryCodes(code) + setCodes(result?.recovery_codes ?? []) + } catch (error) { + setSubmitError((error as Error)?.message || t("Update failed")) + setCode("") + codeRef.current?.focus() + } finally { + setSubmitting(false) + } + } + + return ( + + + + {codes ? t("Save your recovery codes") : t("Generate new recovery codes")} + + {codes + ? t("Your previous recovery codes no longer work.") + : t("Your existing recovery codes will stop working.")} + + + +
+ {codes ? ( + { + onRegenerated() + onOpenChange(false) + reset() + }} + /> + ) : ( +
{ + event.preventDefault() + void submit() + }} + > + {submitError && ( + + {submitError} + + )} + + + {t("Authentication code")} + + { + setCode(event.target.value) + setCodeError("") + }} + autoComplete="one-time-code" + spellCheck={false} + dir="ltr" + className="font-mono" + required + disabled={submitting} + aria-invalid={Boolean(codeError)} + aria-describedby={codeError ? "mfa-regenerate-code-error" : "mfa-regenerate-code-hint"} + /> + + + {t("A 6-digit code, or one of your recovery codes.")} + + {codeError} + +
+ )} +
+ + + {codes ? ( +

{t("These codes are shown only once.")}

+ ) : ( + <> + + + + )} +
+
+
+ ) +} diff --git a/components/account/mfa-setup-dialog.tsx b/components/account/mfa-setup-dialog.tsx new file mode 100644 index 00000000..1882e4d0 --- /dev/null +++ b/components/account/mfa-setup-dialog.tsx @@ -0,0 +1,258 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import Image from "next/image" +import { useTranslation } from "react-i18next" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" +import { CopyInput } from "@/components/copy-input" +import { Field, FieldContent, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field" +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp" +import { Skeleton } from "@/components/ui/skeleton" +import { Spinner } from "@/components/ui/spinner" +import { RecoveryCodesPanel } from "@/components/account/recovery-codes-panel" +import { useAccount } from "@/hooks/use-account" +import { formatManualSetupKey, qrSvgToDataUri, TOTP_CODE_LENGTH, type MfaEnrollment } from "@/lib/mfa" + +type Step = "loading" | "scan" | "codes" | "failed" + +interface MfaSetupDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + /** Called after the factor is active and the codes are acknowledged. */ + onCompleted: () => void +} + +/** + * Turning on two-factor authentication, start to finish. + * + * One dialog with internal steps rather than a chain of dialogs: the design + * guide rules out nesting, and this is one decision ("protect this account") + * even though it takes three screens. Keeping it in one surface also means the + * recovery codes cannot be orphaned by a parent closing underneath them. + * + * The secret lives in component state only. It is never written to storage, and + * it disappears when the dialog unmounts. + */ +export function MfaSetupDialog({ open, onOpenChange, onCompleted }: MfaSetupDialogProps) { + const { t } = useTranslation() + const { enrollMfa, activateMfa } = useAccount() + const codeInputRef = useRef(null) + + const [step, setStep] = useState("loading") + const [enrollment, setEnrollment] = useState(null) + const [loadError, setLoadError] = useState("") + const [code, setCode] = useState("") + const [codeError, setCodeError] = useState("") + const [verifying, setVerifying] = useState(false) + const [recoveryCodes, setRecoveryCodes] = useState([]) + + const startEnrollment = useCallback(async () => { + setStep("loading") + setLoadError("") + setCode("") + setCodeError("") + try { + const result = await enrollMfa() + if (!result) { + setLoadError(t("API not ready")) + setStep("failed") + return + } + setEnrollment(result) + setStep("scan") + } catch (error) { + setLoadError((error as Error)?.message || t("Failed to get data")) + setStep("failed") + } + }, [enrollMfa, t]) + + useEffect(() => { + if (!open) return + void startEnrollment() + }, [open, startEnrollment]) + + const reset = () => { + setStep("loading") + setEnrollment(null) + setLoadError("") + setCode("") + setCodeError("") + setVerifying(false) + setRecoveryCodes([]) + } + + const handleOpenChange = (nextOpen: boolean) => { + // Once the codes are on screen they exist nowhere else, so this step owns + // its own dismissal through the acknowledge button. + if (!nextOpen && step === "codes") return + if (!nextOpen && verifying) return + onOpenChange(nextOpen) + if (!nextOpen) reset() + } + + const verify = async () => { + if (verifying) return + if (code.length !== TOTP_CODE_LENGTH) { + setCodeError(t("Enter the 6-digit code from your authenticator app")) + codeInputRef.current?.focus() + return + } + + setVerifying(true) + setCodeError("") + try { + const result = await activateMfa(code) + setRecoveryCodes(result?.recovery_codes ?? []) + setStep("codes") + } catch (error) { + setCodeError((error as Error)?.message || t("Invalid verification code")) + setCode("") + codeInputRef.current?.focus() + } finally { + setVerifying(false) + } + } + + return ( + + + + {step === "codes" ? t("Save your recovery codes") : t("Two-factor authentication")} + + {step === "codes" + ? t("Two-factor authentication is now on.") + : t("Scan this QR code with your authenticator app.")} + + + +
+ {step === "loading" && ( +
+ + +
+ )} + + {step === "failed" && ( +
+ + {loadError} + + {/* Retry sits with the failure it addresses. */} + +
+ )} + + {step === "scan" && enrollment && ( +
+
+ {t("QR +
+ + + {t("Can't scan? Manual setup key")} + + + + + {t("Time-based, {digits} digits, {period}s period.", { + digits: enrollment.digits, + period: enrollment.period_seconds, + })} + + + + + {t("Enter the 6-digit code")} + + { + setCode(value) + setCodeError("") + }} + onComplete={() => void verify()} + disabled={verifying} + autoFocus + // Codes are always LTR even in a right-to-left locale. + dir="ltr" + aria-invalid={Boolean(codeError)} + aria-describedby={codeError ? "mfa-setup-code-error" : undefined} + > + + {Array.from({ length: TOTP_CODE_LENGTH }, (_, index) => ( + + ))} + + + + {codeError} + +
+ )} + + {step === "codes" && ( + { + onCompleted() + onOpenChange(false) + reset() + }} + /> + )} +
+ + + {step === "codes" ? ( +

{t("These codes are shown only once.")}

+ ) : ( + <> + + + + )} +
+
+
+ ) +} diff --git a/components/account/recovery-codes-panel.tsx b/components/account/recovery-codes-panel.tsx new file mode 100644 index 00000000..61165249 --- /dev/null +++ b/components/account/recovery-codes-panel.tsx @@ -0,0 +1,97 @@ +"use client" + +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { RiCheckLine, RiDownload2Line, RiFileCopyLine } from "@remixicon/react" +import { Button } from "@/components/ui/button" +import { copyToClipboard } from "@/lib/clipboard" +import { download } from "@/lib/export-file" +import { formatRecoveryCodesForExport } from "@/lib/mfa" +import { useMessage } from "@/lib/feedback/message" + +interface RecoveryCodesPanelProps { + codes: string[] + /** Called once the user confirms they have stored the codes. */ + onAcknowledge: () => void + acknowledgeLabel: string + pending?: boolean +} + +/** + * Displays a freshly generated set of recovery codes. + * + * The server keeps only hashes, so this is the one and only time these values + * exist anywhere the user can read them. The panel therefore refuses to be + * dismissed until the user has copied or downloaded them and confirmed — the + * alternative is a user who closes a dialog and has silently lost their only + * way back into a locked account. + */ +export function RecoveryCodesPanel({ + codes, + onAcknowledge, + acknowledgeLabel, + pending = false, +}: RecoveryCodesPanelProps) { + const { t } = useTranslation() + const message = useMessage() + const [saved, setSaved] = useState(false) + + const handleCopy = async () => { + try { + await copyToClipboard(formatRecoveryCodesForExport(codes)) + setSaved(true) + message.success(t("Copy Success")) + } catch { + message.error(t("Copy Failed")) + } + } + + const handleDownload = () => { + download("rustfs-recovery-codes.txt", formatRecoveryCodesForExport(codes)) + setSaved(true) + } + + return ( +
+

+ {t("These codes can be used if you lose access to your authenticator. Each code works once.")} +

+ + {/* One frame around the whole set, not one per code: these are a single + value to copy, not a list of selectable objects. */} +
    + {codes.map((code) => ( +
  • + {code} +
  • + ))} +
+ +
+ + +
+ +

+ {saved ? ( + + + {t("Saved. Store them somewhere only you can reach.")} + + ) : ( + {t("Copy or download the codes before continuing.")} + )} +

+ + +
+ ) +} diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index ca0fe930..d0649ea4 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -7,7 +7,10 @@ import { RiSettings3Line } from "@remixicon/react" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Field, FieldContent, FieldLabel } from "@/components/ui/field" +import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field" +import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp" +import { Spinner } from "@/components/ui/spinner" +import { TOTP_CODE_LENGTH } from "@/lib/mfa" import { ThemeSwitcher } from "@/components/theme-switcher" import { LanguageSwitcher } from "@/components/language-switcher" import { ThemeLogo } from "@/components/theme/logo" @@ -20,6 +23,18 @@ import type { OidcProvider } from "@/types/config" export type LoginMethod = "accessKeyAndSecretKey" | "sts" +/** The second-factor step of the login flow, when the server demands one. */ +export interface SecondFactorStep { + code: string + setCode: (code: string) => void + error: string + submitting: boolean + onSubmit: (event: React.FormEvent) => void + onCancel: () => void + /** The identity being authenticated, so the user knows which account this is. */ + accountName: string +} + export interface LoginFormProps { method: LoginMethod setMethod: (m: LoginMethod) => void @@ -40,6 +55,8 @@ export interface LoginFormProps { handleLogin: (e: React.FormEvent) => void oidcProviders?: OidcProvider[] onOidcLogin?: (providerId: string) => void + /** Present only while the server is waiting for a second factor. */ + secondFactor?: SecondFactorStep } export function LoginForm({ @@ -52,6 +69,7 @@ export function LoginForm({ handleLogin, oidcProviders, onOidcLogin, + secondFactor, }: LoginFormProps) { const { t } = useTranslation() const theme = getThemeManifest() @@ -87,150 +105,228 @@ export function LoginForm({

{t("Login")}

-
- setMethod(v as LoginMethod)} className="flex flex-col gap-4"> - - - {t("Key Login")} - - - {t("STS Login")} - - - -
-
- {method === "accessKeyAndSecretKey" ? ( - <> - - {t("Account")} - - - setAccessKeyAndSecretKey((prev) => ({ - ...prev, - accessKeyId: e.target.value, - })) - } - autoComplete="username" - type="text" - spellCheck={false} - required - className="h-11 text-base sm:h-8 sm:text-xs" - placeholder={t("Please enter account")} - /> - - - - {t("Key")} - - - setAccessKeyAndSecretKey((prev) => ({ - ...prev, - secretAccessKey: e.target.value, - })) - } - autoComplete="current-password" - type="password" - spellCheck={false} - required - className="h-11 text-base sm:h-8 sm:text-xs" - placeholder={t("Please enter key")} - /> - - - - ) : ( - <> - - {t("STS Username")} - - - setSts((prev) => ({ - ...prev, - accessKeyId: e.target.value, - })) - } - autoComplete="new-password" - type="text" - spellCheck={false} - required - className="h-11 text-base sm:h-8 sm:text-xs" - placeholder={t("Please enter STS username")} - /> - - - - {t("STS Key")} - - - setSts((prev) => ({ - ...prev, - secretAccessKey: e.target.value, - })) - } - autoComplete="new-password" - type="password" - spellCheck={false} - required - className="h-11 text-base sm:h-8 sm:text-xs" - placeholder={t("Please enter STS key")} - /> - - - - {t("STS Session Token")} - - - setSts((prev) => ({ - ...prev, - sessionToken: e.target.value, - })) - } - autoComplete="new-password" - type="text" - spellCheck={false} - required - className="h-11 text-base sm:h-8 sm:text-xs" - placeholder={t("Please enter STS session token")} - /> - - - - )} + {secondFactor ? ( + // A separate step, not an extra field: the password has already + // been accepted, and mixing the two would invite the user to + // re-enter credentials that are no longer in question. + +
+

{t("Two-factor authentication")}

+

+ {t("Enter the code from your authenticator app for {account}.", { + account: secondFactor.accountName, + })} +

+
- -
-
-
-
+ + {Array.from({ length: TOTP_CODE_LENGTH }, (_, index) => ( + + ))} + + + + {secondFactor.error} + + + + {t("Or use a recovery code")} + + TOTP_CODE_LENGTH ? secondFactor.code : ""} + onChange={(e) => secondFactor.setCode(e.target.value)} + autoComplete="one-time-code" + spellCheck={false} + dir="ltr" + disabled={secondFactor.submitting} + className="h-11 font-mono text-base sm:h-8 sm:text-xs" + placeholder="XXXX-XXXX-XXXX-XXXX-XXXX" + /> + + + +
+ + +
+ + ) : ( +
+ setMethod(v as LoginMethod)} className="flex flex-col gap-4"> + + + {t("Key Login")} + + + {t("STS Login")} + + + +
+
+ {method === "accessKeyAndSecretKey" ? ( + <> + + {t("Account")} + + + setAccessKeyAndSecretKey((prev) => ({ + ...prev, + accessKeyId: e.target.value, + })) + } + autoComplete="username" + type="text" + spellCheck={false} + required + className="h-11 text-base sm:h-8 sm:text-xs" + placeholder={t("Please enter account")} + /> + + + + {t("Key")} + + + setAccessKeyAndSecretKey((prev) => ({ + ...prev, + secretAccessKey: e.target.value, + })) + } + autoComplete="current-password" + type="password" + spellCheck={false} + required + className="h-11 text-base sm:h-8 sm:text-xs" + placeholder={t("Please enter key")} + /> + + + + ) : ( + <> + + {t("STS Username")} + + + setSts((prev) => ({ + ...prev, + accessKeyId: e.target.value, + })) + } + autoComplete="new-password" + type="text" + spellCheck={false} + required + className="h-11 text-base sm:h-8 sm:text-xs" + placeholder={t("Please enter STS username")} + /> + + + + {t("STS Key")} + + + setSts((prev) => ({ + ...prev, + secretAccessKey: e.target.value, + })) + } + autoComplete="new-password" + type="password" + spellCheck={false} + required + className="h-11 text-base sm:h-8 sm:text-xs" + placeholder={t("Please enter STS key")} + /> + + + + {t("STS Session Token")} + + + setSts((prev) => ({ + ...prev, + sessionToken: e.target.value, + })) + } + autoComplete="new-password" + type="text" + spellCheck={false} + required + className="h-11 text-base sm:h-8 sm:text-xs" + placeholder={t("Please enter STS session token")} + /> + + + + )} + + +
+
+
+
+ )} - {oidcProviders && oidcProviders.length > 0 && onOidcLogin && ( + {!secondFactor && oidcProviders && oidcProviders.length > 0 && onOidcLogin && (
diff --git a/components/object/tiff-viewer.tsx b/components/object/tiff-viewer.tsx index 8941a108..45b61e28 100644 --- a/components/object/tiff-viewer.tsx +++ b/components/object/tiff-viewer.tsx @@ -71,11 +71,7 @@ export function TiffViewer({ url, objectKey }: TiffViewerProps) { } catch (err: unknown) { if (cancelled) return const message = - err instanceof Error && err.name === "AbortError" - ? "" - : err instanceof Error - ? err.message - : String(err) + err instanceof Error && err.name === "AbortError" ? "" : err instanceof Error ? err.message : String(err) setError(message || t("Preview unavailable")) setLoading(false) } @@ -116,21 +112,12 @@ export function TiffViewer({ url, objectKey }: TiffViewerProps) { } if (error) { - return ( -
- {error} -
- ) + return
{error}
} return (
- +
) } diff --git a/components/user/change-password.tsx b/components/user/change-password.tsx deleted file mode 100644 index 10d77b4b..00000000 --- a/components/user/change-password.tsx +++ /dev/null @@ -1,181 +0,0 @@ -"use client" - -import { useState } from "react" -import { useTranslation } from "react-i18next" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Spinner } from "@/components/ui/spinner" -import { Field, FieldContent, FieldError, FieldLabel } from "@/components/ui/field" -import { useMessage } from "@/lib/feedback/message" -import { useApiOptional } from "@/contexts/api-context" -import { useUsers } from "@/hooks/use-users" - -interface ChangePasswordProps { - visible: boolean - onVisibleChange: (visible: boolean) => void -} - -const PASSWORD_MIN_LENGTH = 8 -const PASSWORD_MAX_LENGTH = 40 - -export function ChangePassword({ visible, onVisibleChange }: ChangePasswordProps) { - const { t } = useTranslation() - const message = useMessage() - const api = useApiOptional() - const { createUser } = useUsers() - - const [newSecretKey, setNewSecretKey] = useState("") - const [reNewSecretKey, setReNewSecretKey] = useState("") - const [errors, setErrors] = useState({ - new: "", - reNew: "", - }) - const [submitting, setSubmitting] = useState(false) - - const clearForm = () => { - setNewSecretKey("") - setReNewSecretKey("") - setErrors({ new: "", reNew: "" }) - setSubmitting(false) - } - - const closeModal = (open = false) => { - onVisibleChange(open) - if (!open) clearForm() - } - - const handleOpenChange = (open: boolean) => { - if (!submitting || open) closeModal(open) - } - - const validate = () => { - const newErrors = { - new: !newSecretKey - ? t("Please enter new password") - : newSecretKey.length < PASSWORD_MIN_LENGTH || newSecretKey.length > PASSWORD_MAX_LENGTH - ? t("password length cannot be less than 8 characters and greater than 40 characters") - : "", - reNew: !reNewSecretKey - ? t("Please enter new password again") - : reNewSecretKey !== newSecretKey - ? t("The two passwords are inconsistent") - : "", - } - setErrors(newErrors) - return !newErrors.new && !newErrors.reNew - } - - const submitForm = async () => { - if (submitting) return - if (!validate()) { - message.error(t("Please fill in the correct format")) - return - } - - setSubmitting(true) - try { - if (!api) { - message.error(t("API not ready")) - return - } - const userInfo = (await api.get("/accountinfo")) as { account_name?: string } - if (!userInfo?.account_name) { - message.error(t("Failed to get data")) - return - } - await createUser( - { - accessKey: userInfo.account_name, - secretKey: newSecretKey, - status: "enabled", - }, - { suppress403Redirect: true }, - ) - message.success(t("Updated successfully")) - closeModal() - } catch (error) { - console.error(error) - message.error((error as Error)?.message || t("Update failed")) - } finally { - setSubmitting(false) - } - } - - return ( - - - - {t("Change Password")} - - -
{ - event.preventDefault() - void submitForm() - }} - > -
- - {t("New Password")} - - setNewSecretKey(e.target.value)} - type="password" - autoComplete="new-password" - spellCheck={false} - minLength={PASSWORD_MIN_LENGTH} - maxLength={PASSWORD_MAX_LENGTH} - required - disabled={submitting} - aria-invalid={Boolean(errors.new)} - aria-describedby={errors.new ? "password-new-error" : undefined} - /> - - {errors.new} - - - - {t("Confirm New Password")} - - setReNewSecretKey(e.target.value)} - type="password" - autoComplete="new-password" - spellCheck={false} - minLength={PASSWORD_MIN_LENGTH} - maxLength={PASSWORD_MAX_LENGTH} - required - disabled={!newSecretKey || submitting} - aria-invalid={Boolean(errors.reNew)} - aria-describedby={errors.reNew ? "password-new-confirm-error" : undefined} - /> - - {errors.reNew} - -
- - - - - -
-
-
- ) -} diff --git a/components/user/dropdown.tsx b/components/user/dropdown.tsx index 6e3f6067..b2c03133 100644 --- a/components/user/dropdown.tsx +++ b/components/user/dropdown.tsx @@ -2,16 +2,23 @@ import { useEffect, useState } from "react" import Image from "next/image" +import Link from "next/link" import { useRouter } from "next/navigation" import { useTranslation } from "react-i18next" import { useTheme } from "next-themes" -import { RiUserLine, RiLockPasswordLine, RiLogoutBoxRLine, RiMore2Line } from "@remixicon/react" +import { RiLogoutBoxRLine, RiMore2Line, RiShieldKeyholeLine, RiUserSettingsLine } from "@remixicon/react" import { buildRoute, getLoginRoute } from "@/lib/routes" import { Button } from "@/components/ui/button" -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { useAuth } from "@/contexts/auth-context" import { usePermissions } from "@/hooks/use-permissions" -import { ChangePassword } from "./change-password" import { useSidebar } from "@/components/ui/sidebar" import { getThemeManifest } from "@/lib/theme/manifest" @@ -49,16 +56,10 @@ export function UserDropdown() { const preferredAvatarPath = resolvedTheme === "dark" ? withDarkVariant(baseAvatarPath) : baseAvatarPath const [avatar, setAvatar] = useState(() => resolveAvatarPath(preferredAvatarPath)) - const [changePasswordVisible, setChangePasswordVisible] = useState(false) - useEffect(() => { setAvatar(resolveAvatarPath(preferredAvatarPath)) }, [preferredAvatarPath]) - const handleChangePassword = () => { - setChangePasswordVisible(true) - } - const handleLogout = async () => { const redirected = await logoutWithOidcRedirect() if (!redirected) { @@ -66,54 +67,70 @@ export function UserDropdown() { } } + const accountName = (userInfo as { account_name?: string })?.account_name ?? "" + const roleLabel = isAdmin ? t("Administrator") : t("User") + return ( - <> - - + + + { + const fallback = resolveAvatarPath(baseAvatarPath) + setAvatar((current) => (current === fallback ? current : fallback)) + }} + /> + + {!isCollapsed && ( + <> + {/* The name belongs on the trigger too: the menu should confirm + the identity, not be the only place to discover it. */} + {accountName} + + + )} + + } + /> + + {/* Identity first: who am I, and with what authority. A menu that opens + on an avatar with no name leaves both unanswered. */} + + + {accountName || t("Unknown user")} + + {roleLabel} + + + -
- - {theme.brand.name} { - const fallback = resolveAvatarPath(baseAvatarPath) - setAvatar((current) => (current === fallback ? current : fallback)) - }} - /> - -
- {!isCollapsed && } - + + + {t("Profile")} + } /> - - - - {(userInfo as { account_name?: string })?.account_name ?? ""} -
- } - /> - {!isAdmin && ( - - - {t("Change Password")} - - )} - - - {t("Logout")} - - - - - - + + + {t("Security")} + + } + /> + + + + {t("Logout")} + + + ) } diff --git a/contexts/auth-context.tsx b/contexts/auth-context.tsx index 47168c4a..9b48d850 100644 --- a/contexts/auth-context.tsx +++ b/contexts/auth-context.tsx @@ -6,6 +6,8 @@ import type { SiteConfig } from "@/types/config" import { getLoginRoute } from "@/lib/routes" import { useLocalStorage } from "@/hooks/use-local-storage" import { buildOidcLogoutUrl, type OidcLogoutSession } from "@/lib/oidc" +import { isMfaRequiredError } from "@/lib/mfa" +import { fetchMfaChallenge } from "@/lib/mfa-challenge" interface Credentials { AccessKeyId?: string @@ -14,11 +16,26 @@ interface Credentials { Expiration?: string } +/** + * Result of a first login attempt. + * + * A demand for a second factor is an expected branch of a successful password + * check, not an error, so it is modelled as an outcome rather than thrown. The + * long-term credentials stay in the caller's state for the second call and are + * never persisted. + */ +export type LoginOutcome = { status: "authenticated" } | { status: "mfa-required"; challenge?: string } + interface AuthContextValue { login: ( credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider, customConfig?: SiteConfig, - ) => Promise + ) => Promise + completeLoginWithSecondFactor: ( + credentials: AwsCredentialIdentity, + secondFactor: { code: string; challenge?: string }, + customConfig?: SiteConfig, + ) => Promise loginWithStsCredentials: (credentials: Credentials, oidcSession?: OidcLogoutSession) => Promise logout: () => void logoutAndRedirect: () => void @@ -45,6 +62,19 @@ function isValidCredentials(credentials: Credentials | undefined): boolean { return !isExpired(credentials.Expiration) } +/** + * Whether these credentials are a static key pair we can sign a probe with. + * + * A `AwsCredentialIdentityProvider` is a function; resolving it here to read the + * secret would duplicate what the SDK does during signing, so those callers skip + * the probe and rely on AssumeRole's error instead. + */ +function isStaticCredentials( + credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider, +): credentials is AwsCredentialIdentity { + return typeof credentials !== "function" && typeof credentials?.accessKeyId === "string" +} + function isValidOidcLogoutSession(session: OidcLogoutSession | undefined): session is OidcLogoutSession { return typeof session?.logoutToken === "string" && session.logoutToken.trim().length > 0 } @@ -87,30 +117,85 @@ export function AuthProvider({ children }: { children: ReactNode }) { return !!isAdminStore }, [isAdminStore]) - const login = useCallback( - async (credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider, customConfig?: SiteConfig) => { - if (!customConfig) { - const { configManager } = await import("@/lib/config") - customConfig = await configManager.loadConfig() - } - - const { getStsToken } = await import("@/lib/sts") - const credentialsResponse = await getStsToken(credentials, "arn:aws:iam::*:role/Admin", customConfig) + const resolveConfig = useCallback(async (customConfig?: SiteConfig) => { + if (customConfig) return customConfig + const { configManager } = await import("@/lib/config") + return configManager.loadConfig() + }, []) + const storeStsCredentials = useCallback( + (credentialsResponse: { + AccessKeyId?: string + SecretAccessKey?: string + SessionToken?: string + Expiration?: Date + }) => { setCredentials({ - ...credentialsResponse, AccessKeyId: credentialsResponse.AccessKeyId, SecretAccessKey: credentialsResponse.SecretAccessKey, SessionToken: credentialsResponse.SessionToken, Expiration: credentialsResponse.Expiration?.toISOString(), }) setOidcSession(undefined) - - return credentialsResponse }, [setCredentials, setOidcSession], ) + const login = useCallback( + async ( + credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider, + customConfig?: SiteConfig, + ): Promise => { + const config = await resolveConfig(customConfig) + const { getStsToken } = await import("@/lib/sts") + + // Ask before attempting, when the credentials are a static pair we can + // sign with. A credential *provider* cannot be probed this way, so those + // fall through to the AssumeRole error below. + const staticCredentials = isStaticCredentials(credentials) ? credentials : undefined + if (staticCredentials) { + const challenge = await fetchMfaChallenge( + { + accessKeyId: staticCredentials.accessKeyId, + secretAccessKey: staticCredentials.secretAccessKey, + }, + config, + ) + if (challenge.required) { + return { status: "mfa-required", challenge: challenge.challenge } + } + } + + try { + storeStsCredentials(await getStsToken(credentials, "arn:aws:iam::*:role/Admin", config)) + return { status: "authenticated" } + } catch (error) { + // Backstop for the cases the probe could not cover: a credential + // provider, or a server that has no challenge endpoint but does enforce + // the factor. AssumeRole fails closed and says so. + if (isMfaRequiredError(error)) { + return { status: "mfa-required" } + } + throw error + } + }, + [resolveConfig, storeStsCredentials], + ) + + const completeLoginWithSecondFactor = useCallback( + async ( + credentials: AwsCredentialIdentity, + secondFactor: { code: string; challenge?: string }, + customConfig?: SiteConfig, + ) => { + const config = await resolveConfig(customConfig) + const { getStsToken } = await import("@/lib/sts") + + storeStsCredentials(await getStsToken(credentials, "arn:aws:iam::*:role/Admin", config, secondFactor)) + }, + [resolveConfig, storeStsCredentials], + ) + const loginWithStsCredentials = useCallback( async (creds: Credentials, oidcSession?: OidcLogoutSession) => { setCredentials({ @@ -164,6 +249,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ login, + completeLoginWithSecondFactor, loginWithStsCredentials, logout, logoutAndRedirect, @@ -176,6 +262,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }), [ login, + completeLoginWithSecondFactor, loginWithStsCredentials, logout, logoutAndRedirect, diff --git a/docs/ui-review/register.md b/docs/ui-review/register.md index 6611e3fb..e2701035 100644 --- a/docs/ui-review/register.md +++ b/docs/ui-review/register.md @@ -14,39 +14,41 @@ Use the resolved rows for the PR description: ## Audit record -| ID | Surface | Finding | Impact | Resolution | Evidence key | Status | -| ------ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | -------- | -| UI-001 | Running status | Loading, partial, and failed server data were visually conflated. | Operators could act on stale or incomplete health information. | Separated status states and added explicit feedback/retry treatment. | `running-status-10` | Resolved | -| UI-002 | Pool operations | Long-running rebalance/decommission flows did not consistently expose state, errors, and mobile-safe actions. | Destructive operations were difficult to verify and recover from. | Added state-aware action handling, error surfaces, and responsive layouts. | `pool-operations-9` | Resolved | -| UI-003 | Bucket settings | Configuration tabs had inconsistent hierarchy and several flows could reset state, mask errors, or apply unsafe replication semantics. | Misconfiguration risk and poor recovery in lifecycle, events, replication, and encryption flows. | Normalized settings layout; preserved form state; surfaced errors; corrected replication role semantics. | `bucket-settings-11` | Resolved | -| UI-004 | SSE status | A failed status request could be displayed as “not configured”, while stale backend/metrics remained visible; long default key IDs could overflow mobile cards. | Operators could configure or mutate KMS from an untrusted state, and mobile users could lose key context. | Keep the failure distinct from unconfigured; hide all stale details; block stateful actions while status syncs; wrap identifiers. | `sse-16-overview` | Resolved | -| UI-005 | SSE configuration | Long configuration form had weak grouping, incomplete required semantics, and no field-targeted client-side error recovery. | High cognitive load and inaccessible error correction. | Use fieldsets/legends, client validation with focus, and native/ARIA required state. | `sse-14-progressive` | Resolved | -| UI-006 | SSE mutations | KMS create/reconfigure/key actions could overlap or proceed from stale status; uncertain responses left the UI unverified. | Conflicting writes and incorrect default-key deletion decisions. | Serialize mutations, keep create locked while key inventory is loading or failed, reconcile uncertain outcomes from the server, and recheck default-key status before delete. | `sse-16-recaptured-states` | Resolved | -| UI-007 | SSE key actions | Mobile cards dropped schedule/cancel deletion actions; immediate deletion was over-prominent. | Mobile users did not have desktop-equivalent, safe key management. | Provide action parity and present staged deletion before immediate deletion. | `sse-18-key-card-flow` | Resolved | -| UI-008 | SSE confirmation | Key dialogs did not name the target/default-key risk; service restart/stop bypassed confirmation. | Operators could apply destructive/disruptive actions without enough context. | Target-aware destructive confirmation, service-state confirmation, and dismissal lock while processing. | `sse-17-service-stop` | Resolved | -| UI-009 | SSE secrets | Diagnostic request logging could expose credentials. | Vault token leakage through client diagnostics. | Redact secret-bearing request fields before logging. | `sse-13-security` | Resolved | -| UI-010 | SSE Local backend | Local KMS was offered without the backend contract needed to preserve/validate an opaque master key; a reconfiguration could make existing keys unreadable. | Production Local KMS configuration and default-key updates could weaken secret handling or make data unrecoverable. | Make Local configuration read-only in Console until the backend offers preservation plus verified rotation; keep Local default-key changes server-managed. | `sse-16-local-safety` | Resolved | -| UI-011 | SSE unsaved form | Sidebar navigation, browser history, and page exit could silently discard a long KMS configuration form. | Operators could lose backend settings or newly-entered credentials. | Keep a server baseline, guard unload/link/history navigation, and prohibit discarding while a mutation is in progress. | `sse-16-dirty-form` | Resolved | -| UI-012 | IAM user/group selectors | Multi-select controls did not expose their selected state, search purpose, or listbox semantics; edit mode could also leave keyboard focus behind. | Keyboard and assistive-technology users could not reliably understand or resume the current edit flow. | Added named search fields, put multiselect semantics on the actual CommandList, synchronized selected state around cmdk's active-option attribute, and restored focus only on real edit transitions. | `iam-13-a11y` | Resolved | -| UI-013 | SSE configuration | Reliability and cache controls made the mobile KMS form unnecessarily tall and pushed the primary actions below the fold. | First-time operators had to scan a dense expert-only section before reaching the save/reset actions. | Grouped retry/cache controls under a native Advanced Settings disclosure; validation state reopens the section when an affected field needs attention. | `sse-14-progressive` | Resolved | -| UI-014 | IAM group tables | Members and policy tables had no explicit accessible name in the reusable table primitive. | Screen-reader users could lose context when several tables or dialogs were present. | Added an optional visually-hidden table caption and supplied localized captions for the Members and Policies tables without changing the visual layout. | `iam-14-captions` | Resolved | -| UI-015 | OIDC configuration | Claim and role mapping controls occupied the same long mobile flow as the connection credentials and redirect settings. | Operators had to scan a dense expert-only section before reaching the next page section. | Kept provider identity, credentials, scopes, and redirect controls visible; moved claim/role mapping into a native Advanced Settings disclosure. | `oidc-15-progressive` | Resolved | -| UI-016 | Module switch settings | A failed module-switch read left an empty bordered panel after the toast disappeared, with no local retry and no visible indication that values were unavailable. | Operators could mistake an unavailable settings read for an empty configuration and could not recover in context. | Added a persistent destructive alert with the error detail and Sync retry; retained stale values as non-editable while a refresh is unresolved. | `settings-20-error` | Resolved | -| UI-017 | Access-key dialogs | Long credential forms could push actions out of the viewport and did not consistently explain expiry or field errors. | Operators could lose the only chance to copy credentials or submit a corrected form. | Fixed the dialog header/body/footer layout, kept actions reachable, and added expiry guidance plus field-specific validation. | `remaining-21` | Resolved | -| UI-018 | User/group workflows | Assignment and edit dialogs could grow with badges, conflate loading/error/empty states, and retry already-succeeded requests. | Bulk IAM changes were hard to recover and could leave users unsure which groups were updated. | Bounded dialog bodies, added explicit state/retry treatment, compact selection summaries, and retry only failed group operations. | `remaining-21` | Resolved | -| UI-019 | Shared tables/navigation | Pagination and table semantics were difficult to use on narrow screens and lacked context for assistive technology. | Keyboard and mobile users could lose table context or fail to reach page actions. | Added captions/sort semantics, mobile pagination layout, and a single predictable overflow surface. | `remaining-21` | Resolved | -| UI-020 | Utility surfaces | Long filenames, object rows, and license text could overflow or become unreadable on narrow screens. | Import/export and inspection tasks required horizontal scrolling or clipped content. | Added bounded readable surfaces, safe wrapping, and mobile-first action alignment. | `remaining-21` | Resolved | -| UI-021 | Authentication surfaces | Login/configuration hero layouts and actions did not consistently use dynamic viewport sizing or touch-safe targets. | Mobile sign-in could be clipped or require imprecise taps. | Switched to dynamic viewport-safe layout and full-width 44px mobile actions. | `remaining-21` | Resolved | -| UI-022 | Rule/task dialogs | Event, audit, lifecycle, replication, tier, and site-replication forms could hide their footer actions behind long content. | Operators could not reliably save or cancel long configuration flows. | Standardized one scrolling body with fixed header/footer and disabled dismissal while submitting. | `remaining-21` | Resolved | -| UI-023 | Dialog/form hierarchy | Full rectangular borders were reused for outer surfaces, semantic groups, descriptions, metadata, and progress states. | Repeated nested frames weakened hierarchy, increased cognitive load, and made passive content look interactive. | Initial remediation mechanically replaced boxes with recursive separators, while its fixture misrepresented the real hierarchy. Rework and valid recapture are pending. | `border-depth-22` invalid | Open | -| UI-024 | Section divider hierarchy | SSE configuration repeats dividers across sibling fieldsets, Advanced Settings, and nested cache content; key details and site-replication content also stack dividers across semantic levels. | Repeated lines imply false parent-child relationships and make content appear detached from its heading. | Use one divider system only between peer sections; use headings and spacing inside a section, and remove duplicate dividers after already-divided metadata. | Pending live capture | Open | -| UI-025 | Passive metadata surfaces | SSE status metrics, selected-pool details, and site-replication peer metadata are rendered as complete framed panels inside an existing Card or dialog surface. | Passive values look interactive and create card-inside-card depth. | Replace nested panels with definition lists, open grids, muted backgrounds, or a single section separator while retaining warning and selection affordances. | Pending live capture | Open | -| UI-026 | Compound editor rows | Lifecycle/replication tag editors, access-key policy switches, and IAM edit panels add complete frames around already-bordered controls inside dialogs. | Repeated outlines increase density and make ordinary fields look like independent subcards. | Keep input/control outlines, but express the containing editor row with spacing, a muted background, or one sibling divider unless it is itself a selectable object. | Pending live capture | Open | -| UI-027 | Login form | The desktop form pane used 7/12 of the split panel and allowed a short credential form to expand to `max-w-md`. | Credential fields looked visually loose and the form outweighed the supporting hero. | Balanced the desktop split at 1/2 and constrained the credential form to `max-w-sm` without changing mobile behavior. | `layout-followup-27` | Resolved | -| UI-028 | Edit Policy dialog | The complete form body owned vertical scrolling and the policy editor had a 24rem minimum height with manual resizing. | Name/context and editor moved together, and the editor could force an unnecessarily tall dialog. | Kept the form body fixed, made the Policy field consume remaining space, and confined scrolling to a non-resizable textarea with a 12rem minimum. | `layout-followup-27` | Resolved | -| UI-029 | Running status controls | Mobile `min-h-11` rules were also applied at desktop widths to Refresh, server filters, and the Sort by trigger. | Compact status toolbars appeared stretched and misaligned with surrounding desktop controls. | Preserved 44px controls on small screens and restored the shared compact control height from `sm` upward. | `layout-followup-27` | Resolved | -| UI-030 | Bucket rule detail pages | Events, Replication, and Lifecycle rendered Add/Refresh in a second toolbar below the page header, separate from Back navigation. | Related page-level actions were split across two rows and displaced the table downward. | Added a tab header render seam and composed Back, Add, and Refresh together in the page header for all three scoped bucket pages. | `layout-followup-27` | Resolved | -| UI-031 | Top navigation account | The account artwork and its button trigger were both 32px high, so the circular avatar consumed the trigger's complete vertical space. | The avatar visually touched the trigger boundary and crowded adjacent top-navigation controls. | Reduced the artwork to 24px while retaining the 32px button target, leaving a visible 4px inset around the avatar. | `avatar-inset-28` | Resolved | +| ID | Surface | Finding | Impact | Resolution | Evidence key | Status | +| ------ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------- | -------- | +| UI-001 | Running status | Loading, partial, and failed server data were visually conflated. | Operators could act on stale or incomplete health information. | Separated status states and added explicit feedback/retry treatment. | `running-status-10` | Resolved | +| UI-002 | Pool operations | Long-running rebalance/decommission flows did not consistently expose state, errors, and mobile-safe actions. | Destructive operations were difficult to verify and recover from. | Added state-aware action handling, error surfaces, and responsive layouts. | `pool-operations-9` | Resolved | +| UI-003 | Bucket settings | Configuration tabs had inconsistent hierarchy and several flows could reset state, mask errors, or apply unsafe replication semantics. | Misconfiguration risk and poor recovery in lifecycle, events, replication, and encryption flows. | Normalized settings layout; preserved form state; surfaced errors; corrected replication role semantics. | `bucket-settings-11` | Resolved | +| UI-004 | SSE status | A failed status request could be displayed as “not configured”, while stale backend/metrics remained visible; long default key IDs could overflow mobile cards. | Operators could configure or mutate KMS from an untrusted state, and mobile users could lose key context. | Keep the failure distinct from unconfigured; hide all stale details; block stateful actions while status syncs; wrap identifiers. | `sse-16-overview` | Resolved | +| UI-005 | SSE configuration | Long configuration form had weak grouping, incomplete required semantics, and no field-targeted client-side error recovery. | High cognitive load and inaccessible error correction. | Use fieldsets/legends, client validation with focus, and native/ARIA required state. | `sse-14-progressive` | Resolved | +| UI-006 | SSE mutations | KMS create/reconfigure/key actions could overlap or proceed from stale status; uncertain responses left the UI unverified. | Conflicting writes and incorrect default-key deletion decisions. | Serialize mutations, keep create locked while key inventory is loading or failed, reconcile uncertain outcomes from the server, and recheck default-key status before delete. | `sse-16-recaptured-states` | Resolved | +| UI-007 | SSE key actions | Mobile cards dropped schedule/cancel deletion actions; immediate deletion was over-prominent. | Mobile users did not have desktop-equivalent, safe key management. | Provide action parity and present staged deletion before immediate deletion. | `sse-18-key-card-flow` | Resolved | +| UI-008 | SSE confirmation | Key dialogs did not name the target/default-key risk; service restart/stop bypassed confirmation. | Operators could apply destructive/disruptive actions without enough context. | Target-aware destructive confirmation, service-state confirmation, and dismissal lock while processing. | `sse-17-service-stop` | Resolved | +| UI-009 | SSE secrets | Diagnostic request logging could expose credentials. | Vault token leakage through client diagnostics. | Redact secret-bearing request fields before logging. | `sse-13-security` | Resolved | +| UI-010 | SSE Local backend | Local KMS was offered without the backend contract needed to preserve/validate an opaque master key; a reconfiguration could make existing keys unreadable. | Production Local KMS configuration and default-key updates could weaken secret handling or make data unrecoverable. | Make Local configuration read-only in Console until the backend offers preservation plus verified rotation; keep Local default-key changes server-managed. | `sse-16-local-safety` | Resolved | +| UI-011 | SSE unsaved form | Sidebar navigation, browser history, and page exit could silently discard a long KMS configuration form. | Operators could lose backend settings or newly-entered credentials. | Keep a server baseline, guard unload/link/history navigation, and prohibit discarding while a mutation is in progress. | `sse-16-dirty-form` | Resolved | +| UI-012 | IAM user/group selectors | Multi-select controls did not expose their selected state, search purpose, or listbox semantics; edit mode could also leave keyboard focus behind. | Keyboard and assistive-technology users could not reliably understand or resume the current edit flow. | Added named search fields, put multiselect semantics on the actual CommandList, synchronized selected state around cmdk's active-option attribute, and restored focus only on real edit transitions. | `iam-13-a11y` | Resolved | +| UI-013 | SSE configuration | Reliability and cache controls made the mobile KMS form unnecessarily tall and pushed the primary actions below the fold. | First-time operators had to scan a dense expert-only section before reaching the save/reset actions. | Grouped retry/cache controls under a native Advanced Settings disclosure; validation state reopens the section when an affected field needs attention. | `sse-14-progressive` | Resolved | +| UI-014 | IAM group tables | Members and policy tables had no explicit accessible name in the reusable table primitive. | Screen-reader users could lose context when several tables or dialogs were present. | Added an optional visually-hidden table caption and supplied localized captions for the Members and Policies tables without changing the visual layout. | `iam-14-captions` | Resolved | +| UI-015 | OIDC configuration | Claim and role mapping controls occupied the same long mobile flow as the connection credentials and redirect settings. | Operators had to scan a dense expert-only section before reaching the next page section. | Kept provider identity, credentials, scopes, and redirect controls visible; moved claim/role mapping into a native Advanced Settings disclosure. | `oidc-15-progressive` | Resolved | +| UI-016 | Module switch settings | A failed module-switch read left an empty bordered panel after the toast disappeared, with no local retry and no visible indication that values were unavailable. | Operators could mistake an unavailable settings read for an empty configuration and could not recover in context. | Added a persistent destructive alert with the error detail and Sync retry; retained stale values as non-editable while a refresh is unresolved. | `settings-20-error` | Resolved | +| UI-017 | Access-key dialogs | Long credential forms could push actions out of the viewport and did not consistently explain expiry or field errors. | Operators could lose the only chance to copy credentials or submit a corrected form. | Fixed the dialog header/body/footer layout, kept actions reachable, and added expiry guidance plus field-specific validation. | `remaining-21` | Resolved | +| UI-018 | User/group workflows | Assignment and edit dialogs could grow with badges, conflate loading/error/empty states, and retry already-succeeded requests. | Bulk IAM changes were hard to recover and could leave users unsure which groups were updated. | Bounded dialog bodies, added explicit state/retry treatment, compact selection summaries, and retry only failed group operations. | `remaining-21` | Resolved | +| UI-019 | Shared tables/navigation | Pagination and table semantics were difficult to use on narrow screens and lacked context for assistive technology. | Keyboard and mobile users could lose table context or fail to reach page actions. | Added captions/sort semantics, mobile pagination layout, and a single predictable overflow surface. | `remaining-21` | Resolved | +| UI-020 | Utility surfaces | Long filenames, object rows, and license text could overflow or become unreadable on narrow screens. | Import/export and inspection tasks required horizontal scrolling or clipped content. | Added bounded readable surfaces, safe wrapping, and mobile-first action alignment. | `remaining-21` | Resolved | +| UI-021 | Authentication surfaces | Login/configuration hero layouts and actions did not consistently use dynamic viewport sizing or touch-safe targets. | Mobile sign-in could be clipped or require imprecise taps. | Switched to dynamic viewport-safe layout and full-width 44px mobile actions. | `remaining-21` | Resolved | +| UI-022 | Rule/task dialogs | Event, audit, lifecycle, replication, tier, and site-replication forms could hide their footer actions behind long content. | Operators could not reliably save or cancel long configuration flows. | Standardized one scrolling body with fixed header/footer and disabled dismissal while submitting. | `remaining-21` | Resolved | +| UI-023 | Dialog/form hierarchy | Full rectangular borders were reused for outer surfaces, semantic groups, descriptions, metadata, and progress states. | Repeated nested frames weakened hierarchy, increased cognitive load, and made passive content look interactive. | Initial remediation mechanically replaced boxes with recursive separators, while its fixture misrepresented the real hierarchy. Rework and valid recapture are pending. | `border-depth-22` invalid | Open | +| UI-024 | Section divider hierarchy | SSE configuration repeats dividers across sibling fieldsets, Advanced Settings, and nested cache content; key details and site-replication content also stack dividers across semantic levels. | Repeated lines imply false parent-child relationships and make content appear detached from its heading. | Use one divider system only between peer sections; use headings and spacing inside a section, and remove duplicate dividers after already-divided metadata. | Pending live capture | Open | +| UI-025 | Passive metadata surfaces | SSE status metrics, selected-pool details, and site-replication peer metadata are rendered as complete framed panels inside an existing Card or dialog surface. | Passive values look interactive and create card-inside-card depth. | Replace nested panels with definition lists, open grids, muted backgrounds, or a single section separator while retaining warning and selection affordances. | Pending live capture | Open | +| UI-026 | Compound editor rows | Lifecycle/replication tag editors, access-key policy switches, and IAM edit panels add complete frames around already-bordered controls inside dialogs. | Repeated outlines increase density and make ordinary fields look like independent subcards. | Keep input/control outlines, but express the containing editor row with spacing, a muted background, or one sibling divider unless it is itself a selectable object. | Pending live capture | Open | +| UI-027 | Account menu | The menu opened on an avatar with no name and no role, and password management was hidden from administrators because the backend rejected the `add-user` call it made. | Operators could not tell which identity they were signed in as, and administrators had no way to change their own password at all. | Named the identity and its authority on both the trigger and the menu; added Profile and Security entries; replaced the `add-user` call with `POST /account/password` so the control works for every identity that may use it. | `account-2fa-29` | Resolved | +| UI-028 | Recovery code disclosure | A newly generated recovery-code set is the only copy that will ever exist; a dialog that could be dismissed freely, or closed by its parent, would silently destroy it. | An operator could lose their only fallback into a two-factor-protected account without ever seeing a warning. | Kept setup in one dialog with internal steps instead of a nested chain, disabled the acknowledge action until the codes are copied or downloaded, and removed the close affordance while they are on screen. | `account-2fa-29` | Resolved | +| UI-027 | Login form | The desktop form pane used 7/12 of the split panel and allowed a short credential form to expand to `max-w-md`. | Credential fields looked visually loose and the form outweighed the supporting hero. | Balanced the desktop split at 1/2 and constrained the credential form to `max-w-sm` without changing mobile behavior. | `layout-followup-27` | Resolved | +| UI-028 | Edit Policy dialog | The complete form body owned vertical scrolling and the policy editor had a 24rem minimum height with manual resizing. | Name/context and editor moved together, and the editor could force an unnecessarily tall dialog. | Kept the form body fixed, made the Policy field consume remaining space, and confined scrolling to a non-resizable textarea with a 12rem minimum. | `layout-followup-27` | Resolved | +| UI-029 | Running status controls | Mobile `min-h-11` rules were also applied at desktop widths to Refresh, server filters, and the Sort by trigger. | Compact status toolbars appeared stretched and misaligned with surrounding desktop controls. | Preserved 44px controls on small screens and restored the shared compact control height from `sm` upward. | `layout-followup-27` | Resolved | +| UI-030 | Bucket rule detail pages | Events, Replication, and Lifecycle rendered Add/Refresh in a second toolbar below the page header, separate from Back navigation. | Related page-level actions were split across two rows and displaced the table downward. | Added a tab header render seam and composed Back, Add, and Refresh together in the page header for all three scoped bucket pages. | `layout-followup-27` | Resolved | +| UI-031 | Top navigation account | The account artwork and its button trigger were both 32px high, so the circular avatar consumed the trigger's complete vertical space. | The avatar visually touched the trigger boundary and crowded adjacent top-navigation controls. | Reduced the artwork to 24px while retaining the 32px button target, leaving a visible 4px inset around the avatar. | `avatar-inset-28` | Resolved | ### Reconciled border rule @@ -83,6 +85,7 @@ The 2026-07-11 follow-up reviewed 44 candidate files containing cards, dialogs, - `border-depth-22` is invalid because the fixture invented a nested SSE hierarchy that does not match the component tree. Do not cite it as improvement evidence. - `layout-followup-27` is a source-faithful static fixture for the four changed layout relationships; source tests and component code remain the implementation proof until authenticated live captures replace it. - `avatar-inset-28` is a source-faithful static fixture for the exact 32px trigger and 32px-to-24px artwork change; source tests and component code remain authoritative. +- `account-2fa-29` is a set of live runtime captures, not fixtures: a debug RustFS server with `RUSTFS_IAM_MASTER_KEY` set, this branch on `pnpm dev`, root identity, viewport 1585x1202 CSS px (the setup-dialog frame is 628 CSS px). It covers 2FA off/on, the setup QR, the recovery-code dismissal guard, the profile page, and the login second-factor step. It does **not** include a mobile viewport or a `main`-branch before-capture of the account menu; the responsive rules and the removed administrator gate are covered by source tests, which remain authoritative for those two. - The full on-disk mapping is maintained in the screenshot manifest next to the image archive. ## Latest verification diff --git a/hooks/use-account.ts b/hooks/use-account.ts new file mode 100644 index 00000000..0f0eccbe --- /dev/null +++ b/hooks/use-account.ts @@ -0,0 +1,144 @@ +"use client" + +import { useCallback } from "react" +import { useApiOptional } from "@/contexts/api-context" +import type { MfaChallenge, MfaEnrollment, MfaStatus } from "@/lib/mfa" + +/** Where the calling identity's long-term secret lives. */ +export type CredentialsSource = "env" | "iam" + +/** Which kind of credential is making the request. */ +export type IdentityType = "root" | "iam" | "sts" | "service-account" + +export interface AccountMutability { + password: boolean + username: boolean +} + +export interface AccountMfaSummary { + enabled: boolean + pending: boolean + activated_at?: string + recovery_codes_remaining: number + last_verified_at?: string + enrollment_available: boolean + enrollment_blocked_reason?: string +} + +/** + * The caller, as described to itself by `GET /account/info`. + * + * `mutable` is the server's answer to "may this credential change its own + * password", so the UI disables a control rather than offering a request that is + * guaranteed to fail. A root identity reports `false`: its secret comes from the + * server environment and also derives the internode RPC secret, so it cannot be + * rotated at runtime. + */ +export interface AccountInfo { + access_key: string + identity_type: IdentityType + session_access_key?: string + is_admin: boolean + status: string + member_of: string[] + policies: string[] + credentials_source: CredentialsSource + mutable: AccountMutability + mfa: AccountMfaSummary +} + +export interface RecoveryCodes { + recovery_codes: string[] + generated_at: string +} + +export interface ChangePasswordResult { + sessions_revoked: number +} + +/** + * Access to the self-service account and MFA endpoints. + * + * Every call targets the caller's own identity; none of them take a target, so + * this hook cannot be used to act on another account. Administrative resets live + * in the user-management surface instead. + */ +export function useAccount() { + const api = useApiOptional() + + const getAccountInfo = useCallback(async (): Promise => { + if (!api) return null + // Suppressed so a server without these endpoints surfaces as a caught error + // the caller can degrade on, instead of bouncing the user to /403. + return (await api.get("/account/info", { suppress403Redirect: true })) as AccountInfo + }, [api]) + + const changePassword = useCallback( + async (currentSecretKey: string, newSecretKey: string): Promise => { + if (!api) return null + return (await api.post( + "/account/password", + { current_secret_key: currentSecretKey, new_secret_key: newSecretKey }, + { suppress403Redirect: true }, + )) as ChangePasswordResult + }, + [api], + ) + + const getMfaStatus = useCallback(async (): Promise => { + if (!api) return null + return (await api.get("/account/mfa", { suppress403Redirect: true })) as MfaStatus + }, [api]) + + const enrollMfa = useCallback(async (): Promise => { + if (!api) return null + return (await api.post("/account/mfa/enroll", {}, { suppress403Redirect: true })) as MfaEnrollment + }, [api]) + + const activateMfa = useCallback( + async (code: string): Promise => { + if (!api) return null + return (await api.post("/account/mfa/activate", { code }, { suppress403Redirect: true })) as RecoveryCodes + }, + [api], + ) + + const disableMfa = useCallback( + async (code: string, currentSecretKey: string): Promise => { + if (!api) return + // Both factors: the code proves possession of the authenticator, the + // secret key proves the person removing the protection is the account + // owner and not a hijacked session. + await api.post( + "/account/mfa/disable", + { code, current_secret_key: currentSecretKey }, + { suppress403Redirect: true }, + ) + }, + [api], + ) + + const regenerateRecoveryCodes = useCallback( + async (code: string): Promise => { + if (!api) return null + return (await api.post("/account/mfa/recovery-codes", { code }, { suppress403Redirect: true })) as RecoveryCodes + }, + [api], + ) + + const getMfaChallenge = useCallback(async (): Promise => { + if (!api) return null + return (await api.get("/mfa/challenge", { suppress403Redirect: true })) as MfaChallenge + }, [api]) + + return { + getAccountInfo, + changePassword, + getMfaStatus, + enrollMfa, + activateMfa, + disableMfa, + regenerateRecoveryCodes, + getMfaChallenge, + } +} diff --git a/i18n/locales/ar-MA.json b/i18n/locales/ar-MA.json index 74fb324b..fa232954 100644 --- a/i18n/locales/ar-MA.json +++ b/i18n/locales/ar-MA.json @@ -1609,5 +1609,73 @@ "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.": "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", "Console cannot safely edit a newer or unknown KMS backend type.": "Console cannot safely edit a newer or unknown KMS backend type.", "Local KMS key files must use owner-only permissions such as 384 for 0o600.": "Local KMS key files must use owner-only permissions such as 384 for 0o600.", - "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration." + "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.", + "A 6-digit code, or one of your recovery codes.": "رمز مكوّن من 6 أرقام، أو أحد رموز الاسترداد الخاصة بك.", + "A new authenticator is waiting to be confirmed. Your current one keeps working until then.": "هناك تطبيق مصادقة جديد ينتظر التأكيد. يظل التطبيق الحالي يعمل حتى ذلك الحين.", + "Administrator": "مسؤول", + "At least 8 characters. This is also your S3 secret key.": "8 أحرف على الأقل. هذا أيضًا هو مفتاح S3 السري الخاص بك.", + "Authentication code": "رمز المصادقة", + "Back": "رجوع", + "Can't scan? Manual setup key": "لا يمكنك المسح؟ مفتاح الإعداد اليدوي", + "Change your password. This is also your S3 secret key.": "غيّر كلمة المرور. هذه أيضًا هي مفتاح S3 السري الخاص بك.", + "Changing your password signs out your other sessions and invalidates the old secret key.": "تغيير كلمة المرور يُخرجك من جلساتك الأخرى ويُبطل المفتاح السري القديم.", + "Copy or download the codes before continuing.": "انسخ الرموز أو نزّلها قبل المتابعة.", + "Done": "تم", + "Enable 2FA": "تمكين المصادقة الثنائية", + "Enabled on": "تم التمكين في", + "Enter a code from your authenticator app": "أدخل رمزًا من تطبيق المصادقة", + "Enter a code from your authenticator app or a recovery code": "أدخل رمزًا من تطبيق المصادقة أو رمز استرداد", + "Enter the 6-digit code": "أدخل الرمز المكوّن من 6 أرقام", + "Enter the 6-digit code from your authenticator app": "أدخل الرمز المكوّن من 6 أرقام من تطبيق المصادقة", + "Enter the code from your authenticator app for {account}.": "أدخل الرمز من تطبيق المصادقة لـ {account}.", + "Generate": "إنشاء", + "Generate new recovery codes": "إنشاء رموز استرداد جديدة", + "IAM user": "مستخدم IAM", + "Invalid verification code": "رمز التحقق غير صالح", + "Last used": "آخر استخدام", + "Or use a recovery code": "أو استخدم رمز استرداد", + "Password and two-factor authentication for your account.": "كلمة المرور والمصادقة الثنائية لحسابك.", + "Password must be at least 8 characters": "يجب أن تكون كلمة المرور 8 أحرف على الأقل", + "Password updated.": "تم تحديث كلمة المرور.", + "Password updated. Other sessions have been signed out.": "تم تحديث كلمة المرور. تم إخراج الجلسات الأخرى.", + "Please enter your current password": "أدخل كلمة المرور الحالية", + "Profile": "الملف الشخصي", + "Protect your account with an authenticator app": "احمِ حسابك بتطبيق مصادقة", + "QR code for two-factor authentication setup": "رمز QR لإعداد المصادقة الثنائية", + "Reconfigure authenticator": "إعادة تهيئة تطبيق المصادقة", + "Recovery codes remaining": "رموز الاسترداد المتبقية", + "Role": "الدور", + "Root credential": "بيانات اعتماد الجذر", + "Save your recovery codes": "احفظ رموز الاسترداد", + "Saved. Store them somewhere only you can reach.": "تم الحفظ. احفظها في مكان لا يمكن لغيرك الوصول إليه.", + "Scan this QR code with your authenticator app.": "امسح رمز QR هذا بتطبيق المصادقة.", + "Security": "الأمان", + "Service account": "حساب خدمة", + "Session access key": "مفتاح وصول الجلسة", + "Temporary session": "جلسة مؤقتة", + "That does not look like a valid code": "لا يبدو هذا رمزًا صالحًا", + "The identity you are signed in as.": "الهوية التي سجّلت الدخول بها.", + "The new password must be different from the current one": "يجب أن تختلف كلمة المرور الجديدة عن الحالية", + "These codes are shown only once.": "تُعرض هذه الرموز مرة واحدة فقط.", + "These codes can be used if you lose access to your authenticator. Each code works once.": "يمكن استخدام هذه الرموز إذا فقدت الوصول إلى تطبيق المصادقة. كل رمز يعمل مرة واحدة.", + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.": "تأتي هذه الهوية من بيئة الخادم (RUSTFS_ACCESS_KEY). غيّرها بإعادة تشغيل الخادم بقيم جديدة.", + "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.": "تأتي هذه الهوية من بيئة الخادم (RUSTFS_ACCESS_KEY). يتم تغيير اسم المستخدم وكلمة المرور بإعادة تشغيل الخادم بقيم جديدة، وليس من لوحة التحكم.", + "This identity's password is managed outside the console.": "تُدار كلمة مرور هذه الهوية خارج لوحة التحكم.", + "Time-based, {digits} digits, {period}s period.": "زمني، {digits} أرقام، دورة {period} ثانية.", + "Turn off": "إيقاف", + "Turn off two-factor authentication": "إيقاف المصادقة الثنائية", + "Two-factor authentication": "المصادقة الثنائية", + "Two-factor authentication is now on.": "المصادقة الثنائية مُفعّلة الآن.", + "Two-factor authentication is off.": "المصادقة الثنائية مُعطّلة.", + "Unknown user": "مستخدم غير معروف", + "Update Password": "تحديث كلمة المرور", + "User": "مستخدم", + "Username": "اسم المستخدم", + "Verify": "تحقّق", + "Verify and enable": "تحقّق وفعّل", + "You are running low on recovery codes. Generate a new set to be safe.": "رموز الاسترداد لديك على وشك النفاد. أنشئ مجموعة جديدة للاحتياط.", + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.": "لم تتبقَّ لديك رموز استرداد. أنشئ مجموعة جديدة لتتمكن من الدخول إذا فقدت تطبيق المصادقة.", + "Your existing recovery codes will stop working.": "ستتوقف رموز الاسترداد الحالية عن العمل.", + "Your previous recovery codes no longer work.": "رموز الاسترداد السابقة لم تعد تعمل.", + "{account} will be protected by its password alone, and the recovery codes will stop working.": "سيكون {account} محميًا بكلمة المرور وحدها، وستتوقف رموز الاسترداد عن العمل." } diff --git a/i18n/locales/de-DE.json b/i18n/locales/de-DE.json index 592ce4f0..5fec355a 100644 --- a/i18n/locales/de-DE.json +++ b/i18n/locales/de-DE.json @@ -1609,5 +1609,73 @@ "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.": "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", "Console cannot safely edit a newer or unknown KMS backend type.": "Console cannot safely edit a newer or unknown KMS backend type.", "Local KMS key files must use owner-only permissions such as 384 for 0o600.": "Local KMS key files must use owner-only permissions such as 384 for 0o600.", - "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration." + "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.", + "A 6-digit code, or one of your recovery codes.": "Ein 6-stelliger Code oder einer Ihrer Wiederherstellungscodes.", + "A new authenticator is waiting to be confirmed. Your current one keeps working until then.": "Eine neue Authenticator-App wartet auf Bestätigung. Bis dahin funktioniert die aktuelle weiter.", + "Administrator": "Administrator", + "At least 8 characters. This is also your S3 secret key.": "Mindestens 8 Zeichen. Dies ist auch Ihr S3 Secret Key.", + "Authentication code": "Authentifizierungscode", + "Back": "Zurück", + "Can't scan? Manual setup key": "Scannen nicht möglich? Manueller Einrichtungsschlüssel", + "Change your password. This is also your S3 secret key.": "Ändern Sie Ihr Passwort. Dies ist auch Ihr S3 Secret Key.", + "Changing your password signs out your other sessions and invalidates the old secret key.": "Das Ändern Ihres Passworts beendet Ihre anderen Sitzungen und macht den alten Secret Key ungültig.", + "Copy or download the codes before continuing.": "Kopieren oder laden Sie die Codes herunter, bevor Sie fortfahren.", + "Done": "Fertig", + "Enable 2FA": "2FA aktivieren", + "Enabled on": "Aktiviert am", + "Enter a code from your authenticator app": "Geben Sie einen Code aus Ihrer Authenticator-App ein", + "Enter a code from your authenticator app or a recovery code": "Geben Sie einen Code aus Ihrer Authenticator-App oder einen Wiederherstellungscode ein", + "Enter the 6-digit code": "Geben Sie den 6-stelligen Code ein", + "Enter the 6-digit code from your authenticator app": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein", + "Enter the code from your authenticator app for {account}.": "Geben Sie den Code aus Ihrer Authenticator-App für {account} ein.", + "Generate": "Erzeugen", + "Generate new recovery codes": "Neue Wiederherstellungscodes erzeugen", + "IAM user": "IAM-Benutzer", + "Invalid verification code": "Ungültiger Bestätigungscode", + "Last used": "Zuletzt verwendet", + "Or use a recovery code": "Oder verwenden Sie einen Wiederherstellungscode", + "Password and two-factor authentication for your account.": "Passwort und Zwei-Faktor-Authentifizierung für Ihr Konto.", + "Password must be at least 8 characters": "Das Passwort muss mindestens 8 Zeichen lang sein", + "Password updated.": "Passwort aktualisiert.", + "Password updated. Other sessions have been signed out.": "Passwort aktualisiert. Andere Sitzungen wurden beendet.", + "Please enter your current password": "Bitte geben Sie Ihr aktuelles Passwort ein", + "Profile": "Profil", + "Protect your account with an authenticator app": "Schützen Sie Ihr Konto mit einer Authenticator-App", + "QR code for two-factor authentication setup": "QR-Code für die Einrichtung der Zwei-Faktor-Authentifizierung", + "Reconfigure authenticator": "Authenticator neu einrichten", + "Recovery codes remaining": "Verbleibende Wiederherstellungscodes", + "Role": "Rolle", + "Root credential": "Root-Zugangsdaten", + "Save your recovery codes": "Speichern Sie Ihre Wiederherstellungscodes", + "Saved. Store them somewhere only you can reach.": "Gespeichert. Bewahren Sie sie an einem Ort auf, den nur Sie erreichen.", + "Scan this QR code with your authenticator app.": "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App.", + "Security": "Sicherheit", + "Service account": "Dienstkonto", + "Session access key": "Sitzungs-Access-Key", + "Temporary session": "Temporäre Sitzung", + "That does not look like a valid code": "Das sieht nicht wie ein gültiger Code aus", + "The identity you are signed in as.": "Die Identität, mit der Sie angemeldet sind.", + "The new password must be different from the current one": "Das neue Passwort muss sich vom aktuellen unterscheiden", + "These codes are shown only once.": "Diese Codes werden nur einmal angezeigt.", + "These codes can be used if you lose access to your authenticator. Each code works once.": "Diese Codes können verwendet werden, wenn Sie den Zugriff auf Ihren Authenticator verlieren. Jeder Code funktioniert einmal.", + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.": "Diese Identität stammt aus der Serverumgebung (RUSTFS_ACCESS_KEY). Ändern Sie sie, indem Sie den Server mit neuen Werten neu starten.", + "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.": "Diese Identität stammt aus der Serverumgebung (RUSTFS_ACCESS_KEY). Benutzername und Passwort werden durch einen Neustart des Servers mit neuen Werten geändert, nicht über die Konsole.", + "This identity's password is managed outside the console.": "Das Passwort dieser Identität wird außerhalb der Konsole verwaltet.", + "Time-based, {digits} digits, {period}s period.": "Zeitbasiert, {digits} Stellen, {period}s Intervall.", + "Turn off": "Deaktivieren", + "Turn off two-factor authentication": "Zwei-Faktor-Authentifizierung deaktivieren", + "Two-factor authentication": "Zwei-Faktor-Authentifizierung", + "Two-factor authentication is now on.": "Die Zwei-Faktor-Authentifizierung ist jetzt aktiv.", + "Two-factor authentication is off.": "Die Zwei-Faktor-Authentifizierung ist deaktiviert.", + "Unknown user": "Unbekannter Benutzer", + "Update Password": "Passwort aktualisieren", + "User": "Benutzer", + "Username": "Benutzername", + "Verify": "Bestätigen", + "Verify and enable": "Bestätigen und aktivieren", + "You are running low on recovery codes. Generate a new set to be safe.": "Ihre Wiederherstellungscodes gehen zur Neige. Erzeugen Sie zur Sicherheit einen neuen Satz.", + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.": "Sie haben keine Wiederherstellungscodes mehr. Erzeugen Sie einen neuen Satz, um wieder hineinzukommen, wenn Sie Ihren Authenticator verlieren.", + "Your existing recovery codes will stop working.": "Ihre bestehenden Wiederherstellungscodes funktionieren dann nicht mehr.", + "Your previous recovery codes no longer work.": "Ihre vorherigen Wiederherstellungscodes funktionieren nicht mehr.", + "{account} will be protected by its password alone, and the recovery codes will stop working.": "{account} wird dann nur noch durch das Passwort geschützt, und die Wiederherstellungscodes funktionieren nicht mehr." } diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index 1f38ad02..f6673a4c 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -1609,5 +1609,73 @@ "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.": "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", "Console cannot safely edit a newer or unknown KMS backend type.": "Console cannot safely edit a newer or unknown KMS backend type.", "Local KMS key files must use owner-only permissions such as 384 for 0o600.": "Local KMS key files must use owner-only permissions such as 384 for 0o600.", - "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration." + "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.", + "A 6-digit code, or one of your recovery codes.": "A 6-digit code, or one of your recovery codes.", + "A new authenticator is waiting to be confirmed. Your current one keeps working until then.": "A new authenticator is waiting to be confirmed. Your current one keeps working until then.", + "Administrator": "Administrator", + "At least 8 characters. This is also your S3 secret key.": "At least 8 characters. This is also your S3 secret key.", + "Authentication code": "Authentication code", + "Back": "Back", + "Can't scan? Manual setup key": "Can't scan? Manual setup key", + "Change your password. This is also your S3 secret key.": "Change your password. This is also your S3 secret key.", + "Changing your password signs out your other sessions and invalidates the old secret key.": "Changing your password signs out your other sessions and invalidates the old secret key.", + "Copy or download the codes before continuing.": "Copy or download the codes before continuing.", + "Done": "Done", + "Enable 2FA": "Enable 2FA", + "Enabled on": "Enabled on", + "Enter a code from your authenticator app": "Enter a code from your authenticator app", + "Enter a code from your authenticator app or a recovery code": "Enter a code from your authenticator app or a recovery code", + "Enter the 6-digit code": "Enter the 6-digit code", + "Enter the 6-digit code from your authenticator app": "Enter the 6-digit code from your authenticator app", + "Enter the code from your authenticator app for {account}.": "Enter the code from your authenticator app for {account}.", + "Generate": "Generate", + "Generate new recovery codes": "Generate new recovery codes", + "IAM user": "IAM user", + "Invalid verification code": "Invalid verification code", + "Last used": "Last used", + "Or use a recovery code": "Or use a recovery code", + "Password and two-factor authentication for your account.": "Password and two-factor authentication for your account.", + "Password must be at least 8 characters": "Password must be at least 8 characters", + "Password updated.": "Password updated.", + "Password updated. Other sessions have been signed out.": "Password updated. Other sessions have been signed out.", + "Please enter your current password": "Please enter your current password", + "Profile": "Profile", + "Protect your account with an authenticator app": "Protect your account with an authenticator app", + "QR code for two-factor authentication setup": "QR code for two-factor authentication setup", + "Reconfigure authenticator": "Reconfigure authenticator", + "Recovery codes remaining": "Recovery codes remaining", + "Role": "Role", + "Root credential": "Root credential", + "Save your recovery codes": "Save your recovery codes", + "Saved. Store them somewhere only you can reach.": "Saved. Store them somewhere only you can reach.", + "Scan this QR code with your authenticator app.": "Scan this QR code with your authenticator app.", + "Security": "Security", + "Service account": "Service account", + "Session access key": "Session access key", + "Temporary session": "Temporary session", + "That does not look like a valid code": "That does not look like a valid code", + "The identity you are signed in as.": "The identity you are signed in as.", + "The new password must be different from the current one": "The new password must be different from the current one", + "These codes are shown only once.": "These codes are shown only once.", + "These codes can be used if you lose access to your authenticator. Each code works once.": "These codes can be used if you lose access to your authenticator. Each code works once.", + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.": "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.", + "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.": "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.", + "This identity's password is managed outside the console.": "This identity's password is managed outside the console.", + "Time-based, {digits} digits, {period}s period.": "Time-based, {digits} digits, {period}s period.", + "Turn off": "Turn off", + "Turn off two-factor authentication": "Turn off two-factor authentication", + "Two-factor authentication": "Two-factor authentication", + "Two-factor authentication is now on.": "Two-factor authentication is now on.", + "Two-factor authentication is off.": "Two-factor authentication is off.", + "Unknown user": "Unknown user", + "Update Password": "Update Password", + "User": "User", + "Username": "Username", + "Verify": "Verify", + "Verify and enable": "Verify and enable", + "You are running low on recovery codes. Generate a new set to be safe.": "You are running low on recovery codes. Generate a new set to be safe.", + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.": "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.", + "Your existing recovery codes will stop working.": "Your existing recovery codes will stop working.", + "Your previous recovery codes no longer work.": "Your previous recovery codes no longer work.", + "{account} will be protected by its password alone, and the recovery codes will stop working.": "{account} will be protected by its password alone, and the recovery codes will stop working." } diff --git a/i18n/locales/es-ES.json b/i18n/locales/es-ES.json index 1c4446a5..bc4ee1f7 100644 --- a/i18n/locales/es-ES.json +++ b/i18n/locales/es-ES.json @@ -1609,5 +1609,73 @@ "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.": "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", "Console cannot safely edit a newer or unknown KMS backend type.": "Console cannot safely edit a newer or unknown KMS backend type.", "Local KMS key files must use owner-only permissions such as 384 for 0o600.": "Local KMS key files must use owner-only permissions such as 384 for 0o600.", - "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration." + "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.", + "A 6-digit code, or one of your recovery codes.": "Un código de 6 dígitos o uno de tus códigos de recuperación.", + "A new authenticator is waiting to be confirmed. Your current one keeps working until then.": "Una nueva aplicación de autenticación espera confirmación. La actual seguirá funcionando hasta entonces.", + "Administrator": "Administrador", + "At least 8 characters. This is also your S3 secret key.": "Al menos 8 caracteres. También es tu clave secreta de S3.", + "Authentication code": "Código de autenticación", + "Back": "Volver", + "Can't scan? Manual setup key": "¿No puedes escanear? Clave de configuración manual", + "Change your password. This is also your S3 secret key.": "Cambia tu contraseña. También es tu clave secreta de S3.", + "Changing your password signs out your other sessions and invalidates the old secret key.": "Cambiar tu contraseña cierra tus otras sesiones e invalida la clave secreta anterior.", + "Copy or download the codes before continuing.": "Copia o descarga los códigos antes de continuar.", + "Done": "Listo", + "Enable 2FA": "Activar 2FA", + "Enabled on": "Activada el", + "Enter a code from your authenticator app": "Introduce un código de tu aplicación de autenticación", + "Enter a code from your authenticator app or a recovery code": "Introduce un código de tu aplicación de autenticación o un código de recuperación", + "Enter the 6-digit code": "Introduce el código de 6 dígitos", + "Enter the 6-digit code from your authenticator app": "Introduce el código de 6 dígitos de tu aplicación de autenticación", + "Enter the code from your authenticator app for {account}.": "Introduce el código de tu aplicación de autenticación para {account}.", + "Generate": "Generar", + "Generate new recovery codes": "Generar nuevos códigos de recuperación", + "IAM user": "Usuario IAM", + "Invalid verification code": "Código de verificación no válido", + "Last used": "Último uso", + "Or use a recovery code": "O usa un código de recuperación", + "Password and two-factor authentication for your account.": "Contraseña y autenticación en dos pasos de tu cuenta.", + "Password must be at least 8 characters": "La contraseña debe tener al menos 8 caracteres", + "Password updated.": "Contraseña actualizada.", + "Password updated. Other sessions have been signed out.": "Contraseña actualizada. Se han cerrado las otras sesiones.", + "Please enter your current password": "Introduce tu contraseña actual", + "Profile": "Perfil", + "Protect your account with an authenticator app": "Protege tu cuenta con una aplicación de autenticación", + "QR code for two-factor authentication setup": "Código QR para configurar la autenticación en dos pasos", + "Reconfigure authenticator": "Reconfigurar la aplicación de autenticación", + "Recovery codes remaining": "Códigos de recuperación restantes", + "Role": "Rol", + "Root credential": "Credencial root", + "Save your recovery codes": "Guarda tus códigos de recuperación", + "Saved. Store them somewhere only you can reach.": "Guardados. Consérvalos en un lugar al que solo tú puedas acceder.", + "Scan this QR code with your authenticator app.": "Escanea este código QR con tu aplicación de autenticación.", + "Security": "Seguridad", + "Service account": "Cuenta de servicio", + "Session access key": "Clave de acceso de la sesión", + "Temporary session": "Sesión temporal", + "That does not look like a valid code": "Eso no parece un código válido", + "The identity you are signed in as.": "La identidad con la que has iniciado sesión.", + "The new password must be different from the current one": "La nueva contraseña debe ser distinta de la actual", + "These codes are shown only once.": "Estos códigos se muestran una sola vez.", + "These codes can be used if you lose access to your authenticator. Each code works once.": "Puedes usar estos códigos si pierdes el acceso a tu aplicación de autenticación. Cada código funciona una vez.", + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.": "Esta identidad procede del entorno del servidor (RUSTFS_ACCESS_KEY). Cámbiala reiniciando el servidor con nuevos valores.", + "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.": "Esta identidad procede del entorno del servidor (RUSTFS_ACCESS_KEY). Su usuario y contraseña se cambian reiniciando el servidor con nuevos valores, no desde la consola.", + "This identity's password is managed outside the console.": "La contraseña de esta identidad se gestiona fuera de la consola.", + "Time-based, {digits} digits, {period}s period.": "Basado en tiempo, {digits} dígitos, periodo de {period}s.", + "Turn off": "Desactivar", + "Turn off two-factor authentication": "Desactivar la autenticación en dos pasos", + "Two-factor authentication": "Autenticación en dos pasos", + "Two-factor authentication is now on.": "La autenticación en dos pasos ya está activada.", + "Two-factor authentication is off.": "La autenticación en dos pasos está desactivada.", + "Unknown user": "Usuario desconocido", + "Update Password": "Actualizar contraseña", + "User": "Usuario", + "Username": "Nombre de usuario", + "Verify": "Verificar", + "Verify and enable": "Verificar y activar", + "You are running low on recovery codes. Generate a new set to be safe.": "Te quedan pocos códigos de recuperación. Genera un conjunto nuevo por seguridad.", + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.": "No te quedan códigos de recuperación. Genera un conjunto nuevo para poder volver a entrar si pierdes tu aplicación de autenticación.", + "Your existing recovery codes will stop working.": "Tus códigos de recuperación actuales dejarán de funcionar.", + "Your previous recovery codes no longer work.": "Tus códigos de recuperación anteriores ya no funcionan.", + "{account} will be protected by its password alone, and the recovery codes will stop working.": "{account} quedará protegida solo por su contraseña y los códigos de recuperación dejarán de funcionar." } diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index c704085e..fd37fed5 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -1609,5 +1609,73 @@ "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.": "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", "Console cannot safely edit a newer or unknown KMS backend type.": "Console cannot safely edit a newer or unknown KMS backend type.", "Local KMS key files must use owner-only permissions such as 384 for 0o600.": "Local KMS key files must use owner-only permissions such as 384 for 0o600.", - "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration." + "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.", + "A 6-digit code, or one of your recovery codes.": "Un code à 6 chiffres, ou l'un de vos codes de récupération.", + "A new authenticator is waiting to be confirmed. Your current one keeps working until then.": "Une nouvelle application d'authentification attend confirmation. La précédente continue de fonctionner jusque-là.", + "Administrator": "Administrateur", + "At least 8 characters. This is also your S3 secret key.": "Au moins 8 caractères. C'est aussi votre clé secrète S3.", + "Authentication code": "Code d'authentification", + "Back": "Retour", + "Can't scan? Manual setup key": "Impossible de scanner ? Clé de configuration manuelle", + "Change your password. This is also your S3 secret key.": "Changez votre mot de passe. C'est aussi votre clé secrète S3.", + "Changing your password signs out your other sessions and invalidates the old secret key.": "Changer votre mot de passe déconnecte vos autres sessions et invalide l'ancienne clé secrète.", + "Copy or download the codes before continuing.": "Copiez ou téléchargez les codes avant de continuer.", + "Done": "Terminé", + "Enable 2FA": "Activer la 2FA", + "Enabled on": "Activée le", + "Enter a code from your authenticator app": "Saisissez un code de votre application d'authentification", + "Enter a code from your authenticator app or a recovery code": "Saisissez un code de votre application d'authentification ou un code de récupération", + "Enter the 6-digit code": "Saisissez le code à 6 chiffres", + "Enter the 6-digit code from your authenticator app": "Saisissez le code à 6 chiffres de votre application d'authentification", + "Enter the code from your authenticator app for {account}.": "Saisissez le code de votre application d'authentification pour {account}.", + "Generate": "Générer", + "Generate new recovery codes": "Générer de nouveaux codes de récupération", + "IAM user": "Utilisateur IAM", + "Invalid verification code": "Code de vérification invalide", + "Last used": "Dernière utilisation", + "Or use a recovery code": "Ou utilisez un code de récupération", + "Password and two-factor authentication for your account.": "Mot de passe et authentification à deux facteurs de votre compte.", + "Password must be at least 8 characters": "Le mot de passe doit comporter au moins 8 caractères", + "Password updated.": "Mot de passe mis à jour.", + "Password updated. Other sessions have been signed out.": "Mot de passe mis à jour. Les autres sessions ont été déconnectées.", + "Please enter your current password": "Veuillez saisir votre mot de passe actuel", + "Profile": "Profil", + "Protect your account with an authenticator app": "Protégez votre compte avec une application d'authentification", + "QR code for two-factor authentication setup": "QR code pour la configuration de l'authentification à deux facteurs", + "Reconfigure authenticator": "Reconfigurer l'application d'authentification", + "Recovery codes remaining": "Codes de récupération restants", + "Role": "Rôle", + "Root credential": "Identifiant root", + "Save your recovery codes": "Enregistrez vos codes de récupération", + "Saved. Store them somewhere only you can reach.": "Enregistrés. Conservez-les dans un endroit accessible à vous seul.", + "Scan this QR code with your authenticator app.": "Scannez ce QR code avec votre application d'authentification.", + "Security": "Sécurité", + "Service account": "Compte de service", + "Session access key": "Clé d'accès de session", + "Temporary session": "Session temporaire", + "That does not look like a valid code": "Cela ne ressemble pas à un code valide", + "The identity you are signed in as.": "L'identité avec laquelle vous êtes connecté.", + "The new password must be different from the current one": "Le nouveau mot de passe doit être différent de l'actuel", + "These codes are shown only once.": "Ces codes ne sont affichés qu'une seule fois.", + "These codes can be used if you lose access to your authenticator. Each code works once.": "Ces codes peuvent servir si vous perdez l'accès à votre application d'authentification. Chaque code ne fonctionne qu'une fois.", + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.": "Cette identité provient de l'environnement du serveur (RUSTFS_ACCESS_KEY). Modifiez-la en redémarrant le serveur avec de nouvelles valeurs.", + "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.": "Cette identité provient de l'environnement du serveur (RUSTFS_ACCESS_KEY). Son nom d'utilisateur et son mot de passe se changent en redémarrant le serveur avec de nouvelles valeurs, pas depuis la console.", + "This identity's password is managed outside the console.": "Le mot de passe de cette identité est géré en dehors de la console.", + "Time-based, {digits} digits, {period}s period.": "Basé sur le temps, {digits} chiffres, période de {period}s.", + "Turn off": "Désactiver", + "Turn off two-factor authentication": "Désactiver l'authentification à deux facteurs", + "Two-factor authentication": "Authentification à deux facteurs", + "Two-factor authentication is now on.": "L'authentification à deux facteurs est maintenant activée.", + "Two-factor authentication is off.": "L'authentification à deux facteurs est désactivée.", + "Unknown user": "Utilisateur inconnu", + "Update Password": "Mettre à jour le mot de passe", + "User": "Utilisateur", + "Username": "Nom d'utilisateur", + "Verify": "Vérifier", + "Verify and enable": "Vérifier et activer", + "You are running low on recovery codes. Generate a new set to be safe.": "Il vous reste peu de codes de récupération. Générez-en un nouveau jeu par précaution.", + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.": "Vous n'avez plus de codes de récupération. Générez-en un nouveau jeu pour pouvoir revenir si vous perdez votre application d'authentification.", + "Your existing recovery codes will stop working.": "Vos codes de récupération actuels cesseront de fonctionner.", + "Your previous recovery codes no longer work.": "Vos anciens codes de récupération ne fonctionnent plus.", + "{account} will be protected by its password alone, and the recovery codes will stop working.": "{account} ne sera plus protégé que par son mot de passe, et les codes de récupération cesseront de fonctionner." } diff --git a/i18n/locales/id-ID.json b/i18n/locales/id-ID.json index 346a8546..24598c71 100644 --- a/i18n/locales/id-ID.json +++ b/i18n/locales/id-ID.json @@ -1609,5 +1609,73 @@ "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.": "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", "Console cannot safely edit a newer or unknown KMS backend type.": "Console cannot safely edit a newer or unknown KMS backend type.", "Local KMS key files must use owner-only permissions such as 384 for 0o600.": "Local KMS key files must use owner-only permissions such as 384 for 0o600.", - "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration." + "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.", + "A 6-digit code, or one of your recovery codes.": "Kode 6 digit, atau salah satu kode pemulihan Anda.", + "A new authenticator is waiting to be confirmed. Your current one keeps working until then.": "Aplikasi autentikator baru menunggu konfirmasi. Yang sekarang tetap berfungsi hingga saat itu.", + "Administrator": "Administrator", + "At least 8 characters. This is also your S3 secret key.": "Minimal 8 karakter. Ini juga merupakan secret key S3 Anda.", + "Authentication code": "Kode autentikasi", + "Back": "Kembali", + "Can't scan? Manual setup key": "Tidak bisa memindai? Kunci pengaturan manual", + "Change your password. This is also your S3 secret key.": "Ubah kata sandi Anda. Ini juga merupakan secret key S3 Anda.", + "Changing your password signs out your other sessions and invalidates the old secret key.": "Mengubah kata sandi akan mengeluarkan sesi Anda yang lain dan membatalkan secret key lama.", + "Copy or download the codes before continuing.": "Salin atau unduh kode sebelum melanjutkan.", + "Done": "Selesai", + "Enable 2FA": "Aktifkan 2FA", + "Enabled on": "Diaktifkan pada", + "Enter a code from your authenticator app": "Masukkan kode dari aplikasi autentikator Anda", + "Enter a code from your authenticator app or a recovery code": "Masukkan kode dari aplikasi autentikator Anda atau kode pemulihan", + "Enter the 6-digit code": "Masukkan kode 6 digit", + "Enter the 6-digit code from your authenticator app": "Masukkan kode 6 digit dari aplikasi autentikator Anda", + "Enter the code from your authenticator app for {account}.": "Masukkan kode dari aplikasi autentikator Anda untuk {account}.", + "Generate": "Buat", + "Generate new recovery codes": "Buat kode pemulihan baru", + "IAM user": "Pengguna IAM", + "Invalid verification code": "Kode verifikasi tidak valid", + "Last used": "Terakhir digunakan", + "Or use a recovery code": "Atau gunakan kode pemulihan", + "Password and two-factor authentication for your account.": "Kata sandi dan autentikasi dua faktor untuk akun Anda.", + "Password must be at least 8 characters": "Kata sandi harus minimal 8 karakter", + "Password updated.": "Kata sandi diperbarui.", + "Password updated. Other sessions have been signed out.": "Kata sandi diperbarui. Sesi lain telah dikeluarkan.", + "Please enter your current password": "Masukkan kata sandi Anda saat ini", + "Profile": "Profil", + "Protect your account with an authenticator app": "Lindungi akun Anda dengan aplikasi autentikator", + "QR code for two-factor authentication setup": "Kode QR untuk pengaturan autentikasi dua faktor", + "Reconfigure authenticator": "Konfigurasi ulang autentikator", + "Recovery codes remaining": "Kode pemulihan tersisa", + "Role": "Peran", + "Root credential": "Kredensial root", + "Save your recovery codes": "Simpan kode pemulihan Anda", + "Saved. Store them somewhere only you can reach.": "Tersimpan. Simpan di tempat yang hanya bisa Anda akses.", + "Scan this QR code with your authenticator app.": "Pindai kode QR ini dengan aplikasi autentikator Anda.", + "Security": "Keamanan", + "Service account": "Akun layanan", + "Session access key": "Access key sesi", + "Temporary session": "Sesi sementara", + "That does not look like a valid code": "Itu tampaknya bukan kode yang valid", + "The identity you are signed in as.": "Identitas yang Anda gunakan untuk masuk.", + "The new password must be different from the current one": "Kata sandi baru harus berbeda dari yang sekarang", + "These codes are shown only once.": "Kode ini hanya ditampilkan satu kali.", + "These codes can be used if you lose access to your authenticator. Each code works once.": "Kode ini dapat digunakan jika Anda kehilangan akses ke autentikator. Setiap kode berlaku satu kali.", + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.": "Identitas ini berasal dari environment server (RUSTFS_ACCESS_KEY). Ubah dengan memulai ulang server menggunakan nilai baru.", + "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.": "Identitas ini berasal dari environment server (RUSTFS_ACCESS_KEY). Nama pengguna dan kata sandinya diubah dengan memulai ulang server menggunakan nilai baru, bukan dari konsol.", + "This identity's password is managed outside the console.": "Kata sandi identitas ini dikelola di luar konsol.", + "Time-based, {digits} digits, {period}s period.": "Berbasis waktu, {digits} digit, periode {period} detik.", + "Turn off": "Matikan", + "Turn off two-factor authentication": "Matikan autentikasi dua faktor", + "Two-factor authentication": "Autentikasi dua faktor", + "Two-factor authentication is now on.": "Autentikasi dua faktor kini aktif.", + "Two-factor authentication is off.": "Autentikasi dua faktor tidak aktif.", + "Unknown user": "Pengguna tidak diketahui", + "Update Password": "Perbarui kata sandi", + "User": "Pengguna", + "Username": "Nama pengguna", + "Verify": "Verifikasi", + "Verify and enable": "Verifikasi dan aktifkan", + "You are running low on recovery codes. Generate a new set to be safe.": "Kode pemulihan Anda hampir habis. Buat set baru untuk berjaga-jaga.", + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.": "Kode pemulihan Anda sudah habis. Buat set baru agar Anda tetap bisa masuk jika kehilangan autentikator.", + "Your existing recovery codes will stop working.": "Kode pemulihan Anda yang ada akan berhenti berfungsi.", + "Your previous recovery codes no longer work.": "Kode pemulihan Anda sebelumnya tidak berfungsi lagi.", + "{account} will be protected by its password alone, and the recovery codes will stop working.": "{account} hanya akan dilindungi oleh kata sandinya, dan kode pemulihan akan berhenti berfungsi." } diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index 5b0dba5f..37208a44 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -1609,5 +1609,73 @@ "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.": "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", "Console cannot safely edit a newer or unknown KMS backend type.": "Console cannot safely edit a newer or unknown KMS backend type.", "Local KMS key files must use owner-only permissions such as 384 for 0o600.": "Local KMS key files must use owner-only permissions such as 384 for 0o600.", - "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration." + "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.", + "A 6-digit code, or one of your recovery codes.": "Un codice a 6 cifre o uno dei tuoi codici di recupero.", + "A new authenticator is waiting to be confirmed. Your current one keeps working until then.": "Una nuova app di autenticazione attende conferma. Quella attuale continua a funzionare fino ad allora.", + "Administrator": "Amministratore", + "At least 8 characters. This is also your S3 secret key.": "Almeno 8 caratteri. È anche la tua chiave segreta S3.", + "Authentication code": "Codice di autenticazione", + "Back": "Indietro", + "Can't scan? Manual setup key": "Non riesci a scansionare? Chiave di configurazione manuale", + "Change your password. This is also your S3 secret key.": "Cambia la password. È anche la tua chiave segreta S3.", + "Changing your password signs out your other sessions and invalidates the old secret key.": "Cambiare la password chiude le altre sessioni e invalida la vecchia chiave segreta.", + "Copy or download the codes before continuing.": "Copia o scarica i codici prima di continuare.", + "Done": "Fatto", + "Enable 2FA": "Attiva 2FA", + "Enabled on": "Attivata il", + "Enter a code from your authenticator app": "Inserisci un codice dalla tua app di autenticazione", + "Enter a code from your authenticator app or a recovery code": "Inserisci un codice dalla tua app di autenticazione o un codice di recupero", + "Enter the 6-digit code": "Inserisci il codice a 6 cifre", + "Enter the 6-digit code from your authenticator app": "Inserisci il codice a 6 cifre dalla tua app di autenticazione", + "Enter the code from your authenticator app for {account}.": "Inserisci il codice dalla tua app di autenticazione per {account}.", + "Generate": "Genera", + "Generate new recovery codes": "Genera nuovi codici di recupero", + "IAM user": "Utente IAM", + "Invalid verification code": "Codice di verifica non valido", + "Last used": "Ultimo utilizzo", + "Or use a recovery code": "Oppure usa un codice di recupero", + "Password and two-factor authentication for your account.": "Password e autenticazione a due fattori del tuo account.", + "Password must be at least 8 characters": "La password deve contenere almeno 8 caratteri", + "Password updated.": "Password aggiornata.", + "Password updated. Other sessions have been signed out.": "Password aggiornata. Le altre sessioni sono state chiuse.", + "Please enter your current password": "Inserisci la password attuale", + "Profile": "Profilo", + "Protect your account with an authenticator app": "Proteggi il tuo account con un'app di autenticazione", + "QR code for two-factor authentication setup": "Codice QR per la configurazione dell'autenticazione a due fattori", + "Reconfigure authenticator": "Riconfigura l'app di autenticazione", + "Recovery codes remaining": "Codici di recupero rimanenti", + "Role": "Ruolo", + "Root credential": "Credenziale root", + "Save your recovery codes": "Salva i tuoi codici di recupero", + "Saved. Store them somewhere only you can reach.": "Salvati. Conservali in un posto che solo tu puoi raggiungere.", + "Scan this QR code with your authenticator app.": "Scansiona questo codice QR con la tua app di autenticazione.", + "Security": "Sicurezza", + "Service account": "Account di servizio", + "Session access key": "Chiave di accesso della sessione", + "Temporary session": "Sessione temporanea", + "That does not look like a valid code": "Questo non sembra un codice valido", + "The identity you are signed in as.": "L'identità con cui hai effettuato l'accesso.", + "The new password must be different from the current one": "La nuova password deve essere diversa da quella attuale", + "These codes are shown only once.": "Questi codici vengono mostrati una sola volta.", + "These codes can be used if you lose access to your authenticator. Each code works once.": "Questi codici possono essere usati se perdi l'accesso alla tua app di autenticazione. Ogni codice funziona una volta.", + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.": "Questa identità proviene dall'ambiente del server (RUSTFS_ACCESS_KEY). Modificala riavviando il server con nuovi valori.", + "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.": "Questa identità proviene dall'ambiente del server (RUSTFS_ACCESS_KEY). Nome utente e password si cambiano riavviando il server con nuovi valori, non dalla console.", + "This identity's password is managed outside the console.": "La password di questa identità è gestita fuori dalla console.", + "Time-based, {digits} digits, {period}s period.": "Basato sul tempo, {digits} cifre, periodo di {period}s.", + "Turn off": "Disattiva", + "Turn off two-factor authentication": "Disattiva l'autenticazione a due fattori", + "Two-factor authentication": "Autenticazione a due fattori", + "Two-factor authentication is now on.": "L'autenticazione a due fattori è ora attiva.", + "Two-factor authentication is off.": "L'autenticazione a due fattori è disattivata.", + "Unknown user": "Utente sconosciuto", + "Update Password": "Aggiorna password", + "User": "Utente", + "Username": "Nome utente", + "Verify": "Verifica", + "Verify and enable": "Verifica e attiva", + "You are running low on recovery codes. Generate a new set to be safe.": "Ti restano pochi codici di recupero. Generane un nuovo set per sicurezza.", + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.": "Non hai più codici di recupero. Generane un nuovo set per poter rientrare se perdi la tua app di autenticazione.", + "Your existing recovery codes will stop working.": "I tuoi codici di recupero attuali smetteranno di funzionare.", + "Your previous recovery codes no longer work.": "I tuoi codici di recupero precedenti non funzionano più.", + "{account} will be protected by its password alone, and the recovery codes will stop working.": "{account} sarà protetta solo dalla password e i codici di recupero smetteranno di funzionare." } diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index 82d47ed6..05efeba3 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -1609,5 +1609,73 @@ "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.": "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", "Console cannot safely edit a newer or unknown KMS backend type.": "Console cannot safely edit a newer or unknown KMS backend type.", "Local KMS key files must use owner-only permissions such as 384 for 0o600.": "Local KMS key files must use owner-only permissions such as 384 for 0o600.", - "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration." + "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.", + "A 6-digit code, or one of your recovery codes.": "6 桁のコード、またはリカバリコードのいずれかです。", + "A new authenticator is waiting to be confirmed. Your current one keeps working until then.": "新しい認証アプリが確認を待っています。それまでは現在の認証アプリが引き続き使えます。", + "Administrator": "管理者", + "At least 8 characters. This is also your S3 secret key.": "8 文字以上。これは S3 のシークレットキーでもあります。", + "Authentication code": "認証コード", + "Back": "戻る", + "Can't scan? Manual setup key": "スキャンできない場合は、手動設定キーを使用", + "Change your password. This is also your S3 secret key.": "パスワードを変更します。これは S3 のシークレットキーでもあります。", + "Changing your password signs out your other sessions and invalidates the old secret key.": "パスワードを変更すると他のセッションからサインアウトされ、以前のシークレットキーは無効になります。", + "Copy or download the codes before continuing.": "続行する前にコードをコピーまたはダウンロードしてください。", + "Done": "完了", + "Enable 2FA": "2 要素認証を有効にする", + "Enabled on": "有効化日時", + "Enter a code from your authenticator app": "認証アプリのコードを入力してください", + "Enter a code from your authenticator app or a recovery code": "認証アプリのコードまたはリカバリコードを入力してください", + "Enter the 6-digit code": "6 桁のコードを入力", + "Enter the 6-digit code from your authenticator app": "認証アプリの 6 桁のコードを入力してください", + "Enter the code from your authenticator app for {account}.": "{account} の認証アプリのコードを入力してください。", + "Generate": "生成", + "Generate new recovery codes": "新しいリカバリコードを生成", + "IAM user": "IAM ユーザー", + "Invalid verification code": "確認コードが正しくありません", + "Last used": "最終使用", + "Or use a recovery code": "またはリカバリコードを使用", + "Password and two-factor authentication for your account.": "アカウントのパスワードと 2 要素認証。", + "Password must be at least 8 characters": "パスワードは 8 文字以上である必要があります", + "Password updated.": "パスワードを更新しました。", + "Password updated. Other sessions have been signed out.": "パスワードを更新しました。他のセッションはサインアウトされました。", + "Please enter your current password": "現在のパスワードを入力してください", + "Profile": "プロフィール", + "Protect your account with an authenticator app": "認証アプリでアカウントを保護します", + "QR code for two-factor authentication setup": "2 要素認証の設定用 QR コード", + "Reconfigure authenticator": "認証アプリを再設定", + "Recovery codes remaining": "残りのリカバリコード", + "Role": "ロール", + "Root credential": "ルート認証情報", + "Save your recovery codes": "リカバリコードを保存してください", + "Saved. Store them somewhere only you can reach.": "保存しました。自分だけがアクセスできる場所に保管してください。", + "Scan this QR code with your authenticator app.": "認証アプリでこの QR コードをスキャンしてください。", + "Security": "セキュリティ", + "Service account": "サービスアカウント", + "Session access key": "セッションのアクセスキー", + "Temporary session": "一時セッション", + "That does not look like a valid code": "有効なコードではないようです", + "The identity you are signed in as.": "現在サインインしているアイデンティティです。", + "The new password must be different from the current one": "新しいパスワードは現在のものと異なる必要があります", + "These codes are shown only once.": "これらのコードは一度だけ表示されます。", + "These codes can be used if you lose access to your authenticator. Each code works once.": "認証アプリを使えなくなった場合にこれらのコードを使用できます。各コードは一度だけ有効です。", + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.": "このアイデンティティはサーバー環境(RUSTFS_ACCESS_KEY)から設定されています。新しい値でサーバーを再起動して変更してください。", + "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.": "このアイデンティティはサーバー環境(RUSTFS_ACCESS_KEY)から設定されています。ユーザー名とパスワードはコンソールではなく、新しい値でサーバーを再起動して変更します。", + "This identity's password is managed outside the console.": "このアイデンティティのパスワードはコンソールの外部で管理されています。", + "Time-based, {digits} digits, {period}s period.": "時間ベース、{digits} 桁、周期 {period} 秒。", + "Turn off": "無効にする", + "Turn off two-factor authentication": "2 要素認証を無効にする", + "Two-factor authentication": "2 要素認証", + "Two-factor authentication is now on.": "2 要素認証が有効になりました。", + "Two-factor authentication is off.": "2 要素認証は無効です。", + "Unknown user": "不明なユーザー", + "Update Password": "パスワードを更新", + "User": "ユーザー", + "Username": "ユーザー名", + "Verify": "確認", + "Verify and enable": "確認して有効にする", + "You are running low on recovery codes. Generate a new set to be safe.": "リカバリコードが少なくなっています。念のため新しく生成してください。", + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.": "リカバリコードが残っていません。認証アプリを失っても復帰できるよう、新しく生成してください。", + "Your existing recovery codes will stop working.": "現在のリカバリコードは使えなくなります。", + "Your previous recovery codes no longer work.": "以前のリカバリコードは使用できなくなりました。", + "{account} will be protected by its password alone, and the recovery codes will stop working.": "{account} はパスワードのみで保護され、リカバリコードは使えなくなります。" } diff --git a/i18n/locales/ko-KR.json b/i18n/locales/ko-KR.json index e577626e..ba658f51 100644 --- a/i18n/locales/ko-KR.json +++ b/i18n/locales/ko-KR.json @@ -1609,5 +1609,73 @@ "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.": "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", "Console cannot safely edit a newer or unknown KMS backend type.": "Console cannot safely edit a newer or unknown KMS backend type.", "Local KMS key files must use owner-only permissions such as 384 for 0o600.": "Local KMS key files must use owner-only permissions such as 384 for 0o600.", - "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration." + "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.", + "A 6-digit code, or one of your recovery codes.": "6자리 코드 또는 복구 코드 중 하나입니다.", + "A new authenticator is waiting to be confirmed. Your current one keeps working until then.": "새 인증 앱이 확인을 기다리고 있습니다. 그때까지는 현재 인증 앱이 계속 작동합니다.", + "Administrator": "관리자", + "At least 8 characters. This is also your S3 secret key.": "8자 이상. S3 시크릿 키로도 사용됩니다.", + "Authentication code": "인증 코드", + "Back": "뒤로", + "Can't scan? Manual setup key": "스캔할 수 없나요? 수동 설정 키", + "Change your password. This is also your S3 secret key.": "비밀번호를 변경합니다. S3 시크릿 키로도 사용됩니다.", + "Changing your password signs out your other sessions and invalidates the old secret key.": "비밀번호를 변경하면 다른 세션에서 로그아웃되고 이전 시크릿 키는 무효화됩니다.", + "Copy or download the codes before continuing.": "계속하기 전에 코드를 복사하거나 다운로드하세요.", + "Done": "완료", + "Enable 2FA": "2단계 인증 사용", + "Enabled on": "사용 시작", + "Enter a code from your authenticator app": "인증 앱의 코드를 입력하세요", + "Enter a code from your authenticator app or a recovery code": "인증 앱의 코드 또는 복구 코드를 입력하세요", + "Enter the 6-digit code": "6자리 코드를 입력하세요", + "Enter the 6-digit code from your authenticator app": "인증 앱의 6자리 코드를 입력하세요", + "Enter the code from your authenticator app for {account}.": "{account}의 인증 앱 코드를 입력하세요.", + "Generate": "생성", + "Generate new recovery codes": "새 복구 코드 생성", + "IAM user": "IAM 사용자", + "Invalid verification code": "인증 코드가 올바르지 않습니다", + "Last used": "마지막 사용", + "Or use a recovery code": "또는 복구 코드 사용", + "Password and two-factor authentication for your account.": "계정의 비밀번호와 2단계 인증입니다.", + "Password must be at least 8 characters": "비밀번호는 8자 이상이어야 합니다", + "Password updated.": "비밀번호가 변경되었습니다.", + "Password updated. Other sessions have been signed out.": "비밀번호가 변경되었습니다. 다른 세션에서 로그아웃되었습니다.", + "Please enter your current password": "현재 비밀번호를 입력하세요", + "Profile": "프로필", + "Protect your account with an authenticator app": "인증 앱으로 계정을 보호하세요", + "QR code for two-factor authentication setup": "2단계 인증 설정용 QR 코드", + "Reconfigure authenticator": "인증 앱 재설정", + "Recovery codes remaining": "남은 복구 코드", + "Role": "역할", + "Root credential": "루트 자격 증명", + "Save your recovery codes": "복구 코드를 저장하세요", + "Saved. Store them somewhere only you can reach.": "저장했습니다. 본인만 접근할 수 있는 곳에 보관하세요.", + "Scan this QR code with your authenticator app.": "인증 앱으로 이 QR 코드를 스캔하세요.", + "Security": "보안", + "Service account": "서비스 계정", + "Session access key": "세션 액세스 키", + "Temporary session": "임시 세션", + "That does not look like a valid code": "유효한 코드가 아닌 것 같습니다", + "The identity you are signed in as.": "현재 로그인한 아이덴티티입니다.", + "The new password must be different from the current one": "새 비밀번호는 현재 비밀번호와 달라야 합니다", + "These codes are shown only once.": "이 코드는 한 번만 표시됩니다.", + "These codes can be used if you lose access to your authenticator. Each code works once.": "인증 앱을 사용할 수 없게 되면 이 코드를 사용할 수 있습니다. 각 코드는 한 번만 사용됩니다.", + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.": "이 아이덴티티는 서버 환경(RUSTFS_ACCESS_KEY)에서 제공됩니다. 새 값으로 서버를 재시작하여 변경하세요.", + "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.": "이 아이덴티티는 서버 환경(RUSTFS_ACCESS_KEY)에서 제공됩니다. 사용자 이름과 비밀번호는 콘솔이 아니라 새 값으로 서버를 재시작하여 변경합니다.", + "This identity's password is managed outside the console.": "이 아이덴티티의 비밀번호는 콘솔 외부에서 관리됩니다.", + "Time-based, {digits} digits, {period}s period.": "시간 기반, {digits}자리, 주기 {period}초.", + "Turn off": "사용 중지", + "Turn off two-factor authentication": "2단계 인증 사용 중지", + "Two-factor authentication": "2단계 인증", + "Two-factor authentication is now on.": "2단계 인증이 켜졌습니다.", + "Two-factor authentication is off.": "2단계 인증이 꺼졌습니다.", + "Unknown user": "알 수 없는 사용자", + "Update Password": "비밀번호 변경", + "User": "사용자", + "Username": "사용자 이름", + "Verify": "확인", + "Verify and enable": "확인하고 사용", + "You are running low on recovery codes. Generate a new set to be safe.": "복구 코드가 얼마 남지 않았습니다. 안전을 위해 새로 생성하세요.", + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.": "남은 복구 코드가 없습니다. 인증 앱을 잃어버려도 로그인할 수 있도록 새로 생성하세요.", + "Your existing recovery codes will stop working.": "기존 복구 코드는 더 이상 작동하지 않습니다.", + "Your previous recovery codes no longer work.": "이전 복구 코드는 더 이상 작동하지 않습니다.", + "{account} will be protected by its password alone, and the recovery codes will stop working.": "{account}은(는) 비밀번호만으로 보호되며 복구 코드는 작동하지 않습니다." } diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index dc9b7c0f..1474701b 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -1609,5 +1609,73 @@ "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.": "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", "Console cannot safely edit a newer or unknown KMS backend type.": "Console cannot safely edit a newer or unknown KMS backend type.", "Local KMS key files must use owner-only permissions such as 384 for 0o600.": "Local KMS key files must use owner-only permissions such as 384 for 0o600.", - "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration." + "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.", + "A 6-digit code, or one of your recovery codes.": "Um código de 6 dígitos ou um dos seus códigos de recuperação.", + "A new authenticator is waiting to be confirmed. Your current one keeps working until then.": "Um novo aplicativo autenticador aguarda confirmação. O atual continua funcionando até então.", + "Administrator": "Administrador", + "At least 8 characters. This is also your S3 secret key.": "Pelo menos 8 caracteres. Também é a sua chave secreta do S3.", + "Authentication code": "Código de autenticação", + "Back": "Voltar", + "Can't scan? Manual setup key": "Não consegue escanear? Chave de configuração manual", + "Change your password. This is also your S3 secret key.": "Altere sua senha. Também é a sua chave secreta do S3.", + "Changing your password signs out your other sessions and invalidates the old secret key.": "Alterar sua senha encerra suas outras sessões e invalida a chave secreta antiga.", + "Copy or download the codes before continuing.": "Copie ou baixe os códigos antes de continuar.", + "Done": "Concluído", + "Enable 2FA": "Ativar 2FA", + "Enabled on": "Ativada em", + "Enter a code from your authenticator app": "Digite um código do seu aplicativo autenticador", + "Enter a code from your authenticator app or a recovery code": "Digite um código do seu aplicativo autenticador ou um código de recuperação", + "Enter the 6-digit code": "Digite o código de 6 dígitos", + "Enter the 6-digit code from your authenticator app": "Digite o código de 6 dígitos do seu aplicativo autenticador", + "Enter the code from your authenticator app for {account}.": "Digite o código do seu aplicativo autenticador para {account}.", + "Generate": "Gerar", + "Generate new recovery codes": "Gerar novos códigos de recuperação", + "IAM user": "Usuário IAM", + "Invalid verification code": "Código de verificação inválido", + "Last used": "Último uso", + "Or use a recovery code": "Ou use um código de recuperação", + "Password and two-factor authentication for your account.": "Senha e autenticação em dois fatores da sua conta.", + "Password must be at least 8 characters": "A senha deve ter pelo menos 8 caracteres", + "Password updated.": "Senha atualizada.", + "Password updated. Other sessions have been signed out.": "Senha atualizada. As outras sessões foram encerradas.", + "Please enter your current password": "Digite sua senha atual", + "Profile": "Perfil", + "Protect your account with an authenticator app": "Proteja sua conta com um aplicativo autenticador", + "QR code for two-factor authentication setup": "Código QR para configurar a autenticação em dois fatores", + "Reconfigure authenticator": "Reconfigurar o autenticador", + "Recovery codes remaining": "Códigos de recuperação restantes", + "Role": "Função", + "Root credential": "Credencial root", + "Save your recovery codes": "Salve seus códigos de recuperação", + "Saved. Store them somewhere only you can reach.": "Salvos. Guarde-os em um lugar que só você alcança.", + "Scan this QR code with your authenticator app.": "Escaneie este código QR com seu aplicativo autenticador.", + "Security": "Segurança", + "Service account": "Conta de serviço", + "Session access key": "Chave de acesso da sessão", + "Temporary session": "Sessão temporária", + "That does not look like a valid code": "Isso não parece um código válido", + "The identity you are signed in as.": "A identidade com a qual você está conectado.", + "The new password must be different from the current one": "A nova senha deve ser diferente da atual", + "These codes are shown only once.": "Estes códigos são exibidos apenas uma vez.", + "These codes can be used if you lose access to your authenticator. Each code works once.": "Estes códigos podem ser usados se você perder o acesso ao seu autenticador. Cada código funciona uma vez.", + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.": "Esta identidade vem do ambiente do servidor (RUSTFS_ACCESS_KEY). Altere-a reiniciando o servidor com novos valores.", + "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.": "Esta identidade vem do ambiente do servidor (RUSTFS_ACCESS_KEY). Seu nome de usuário e senha são alterados reiniciando o servidor com novos valores, não pelo console.", + "This identity's password is managed outside the console.": "A senha desta identidade é gerenciada fora do console.", + "Time-based, {digits} digits, {period}s period.": "Baseado em tempo, {digits} dígitos, período de {period}s.", + "Turn off": "Desativar", + "Turn off two-factor authentication": "Desativar a autenticação em dois fatores", + "Two-factor authentication": "Autenticação em dois fatores", + "Two-factor authentication is now on.": "A autenticação em dois fatores está ativada.", + "Two-factor authentication is off.": "A autenticação em dois fatores está desativada.", + "Unknown user": "Usuário desconhecido", + "Update Password": "Atualizar senha", + "User": "Usuário", + "Username": "Nome de usuário", + "Verify": "Verificar", + "Verify and enable": "Verificar e ativar", + "You are running low on recovery codes. Generate a new set to be safe.": "Seus códigos de recuperação estão acabando. Gere um novo conjunto por segurança.", + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.": "Você não tem mais códigos de recuperação. Gere um novo conjunto para conseguir entrar se perder seu autenticador.", + "Your existing recovery codes will stop working.": "Seus códigos de recuperação atuais deixarão de funcionar.", + "Your previous recovery codes no longer work.": "Seus códigos de recuperação anteriores não funcionam mais.", + "{account} will be protected by its password alone, and the recovery codes will stop working.": "{account} ficará protegida apenas pela senha, e os códigos de recuperação deixarão de funcionar." } diff --git a/i18n/locales/ru-RU.json b/i18n/locales/ru-RU.json index c3d4363b..7fe2485c 100644 --- a/i18n/locales/ru-RU.json +++ b/i18n/locales/ru-RU.json @@ -1609,5 +1609,73 @@ "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.": "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", "Console cannot safely edit a newer or unknown KMS backend type.": "Console cannot safely edit a newer or unknown KMS backend type.", "Local KMS key files must use owner-only permissions such as 384 for 0o600.": "Local KMS key files must use owner-only permissions such as 384 for 0o600.", - "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration." + "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.", + "A 6-digit code, or one of your recovery codes.": "Шестизначный код или один из ваших кодов восстановления.", + "A new authenticator is waiting to be confirmed. Your current one keeps working until then.": "Новое приложение-аутентификатор ожидает подтверждения. До этого текущее продолжает работать.", + "Administrator": "Администратор", + "At least 8 characters. This is also your S3 secret key.": "Не менее 8 символов. Это также ваш секретный ключ S3.", + "Authentication code": "Код аутентификации", + "Back": "Назад", + "Can't scan? Manual setup key": "Не удаётся отсканировать? Ключ для ручной настройки", + "Change your password. This is also your S3 secret key.": "Измените пароль. Это также ваш секретный ключ S3.", + "Changing your password signs out your other sessions and invalidates the old secret key.": "Смена пароля завершает другие ваши сеансы и делает старый секретный ключ недействительным.", + "Copy or download the codes before continuing.": "Скопируйте или скачайте коды перед продолжением.", + "Done": "Готово", + "Enable 2FA": "Включить 2FA", + "Enabled on": "Включена", + "Enter a code from your authenticator app": "Введите код из приложения-аутентификатора", + "Enter a code from your authenticator app or a recovery code": "Введите код из приложения-аутентификатора или код восстановления", + "Enter the 6-digit code": "Введите шестизначный код", + "Enter the 6-digit code from your authenticator app": "Введите шестизначный код из приложения-аутентификатора", + "Enter the code from your authenticator app for {account}.": "Введите код из приложения-аутентификатора для {account}.", + "Generate": "Создать", + "Generate new recovery codes": "Создать новые коды восстановления", + "IAM user": "Пользователь IAM", + "Invalid verification code": "Неверный код подтверждения", + "Last used": "Последнее использование", + "Or use a recovery code": "Или используйте код восстановления", + "Password and two-factor authentication for your account.": "Пароль и двухфакторная аутентификация вашей учётной записи.", + "Password must be at least 8 characters": "Пароль должен содержать не менее 8 символов", + "Password updated.": "Пароль обновлён.", + "Password updated. Other sessions have been signed out.": "Пароль обновлён. Другие сеансы завершены.", + "Please enter your current password": "Введите текущий пароль", + "Profile": "Профиль", + "Protect your account with an authenticator app": "Защитите учётную запись приложением-аутентификатором", + "QR code for two-factor authentication setup": "QR-код для настройки двухфакторной аутентификации", + "Reconfigure authenticator": "Перенастроить аутентификатор", + "Recovery codes remaining": "Осталось кодов восстановления", + "Role": "Роль", + "Root credential": "Учётные данные root", + "Save your recovery codes": "Сохраните коды восстановления", + "Saved. Store them somewhere only you can reach.": "Сохранено. Храните их там, где доступ есть только у вас.", + "Scan this QR code with your authenticator app.": "Отсканируйте этот QR-код приложением-аутентификатором.", + "Security": "Безопасность", + "Service account": "Сервисная учётная запись", + "Session access key": "Ключ доступа сеанса", + "Temporary session": "Временный сеанс", + "That does not look like a valid code": "Это не похоже на действительный код", + "The identity you are signed in as.": "Личность, под которой вы вошли.", + "The new password must be different from the current one": "Новый пароль должен отличаться от текущего", + "These codes are shown only once.": "Эти коды показываются только один раз.", + "These codes can be used if you lose access to your authenticator. Each code works once.": "Эти коды можно использовать, если вы потеряете доступ к аутентификатору. Каждый код работает один раз.", + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.": "Эта личность задаётся окружением сервера (RUSTFS_ACCESS_KEY). Измените её, перезапустив сервер с новыми значениями.", + "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.": "Эта личность задаётся окружением сервера (RUSTFS_ACCESS_KEY). Имя пользователя и пароль меняются перезапуском сервера с новыми значениями, а не из консоли.", + "This identity's password is managed outside the console.": "Пароль этой личности управляется вне консоли.", + "Time-based, {digits} digits, {period}s period.": "По времени, {digits} цифр, период {period} с.", + "Turn off": "Отключить", + "Turn off two-factor authentication": "Отключить двухфакторную аутентификацию", + "Two-factor authentication": "Двухфакторная аутентификация", + "Two-factor authentication is now on.": "Двухфакторная аутентификация включена.", + "Two-factor authentication is off.": "Двухфакторная аутентификация отключена.", + "Unknown user": "Неизвестный пользователь", + "Update Password": "Обновить пароль", + "User": "Пользователь", + "Username": "Имя пользователя", + "Verify": "Подтвердить", + "Verify and enable": "Подтвердить и включить", + "You are running low on recovery codes. Generate a new set to be safe.": "Кодов восстановления осталось мало. Создайте новый набор на всякий случай.", + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.": "Коды восстановления закончились. Создайте новый набор, чтобы вернуться, если потеряете аутентификатор.", + "Your existing recovery codes will stop working.": "Ваши текущие коды восстановления перестанут работать.", + "Your previous recovery codes no longer work.": "Ваши предыдущие коды восстановления больше не работают.", + "{account} will be protected by its password alone, and the recovery codes will stop working.": "{account} будет защищена только паролем, а коды восстановления перестанут работать." } diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index 021d1825..8af310d7 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -1609,5 +1609,73 @@ "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.": "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", "Console cannot safely edit a newer or unknown KMS backend type.": "Console cannot safely edit a newer or unknown KMS backend type.", "Local KMS key files must use owner-only permissions such as 384 for 0o600.": "Local KMS key files must use owner-only permissions such as 384 for 0o600.", - "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration." + "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.", + "A 6-digit code, or one of your recovery codes.": "6 haneli bir kod ya da kurtarma kodlarınızdan biri.", + "A new authenticator is waiting to be confirmed. Your current one keeps working until then.": "Yeni bir doğrulayıcı onay bekliyor. O zamana kadar mevcut doğrulayıcınız çalışmaya devam eder.", + "Administrator": "Yönetici", + "At least 8 characters. This is also your S3 secret key.": "En az 8 karakter. Bu aynı zamanda S3 secret key'inizdir.", + "Authentication code": "Doğrulama kodu", + "Back": "Geri", + "Can't scan? Manual setup key": "Taratamıyor musunuz? Elle kurulum anahtarı", + "Change your password. This is also your S3 secret key.": "Parolanızı değiştirin. Bu aynı zamanda S3 secret key'inizdir.", + "Changing your password signs out your other sessions and invalidates the old secret key.": "Parolanızı değiştirmek diğer oturumlarınızı kapatır ve eski secret key'i geçersiz kılar.", + "Copy or download the codes before continuing.": "Devam etmeden önce kodları kopyalayın veya indirin.", + "Done": "Tamam", + "Enable 2FA": "2FA'yı etkinleştir", + "Enabled on": "Etkinleştirilme", + "Enter a code from your authenticator app": "Doğrulayıcı uygulamanızdan bir kod girin", + "Enter a code from your authenticator app or a recovery code": "Doğrulayıcı uygulamanızdan bir kod ya da bir kurtarma kodu girin", + "Enter the 6-digit code": "6 haneli kodu girin", + "Enter the 6-digit code from your authenticator app": "Doğrulayıcı uygulamanızdaki 6 haneli kodu girin", + "Enter the code from your authenticator app for {account}.": "{account} için doğrulayıcı uygulamanızdaki kodu girin.", + "Generate": "Oluştur", + "Generate new recovery codes": "Yeni kurtarma kodları oluştur", + "IAM user": "IAM kullanıcısı", + "Invalid verification code": "Doğrulama kodu geçersiz", + "Last used": "Son kullanım", + "Or use a recovery code": "Ya da bir kurtarma kodu kullanın", + "Password and two-factor authentication for your account.": "Hesabınızın parolası ve iki adımlı doğrulaması.", + "Password must be at least 8 characters": "Parola en az 8 karakter olmalı", + "Password updated.": "Parola güncellendi.", + "Password updated. Other sessions have been signed out.": "Parola güncellendi. Diğer oturumlar kapatıldı.", + "Please enter your current password": "Lütfen mevcut parolanızı girin", + "Profile": "Profil", + "Protect your account with an authenticator app": "Hesabınızı bir doğrulayıcı uygulamayla koruyun", + "QR code for two-factor authentication setup": "İki adımlı doğrulama kurulumu için QR kod", + "Reconfigure authenticator": "Doğrulayıcıyı yeniden yapılandır", + "Recovery codes remaining": "Kalan kurtarma kodu", + "Role": "Rol", + "Root credential": "Root kimlik bilgisi", + "Save your recovery codes": "Kurtarma kodlarınızı kaydedin", + "Saved. Store them somewhere only you can reach.": "Kaydedildi. Yalnızca sizin erişebileceğiniz bir yerde saklayın.", + "Scan this QR code with your authenticator app.": "Bu QR kodu doğrulayıcı uygulamanızla taratın.", + "Security": "Güvenlik", + "Service account": "Servis hesabı", + "Session access key": "Oturum access key'i", + "Temporary session": "Geçici oturum", + "That does not look like a valid code": "Bu geçerli bir kod gibi görünmüyor", + "The identity you are signed in as.": "Oturum açtığınız kimlik.", + "The new password must be different from the current one": "Yeni parola mevcut paroladan farklı olmalı", + "These codes are shown only once.": "Bu kodlar yalnızca bir kez gösterilir.", + "These codes can be used if you lose access to your authenticator. Each code works once.": "Doğrulayıcınıza erişiminizi kaybederseniz bu kodları kullanabilirsiniz. Her kod bir kez çalışır.", + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.": "Bu kimlik sunucu ortamından sağlanıyor (RUSTFS_ACCESS_KEY). Değiştirmek için sunucuyu yeni değerlerle yeniden başlatın.", + "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.": "Bu kimlik sunucu ortamından sağlanıyor (RUSTFS_ACCESS_KEY). Kullanıcı adı ve parolası Console'dan değil, sunucu yeni değerlerle yeniden başlatılarak değiştirilir.", + "This identity's password is managed outside the console.": "Bu kimliğin parolası Console dışında yönetiliyor.", + "Time-based, {digits} digits, {period}s period.": "Zaman tabanlı, {digits} hane, {period}s periyot.", + "Turn off": "Kapat", + "Turn off two-factor authentication": "İki adımlı doğrulamayı kapat", + "Two-factor authentication": "İki adımlı doğrulama", + "Two-factor authentication is now on.": "İki adımlı doğrulama artık açık.", + "Two-factor authentication is off.": "İki adımlı doğrulama kapatıldı.", + "Unknown user": "Bilinmeyen kullanıcı", + "Update Password": "Parolayı güncelle", + "User": "Kullanıcı", + "Username": "Kullanıcı adı", + "Verify": "Doğrula", + "Verify and enable": "Doğrula ve etkinleştir", + "You are running low on recovery codes. Generate a new set to be safe.": "Kurtarma kodlarınız azalıyor. Güvende olmak için yeni bir set oluşturun.", + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.": "Hiç kurtarma kodunuz kalmadı. Doğrulayıcınızı kaybederseniz geri girebilmek için yeni bir set oluşturun.", + "Your existing recovery codes will stop working.": "Mevcut kurtarma kodlarınız çalışmayı bırakacak.", + "Your previous recovery codes no longer work.": "Önceki kurtarma kodlarınız artık çalışmıyor.", + "{account} will be protected by its password alone, and the recovery codes will stop working.": "{account} yalnızca parolasıyla korunacak ve kurtarma kodları çalışmayı bırakacak." } diff --git a/i18n/locales/vi-VN.json b/i18n/locales/vi-VN.json index 484ec3b5..c8422323 100644 --- a/i18n/locales/vi-VN.json +++ b/i18n/locales/vi-VN.json @@ -1609,5 +1609,73 @@ "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.": "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", "Console cannot safely edit a newer or unknown KMS backend type.": "Console cannot safely edit a newer or unknown KMS backend type.", "Local KMS key files must use owner-only permissions such as 384 for 0o600.": "Local KMS key files must use owner-only permissions such as 384 for 0o600.", - "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration." + "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.", + "A 6-digit code, or one of your recovery codes.": "Mã 6 chữ số, hoặc một trong các mã phục hồi của bạn.", + "A new authenticator is waiting to be confirmed. Your current one keeps working until then.": "Một ứng dụng xác thực mới đang chờ xác nhận. Ứng dụng hiện tại vẫn hoạt động cho đến lúc đó.", + "Administrator": "Quản trị viên", + "At least 8 characters. This is also your S3 secret key.": "Tối thiểu 8 ký tự. Đây cũng là secret key S3 của bạn.", + "Authentication code": "Mã xác thực", + "Back": "Quay lại", + "Can't scan? Manual setup key": "Không quét được? Khóa thiết lập thủ công", + "Change your password. This is also your S3 secret key.": "Đổi mật khẩu. Đây cũng là secret key S3 của bạn.", + "Changing your password signs out your other sessions and invalidates the old secret key.": "Đổi mật khẩu sẽ đăng xuất các phiên khác và làm secret key cũ không còn hiệu lực.", + "Copy or download the codes before continuing.": "Hãy sao chép hoặc tải các mã về trước khi tiếp tục.", + "Done": "Xong", + "Enable 2FA": "Bật 2FA", + "Enabled on": "Bật vào", + "Enter a code from your authenticator app": "Nhập mã từ ứng dụng xác thực của bạn", + "Enter a code from your authenticator app or a recovery code": "Nhập mã từ ứng dụng xác thực hoặc một mã phục hồi", + "Enter the 6-digit code": "Nhập mã 6 chữ số", + "Enter the 6-digit code from your authenticator app": "Nhập mã 6 chữ số từ ứng dụng xác thực của bạn", + "Enter the code from your authenticator app for {account}.": "Nhập mã từ ứng dụng xác thực cho {account}.", + "Generate": "Tạo", + "Generate new recovery codes": "Tạo mã phục hồi mới", + "IAM user": "Người dùng IAM", + "Invalid verification code": "Mã xác minh không hợp lệ", + "Last used": "Lần dùng cuối", + "Or use a recovery code": "Hoặc dùng mã phục hồi", + "Password and two-factor authentication for your account.": "Mật khẩu và xác thực hai yếu tố cho tài khoản của bạn.", + "Password must be at least 8 characters": "Mật khẩu phải có ít nhất 8 ký tự", + "Password updated.": "Đã cập nhật mật khẩu.", + "Password updated. Other sessions have been signed out.": "Đã cập nhật mật khẩu. Các phiên khác đã bị đăng xuất.", + "Please enter your current password": "Vui lòng nhập mật khẩu hiện tại", + "Profile": "Hồ sơ", + "Protect your account with an authenticator app": "Bảo vệ tài khoản bằng ứng dụng xác thực", + "QR code for two-factor authentication setup": "Mã QR để thiết lập xác thực hai yếu tố", + "Reconfigure authenticator": "Cấu hình lại ứng dụng xác thực", + "Recovery codes remaining": "Số mã phục hồi còn lại", + "Role": "Vai trò", + "Root credential": "Thông tin đăng nhập root", + "Save your recovery codes": "Lưu các mã phục hồi của bạn", + "Saved. Store them somewhere only you can reach.": "Đã lưu. Hãy giữ ở nơi chỉ bạn truy cập được.", + "Scan this QR code with your authenticator app.": "Quét mã QR này bằng ứng dụng xác thực của bạn.", + "Security": "Bảo mật", + "Service account": "Tài khoản dịch vụ", + "Session access key": "Access key của phiên", + "Temporary session": "Phiên tạm thời", + "That does not look like a valid code": "Đó không giống một mã hợp lệ", + "The identity you are signed in as.": "Danh tính bạn đang đăng nhập.", + "The new password must be different from the current one": "Mật khẩu mới phải khác mật khẩu hiện tại", + "These codes are shown only once.": "Các mã này chỉ được hiển thị một lần.", + "These codes can be used if you lose access to your authenticator. Each code works once.": "Bạn có thể dùng các mã này nếu mất quyền truy cập ứng dụng xác thực. Mỗi mã chỉ dùng được một lần.", + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.": "Danh tính này lấy từ môi trường của server (RUSTFS_ACCESS_KEY). Hãy thay đổi bằng cách khởi động lại server với giá trị mới.", + "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.": "Danh tính này lấy từ môi trường của server (RUSTFS_ACCESS_KEY). Tên người dùng và mật khẩu được thay đổi bằng cách khởi động lại server với giá trị mới, không phải từ console.", + "This identity's password is managed outside the console.": "Mật khẩu của danh tính này được quản lý bên ngoài console.", + "Time-based, {digits} digits, {period}s period.": "Dựa trên thời gian, {digits} chữ số, chu kỳ {period} giây.", + "Turn off": "Tắt", + "Turn off two-factor authentication": "Tắt xác thực hai yếu tố", + "Two-factor authentication": "Xác thực hai yếu tố", + "Two-factor authentication is now on.": "Xác thực hai yếu tố đã được bật.", + "Two-factor authentication is off.": "Xác thực hai yếu tố đã tắt.", + "Unknown user": "Người dùng không xác định", + "Update Password": "Cập nhật mật khẩu", + "User": "Người dùng", + "Username": "Tên người dùng", + "Verify": "Xác minh", + "Verify and enable": "Xác minh và bật", + "You are running low on recovery codes. Generate a new set to be safe.": "Bạn còn ít mã phục hồi. Hãy tạo bộ mới để an toàn.", + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.": "Bạn không còn mã phục hồi nào. Hãy tạo bộ mới để vẫn vào được nếu mất ứng dụng xác thực.", + "Your existing recovery codes will stop working.": "Các mã phục hồi hiện có của bạn sẽ ngừng hoạt động.", + "Your previous recovery codes no longer work.": "Các mã phục hồi trước đây của bạn không còn hoạt động.", + "{account} will be protected by its password alone, and the recovery codes will stop working.": "{account} sẽ chỉ được bảo vệ bằng mật khẩu, và các mã phục hồi sẽ ngừng hoạt động." } diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index a80cb570..c83ffa79 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -1609,5 +1609,73 @@ "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.": "This KMS backend is not supported by this Console version. Upgrade Console before reconfiguring KMS.", "Console cannot safely edit a newer or unknown KMS backend type.": "Console cannot safely edit a newer or unknown KMS backend type.", "Local KMS key files must use owner-only permissions such as 384 for 0o600.": "Local KMS key files must use owner-only permissions such as 384 for 0o600.", - "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration." + "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.": "Key created but could not update the default SSE key. The new key ID has been placed in the default key field so you can retry saving the configuration.", + "A 6-digit code, or one of your recovery codes.": "一个 6 位验证码,或者你的某个恢复码。", + "A new authenticator is waiting to be confirmed. Your current one keeps working until then.": "新的验证器应用正在等待确认。在此之前,当前的验证器仍然有效。", + "Administrator": "管理员", + "At least 8 characters. This is also your S3 secret key.": "至少 8 个字符。这同时也是你的 S3 秘密密钥。", + "Authentication code": "验证码", + "Back": "返回", + "Can't scan? Manual setup key": "无法扫描?手动设置密钥", + "Change your password. This is also your S3 secret key.": "修改密码。这同时也是你的 S3 秘密密钥。", + "Changing your password signs out your other sessions and invalidates the old secret key.": "修改密码会退出你的其他会话,并使旧的秘密密钥失效。", + "Copy or download the codes before continuing.": "请先复制或下载这些恢复码,然后继续。", + "Done": "完成", + "Enable 2FA": "启用两步验证", + "Enabled on": "启用时间", + "Enter a code from your authenticator app": "请输入验证器应用中的验证码", + "Enter a code from your authenticator app or a recovery code": "请输入验证器应用中的验证码或一个恢复码", + "Enter the 6-digit code": "请输入 6 位验证码", + "Enter the 6-digit code from your authenticator app": "请输入验证器应用中的 6 位验证码", + "Enter the code from your authenticator app for {account}.": "请输入 {account} 在验证器应用中的验证码。", + "Generate": "生成", + "Generate new recovery codes": "生成新的恢复码", + "IAM user": "IAM 用户", + "Invalid verification code": "验证码无效", + "Last used": "最近使用", + "Or use a recovery code": "或使用恢复码", + "Password and two-factor authentication for your account.": "你账户的密码与两步验证。", + "Password must be at least 8 characters": "密码至少需要 8 个字符", + "Password updated.": "密码已更新。", + "Password updated. Other sessions have been signed out.": "密码已更新。其他会话已退出。", + "Please enter your current password": "请输入当前密码", + "Profile": "个人资料", + "Protect your account with an authenticator app": "使用验证器应用保护你的账户", + "QR code for two-factor authentication setup": "用于设置两步验证的二维码", + "Reconfigure authenticator": "重新配置验证器", + "Recovery codes remaining": "剩余恢复码", + "Role": "角色", + "Root credential": "Root 凭证", + "Save your recovery codes": "保存你的恢复码", + "Saved. Store them somewhere only you can reach.": "已保存。请存放在只有你能访问的地方。", + "Scan this QR code with your authenticator app.": "使用验证器应用扫描此二维码。", + "Security": "安全", + "Service account": "服务账户", + "Session access key": "会话访问密钥", + "Temporary session": "临时会话", + "That does not look like a valid code": "这看起来不是有效的验证码", + "The identity you are signed in as.": "你当前登录使用的身份。", + "The new password must be different from the current one": "新密码必须与当前密码不同", + "These codes are shown only once.": "这些恢复码仅显示一次。", + "These codes can be used if you lose access to your authenticator. Each code works once.": "如果你无法访问验证器,可以使用这些恢复码。每个恢复码只能使用一次。", + "This identity is provisioned from the server environment (RUSTFS_ACCESS_KEY). Change it by restarting the server with new values.": "该身份来自服务器环境变量(RUSTFS_ACCESS_KEY)。请使用新值重启服务器来修改。", + "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.": "该身份来自服务器环境变量(RUSTFS_ACCESS_KEY)。其用户名和密码需通过使用新值重启服务器来修改,而不是在控制台修改。", + "This identity's password is managed outside the console.": "该身份的密码在控制台之外管理。", + "Time-based, {digits} digits, {period}s period.": "基于时间,{digits} 位,周期 {period} 秒。", + "Turn off": "关闭", + "Turn off two-factor authentication": "关闭两步验证", + "Two-factor authentication": "两步验证", + "Two-factor authentication is now on.": "两步验证已开启。", + "Two-factor authentication is off.": "两步验证已关闭。", + "Unknown user": "未知用户", + "Update Password": "更新密码", + "User": "用户", + "Username": "用户名", + "Verify": "验证", + "Verify and enable": "验证并启用", + "You are running low on recovery codes. Generate a new set to be safe.": "你的恢复码快用完了。请生成一组新的以确保安全。", + "You have no recovery codes left. Generate a new set so you can get back in if you lose your authenticator.": "你已没有剩余恢复码。请生成一组新的,以便在丢失验证器时仍能登录。", + "Your existing recovery codes will stop working.": "你现有的恢复码将失效。", + "Your previous recovery codes no longer work.": "你之前的恢复码已失效。", + "{account} will be protected by its password alone, and the recovery codes will stop working.": "{account} 将仅由密码保护,恢复码也将失效。" } diff --git a/lib/mfa-challenge.ts b/lib/mfa-challenge.ts new file mode 100644 index 00000000..7e3e998d --- /dev/null +++ b/lib/mfa-challenge.ts @@ -0,0 +1,58 @@ +import { joinURL } from "ufo" +import { AwsClient } from "@/lib/aws4fetch" +import type { MfaChallenge } from "@/lib/mfa" +import type { SiteConfig } from "@/types/config" + +/** + * Ask the server whether an identity needs a second factor, before any session + * exists. + * + * This runs at login time, so it cannot go through `ApiProvider`: that client is + * built from STS credentials the user does not have yet. It signs with the + * long-term access key the user just typed, which is also what keeps the + * endpoint from being an enumeration oracle — a caller only ever learns about + * the identity whose secret it already holds. + * + * A server that predates this endpoint answers 404 or 501. That is reported as + * "no second factor required" rather than as a failure, so the console keeps + * working against an older cluster. + */ +export async function fetchMfaChallenge( + credentials: { accessKeyId: string; secretAccessKey: string }, + config: SiteConfig, +): Promise { + const client = new AwsClient({ + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + region: config.s3.region || "us-east-1", + service: "s3", + }) + + const url = joinURL(config.api.baseURL, "/mfa/challenge") + + let response: Response + try { + response = await client.fetch(url, { method: "GET" }) + } catch { + // A transport failure here must not silently skip the second factor: fall + // through to `AssumeRole`, which fails closed on its own if a factor is + // enrolled. Returning `required: false` only means "we could not ask". + return { required: false } + } + + if (response.status === 404 || response.status === 501) { + return { required: false } + } + + if (!response.ok) { + // Wrong credentials land here as a 403. Let AssumeRole produce the + // authoritative error rather than inventing one from this probe. + return { required: false } + } + + try { + return (await response.json()) as MfaChallenge + } catch { + return { required: false } + } +} diff --git a/lib/mfa.ts b/lib/mfa.ts new file mode 100644 index 00000000..737aa3b6 --- /dev/null +++ b/lib/mfa.ts @@ -0,0 +1,174 @@ +/** + * Client-side helpers for the two-factor authentication flow. + * + * Deliberately thin: every security decision — whether a code is valid, whether + * a challenge is still fresh, whether an identity needs a second factor — is the + * server's. What lives here is presentation logic (which input to show, which + * message to render) plus the detection of the one server signal the login flow + * has to branch on. + * + * Nothing here stores a TOTP secret. The secret is rendered during setup and + * then discarded with the component; it never reaches localStorage. + */ + +/** Digits in a TOTP code, mirroring the server's `TOTP_DIGITS`. */ +export const TOTP_CODE_LENGTH = 6 + +/** + * Significant characters in a recovery code, mirroring the server's format of + * five dash-separated groups of four. + */ +export const RECOVERY_CODE_LENGTH = 20 + +/** + * Error code the server returns from `AssumeRole` when the identity has a second + * factor enrolled and the request carried none. + * + * Matched as a substring because the server embeds it in a human-readable + * message, and because the AWS SDK surfaces STS errors with varying envelopes + * depending on how the response is parsed. + */ +export const ERR_MFA_REQUIRED = "MultiFactorAuthRequired" + +/** Characters the server's recovery-code alphabet excludes. */ +const RECOVERY_ALPHABET = /^[0-9A-HJKMNP-TV-Z]+$/ + +/** Strip formatting so a pasted or hand-typed code can be classified. */ +export function normalizeCode(input: string): string { + return input.replace(/[\s-]/g, "").toUpperCase() +} + +/** Whether `input` has the shape of a TOTP code. */ +export function looksLikeTotpCode(input: string): boolean { + const normalized = normalizeCode(input) + return normalized.length === TOTP_CODE_LENGTH && /^[0-9]+$/.test(normalized) +} + +/** Whether `input` has the shape of a recovery code. */ +export function looksLikeRecoveryCode(input: string): boolean { + const normalized = normalizeCode(input) + return normalized.length === RECOVERY_CODE_LENGTH && RECOVERY_ALPHABET.test(normalized) +} + +/** + * Whether `input` could be submitted at all. + * + * Shape-only: a well-formed code can still be wrong, and only the server knows. + * This exists to keep the submit button from firing a request that cannot + * possibly succeed, not to pre-judge validity. + */ +export function isSubmittableCode(input: string): boolean { + return looksLikeTotpCode(input) || looksLikeRecoveryCode(input) +} + +/** + * Whether a failed login was a demand for a second factor rather than a + * rejection of the credentials. + * + * The distinction decides whether the user sees "wrong password" or a code + * prompt, so it checks every place the SDK might have put the server's message. + */ +export function isMfaRequiredError(error: unknown): boolean { + return collectErrorText(error).includes(ERR_MFA_REQUIRED) +} + +function collectErrorText(error: unknown): string { + if (error == null) return "" + if (typeof error === "string") return error + + const parts: string[] = [] + const candidate = error as { + message?: unknown + name?: unknown + Code?: unknown + code?: unknown + Message?: unknown + error?: unknown + cause?: unknown + } + + for (const value of [candidate.message, candidate.name, candidate.Code, candidate.code, candidate.Message]) { + if (typeof value === "string") parts.push(value) + } + // One level of nesting only: the SDK wraps a service error in a client error, + // but deeper recursion risks a cycle on error objects that reference a request. + for (const nested of [candidate.error, candidate.cause]) { + if (nested && typeof nested === "object") { + const inner = nested as { message?: unknown; Code?: unknown; code?: unknown } + for (const value of [inner.message, inner.Code, inner.code]) { + if (typeof value === "string") parts.push(value) + } + } else if (typeof nested === "string") { + parts.push(nested) + } + } + + return parts.join(" ") +} + +/** Server-reported MFA state for the calling identity. */ +export interface MfaStatus { + enabled: boolean + pending: boolean + algorithm: string + digits: number + period_seconds: number + activated_at?: string + pending_expires_at?: string + recovery_codes_remaining: number + last_verified_at?: string + enrollment_available: boolean + enrollment_blocked_reason?: string +} + +/** Response of starting an enrollment. */ +export interface MfaEnrollment { + secret_base32: string + otpauth_uri: string + qr_svg: string + qr_utf8: string + algorithm: string + digits: number + period_seconds: number + expires_at: string +} + +/** Server-issued login challenge. */ +export interface MfaChallenge { + required: boolean + challenge?: string + expires_at?: string +} + +/** + * Wrap the server's QR SVG as an image source. + * + * An image source, not injected markup: the SVG is server-generated today, but + * routing it through `` keeps it from ever becoming a same-origin script + * sink, and the page needs no QR library of its own. + */ +export function qrSvgToDataUri(svg: string): string { + // encodeURIComponent rather than btoa: the SVG can legitimately contain + // non-Latin-1 characters, which btoa throws on. + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` +} + +/** + * Group a base32 secret for manual entry. + * + * Authenticator apps ignore the spaces, and a human transcribing 32 unbroken + * characters will lose their place. + */ +export function formatManualSetupKey(secret: string): string { + return (secret.match(/.{1,4}/g) ?? []).join(" ") +} + +/** Render recovery codes as a plain-text file body. */ +export function formatRecoveryCodesForExport(codes: string[]): string { + return `${codes.join("\n")}\n` +} + +/** Whether the user should be nudged to generate a fresh set. */ +export function recoveryCodesRunningLow(remaining: number): boolean { + return remaining > 0 && remaining <= 3 +} diff --git a/lib/sts.ts b/lib/sts.ts index 8b36958e..f41e0755 100644 --- a/lib/sts.ts +++ b/lib/sts.ts @@ -3,10 +3,26 @@ import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from "@aws- import { addApiPrefixMiddleware } from "@/lib/api-prefix-middleware" import type { SiteConfig } from "@/types/config" +/** + * Second factor to present alongside the credentials. + * + * Carried on `AssumeRole`'s own `SerialNumber`/`TokenCode` fields rather than a + * custom endpoint: they are part of the STS API, so the AWS SDK sends them + * unchanged and a script using `aws sts assume-role` can authenticate the same + * way the console does. + */ +export interface StsSecondFactor { + /** The challenge from `GET /mfa/challenge`, echoed back. */ + challenge?: string + /** A six-digit TOTP code or a recovery code. */ + code: string +} + export async function getStsToken( credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider, roleArn: string, customConfig: SiteConfig, + secondFactor?: StsSecondFactor, ) { const stsClient = new STSClient({ endpoint: customConfig.s3.endpoint, @@ -20,6 +36,12 @@ export async function getStsToken( RoleArn: roleArn, RoleSessionName: "console", DurationSeconds: customConfig.session?.durationSeconds || 3600 * 12, + ...(secondFactor + ? { + SerialNumber: secondFactor.challenge, + TokenCode: secondFactor.code, + } + : {}), }) const response = await stsClient.send(command) diff --git a/tests/lib/account-surface.test.js b/tests/lib/account-surface.test.js new file mode 100644 index 00000000..e02178e3 --- /dev/null +++ b/tests/lib/account-surface.test.js @@ -0,0 +1,127 @@ +import test from "node:test" +import assert from "node:assert/strict" +import fs from "node:fs" + +const read = (file) => fs.readFileSync(file, "utf8") + +test("the profile page distinguishes a failed read from an empty profile", () => { + const source = read("app/(dashboard)/account/page.tsx") + + // Rendering a read failure as an empty profile would tell the user their + // account has no policies or groups, which is a different and false claim. + assert.match(source, /setLoadError/) + assert.match(source, /t\("Retry"\)/) + assert.match(source, /Skeleton/) + // Passive metadata as a definition list, not a grid of bordered cards. + assert.match(source, /
{ + const source = read("app/(dashboard)/account/page.tsx") + + // A disabled control with no explanation is worse than no control. + assert.match(source, /credentials_source === "env"/) + assert.match(source, /RUSTFS_ACCESS_KEY/) +}) + +test("the security page gates its actions on what the server says is possible", () => { + const source = read("app/(dashboard)/account/security/page.tsx") + + assert.match(source, /info\?\.mutable\.password \?\? false/) + assert.match(source, /mfa\?\.enrollment_available \?\? false/) + assert.match(source, /enrollment_blocked_reason/) + // Zero remaining codes is a distinct, louder state than running low. + assert.match(source, /recovery_codes_remaining === 0/) + assert.match(source, /recoveryCodesRunningLow/) +}) + +test("recovery codes cannot be dismissed before they are stored", () => { + const panel = read("components/account/recovery-codes-panel.tsx") + + // The server keeps only hashes, so this is the one time the values exist + // anywhere the user can read them. + assert.match(panel, /disabled=\{!saved \|\| pending\}/) + assert.match(panel, /setSaved\(true\)/) +}) + +test("the setup flow keeps the recovery codes in the dialog that produced them", () => { + const source = read("components/account/mfa-setup-dialog.tsx") + + // Nested dialogs are ruled out by the design guide, and a parent closing + // underneath the codes would orphan them. + assert.match(source, /if \(!nextOpen && step === "codes"\) return/) + assert.match(source, /showCloseButton=\{step !== "codes"\}/) + assert.match(source, /RecoveryCodesPanel/) +}) + +test("the setup dialog never persists the shared secret", () => { + const source = read("components/account/mfa-setup-dialog.tsx") + + // A TOTP secret in browser storage defeats the second factor. + assert.doesNotMatch(source, /localStorage/) + assert.doesNotMatch(source, /sessionStorage/) + assert.doesNotMatch(source, /useLocalStorage/) +}) + +test("turning off the second factor requires both factors and names the account", () => { + const source = read("components/account/mfa-disable-dialog.tsx") + + assert.match(source, /disableMfa\(code, password\)/) + assert.match(source, /autoComplete="current-password"/) + assert.match(source, /\{account\} will be protected by its password alone/) + assert.match(source, /variant="destructive"/) +}) + +test("the login flow keeps long-term credentials out of storage", () => { + const authContext = read("contexts/auth-context.tsx") + + // The credentials for the second call live in component state only; only the + // resulting STS session is persisted. + assert.match(authContext, /storeStsCredentials/) + assert.match(authContext, /completeLoginWithSecondFactor/) + assert.doesNotMatch(authContext, /setStore\(\{[\s\S]*secretAccessKey/) +}) + +test("a demand for a second factor is not reported as a failed login", () => { + const page = read("app/(auth)/auth/login/page.tsx") + + assert.match(page, /outcome\.status === "mfa-required"/) + assert.match(page, /setPendingMfa/) + // The success/failure toast must not fire on the mfa-required branch. + assert.match(page, /setMfaError\(""\)\n\s*return/) +}) + +test("the challenge probe cannot become an enumeration oracle", () => { + const source = read("lib/mfa-challenge.ts") + + // It is signed with the caller's own key, so a caller only learns about the + // identity whose secret it already holds. + assert.match(source, /new AwsClient\(/) + assert.match(source, /accessKeyId: credentials\.accessKeyId/) + // An older server, or any failure, must not silently skip the factor: it + // falls through to AssumeRole, which fails closed on its own. + assert.match(source, /response\.status === 404 \|\| response\.status === 501/) + assert.match(source, /return \{ required: false \}/) +}) + +test("the second factor rides AssumeRole's own fields", () => { + const source = read("lib/sts.ts") + + // SerialNumber/TokenCode are part of the STS API, so the SDK sends them + // unchanged and a script can authenticate the same way. + assert.match(source, /SerialNumber: secondFactor\.challenge/) + assert.match(source, /TokenCode: secondFactor\.code/) +}) + +test("the account pages stay reachable for every authenticated identity", () => { + const permissions = read("lib/console-permissions.ts") + const routeMeta = read("lib/dashboard-route-meta.ts") + + // Self-service must not require a console scope: a user who cannot list + // policies still has to be able to change their own password. A future + // PAGE_PERMISSIONS entry for /account would silently lock them out. + assert.doesNotMatch(permissions, /"\/account"/) + assert.doesNotMatch(routeMeta, /"\/account"/) +}) diff --git a/tests/lib/iam-remaining-safety.test.js b/tests/lib/iam-remaining-safety.test.js index 584e06b3..0147e226 100644 --- a/tests/lib/iam-remaining-safety.test.js +++ b/tests/lib/iam-remaining-safety.test.js @@ -18,16 +18,37 @@ test("new user dialog distinguishes assignment loading errors and keeps actions assert.doesNotMatch(source, /max-h-\[80vh\]/) }) -test("change password asks only for values the request actually uses", () => { - const source = read("components/user/change-password.tsx") +test("change password proves knowledge of the current secret before rotating it", () => { + const source = read("components/account/change-password-dialog.tsx") - assert.doesNotMatch(source, /currentSecretKey/) - assert.doesNotMatch(source, /password-current/) + // The request is already signed, but the console signs with a short-lived + // session: a signature proves the session is live, not that the person at the + // keyboard knows the password. Dropping this field would let a borrowed tab + // change the account's credentials. + assert.match(source, /changePassword\(current, next\)/) + assert.match(source, /autoComplete="current-password"/) assert.match(source, /PASSWORD_MIN_LENGTH = 8/) - assert.match(source, /PASSWORD_MAX_LENGTH = 40/) assert.match(source, / { + const source = read("hooks/use-account.ts") + + assert.match(source, /"\/account\/password"/) + assert.match(source, /current_secret_key: currentSecretKey, new_secret_key: newSecretKey/) + // Self-service only: no target parameter, so this cannot act on another + // identity even if a caller tried. + assert.doesNotMatch(source, /accessKey=\$\{/) }) test("credential result prevents accidental dismissal and names copy actions", () => { diff --git a/tests/lib/mfa.test.ts b/tests/lib/mfa.test.ts new file mode 100644 index 00000000..0e169c99 --- /dev/null +++ b/tests/lib/mfa.test.ts @@ -0,0 +1,116 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { + ERR_MFA_REQUIRED, + formatManualSetupKey, + formatRecoveryCodesForExport, + isMfaRequiredError, + isSubmittableCode, + looksLikeRecoveryCode, + looksLikeTotpCode, + normalizeCode, + qrSvgToDataUri, + recoveryCodesRunningLow, +} from "../../lib/mfa" + +test("a six-digit code is recognised as a TOTP code", () => { + assert.equal(looksLikeTotpCode("123456"), true) + assert.equal(looksLikeTotpCode("000000"), true) + // Users paste codes with the spacing their authenticator shows. + assert.equal(looksLikeTotpCode("123 456"), true) +}) + +test("codes of the wrong length or alphabet are not TOTP codes", () => { + for (const input of ["", "12345", "1234567", "12345a", "abcdef"]) { + assert.equal(looksLikeTotpCode(input), false, input) + } +}) + +test("a five-group code is recognised as a recovery code", () => { + assert.equal(looksLikeRecoveryCode("ABCD-EFGH-JKMN-PQRS-TVWX"), true) + assert.equal(looksLikeRecoveryCode("abcd-efgh-jkmn-pqrs-tvwx"), true) + assert.equal(looksLikeRecoveryCode("ABCDEFGHJKMNPQRSTVWX"), true) +}) + +test("recovery codes reject the characters the server's alphabet excludes", () => { + // I, L, O and U are absent from the server alphabet so a handwritten code + // cannot be ambiguous. A string using them is not a code this server issued. + assert.equal(looksLikeRecoveryCode("IIII-LLLL-OOOO-UUUU-IIII"), false) +}) + +test("the two code shapes never overlap", () => { + // Overlap would make routing ambiguous: the login form has to decide which + // input the user filled without asking them. + assert.equal(looksLikeRecoveryCode("123456"), false) + assert.equal(looksLikeTotpCode("ABCD-EFGH-JKMN-PQRS-TVWX"), false) +}) + +test("only recognisable shapes are submittable", () => { + assert.equal(isSubmittableCode("123456"), true) + assert.equal(isSubmittableCode("ABCD-EFGH-JKMN-PQRS-TVWX"), true) + assert.equal(isSubmittableCode("12"), false) + assert.equal(isSubmittableCode(""), false) +}) + +test("normalisation strips only formatting", () => { + assert.equal(normalizeCode(" abcd-efgh "), "ABCDEFGH") + assert.equal(normalizeCode("123 456"), "123456") +}) + +test("a demand for a second factor is told apart from a rejected password", () => { + // The whole login branch depends on this: reporting "login failed" here would + // send the user to reset a password that is working. + assert.equal(isMfaRequiredError(new Error(`${ERR_MFA_REQUIRED}: a second factor is required`)), true) + assert.equal(isMfaRequiredError({ name: ERR_MFA_REQUIRED }), true) + assert.equal(isMfaRequiredError({ Code: ERR_MFA_REQUIRED }), true) + // The SDK wraps a service error inside a client error. + assert.equal(isMfaRequiredError({ message: "failed", cause: { Code: ERR_MFA_REQUIRED } }), true) + assert.equal(isMfaRequiredError({ message: "failed", error: { message: ERR_MFA_REQUIRED } }), true) +}) + +test("ordinary failures are not treated as a second-factor demand", () => { + assert.equal(isMfaRequiredError(new Error("InvalidAccessKeyId")), false) + assert.equal(isMfaRequiredError("AccessDenied"), false) + assert.equal(isMfaRequiredError(null), false) + assert.equal(isMfaRequiredError(undefined), false) + assert.equal(isMfaRequiredError({}), false) +}) + +test("error inspection does not recurse into a cycle", () => { + // Error objects routinely reference the request that produced them, which can + // reference the error back. + const error: Record = { message: "boom" } + error.cause = error + assert.equal(isMfaRequiredError(error), false) +}) + +test("the QR is exposed as an image source, never as markup", () => { + // Routing it through a data URI keeps a server-rendered SVG from ever + // becoming a same-origin script sink. + const uri = qrSvgToDataUri('') + + assert.ok(uri.startsWith("data:image/svg+xml;charset=utf-8,")) + assert.ok(!uri.includes(" { + // btoa would throw here; encodeURIComponent is why it does not. + assert.doesNotThrow(() => qrSvgToDataUri("—çğ日本")) +}) + +test("the manual setup key is grouped for transcription", () => { + assert.equal(formatManualSetupKey("JBSWY3DPEHPK3PXP"), "JBSW Y3DP EHPK 3PXP") + assert.equal(formatManualSetupKey(""), "") +}) + +test("exported recovery codes are one per line and newline-terminated", () => { + assert.equal(formatRecoveryCodesForExport(["AAAA", "BBBB"]), "AAAA\nBBBB\n") +}) + +test("running low is a warning, not an error state", () => { + assert.equal(recoveryCodesRunningLow(3), true) + assert.equal(recoveryCodesRunningLow(1), true) + // Zero is its own, louder state; it must not be folded into "running low". + assert.equal(recoveryCodesRunningLow(0), false) + assert.equal(recoveryCodesRunningLow(10), false) +}) diff --git a/tests/lib/ui-layout-source.test.js b/tests/lib/ui-layout-source.test.js index 9c3ae0f9..d631f7e1 100644 --- a/tests/lib/ui-layout-source.test.js +++ b/tests/lib/ui-layout-source.test.js @@ -35,13 +35,29 @@ test("top navigation keeps the account avatar inside its trigger with visible in assert.match(source, /