feat(auth): add TOTP (RFC 6238) second factor with recovery codes

- store the per-Account shared secret encrypted at rest (Fernet keyed off
  the instance JWT secret via HKDF); never persist it in plaintext
- store recovery codes only as salted PBKDF2-HMAC-SHA256 digests
- add TotpService covering enrol / verify / disable and recovery-code use
- expose the login second-factor challenge (code `totp_required`) and the
  recovery-code path in the auth / reset flows
- add the `totp_credentials` migration (0025, revises 0024_passkey_credentials)
- web: TOTP challenge step on login, TOTP / recovery-code methods on
  reset-password, TotpEnrollDialog in account settings, i18n for all locales
This commit is contained in:
TyperBody
2026-09-13 00:01:54 +08:00
parent d26d0635c5
commit a40051daf1
19 changed files with 1597 additions and 90 deletions
@@ -21,9 +21,11 @@ import {
Plus,
Trash2,
Pencil,
ShieldCheck,
} from 'lucide-react';
import { startRegistration } from '@simplewebauthn/browser';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
import TotpEnrollDialog from './TotpEnrollDialog';
import { PanelBody } from '../settings-dialog/panel-layout';
interface AccountSettingsPanelProps {
@@ -56,11 +58,16 @@ export default function AccountSettingsPanel({
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [registeringPasskey, setRegisteringPasskey] = useState(false);
const [totpEnabled, setTotpEnabled] = useState(false);
const [remainingRecoveryCodes, setRemainingRecoveryCodes] = useState(0);
const [totpLoading, setTotpLoading] = useState(false);
const [totpDialogOpen, setTotpDialogOpen] = useState(false);
useEffect(() => {
if (active) {
loadUserInfo();
loadPasskeys();
loadTotpStatus();
}
}, [active]);
@@ -91,6 +98,19 @@ export default function AccountSettingsPanel({
}
}
async function loadTotpStatus() {
setTotpLoading(true);
try {
const status = await httpClient.getTotpStatus();
setTotpEnabled(status.enabled);
setRemainingRecoveryCodes(status.remaining_recovery_codes);
} catch {
// ignore
} finally {
setTotpLoading(false);
}
}
const handleAddPasskey = async () => {
setRegisteringPasskey(true);
try {
@@ -332,6 +352,56 @@ export default function AccountSettingsPanel({
</div>
)}
</div>
{/* TOTP (2FA) Section */}
<div className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">
{t('account.totpSectionTitle')}
</h4>
<p className="text-xs text-muted-foreground">
{t('account.totpSectionDesc')}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setTotpDialogOpen(true)}
disabled={totpLoading || !systemInfo.allow_modify_login_info}
className="cursor-pointer"
>
{totpLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ShieldCheck className="mr-2 h-4 w-4" />
)}
{totpEnabled
? t('account.disableTotp')
: t('account.enableTotp')}
</Button>
</div>
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<ShieldCheck className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>
{totpEnabled
? t('account.totpEnabled')
: t('account.totpDisabled')}
</ItemTitle>
<ItemDescription>
{totpEnabled
? t('account.totpRecoveryCodesRemaining', {
count: remainingRecoveryCodes,
})
: t('account.totpSectionDesc')}
</ItemDescription>
</ItemContent>
</Item>
</div>
</div>
)}
@@ -340,6 +410,13 @@ export default function AccountSettingsPanel({
onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword}
/>
<TotpEnrollDialog
open={totpDialogOpen}
onOpenChange={setTotpDialogOpen}
enabled={totpEnabled}
onChanged={loadTotpStatus}
/>
</PanelBody>
);
}
+87 -4
View File
@@ -1241,10 +1241,21 @@ export class BackendClient extends BaseHttpClient {
);
}
public authUser(user: string, password: string): Promise<ApiRespUserToken> {
public authUser(
user: string,
password: string,
secondFactor?: { totpCode?: string; recoveryCode?: string },
): Promise<ApiRespUserToken> {
return this.post(
'/api/v1/user/auth',
{ user, password },
{
user,
password,
...(secondFactor?.totpCode ? { totp_code: secondFactor.totpCode } : {}),
...(secondFactor?.recoveryCode
? { recovery_code: secondFactor.recoveryCode }
: {}),
},
{ skipWorkspace: true },
);
}
@@ -1257,15 +1268,25 @@ export class BackendClient extends BaseHttpClient {
public resetPassword(
user: string,
recoveryKey: string,
newPassword: string,
factor:
| { recoveryKey: string }
| { totpCode: string }
| { recoveryCode: string },
): Promise<{ user: string }> {
return this.post(
'/api/v1/user/reset-password',
{
user,
recovery_key: recoveryKey,
new_password: newPassword,
// Exactly one proof-of-ownership factor is accepted by the backend.
...('recoveryKey' in factor
? { recovery_key: factor.recoveryKey }
: {}),
...('totpCode' in factor ? { totp_code: factor.totpCode } : {}),
...('recoveryCode' in factor
? { recovery_code: factor.recoveryCode }
: {}),
},
{ skipWorkspace: true },
);
@@ -1290,6 +1311,7 @@ export class BackendClient extends BaseHttpClient {
user: string;
account_type: 'local' | 'space';
has_password: boolean;
totp_enabled?: boolean;
}> {
return this.get('/api/v1/user/info', undefined, { skipWorkspace: true });
}
@@ -1306,12 +1328,28 @@ export class BackendClient extends BaseHttpClient {
space_login_enabled?: boolean;
passkey_login_enabled?: boolean;
passkey_supported?: boolean;
totp_supported?: boolean;
}> {
return this.get('/api/v1/user/account-info', undefined, {
skipWorkspace: true,
});
}
/**
* Whether the account identified by the given email has TOTP enabled.
*
* This endpoint is unauthenticated so the password-recovery page can decide
* whether to offer the TOTP / recovery-code verification methods. The
* response only exposes the boolean capability.
*/
public checkTotpForEmail(user: string): Promise<{ totp_enabled: boolean }> {
return this.post(
'/api/v1/user/totp/check',
{ user },
{ skipWorkspace: true },
);
}
// ============ Passkey (WebAuthn) API ============
public getPasskeyAuthOptions(
email?: string,
@@ -1390,6 +1428,51 @@ export class BackendClient extends BaseHttpClient {
});
}
// ============ TOTP (2FA) API ============
public getTotpStatus(): Promise<{
enabled: boolean;
remaining_recovery_codes: number;
}> {
return this.get('/api/v1/user/totp/status', undefined, {
skipWorkspace: true,
});
}
public beginTotpEnrollment(): Promise<{
secret: string;
otpauth_uri: string;
qr_svg: string;
recovery_codes: string[];
}> {
return this.post('/api/v1/user/totp/enroll', {}, { skipWorkspace: true });
}
public verifyTotpEnrollment(code: string): Promise<{ enabled: boolean }> {
return this.post(
'/api/v1/user/totp/enroll/verify',
{ code },
{ skipWorkspace: true },
);
}
public regenerateTotpRecoveryCodes(
code: string,
): Promise<{ recovery_codes: string[] }> {
return this.post(
'/api/v1/user/totp/recovery-codes',
{ code },
{ skipWorkspace: true },
);
}
public disableTotp(code: string): Promise<{ success: boolean }> {
return this.post(
'/api/v1/user/totp/disable',
{ code },
{ skipWorkspace: true },
);
}
// ============ Workspace API ============
public getWorkspaceBootstrap(): Promise<WorkspaceBootstrapResponse> {
return this.get('/api/v1/workspaces/bootstrap', undefined, {
+124 -15
View File
@@ -36,6 +36,7 @@ import {
RefreshCw,
Layers,
Fingerprint,
ShieldCheck,
} from 'lucide-react';
import { startAuthentication } from '@simplewebauthn/browser';
import langbotIcon from '@/app/assets/langbot-logo.webp';
@@ -71,6 +72,15 @@ export default function Login() {
const [loadError, setLoadError] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
const autoSpaceLoginStarted = useRef(false);
// Second-factor state: when /auth replies with totp_required we keep the
// credentials and ask for a TOTP or recovery code instead of a password.
const [totpRequired, setTotpRequired] = useState(false);
const [totpCode, setTotpCode] = useState('');
const [totpSubmitting, setTotpSubmitting] = useState(false);
const [pendingCredentials, setPendingCredentials] = useState<{
username: string;
password: string;
} | null>(null);
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
resolver: zodResolver(formSchema(t)),
@@ -223,11 +233,49 @@ export default function Login() {
toast.success(t('common.loginSuccess'));
}
})
.catch(() => {
.catch((error: unknown) => {
const apiError = error as { code?: string };
if (apiError?.code === 'totp_required') {
// Password was accepted; the account additionally requires TOTP.
setPendingCredentials({ username, password });
setTotpCode('');
setTotpRequired(true);
return;
}
toast.error(t('common.loginFailed'));
});
}
async function handleTotpSubmit() {
if (!pendingCredentials || !totpCode.trim()) {
return;
}
setTotpSubmitting(true);
try {
const code = totpCode.trim();
// A recovery code is longer than six digits; treat it as such so users
// can sign in even when the authenticator is unavailable.
const isRecoveryCode = code.replace(/\s/g, '').length !== 6;
const res = await httpClient.authUser(
pendingCredentials.username,
pendingCredentials.password,
isRecoveryCode ? { recoveryCode: code } : { totpCode: code },
);
setTotpRequired(false);
setPendingCredentials(null);
if (await finishLogin(res.token, pendingCredentials.username)) {
toast.success(t('common.loginSuccess'));
}
} catch (error: unknown) {
const apiError = error as { code?: string; message?: string };
// Keep the second-factor step open so the user can retry; surface the
// server message when available.
toast.error(apiError?.message || t('common.loginTotpInvalid'));
} finally {
setTotpSubmitting(false);
}
}
const handleSpaceLoginClick = useCallback(async () => {
setSpaceLoading(true);
try {
@@ -336,8 +384,67 @@ export default function Login() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* TOTP second-factor step: shown after the password is accepted. */}
{totpRequired && (
<div className="space-y-4">
<div className="flex flex-col items-center gap-1 text-center">
<ShieldCheck className="h-8 w-8 text-primary" />
<p className="text-sm font-medium">
{t('common.loginTotpTitle')}
</p>
<p className="text-xs text-muted-foreground">
{t('common.loginTotpDesc')}
</p>
</div>
<div className="relative">
<ShieldCheck className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
placeholder={t('common.loginTotpPlaceholder')}
className="pl-10 font-mono tracking-widest"
inputMode="text"
autoComplete="one-time-code"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
void handleTotpSubmit();
}
}}
/>
</div>
<Button
type="button"
className="w-full cursor-pointer"
onClick={handleTotpSubmit}
disabled={totpSubmitting || !totpCode.trim()}
>
{totpSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ShieldCheck className="mr-2 h-4 w-4" />
)}
{totpSubmitting
? t('common.loginTotpVerifying')
: t('common.loginTotpVerify')}
</Button>
<Button
type="button"
variant="ghost"
className="w-full cursor-pointer"
onClick={() => {
setTotpRequired(false);
setPendingCredentials(null);
setTotpCode('');
}}
>
{t('common.backToLogin')}
</Button>
</div>
)}
{/* Space and password login are per-account capabilities. */}
{showSpaceLogin && (
{!totpRequired && showSpaceLogin && (
<div className="space-y-3">
<Button
type="button"
@@ -355,7 +462,7 @@ export default function Login() {
</div>
)}
{showPasskeyLogin && (
{!totpRequired && showPasskeyLogin && (
<div className="space-y-3">
<Button
type="button"
@@ -375,21 +482,23 @@ export default function Login() {
)}
{/* Divider - only show if both login methods are available */}
{(showSpaceLogin || showPasskeyLogin) && showLocalLogin && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
{!totpRequired &&
(showSpaceLogin || showPasskeyLogin) &&
showLocalLogin && (
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
{t('common.or')}
</span>
</div>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
{t('common.or')}
</span>
</div>
</div>
)}
)}
{/* Password login remains available to every account with a password. */}
{showLocalLogin && (
{!totpRequired && showLocalLogin && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
+6 -1
View File
@@ -22,7 +22,7 @@ import {
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useNavigate } from 'react-router-dom';
import { Mail, Lock, Loader2, Info, Layers } from 'lucide-react';
import { Mail, Lock, Loader2, Info, Layers, ShieldCheck } from 'lucide-react';
import {
Popover,
PopoverContent,
@@ -236,6 +236,11 @@ export default function Register() {
>
{t('register.registerWithPassword')}
</Button>
{/* Recommend enabling TOTP once the account exists */}
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
<ShieldCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" />
<span>{t('register.totpHint')}</span>
</p>
</form>
</Form>
</>
+225 -33
View File
@@ -19,19 +19,24 @@ import {
FormMessage,
FormDescription,
} from '@/components/ui/form';
import { useState } from 'react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useNavigate } from 'react-router-dom';
import { Mail, Lock, ArrowLeft, KeyRound } from 'lucide-react';
import { Mail, Lock, ArrowLeft, KeyRound, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { ThemeToggle } from '@/components/ui/theme-toggle';
type RecoveryMethod = 'recoveryKey' | 'totp' | 'recoveryCode';
const formSchema = (t: (key: string) => string) =>
z.object({
email: z.string().email(t('common.invalidEmail')),
recoveryKey: z.string().min(1, t('resetPassword.recoveryKeyRequired')),
recoveryKey: z.string().optional(),
totpCode: z.string().optional(),
recoveryCode: z.string().optional(),
newPassword: z.string().min(1, t('resetPassword.newPasswordRequired')),
});
@@ -39,34 +44,129 @@ export default function ResetPassword() {
const navigate = useNavigate();
const { t } = useTranslation();
const [isResetting, setIsResetting] = useState(false);
const [method, setMethod] = useState<RecoveryMethod>('recoveryKey');
// Whether TOTP is enabled for the email currently entered. `null` means we have
// not yet resolved it (empty/invalid email), so the TOTP methods stay disabled
// until we can confirm the account actually enrolled one.
const [totpEnabledForEmail, setTotpEnabledForEmail] = useState<
boolean | null
>(null);
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
resolver: zodResolver(formSchema(t)),
defaultValues: {
email: '',
recoveryKey: '',
totpCode: '',
recoveryCode: '',
newPassword: '',
},
});
// Watch the email so we can resolve, per account, whether TOTP is enabled.
const email = form.watch('email');
// Resolve whether the entered email has TOTP enabled; only then may the user
// pick the TOTP / recovery-code verification methods. While unresolved (empty
// or invalid email) both TOTP methods stay disabled, so an account without
// TOTP can never select them.
useEffect(() => {
if (!email || !z.string().email().safeParse(email).success) {
setTotpEnabledForEmail(null);
setMethod('recoveryKey');
return;
}
let cancelled = false;
// Debounce so we only query once the user pauses typing.
const timer = setTimeout(() => {
httpClient
.checkTotpForEmail(email)
.then((res) => {
if (cancelled) {
return;
}
setTotpEnabledForEmail(res.totp_enabled);
if (!res.totp_enabled) {
setMethod('recoveryKey');
}
})
.catch(() => {
if (!cancelled) {
// Fail closed: if we cannot confirm TOTP, only the recovery key is
// offered rather than letting an unverified TOTP path through.
setTotpEnabledForEmail(null);
setMethod('recoveryKey');
}
});
}, 400);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [email]);
const totpMethodsDisabled = totpEnabledForEmail !== true;
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
handleResetPassword(values.email, values.recoveryKey, values.newPassword);
if (method === 'recoveryKey') {
if (!values.recoveryKey || !values.recoveryKey.trim()) {
toast.error(t('resetPassword.recoveryKeyRequired'));
return;
}
handleResetPassword(
values.email,
{ recoveryKey: values.recoveryKey.trim() },
values.newPassword,
);
return;
}
if (method === 'totp') {
if (!values.totpCode || !values.totpCode.trim()) {
toast.error(t('resetPassword.totpCodeRequired'));
return;
}
handleResetPassword(
values.email,
{ totpCode: values.totpCode.trim() },
values.newPassword,
);
return;
}
if (!values.recoveryCode || !values.recoveryCode.trim()) {
toast.error(t('resetPassword.recoveryCodeRequired'));
return;
}
handleResetPassword(
values.email,
{ recoveryCode: values.recoveryCode.trim() },
values.newPassword,
);
}
function handleResetPassword(
email: string,
recoveryKey: string,
factor:
| { recoveryKey: string }
| { totpCode: string }
| { recoveryCode: string },
newPassword: string,
) {
setIsResetting(true);
httpClient
.resetPassword(email, recoveryKey, newPassword)
.resetPassword(email, newPassword, factor)
.then(() => {
toast.success(t('resetPassword.resetSuccess'));
navigate('/login');
})
.catch(() => {
toast.error(t('resetPassword.resetFailed'));
.catch((error: unknown) => {
const apiError = error as { code?: string };
if (apiError?.code === 'totp_not_enabled') {
toast.error(t('resetPassword.totpNotEnabled'));
} else if (apiError?.code === 'totp_invalid_code') {
toast.error(t('resetPassword.invalidTotpCode'));
} else {
toast.error(t('resetPassword.resetFailed'));
}
})
.finally(() => {
setIsResetting(false);
@@ -118,32 +218,124 @@ export default function ResetPassword() {
)}
/>
<FormField
control={form.control}
name="recoveryKey"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetPassword.recoveryKey')}</FormLabel>
<FormDescription>
{t('resetPassword.recoveryKeyDescription')}
</FormDescription>
<FormControl>
{/* Recovery keys are case-sensitive base64url strings; send them verbatim */}
<div className="relative">
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder={t('resetPassword.enterRecoveryKey')}
className="pl-10 font-mono"
autoComplete="off"
spellCheck={false}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
{/* Recovery method selector: recovery key, TOTP, or recovery code.
The TOTP-based methods are only selectable once we have
confirmed the entered account actually enrolled TOTP. */}
<div className="space-y-3">
<FormLabel>{t('resetPassword.verifyMethod')}</FormLabel>
<Tabs
value={method}
onValueChange={(v) => setMethod(v as RecoveryMethod)}
>
<TabsList className="w-full">
<TabsTrigger value="recoveryKey" className="flex-1">
{t('resetPassword.recoveryKey')}
</TabsTrigger>
<TabsTrigger
value="totp"
className="flex-1"
disabled={totpMethodsDisabled}
>
{t('resetPassword.totpMethod')}
</TabsTrigger>
<TabsTrigger
value="recoveryCode"
className="flex-1"
disabled={totpMethodsDisabled}
>
{t('resetPassword.recoveryCodeMethod')}
</TabsTrigger>
</TabsList>
</Tabs>
{totpMethodsDisabled && (
<p className="text-xs text-muted-foreground">
{t('resetPassword.totpMethodsUnavailable')}
</p>
)}
/>
</div>
{method === 'recoveryKey' && (
<FormField
control={form.control}
name="recoveryKey"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetPassword.recoveryKey')}</FormLabel>
<FormDescription>
{t('resetPassword.recoveryKeyDescription')}
</FormDescription>
<FormControl>
{/* Recovery keys are case-sensitive base64url strings; send them verbatim */}
<div className="relative">
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder={t('resetPassword.enterRecoveryKey')}
className="pl-10 font-mono"
autoComplete="off"
spellCheck={false}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{method === 'totp' && (
<FormField
control={form.control}
name="totpCode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetPassword.totpCode')}</FormLabel>
<FormDescription>
{t('resetPassword.totpMethodDescription')}
</FormDescription>
<FormControl>
<div className="relative">
<ShieldCheck className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder={t('resetPassword.enterTotpCode')}
className="pl-10 font-mono tracking-widest"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{method === 'recoveryCode' && (
<FormField
control={form.control}
name="recoveryCode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetPassword.recoveryCode')}</FormLabel>
<FormControl>
<div className="relative">
<ShieldCheck className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder={t('resetPassword.enterRecoveryCode')}
className="pl-10 font-mono"
autoComplete="off"
spellCheck={false}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
+57
View File
@@ -90,6 +90,13 @@ const enUS = {
passkeyLoginSuccess: 'Passkey verified successfully, signing in...',
passkeyLoginFailed: 'Failed to sign in with Passkey',
passkeyNotSupported: 'Passkey is not supported on this browser or device',
loginTotpTitle: 'Two-Factor Verification',
loginTotpDesc:
'Enter the 6-digit code from your authenticator app, or a recovery code',
loginTotpPlaceholder: 'Authenticator or recovery code',
loginTotpVerify: 'Verify',
loginTotpVerifying: 'Verifying...',
loginTotpInvalid: 'Invalid code, please try again',
spaceLoginTitle: 'Login with LangBot Account',
spaceLoginDescription:
'Scan the QR code or visit the link below to authorize',
@@ -1279,6 +1286,8 @@ const enUS = {
registerWithPassword: 'Register with email and password',
initSuccess: 'Initialization successful, please login',
initFailed: 'Initialization failed: ',
totpHint:
'Recommended: enable two-factor authentication (TOTP) after signing in to secure your account.',
},
resetPassword: {
title: 'Reset Password 🔐',
@@ -1298,6 +1307,22 @@ const enUS = {
resetFailed:
'Password reset failed, please check your email and recovery key',
backToLogin: 'Back to Login',
totpMethod: 'TOTP Authenticator',
recoveryCodeMethod: 'Recovery Code',
verifyMethod: 'Verification Method',
totpMethodsUnavailable:
'TOTP is not enabled for this account; only the recovery key can be used.',
totpCode: 'Authenticator Code',
enterTotpCode: 'Enter the 6-digit code from your authenticator app',
recoveryCode: 'Recovery Code',
enterRecoveryCode: 'Enter one of your recovery codes',
totpCodeRequired: 'Authenticator code cannot be empty',
recoveryCodeRequired: 'Recovery code cannot be empty',
totpNotEnabled:
'TOTP is not enabled for this account, use the recovery key instead',
invalidTotpCode: 'Invalid verification code, please try again',
totpMethodDescription:
'Verify with a TOTP authenticator app or one of your recovery codes',
},
embedding: {
description: 'Manage Embedding models for text vectorization',
@@ -1357,6 +1382,38 @@ const enUS = {
passkeyAddedSuccess: 'Passkey added successfully',
passkeyDeleteSuccess: 'Passkey deleted',
passkeyRenameSuccess: 'Passkey renamed successfully',
totpSectionTitle: 'Two-Factor Authentication (TOTP)',
totpSectionDesc:
'Scan a QR code to add a TOTP authenticator for extra login security',
totpEnabled: 'Enabled',
totpDisabled: 'Disabled',
enableTotp: 'Enable TOTP',
disableTotp: 'Disable TOTP',
totpEnabledSuccess: 'Two-factor authentication enabled',
totpDisabledSuccess: 'Two-factor authentication disabled',
totpEnrollTitle: 'Add TOTP Authenticator',
totpEnrollDesc:
'Scan the QR code with your authenticator app, then enter the 6-digit code to confirm',
totpScanHint: 'Scan this QR code with your authenticator app',
totpManualSecret: 'Or enter this key manually',
totpCodeLabel: 'Authenticator Code',
totpCodePlaceholder: '6-digit code',
totpVerify: 'Verify and Enable',
totpVerifying: 'Verifying...',
totpRecoveryCodesTitle: 'Recovery Codes',
totpRecoveryCodesDesc:
'Store these codes somewhere safe. Each code can be used once if you lose access to your authenticator.',
totpRecoveryCodesRemaining: '{{count}} recovery codes remaining',
totpRegenerateRecoveryCodes: 'Regenerate Recovery Codes',
totpRecoveryCodesRegenerated: 'Recovery codes regenerated',
totpDisableTitle: 'Disable Two-Factor Authentication',
totpDisableDesc:
'Enter a valid authenticator code to disable two-factor authentication',
totpConfirmDisable: 'Disable',
totpInvalidCode: 'Invalid code, please try again',
totpLoadFailed: 'Failed to load two-factor authentication status',
totpCopySecret: 'Copy key',
totpCopied: 'Copied to clipboard',
bindSpaceFailed: 'Failed to bind LangBot Account',
bindSpaceInvalidState:
'Invalid bind request. Please try again from account settings.',
+2
View File
@@ -1330,6 +1330,8 @@ const esES = {
newPasswordRequired: 'La nueva contraseña no puede estar vacía',
resetPassword: 'Restablecer contraseña',
resetting: 'Restableciendo...',
totpMethodsUnavailable:
'TOTP no está habilitado para esta cuenta; solo se puede usar la clave de recuperación.',
resetSuccess:
'Contraseña restablecida correctamente, por favor inicia sesión',
resetFailed:
+55
View File
@@ -92,6 +92,13 @@ const jaJP = {
passkeyLoginFailed: 'パスキーでのログインに失敗しました',
passkeyNotSupported:
'お使いのブラウザまたはデバイスはパスキーをサポートしていません',
loginTotpTitle: '二要素認証',
loginTotpDesc:
'認証アプリの6桁のコード、またはリカバリーコードを入力してください',
loginTotpPlaceholder: '認証コードまたはリカバリーコード',
loginTotpVerify: '確認',
loginTotpVerifying: '確認中...',
loginTotpInvalid: 'コードが無効です。もう一度お試しください',
spaceLoginTitle: 'LangBot アカウントでログイン',
spaceLoginDescription:
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
@@ -1286,6 +1293,8 @@ const jaJP = {
registerWithPassword: 'メールアドレスとパスワードで登録',
initSuccess: '初期化に成功しました。ログインしてください',
initFailed: '初期化に失敗しました:',
totpHint:
'推奨:ログイン後、アカウント設定で二要素認証(TOTP)を有効にしてアカウントを保護してください。',
},
resetPassword: {
title: 'パスワードをリセット 🔐',
@@ -1305,6 +1314,21 @@ const jaJP = {
resetFailed:
'パスワードのリセットに失敗しました。メールアドレスと復旧キーを確認してください',
backToLogin: 'ログインに戻る',
totpMethod: 'TOTP 認証アプリ',
recoveryCodeMethod: 'リカバリーコード',
verifyMethod: '確認方法',
totpMethodsUnavailable:
'このアカウントでは TOTP が有効になっていません。リカバリーキーのみ使用できます。',
totpCode: '認証コード',
enterTotpCode: '認証アプリに表示される6桁のコードを入力',
recoveryCode: 'リカバリーコード',
enterRecoveryCode: 'リカバリーコードのいずれかを入力',
totpCodeRequired: '認証コードは必須です',
recoveryCodeRequired: 'リカバリーコードは必須です',
totpNotEnabled:
'このアカウントでは TOTP が有効になっていません。復旧キーを使用してください',
invalidTotpCode: '認証コードが無効です。もう一度お試しください',
totpMethodDescription: 'TOTP 認証アプリまたはリカバリーコードで確認します',
},
embedding: {
description: 'テキストのベクトル化に使用する埋め込みモデルを管理します',
@@ -1364,6 +1388,37 @@ const jaJP = {
passkeyAddedSuccess: 'パスキーが正常に追加されました',
passkeyDeleteSuccess: 'パスキーを削除しました',
passkeyRenameSuccess: 'パスキー名を変更しました',
totpSectionTitle: '二要素認証 (TOTP)',
totpSectionDesc:
'QR コードをスキャンして TOTP 認証アプリを追加し、ログインの安全性を高めます',
totpEnabled: '有効',
totpDisabled: '無効',
enableTotp: 'TOTP を有効化',
disableTotp: 'TOTP を無効化',
totpEnabledSuccess: '二要素認証を有効にしました',
totpDisabledSuccess: '二要素認証を無効にしました',
totpEnrollTitle: 'TOTP 認証アプリを追加',
totpEnrollDesc:
'認証アプリで QR コードをスキャンし、6桁のコードを入力して確認します',
totpScanHint: '認証アプリでこの QR コードをスキャンしてください',
totpManualSecret: 'またはこのキーを手動で入力',
totpCodeLabel: '認証コード',
totpCodePlaceholder: '6桁のコード',
totpVerify: '確認して有効化',
totpVerifying: '確認中...',
totpRecoveryCodesTitle: 'リカバリーコード',
totpRecoveryCodesDesc:
'これらのコードは安全な場所に保管してください。認証アプリが使えない場合、各コードは一度だけ使用できます。',
totpRecoveryCodesRemaining: '残り {{count}} 個のリカバリーコード',
totpRegenerateRecoveryCodes: 'リカバリーコードを再生成',
totpRecoveryCodesRegenerated: 'リカバリーコードを再生成しました',
totpDisableTitle: '二要素認証を無効化',
totpDisableDesc: '有効な認証コードを入力して二要素認証を無効化します',
totpConfirmDisable: '無効化',
totpInvalidCode: 'コードが無効です。もう一度お試しください',
totpLoadFailed: '二要素認証の状態の読み込みに失敗しました',
totpCopySecret: 'キーをコピー',
totpCopied: 'クリップボードにコピーしました',
bindSpaceFailed: 'LangBot アカウントの連携に失敗しました',
bindSpaceInvalidState:
'無効な連携リクエストです。アカウント設定から再度お試しください。',
+2
View File
@@ -1306,6 +1306,8 @@ const ruRU = {
newPasswordRequired: 'Новый пароль не может быть пустым',
resetPassword: 'Сбросить пароль',
resetting: 'Сброс...',
totpMethodsUnavailable:
'TOTP не включён для этой учётной записи; доступен только ключ восстановления.',
resetSuccess: 'Пароль успешно сброшен, пожалуйста, войдите',
resetFailed: 'Ошибка сброса пароля, проверьте email и ключ восстановления',
backToLogin: 'Вернуться к входу',
+2
View File
@@ -1277,6 +1277,8 @@ const thTH = {
newPasswordRequired: 'รหัสผ่านใหม่ต้องไม่ว่างเปล่า',
resetPassword: 'รีเซ็ตรหัสผ่าน',
resetting: 'กำลังรีเซ็ต...',
totpMethodsUnavailable:
'บัญชีนี้ยังไม่ได้เปิดใช้ TOTP ใช้ได้เฉพาะคีย์กู้คืนเท่านั้น',
resetSuccess: 'รีเซ็ตรหัสผ่านสำเร็จ กรุณาเข้าสู่ระบบ',
resetFailed: 'รีเซ็ตรหัสผ่านล้มเหลว กรุณาตรวจสอบอีเมลและคีย์กู้คืน',
backToLogin: 'กลับไปหน้าเข้าสู่ระบบ',
+2
View File
@@ -1298,6 +1298,8 @@ const viVN = {
newPasswordRequired: 'Mật khẩu mới không được để trống',
resetPassword: 'Đặt lại mật khẩu',
resetting: 'Đang đặt lại...',
totpMethodsUnavailable:
'TOTP chưa được bật cho tài khoản này; chỉ có thể dùng khóa khôi phục.',
resetSuccess: 'Đặt lại mật khẩu thành công, vui lòng đăng nhập',
resetFailed:
'Đặt lại mật khẩu thất bại, vui lòng kiểm tra email và khóa khôi phục',
+50
View File
@@ -88,6 +88,12 @@ const zhHans = {
passkeyLoginSuccess: 'Passkey 验证成功,正在登录...',
passkeyLoginFailed: 'Passkey 登录失败',
passkeyNotSupported: '当前浏览器或设备不支持 Passkey',
loginTotpTitle: '两步验证',
loginTotpDesc: '请输入验证器应用中的 6 位验证码,或使用恢复码',
loginTotpPlaceholder: '验证码或恢复码',
loginTotpVerify: '验证',
loginTotpVerifying: '验证中...',
loginTotpInvalid: '验证码无效,请重试',
spaceLoginTitle: '通过 LangBot 账号登录',
spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
spaceLoginUserCode: '您的验证码',
@@ -1219,6 +1225,8 @@ const zhHans = {
registerWithPassword: '通过邮箱密码组合注册',
initSuccess: '初始化成功 请登录',
initFailed: '初始化失败:',
totpHint:
'推荐:登录后在账户设置中开启两步验证(TOTP)以保护您的账户安全。',
},
resetPassword: {
title: '重置密码 🔐',
@@ -1236,6 +1244,19 @@ const zhHans = {
resetSuccess: '密码重置成功,请登录',
resetFailed: '密码重置失败,请检查邮箱和恢复密钥是否正确',
backToLogin: '返回登录',
totpMethod: 'TOTP 验证器',
recoveryCodeMethod: '恢复码',
verifyMethod: '验证方式',
totpCode: '验证器验证码',
enterTotpCode: '输入验证器应用中的 6 位验证码',
recoveryCode: '恢复码',
enterRecoveryCode: '输入您的其中一个恢复码',
totpCodeRequired: '验证码不能为空',
recoveryCodeRequired: '恢复码不能为空',
totpNotEnabled: '该账户未开启 TOTP,请改用恢复密钥',
invalidTotpCode: '验证码无效,请重试',
totpMethodDescription: '使用 TOTP 验证器应用或恢复码进行验证',
totpMethodsUnavailable: '该账户未开启 TOTP 验证,仅可使用恢复密钥重置密码',
},
embedding: {
description: '管理嵌入模型,用于向量化文本',
@@ -1291,6 +1312,35 @@ const zhHans = {
passkeyAddedSuccess: '通行密钥添加成功',
passkeyDeleteSuccess: '通行密钥已删除',
passkeyRenameSuccess: '通行密钥重命名成功',
totpSectionTitle: '两步验证 (TOTP)',
totpSectionDesc: '扫描二维码添加 TOTP 验证器,提升登录安全性',
totpEnabled: '已开启',
totpDisabled: '未开启',
enableTotp: '开启 TOTP',
disableTotp: '关闭 TOTP',
totpEnabledSuccess: '两步验证已开启',
totpDisabledSuccess: '两步验证已关闭',
totpEnrollTitle: '添加 TOTP 验证器',
totpEnrollDesc: '使用验证器应用扫描二维码,然后输入 6 位验证码完成确认',
totpScanHint: '使用验证器应用扫描此二维码',
totpManualSecret: '或手动输入此密钥',
totpCodeLabel: '验证码',
totpCodePlaceholder: '6 位验证码',
totpVerify: '验证并开启',
totpVerifying: '验证中...',
totpRecoveryCodesTitle: '恢复码',
totpRecoveryCodesDesc:
'请妥善保存这些恢复码。当您无法使用验证器时,每个恢复码可使用一次。',
totpRecoveryCodesRemaining: '剩余 {{count}} 个恢复码',
totpRegenerateRecoveryCodes: '重新生成恢复码',
totpRecoveryCodesRegenerated: '恢复码已重新生成',
totpDisableTitle: '关闭两步验证',
totpDisableDesc: '输入有效的验证器验证码以关闭两步验证',
totpConfirmDisable: '关闭',
totpInvalidCode: '验证码无效,请重试',
totpLoadFailed: '加载两步验证状态失败',
totpCopySecret: '复制密钥',
totpCopied: '已复制到剪贴板',
bindSpaceFailed: '绑定 LangBot 账号失败',
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
setPasswordHint: '设置密码后可使用邮箱密码登录',
+1
View File
@@ -1234,6 +1234,7 @@ const zhHant = {
newPasswordRequired: '新密碼不能為空',
resetPassword: '重設密碼',
resetting: '重設中...',
totpMethodsUnavailable: '此帳戶未開啟 TOTP 驗證,僅可使用恢復金鑰重設密碼',
resetSuccess: '密碼重設成功,請登入',
resetFailed: '密碼重設失敗,請檢查電子郵件和恢復金鑰是否正確',
backToLogin: '返回登入',