feat(auth): add TOTP two-factor authentication

Add a per-Account time-based one-time password (TOTP) second sign-in
factor, plus the owner/admin tooling needed to operate it.

Backend
- TotpService: enrolment, constant-time verification with a +/- one step
  drift window, single-use recovery codes and disablement.
  * shared secret is only ever persisted as a Fernet token whose key is
    derived (HKDF-SHA256) from the instance JWT secret and gated by a
    key_version epoch;
  * recovery codes are only ever persisted as salted
    PBKDF2-HMAC-SHA256 digests (600k iterations) and are single-use;
  * the consumed counter advances monotonically so a captured code cannot
    be replayed inside the same time window.
- New persistence entities and alembic migrations for credentials and
  recovery codes.
- Login second-factor challenge, bound to the Account that passed the
  password step.
- Owner/admin oversight endpoints to inspect and re-bind the second
  factor of any Account.

Frontend
- Account settings panel for enrolling, managing, re-binding and
  disabling the second factor.
- Login second-factor step and the matching client methods.
- Strings for all eight locales.

The shared secret never crosses the API boundary: enrolment returns a
server-rendered QR code (as a data: URL) and the plaintext secret is
discarded as soon as the image is produced.
This commit is contained in:
TyperBody
2026-09-24 01:31:54 +08:00
parent 6b83089535
commit 8633567ce7
25 changed files with 3493 additions and 112 deletions
+1
View File
@@ -83,6 +83,7 @@ dependencies = [
"litellm>=1.0.0",
"valkey-glide>=2.4.1,<3.0.0; sys_platform != 'win32'", # No Windows wheels are published
"webauthn>=3.0.0",
"httpx[socks]>=0.28.1",
]
keywords = [
"bot",
@@ -15,6 +15,7 @@ from .....entity.errors import account as account_errors
from ...context import RequestContext
from .....cloud.launch import SpaceLaunchError
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
from ...service import totp as totp_module
# Fixed-window admission quota for the unauthenticated reset-password endpoint (#2392).
# The admission check and slot bump share ONE synchronous critical section with no await
@@ -112,15 +113,93 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(403, 'password_login_disabled', 'Password login is disabled on LangBot Cloud')
json_data = await quart.request.json
user_email = json_data['user']
password = json_data['password']
totp_code = json_data.get('totp_code') or json_data.get('code')
try:
token = await self.ap.user_service.authenticate(json_data['user'], json_data['password'])
token = await self.ap.user_service.authenticate(user_email, password, totp_code)
except argon2.exceptions.VerifyMismatchError:
return self.fail(1, 'Invalid username or password')
except totp_module.TotpRequiredError:
# Primary factors passed, but the second factor is still missing.
# Issue a challenge instead of a session token.
challenge_token = await self.ap.user_service.issue_totp_login_challenge(user_email)
if challenge_token is None:
return self.fail(1, 'A second factor is required')
return (
quart.jsonify(
{
'code': 'totp_required',
'msg': 'A TOTP second factor is required',
'data': {'challenge_token': challenge_token},
}
),
401,
)
except totp_module.TotpInvalidCodeError:
return self.http_status(401, 'totp_invalid_code', 'Invalid TOTP or recovery code')
except ValueError as e:
return self.fail(1, str(e))
return self.success(data={'token': token})
# ---- TOTP second factor (login challenge) ----
@self.route('/totp/challenge', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Start a login second-factor challenge.
Always answers with a token, even for unknown or non-enrolled
Accounts: a distinguishable reply would let an unauthenticated caller
enumerate which emails have a second factor.
"""
json_data = (await quart.request.json) or {}
user_email = json_data.get('user')
if not isinstance(user_email, str) or not user_email:
return self.fail(1, 'Missing user parameter')
challenge_token = await self.ap.user_service.issue_uniform_totp_login_challenge(user_email)
return self.success(data={'challenge_token': challenge_token})
@self.route('/totp/verify', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Complete the login second factor and return a session token.
A ``challenge_token`` is mandatory: it proves the password step ran
for this Account, so a bare code can never mint a session on its own.
"""
json_data = (await quart.request.json) or {}
user_email = json_data.get('user')
code = json_data.get('code')
challenge_token = json_data.get('challenge_token')
if (
not isinstance(user_email, str)
or not user_email
or not isinstance(code, str)
or not code
or not isinstance(challenge_token, str)
or not challenge_token
):
return self.fail(1, 'Missing user, code or challenge_token parameter')
verified = await self.ap.user_service.verify_totp_second_factor(
user_email,
code,
challenge_token=challenge_token,
)
if not verified:
return self.http_status(401, 'totp_invalid_code', 'Invalid TOTP or recovery code')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(401, 'totp_invalid_code', 'Invalid TOTP or recovery code')
token = await self.ap.user_service.generate_jwt_token(user_obj)
return self.success(data={'token': token, 'user': user_obj.user})
@self.route('/check-token', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(account) -> str:
token = await self.ap.user_service.generate_jwt_token(account)
@@ -138,8 +217,10 @@ class UserRouterGroup(group.RouterGroup):
json_data = await quart.request.json
user_email = json_data['user']
recovery_key = json_data['recovery_key']
new_password = json_data['new_password']
# Second-factor method: 'recovery_key' (instance-wide, default),
# 'totp' (an authenticator code) or 'recovery_code' (a one-time code).
method = json_data.get('method') or 'recovery_key'
# hard sleep 3s for security
await asyncio.sleep(3)
@@ -152,19 +233,35 @@ class UserRouterGroup(group.RouterGroup):
if user_obj is None:
return self.http_status(400, -1, 'User not found')
stored_key = self.ap.instance_config.data['system']['recovery_key']
try:
key_matches = (
isinstance(recovery_key, str)
and isinstance(stored_key, str)
and hmac.compare_digest(recovery_key.encode(), stored_key.encode())
)
except UnicodeEncodeError:
# JSON can contain lone surrogates, which are not valid UTF-8.
key_matches = False
if method in ('totp', 'recovery_code'):
# Account-scoped second factor: a live TOTP code or, for the
# recovery_code method, a one-time recovery code.
allow_recovery = method == 'recovery_code'
second_factor = json_data.get('totp_code') or json_data.get('code')
if not isinstance(second_factor, str) or not second_factor:
return self.http_status(400, -1, 'Missing TOTP or recovery code')
if not key_matches:
return self.http_status(403, -1, 'Invalid recovery key')
if not await self.ap.user_service.verify_totp_second_factor(
user_email,
second_factor,
allow_recovery=allow_recovery,
):
return self.http_status(403, -1, 'Invalid TOTP or recovery code')
else:
recovery_key = json_data.get('recovery_key')
stored_key = self.ap.instance_config.data['system']['recovery_key']
try:
key_matches = (
isinstance(recovery_key, str)
and isinstance(stored_key, str)
and hmac.compare_digest(recovery_key.encode(), stored_key.encode())
)
except UnicodeEncodeError:
# JSON can contain lone surrogates, which are not valid UTF-8.
key_matches = False
if not key_matches:
return self.http_status(403, -1, 'Invalid recovery key')
await self.ap.user_service.reset_password(user_email, new_password)
@@ -674,6 +771,272 @@ class UserRouterGroup(group.RouterGroup):
return self.http_status(404, -1, 'Passkey not found')
return self.success()
# ---- TOTP second factor (account settings) ----
# The second factor belongs to the Account, so these routes are
# ACCOUNT_TOKEN scoped. Requiring a Workspace would lock out an Account
# that has not been added to one yet.
@self.route('/totp/status', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(account) -> str:
"""Report second-factor state without ever returning the secret."""
totp_service = self.ap.totp_service
credential = await totp_service.get_credential(account.uuid)
return self.success(
data={
'enabled': bool(credential is not None and credential.confirmed_at is not None),
'pending': bool(credential is not None and credential.confirmed_at is None),
'confirmed_at': (
credential.confirmed_at.isoformat() if credential and credential.confirmed_at else None
),
'last_used_at': (
credential.last_used_at.isoformat() if credential and credential.last_used_at else None
),
'recovery_codes_remaining': (
await totp_service.count_unused_recovery_codes(account.uuid) if credential else 0
),
}
)
@self.route('/totp/enroll', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(account) -> str:
"""Start TOTP enrolment; returns a server-rendered QR code only.
The shared secret is intentionally never returned to the client so it
cannot be read out of the browser or any proxy in between.
"""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
json_data = (await quart.request.json) or {}
# rotate=True (explicit refresh) mints a new secret; the default
# reuses any pending enrolment so duplicate requests cannot
# invalidate the QR code already displayed to the operator.
rotate = bool(json_data.get('rotate', False))
try:
enrollment = await self.ap.totp_service.begin_enrollment(
account.uuid, account.user, rotate=rotate
)
except totp_module.TotpError as e:
return self.http_status(409, e.code, str(e))
return self.success(
data={
'uuid': enrollment.uuid,
'qr_code_data_url': enrollment.qr_code_data_url,
'algorithm': enrollment.algorithm,
'digits': enrollment.digits,
'period': enrollment.period,
}
)
@self.route('/totp/enroll/confirm', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(account) -> str:
"""Confirm enrolment with the first code; returns recovery codes once."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
json_data = (await quart.request.json) or {}
code = json_data.get('code')
if not isinstance(code, str) or not code:
return self.fail(1, 'Missing code parameter')
try:
result = await self.ap.totp_service.confirm_enrollment(account.uuid, code)
except totp_module.TotpNotEnrolledError as e:
return self.http_status(404, e.code, str(e))
except totp_module.TotpInvalidCodeError as e:
return self.http_status(400, e.code, str(e))
except totp_module.TotpError as e:
return self.http_status(409, e.code, str(e))
return self.success(data={'recovery_codes': result.codes})
@self.route('/totp/recovery-codes', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(account) -> str:
"""Regenerate recovery codes after a valid TOTP or recovery code."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
json_data = (await quart.request.json) or {}
code = json_data.get('code')
if not isinstance(code, str) or not code:
return self.fail(1, 'Missing code parameter')
try:
result = await self.ap.totp_service.regenerate_recovery_codes(account.uuid, code)
except totp_module.TotpNotEnrolledError as e:
return self.http_status(404, e.code, str(e))
except totp_module.TotpInvalidCodeError as e:
return self.http_status(403, e.code, str(e))
return self.success(data={'recovery_codes': result.codes})
@self.route('/totp/disable', methods=['POST'], auth_type=group.AuthType.ACCOUNT_TOKEN)
async def _(account) -> str:
"""Disable TOTP, requiring a live TOTP or recovery code."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
json_data = (await quart.request.json) or {}
code = json_data.get('code')
try:
await self.ap.totp_service.disable(account.uuid, code=code)
except totp_module.TotpNotEnrolledError as e:
return self.http_status(404, e.code, str(e))
except totp_module.TotpInvalidCodeError as e:
return self.http_status(403, e.code, str(e))
return self.success()
# ---- TOTP oversight for owners and admins ----
async def _require_workspace_manager(request_context: RequestContext) -> None:
"""Raise unless the caller's Workspace role is owner or admin."""
if request_context is None:
raise PermissionError('A Workspace context is required')
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
request_context.account_uuid,
request_context.workspace_uuid,
)
if access.membership.role not in ('owner', 'admin'):
raise PermissionError('Only Workspace owners and admins may manage other Accounts')
@self.route('/totp/accounts', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _(request_context: RequestContext) -> str:
"""List the second-factor state of every Account for owners/admins.
Oversight is deliberately instance-wide: managing the second factor
of any Account is an owner/admin responsibility and is not scoped to
the caller's Workspace.
"""
try:
await _require_workspace_manager(request_context)
except PermissionError as e:
return self.http_status(403, 'permission_denied', str(e))
except Exception:
return self.http_status(403, 'permission_denied', 'Not permitted')
accounts = await self.ap.totp_service.list_account_states()
return self.success(data={'accounts': accounts})
@self.route('/totp/accounts/<target_account_uuid>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
async def _(request_context: RequestContext, target_account_uuid: str) -> str:
"""Revoke another Account's second factor when its codes are lost."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
try:
await _require_workspace_manager(request_context)
except PermissionError as e:
return self.http_status(403, 'permission_denied', str(e))
except Exception:
return self.http_status(403, 'permission_denied', 'Not permitted')
revoked = await self.ap.totp_service.revoke_for_account(target_account_uuid)
if not revoked:
return self.http_status(404, 'totp_not_enrolled', 'TOTP is not enabled for that Account')
return self.success()
@self.route(
'/totp/accounts/<target_account_uuid>/enroll',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
)
async def _(request_context: RequestContext, target_account_uuid: str) -> str:
"""Start a forced re-binding of another Account's second factor.
Returns a server-rendered QR code so an owner/admin can walk the
Account through binding a new authenticator. The shared secret is
never returned to the client.
"""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
try:
await _require_workspace_manager(request_context)
except PermissionError as e:
return self.http_status(403, 'permission_denied', str(e))
except Exception:
return self.http_status(403, 'permission_denied', 'Not permitted')
target = await self.ap.totp_service.get_account(target_account_uuid)
if target is None:
return self.http_status(404, 'account_not_found', 'Account not found')
try:
enrollment = await self.ap.totp_service.begin_enrollment(
target_account_uuid, target.user, force=True
)
except totp_module.TotpError as e:
return self.http_status(409, e.code, str(e))
return self.success(
data={
'uuid': enrollment.uuid,
'qr_code_data_url': enrollment.qr_code_data_url,
'algorithm': enrollment.algorithm,
'digits': enrollment.digits,
'period': enrollment.period,
}
)
@self.route(
'/totp/accounts/<target_account_uuid>/enroll/confirm',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN,
)
async def _(request_context: RequestContext, target_account_uuid: str) -> str:
"""Activate a forced re-binding with the first code; returns recovery codes once."""
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
try:
await _require_workspace_manager(request_context)
except PermissionError as e:
return self.http_status(403, 'permission_denied', str(e))
except Exception:
return self.http_status(403, 'permission_denied', 'Not permitted')
json_data = (await quart.request.json) or {}
code = json_data.get('code')
if not isinstance(code, str) or not code:
return self.fail(1, 'Missing code parameter')
try:
result = await self.ap.totp_service.confirm_enrollment(target_account_uuid, code)
except totp_module.TotpNotEnrolledError as e:
return self.http_status(404, e.code, str(e))
except totp_module.TotpInvalidCodeError as e:
return self.http_status(400, e.code, str(e))
except totp_module.TotpError as e:
return self.http_status(409, e.code, str(e))
return self.success(data={'recovery_codes': result.codes})
async def _handle_space_direct_launch(
self,
launch_assertion: str,
+717
View File
@@ -0,0 +1,717 @@
from __future__ import annotations
import base64
import dataclasses
import datetime
import hashlib
import hmac
import io
import secrets
import time
import typing
import uuid as uuid_lib
from urllib.parse import quote as url_quote
import qrcode
import sqlalchemy
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ....entity.persistence import totp as totp_entity
from ....entity.persistence import user
if typing.TYPE_CHECKING:
from ....core.app import Application
# HKDF context: rotating the wrapping key means bumping the key version, never
# re-using an existing derivation context.
_HKDF_SALT = b'langbot.totp.secret.v1'
_HKDF_INFO = b'langbot-totp-secret-encryption'
_HKDF_LENGTH = 32
# Recovery codes: PBKDF2-HMAC-SHA256. Only the digest is ever persisted.
_RECOVERY_CODE_ALGORITHM = 'pbkdf2_hmac_sha256'
_PBKDF2_ITERATIONS = 600_000
_PBKDF2_SALT_BYTES = 16
_RECOVERY_CODE_COUNT = 4
_RECOVERY_CODE_GROUPS = 4
_RECOVERY_CODE_GROUP_LENGTH = 5
# Login second-factor challenge lifetime and admission bounds.
_TOTP_CHALLENGE_TTL_SECONDS = 5 * 60
_TOTP_CHALLENGE_MAX_ENTRIES = 4096
_TOTP_MAX_ATTEMPTS = 5
# Unambiguous base32 alphabet used for recovery codes (no 0/O/1/I confusion).
_RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
class TotpError(ValueError):
"""Base class for TOTP second-factor failures."""
code = 'totp_error'
class TotpNotEnrolledError(TotpError):
code = 'totp_not_enrolled'
class TotpAlreadyEnrolledError(TotpError):
code = 'totp_already_enrolled'
class TotpInvalidCodeError(TotpError):
code = 'totp_invalid_code'
class TotpRequiredError(TotpError):
"""Raised when the password flow still requires a second factor."""
code = 'totp_required'
class TotpChallengeError(TotpError):
code = 'totp_challenge_invalid'
@dataclasses.dataclass(frozen=True, slots=True)
class TotpChallengeData:
account_uuid: str
user_email: str
expires_at: float
attempts: int = 0
@dataclasses.dataclass(frozen=True, slots=True)
class TotpEnrollment:
"""Result of starting an enrolment.
The shared secret never leaves the backend: only a server-rendered QR code
(as a ``data:`` URL) is handed to the client so the operator can scan it into
an authenticator. The secret itself is persisted solely as a ciphertext token
and is never returned in plaintext.
"""
uuid: str
qr_code_data_url: str
algorithm: str
digits: int
period: int
@dataclasses.dataclass(frozen=True, slots=True)
class TotpRecoveryCodes:
"""Recovery codes returned exactly once, at confirmation or regeneration."""
codes: list[str]
def _derive_wrapping_key(jwt_secret: str, key_version: int) -> bytes:
"""HKDF-SHA256 derivation of the Fernet key from the instance JWT secret."""
if not jwt_secret:
raise TotpError('Instance JWT secret is not configured')
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=_HKDF_LENGTH,
salt=_HKDF_SALT,
info=_HKDF_INFO + b'.v' + str(int(key_version)).encode('ascii'),
)
# Fernet requires a url-safe base64 encoded 32-byte key.
return base64.urlsafe_b64encode(hkdf.derive(jwt_secret.encode('utf-8')))
def _generate_totp_secret(length: int = 32) -> str:
"""Generate a base32 TOTP shared secret from a CSPRNG."""
return base64.b32encode(secrets.token_bytes(length)).decode('ascii').rstrip('=')
def _normalize_code(value: typing.Any) -> str:
return ''.join(ch for ch in str(value or '').upper() if ch.isalnum())
def _hotp(secret: bytes, counter: int, digits: int) -> str:
"""RFC 4226 HMAC-SHA1 dynamic truncation."""
digest = hmac.new(secret, counter.to_bytes(8, byteorder='big'), hashlib.sha1).digest()
offset = digest[-1] & 0x0F
truncated = (
(digest[offset] & 0x7F) << 24
| (digest[offset + 1] & 0xFF) << 16
| (digest[offset + 2] & 0xFF) << 8
| (digest[offset + 3] & 0xFF)
)
return str(truncated % (10**digits)).zfill(digits)
def _decode_secret(secret: str) -> bytes:
padding = '=' * ((8 - len(secret) % 8) % 8)
try:
return base64.b32decode(secret.upper() + padding)
except Exception as exc: # noqa: BLE001 - surface as a domain error
raise TotpError('TOTP secret is not valid base32') from exc
def _totp_counter(period: int, *, at: float | None = None) -> int:
"""RFC 6238 time step counter."""
moment = time.time() if at is None else at
return int(moment // max(1, period))
def _pbkdf2_digest(code: str, salt: str, iterations: int = _PBKDF2_ITERATIONS) -> str:
"""PBKDF2-HMAC-SHA256 digest of a recovery code, hex encoded."""
return hashlib.pbkdf2_hmac(
'sha256',
_normalize_code(code).encode('utf-8'),
salt.encode('utf-8'),
iterations,
).hex()
def _generate_recovery_codes() -> list[str]:
"""Human-transcribable single-use recovery codes."""
length = _RECOVERY_CODE_GROUPS * _RECOVERY_CODE_GROUP_LENGTH
codes: list[str] = []
for _ in range(_RECOVERY_CODE_COUNT):
raw = ''.join(secrets.choice(_RECOVERY_ALPHABET) for _ in range(length))
codes.append(
'-'.join(raw[i : i + _RECOVERY_CODE_GROUP_LENGTH] for i in range(0, length, _RECOVERY_CODE_GROUP_LENGTH))
)
return codes
def build_totp_uri(secret: str, account: str, issuer: str = 'LangBot') -> str:
"""Build an otpauth:// provisioning URI for an authenticator app."""
return (
f'otpauth://totp/{url_quote(account)}?secret={secret}'
f'&issuer={url_quote(issuer)}&algorithm=SHA1&digits=6&period=30'
)
def render_totp_qr_data_url(otpauth_uri: str) -> str:
"""Render a provisioning URI as a PNG ``data:`` URL.
The QR code is produced on the server so the shared secret never has to
travel to the browser as a plaintext value.
"""
image = qrcode.QRCode(border=1, box_size=8)
image.add_data(otpauth_uri)
image.make(fit=True)
picture = image.make_image(fill_color='black', back_color='white')
buffer = io.BytesIO()
picture.save(buffer, format='PNG')
encoded = base64.b64encode(buffer.getvalue()).decode('ascii')
return f'data:image/png;base64,{encoded}'
class TotpService:
"""TOTP enrolment, verification, disablement and recovery-code use.
Security invariants enforced here:
* the shared secret is only ever persisted as a Fernet token whose key is
derived (HKDF-SHA256) from the instance JWT secret;
* recovery codes are only ever persisted as salted PBKDF2-HMAC-SHA256
digests and are single-use;
* the consumed counter advances monotonically so a captured code cannot be
replayed inside the same time window.
"""
ap: Application
def __init__(self, ap: Application) -> None:
self.ap = ap
self._challenges: dict[str, TotpChallengeData] = {}
# ------------------------------------------------------------------
# configuration / storage helpers
# ------------------------------------------------------------------
def _session_factory(self) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False)
def _jwt_secret(self) -> str:
secret = self.ap.instance_config.data['system']['jwt']['secret']
if not isinstance(secret, str) or not secret:
raise TotpError('Instance JWT secret is not configured')
return secret
def _fernet(self, key_version: int = 1) -> Fernet:
return Fernet(_derive_wrapping_key(self._jwt_secret(), key_version))
async def is_enrolled(self, account_uuid: str) -> bool:
credential = await self.get_credential(account_uuid)
return credential is not None and credential.confirmed_at is not None
async def get_credential(self, account_uuid: str) -> totp_entity.TotpCredential | None:
statement = (
sqlalchemy.select(totp_entity.TotpCredential)
.where(
totp_entity.TotpCredential.account_uuid == account_uuid,
totp_entity.TotpCredential.disabled_at.is_(None),
)
.order_by(totp_entity.TotpCredential.created_at.desc())
.limit(1)
)
async with self._session_factory()() as session:
return await session.scalar(statement)
# ------------------------------------------------------------------
# login second-factor challenge
# ------------------------------------------------------------------
async def issue_login_challenge(self, account_uuid: str, user_email: str) -> str:
"""Issue a single-use second-factor challenge for one Account."""
token = secrets.token_urlsafe(32)
self._prune_challenges()
# Bound the challenge table so unauthenticated login attempts cannot
# grow process memory without limit.
while len(self._challenges) >= _TOTP_CHALLENGE_MAX_ENTRIES:
self._challenges.pop(next(iter(self._challenges)), None)
self._challenges[token] = TotpChallengeData(
account_uuid=account_uuid,
user_email=user_email,
expires_at=time.monotonic() + _TOTP_CHALLENGE_TTL_SECONDS,
)
return token
def consume_login_challenge(self, token: str) -> TotpChallengeData:
data = self._challenges.get(token)
if data is None or data.expires_at < time.monotonic():
self._challenges.pop(token, None)
raise TotpChallengeError('Invalid or expired second-factor challenge')
return data
def complete_login_challenge(self, token: str) -> None:
self._challenges.pop(token, None)
def record_failed_attempt(self, token: str) -> None:
"""Count a failed attempt and burn the challenge once the cap is hit."""
data = self._challenges.get(token)
if data is None:
return
attempts = data.attempts + 1
if attempts >= _TOTP_MAX_ATTEMPTS:
self._challenges.pop(token, None)
return
self._challenges[token] = dataclasses.replace(data, attempts=attempts)
def _prune_challenges(self) -> None:
now = time.monotonic()
for token in [token for token, data in self._challenges.items() if data.expires_at < now]:
self._challenges.pop(token, None)
# ------------------------------------------------------------------
# enrolment
# ------------------------------------------------------------------
async def begin_enrollment(
self,
account_uuid: str,
user_email: str,
*,
rotate: bool = False,
force: bool = False,
) -> TotpEnrollment:
"""Create or replace the pending TOTP credential for an Account.
Any previous unconfirmed attempt is retired and outstanding recovery
codes are cleared, so a half-finished enrolment can never linger.
The plaintext secret is discarded after this call: callers only ever
receive a server-rendered QR code.
``force`` is used by Workspace owners/admins to re-bind an Account whose
authenticator was lost: it retires an already-confirmed credential and
starts a fresh pending enrolment.
"""
credential = await self.get_credential(account_uuid)
if credential is not None and credential.confirmed_at is not None and not force:
raise TotpAlreadyEnrolledError('TOTP is already enabled for this Account')
# Reuse an unconfirmed (pending) enrolment instead of minting a new
# secret. Duplicate requests are normal - React StrictMode invokes
# effects twice in development and browsers/proxies may retry - and
# rotating the secret would invalidate the QR code already on screen,
# making the subsequent confirm_enrollment() call fail with
# "Invalid TOTP code". Returning the same QR keeps them in sync.
# An explicit "refresh" passes rotate=True to opt back into rotation.
if credential is not None and not rotate and not force:
pending_secret = self._decrypt_secret(credential.secret_ciphertext, credential.key_version)
return TotpEnrollment(
uuid=credential.uuid,
qr_code_data_url=render_totp_qr_data_url(
build_totp_uri(pending_secret.decode('ascii'), user_email)
),
algorithm=credential.algorithm,
digits=credential.digits,
period=credential.period,
)
secret = _generate_totp_secret()
ciphertext = self._fernet().encrypt(secret.encode('utf-8')).decode('ascii')
record_uuid = str(uuid_lib.uuid4())
async with self._session_factory()() as session:
async with session.begin():
await session.execute(
sqlalchemy.update(totp_entity.TotpCredential)
.where(totp_entity.TotpCredential.account_uuid == account_uuid)
.values(disabled_at=datetime.datetime.now(datetime.timezone.utc))
)
await session.execute(
sqlalchemy.delete(totp_entity.TotpRecoveryCode).where(
totp_entity.TotpRecoveryCode.account_uuid == account_uuid
)
)
session.add(
totp_entity.TotpCredential(
uuid=record_uuid,
account_uuid=account_uuid,
secret_ciphertext=ciphertext,
key_version=1,
algorithm='SHA1',
digits=6,
period=30,
)
)
# Render the QR code here (and drop the plaintext secret) so the shared
# secret never crosses the API boundary towards the browser.
qr_code_data_url = render_totp_qr_data_url(build_totp_uri(secret, user_email))
return TotpEnrollment(
uuid=record_uuid,
qr_code_data_url=qr_code_data_url,
algorithm='SHA1',
digits=6,
period=30,
)
async def confirm_enrollment(self, account_uuid: str, code: str) -> TotpRecoveryCodes:
"""Verify the first code, activate the credential and mint recovery codes.
Recovery codes are returned exactly once; only their salted PBKDF2
digests are stored.
"""
credential = await self.get_credential(account_uuid)
if credential is None:
raise TotpNotEnrolledError('No pending TOTP enrolment for this Account')
if credential.confirmed_at is not None:
raise TotpAlreadyEnrolledError('TOTP is already enabled for this Account')
counter = self._verify_counter(credential, code)
codes = _generate_recovery_codes()
now = datetime.datetime.now(datetime.timezone.utc)
async with self._session_factory()() as session:
async with session.begin():
await session.execute(
sqlalchemy.update(totp_entity.TotpCredential)
.where(
totp_entity.TotpCredential.uuid == credential.uuid,
totp_entity.TotpCredential.confirmed_at.is_(None),
)
.values(confirmed_at=now, last_used_at=now, last_used_counter=counter)
)
self._store_recovery_codes(session, account_uuid, credential.uuid, codes)
return TotpRecoveryCodes(codes=codes)
async def regenerate_recovery_codes(self, account_uuid: str, code: str) -> TotpRecoveryCodes:
"""Replace all recovery codes, requiring a valid live TOTP code first."""
credential = await self.get_credential(account_uuid)
if credential is None or credential.confirmed_at is None:
raise TotpNotEnrolledError('TOTP is not enabled for this Account')
if not await self.verify_code(account_uuid, code, allow_recovery=True):
raise TotpInvalidCodeError('Invalid TOTP code')
codes = _generate_recovery_codes()
async with self._session_factory()() as session:
async with session.begin():
await session.execute(
sqlalchemy.delete(totp_entity.TotpRecoveryCode).where(
totp_entity.TotpRecoveryCode.account_uuid == account_uuid
)
)
self._store_recovery_codes(session, account_uuid, credential.uuid, codes)
return TotpRecoveryCodes(codes=codes)
def _store_recovery_codes(
self,
session: AsyncSession,
account_uuid: str,
credential_uuid: str,
codes: list[str],
) -> None:
"""Persist recovery codes as salted PBKDF2-HMAC-SHA256 digests only."""
for index, code_value in enumerate(codes):
salt = secrets.token_hex(_PBKDF2_SALT_BYTES)
session.add(
totp_entity.TotpRecoveryCode(
uuid=str(uuid_lib.uuid4()),
account_uuid=account_uuid,
code_id=f'{credential_uuid[:8]}-{index:02d}',
salt=salt,
digest=_pbkdf2_digest(code_value, salt),
algorithm=_RECOVERY_CODE_ALGORITHM,
iterations=_PBKDF2_ITERATIONS,
)
)
async def disable(self, account_uuid: str, *, code: str | None = None) -> bool:
"""Disable TOTP. A live TOTP code or a recovery code is required."""
credential = await self.get_credential(account_uuid)
if credential is None or credential.confirmed_at is None:
raise TotpNotEnrolledError('TOTP is not enabled for this Account')
if not code:
raise TotpInvalidCodeError('A TOTP or recovery code is required to disable TOTP')
if not await self.verify_code(account_uuid, code, allow_recovery=True):
raise TotpInvalidCodeError('Invalid TOTP code')
now = datetime.datetime.now(datetime.timezone.utc)
async with self._session_factory()() as session:
async with session.begin():
await session.execute(
sqlalchemy.update(totp_entity.TotpCredential)
.where(totp_entity.TotpCredential.account_uuid == account_uuid)
.values(disabled_at=now)
)
await session.execute(
sqlalchemy.delete(totp_entity.TotpRecoveryCode).where(
totp_entity.TotpRecoveryCode.account_uuid == account_uuid
)
)
return True
# ------------------------------------------------------------------
# owner/admin oversight
# ------------------------------------------------------------------
async def list_account_states(self) -> list[dict[str, typing.Any]]:
"""Second-factor state for every Account, for owner/admin oversight.
Oversight is instance-wide by design: an owner/admin may manage the
second factor of any Account.
Only state is returned: no secret and no recovery-code material.
"""
credential = totp_entity.TotpCredential
unused_codes = (
sqlalchemy.select(
totp_entity.TotpRecoveryCode.account_uuid.label('account_uuid'),
sqlalchemy.func.count().label('unused'),
)
.where(totp_entity.TotpRecoveryCode.used_at.is_(None))
.group_by(totp_entity.TotpRecoveryCode.account_uuid)
.subquery()
)
statement = (
sqlalchemy.select(
user.User.uuid,
user.User.user,
user.User.status,
credential.confirmed_at,
credential.last_used_at,
sqlalchemy.func.coalesce(unused_codes.c.unused, 0),
)
.outerjoin(
credential,
sqlalchemy.and_(
credential.account_uuid == user.User.uuid,
credential.disabled_at.is_(None),
),
)
.outerjoin(unused_codes, unused_codes.c.account_uuid == user.User.uuid)
.order_by(user.User.user)
)
async with self._session_factory()() as session:
rows = (await session.execute(statement)).all()
return [
{
'account_uuid': row[0],
'user': row[1],
'status': row[2],
'enabled': row[3] is not None,
'last_used_at': row[4].isoformat() if row[4] else None,
'recovery_codes_remaining': int(row[5] or 0),
}
for row in rows
]
async def revoke_for_account(self, account_uuid: str) -> bool:
"""Owner/admin override: retire an Account's factor without its code.
Used when the operator has lost the authenticator and every recovery
code. The credential is soft-disabled so the action stays auditable and
outstanding recovery codes are destroyed.
"""
now = datetime.datetime.now(datetime.timezone.utc)
async with self._session_factory()() as session:
async with session.begin():
result = await session.execute(
sqlalchemy.update(totp_entity.TotpCredential)
.where(
totp_entity.TotpCredential.account_uuid == account_uuid,
totp_entity.TotpCredential.disabled_at.is_(None),
)
.values(disabled_at=now)
)
await session.execute(
sqlalchemy.delete(totp_entity.TotpRecoveryCode).where(
totp_entity.TotpRecoveryCode.account_uuid == account_uuid
)
)
return bool(result.rowcount)
# ------------------------------------------------------------------
# verification
# ------------------------------------------------------------------
def _decrypt_secret(self, ciphertext: str, key_version: int) -> bytes:
try:
return self._fernet(key_version).decrypt(ciphertext.encode('ascii'))
except InvalidToken as exc:
raise TotpError('Stored TOTP secret cannot be decrypted with the instance key') from exc
def _verify_counter(self, credential: totp_entity.TotpCredential, code: str) -> int:
"""Constant-time TOTP verification with a +/- one step drift window."""
normalized = _normalize_code(code)
if credential.algorithm.upper() != 'SHA1':
raise TotpError('Only SHA1 TOTP is supported')
if len(normalized) != credential.digits:
raise TotpInvalidCodeError('Invalid TOTP code')
# The stored plaintext is the base32 secret; HMAC needs the raw key.
encoded_secret = self._decrypt_secret(credential.secret_ciphertext, credential.key_version)
secret_bytes = _decode_secret(encoded_secret.decode('ascii'))
current = _totp_counter(credential.period)
for candidate in (current, current - 1, current + 1):
if credential.last_used_counter is not None and candidate <= credential.last_used_counter:
# Reject replays inside an already-consumed window.
continue
if hmac.compare_digest(_hotp(secret_bytes, candidate, credential.digits), normalized):
return candidate
raise TotpInvalidCodeError('Invalid TOTP code')
async def verify_code(
self,
account_uuid: str,
code: str,
*,
allow_recovery: bool = False,
consume_counter: bool = True,
) -> bool:
"""Verify a TOTP code, optionally falling back to a recovery code."""
credential = await self.get_credential(account_uuid)
if credential is None or credential.confirmed_at is None:
raise TotpNotEnrolledError('TOTP is not enabled for this Account')
try:
counter = self._verify_counter(credential, code)
except TotpInvalidCodeError:
if not allow_recovery:
raise
if await self.use_recovery_code(account_uuid, code):
return True
raise
if consume_counter:
now = datetime.datetime.now(datetime.timezone.utc)
async with self._session_factory()() as session:
async with session.begin():
await session.execute(
sqlalchemy.update(totp_entity.TotpCredential)
.where(
totp_entity.TotpCredential.uuid == credential.uuid,
sqlalchemy.or_(
totp_entity.TotpCredential.last_used_counter.is_(None),
totp_entity.TotpCredential.last_used_counter < counter,
),
)
.values(last_used_counter=counter, last_used_at=now)
)
return True
async def use_recovery_code(self, account_uuid: str, code: str) -> bool:
"""Consume one unused recovery code. The code is never logged."""
normalized = _normalize_code(code)
if len(normalized) < 16:
return False
async with self._session_factory()() as session:
rows = list(
await session.scalars(
sqlalchemy.select(totp_entity.TotpRecoveryCode).where(
totp_entity.TotpRecoveryCode.account_uuid == account_uuid,
totp_entity.TotpRecoveryCode.used_at.is_(None),
)
)
)
# Compare every stored digest in constant time and only remember
# whether a match happened: an early `break` leaks, through response
# timing, how many codes were probed before the match was found.
matched_id: int | None = None
for row in rows:
if hmac.compare_digest(_pbkdf2_digest(normalized, row.salt, row.iterations), row.digest):
matched_id = row.id
if matched_id is None:
return False
# Single-use: the guard on used_at keeps concurrent spends from
# both succeeding.
result = await session.execute(
sqlalchemy.update(totp_entity.TotpRecoveryCode)
.where(
totp_entity.TotpRecoveryCode.id == matched_id,
totp_entity.TotpRecoveryCode.used_at.is_(None),
)
.values(used_at=datetime.datetime.now(datetime.timezone.utc))
)
await session.commit()
return bool(result.rowcount)
async def count_unused_recovery_codes(self, account_uuid: str) -> int:
statement = sqlalchemy.select(sqlalchemy.func.count()).select_from(totp_entity.TotpRecoveryCode).where(
totp_entity.TotpRecoveryCode.account_uuid == account_uuid,
totp_entity.TotpRecoveryCode.used_at.is_(None),
)
async with self._session_factory()() as session:
return int(await session.scalar(statement) or 0)
async def get_account(self, account_uuid: str) -> user.User | None:
statement = sqlalchemy.select(user.User).where(user.User.uuid == account_uuid)
async with self._session_factory()() as session:
return await session.scalar(statement)
__all__ = [
'TotpAlreadyEnrolledError',
'TotpChallengeData',
'TotpChallengeError',
'TotpEnrollment',
'TotpError',
'TotpInvalidCodeError',
'TotpNotEnrolledError',
'TotpRecoveryCodes',
'TotpRequiredError',
'TotpService',
'build_totp_uri',
'render_totp_qr_data_url',
]
+113 -1
View File
@@ -26,6 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ....entity.persistence import user
from ....entity.persistence import passkey
from . import totp as totp_service_module
from ....entity.persistence.workspace import MembershipRole, MembershipStatus, WorkspaceMembership
from ....utils import constants
from ....entity.errors import account as account_errors
@@ -413,7 +414,13 @@ class UserService:
f'space:{space_account_uuid}',
)
async def authenticate(self, user_email: str, password: str) -> str | None:
async def authenticate(self, user_email: str, password: str, totp_code: str | None = None) -> str | None:
"""Verify primary credentials, then any enrolled second factor.
When TOTP is enrolled but no code was supplied, ``TotpRequiredError`` is
raised so the caller can issue a challenge instead of a session token.
"""
user_obj = await self.get_user_by_email(user_email)
if user_obj is None:
raise ValueError('用户不存在')
@@ -425,6 +432,14 @@ class UserService:
await self._verify_password(user_obj.password, password)
totp_service = getattr(self.ap, 'totp_service', None)
if totp_service is not None and await totp_service.is_enrolled(user_obj.uuid):
if not totp_code:
raise totp_service_module.TotpRequiredError('A second factor is required')
# Recovery codes are accepted here as well, so a lost authenticator
# does not lock an Account out of its own instance.
await totp_service.verify_code(user_obj.uuid, totp_code, allow_recovery=True)
return await self.generate_jwt_token(user_obj)
async def generate_jwt_token(
@@ -534,6 +549,103 @@ class UserService:
if isinstance(status, str) and status != user.AccountStatus.ACTIVE.value:
raise AccountDisabledError('Account is disabled')
async def issue_totp_login_challenge(self, user_email: str) -> str | None:
"""Issue a login second-factor challenge for a TOTP-enrolled Account.
Returns ``None`` when the Account has no active second factor, so the
caller can proceed with the primary factors alone.
"""
totp_service = getattr(self.ap, 'totp_service', None)
if totp_service is None:
return None
user_obj = await self.get_user_by_email(user_email)
if user_obj is None:
return None
if not await totp_service.is_enrolled(user_obj.uuid):
return None
return await totp_service.issue_login_challenge(user_obj.uuid, user_obj.user)
async def issue_uniform_totp_login_challenge(self, user_email: str) -> str:
"""Issue a challenge token even when the Account has no second factor.
The login endpoints are unauthenticated. Returning a distinguishable
error for "no second factor enrolled" would turn them into an Account
enumeration oracle, so the caller always receives a token. Supplying a
code for a non-enrolled or unknown Account simply fails verification.
"""
totp_service = getattr(self.ap, 'totp_service', None)
if totp_service is None:
raise ValueError('TOTP service is unavailable')
user_obj = await self.get_user_by_email(user_email)
if user_obj is None or not await totp_service.is_enrolled(user_obj.uuid):
return await totp_service.issue_login_challenge('', '')
return await totp_service.issue_login_challenge(user_obj.uuid, user_obj.user)
async def has_totp_enrolled(self, user_email: str) -> bool:
"""Whether the Account behind this email has an active second factor."""
totp_service = getattr(self.ap, 'totp_service', None)
if totp_service is None:
return False
user_obj = await self.get_user_by_email(user_email)
if user_obj is None:
return False
return await totp_service.is_enrolled(user_obj.uuid)
async def verify_totp_second_factor(
self,
user_email: str,
code: str,
*,
allow_recovery: bool = True,
challenge_token: str | None = None,
) -> bool:
"""Verify a TOTP or recovery code for the Account behind this email.
When ``challenge_token`` is supplied it must be the token issued for a
successful primary-factor check on this exact Account. This stops the
code-only path from minting a session without ever proving the password,
and it applies the per-challenge attempt cap.
"""
totp_service = getattr(self.ap, 'totp_service', None)
if totp_service is None:
return False
user_obj = await self.get_user_by_email(user_email)
if user_obj is None:
return False
challenge = None
if challenge_token:
try:
challenge = totp_service.consume_login_challenge(challenge_token)
except totp_service_module.TotpChallengeError:
return False
# A challenge is bound to one Account: it cannot be redeemed for
# another, nor for an Account that never had a second factor.
if challenge.account_uuid != user_obj.uuid:
totp_service.record_failed_attempt(challenge_token)
return False
try:
verified = await totp_service.verify_code(
user_obj.uuid, code, allow_recovery=allow_recovery
)
except totp_service_module.TotpError:
# Covers "not enrolled" and "invalid code" alike. Both are simply a
# failed verification for this caller; neither should surface as a
# server error or disclose whether the Account has a second factor.
verified = False
if not verified and challenge_token:
totp_service.record_failed_attempt(challenge_token)
return False
if verified and challenge_token:
totp_service.complete_login_challenge(challenge_token)
return verified
async def reset_password(self, user_email: str, new_password: str) -> None:
hashed_password = await self._hash_password(new_password)
normalized_email = normalize_email(user_email)
+2
View File
@@ -24,6 +24,7 @@ from ..persistence import mgr as persistencemgr
from ..api.http.controller import main as http_controller
from ..api.http.service import user as user_service
from ..api.http.service import space as space_service
from ..api.http.service import totp as totp_service
from ..api.http.service import model as model_service
from ..api.http.service import provider as provider_service
from ..api.http.service import pipeline as pipeline_service
@@ -160,6 +161,7 @@ class Application:
# ========= HTTP Services =========
user_service: user_service.UserService = None
totp_service: totp_service.TotpService = None
space_service: space_service.SpaceService = None
+3
View File
@@ -18,6 +18,7 @@ from ...persistence import mgr as persistencemgr
from ...api.http.controller import main as http_controller
from ...api.http.service import user as user_service
from ...api.http.service import space as space_service
from ...api.http.service import totp as totp_service
from ...api.http.service import model as model_service
from ...api.http.service import provider as provider_service
from ...api.http.service import pipeline as pipeline_service
@@ -198,6 +199,8 @@ class BuildAppStage(stage.BootingStage):
user_service_inst = user_service.UserService(ap)
ap.user_service = user_service_inst
ap.totp_service = totp_service.TotpService(ap)
async def resolve_singleton_execution_context() -> ExecutionContext:
if workspace_policy.multi_workspace_enabled:
raise WorkspaceRequiredError('Cloud runtime work requires an explicit Workspace context')
@@ -0,0 +1,78 @@
from __future__ import annotations
import uuid as uuid_lib
import sqlalchemy
from .base import Base
class TotpCredential(Base):
"""Per-Account TOTP enrolment.
``secret_ciphertext`` stores the Fernet token produced by encrypting the
base32 TOTP shared secret with a key derived from the instance JWT secret
(HKDF-SHA256). The plaintext secret is never written to disk and never
returned by read endpoints after enrolment completes.
"""
__tablename__ = 'totp_credentials'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
uuid = sqlalchemy.Column(
sqlalchemy.String(36),
nullable=False,
default=lambda: str(uuid_lib.uuid4()),
)
account_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
)
secret_ciphertext = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
# Key derivation epoch: allows rotating the wrapping key without losing the secret.
key_version = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='1')
algorithm = sqlalchemy.Column(sqlalchemy.String(16), nullable=False, server_default='SHA1')
digits = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='6')
period = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, server_default='30')
confirmed_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
last_used_counter = sqlalchemy.Column(sqlalchemy.BigInteger, nullable=True)
disabled_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
__table_args__ = (
sqlalchemy.Index('uq_totp_credentials_uuid', 'uuid', unique=True),
sqlalchemy.Index('ix_totp_credentials_account', 'account_uuid'),
)
class TotpRecoveryCode(Base):
"""Single-use TOTP recovery code stored only as a salted PBKDF2 digest."""
__tablename__ = 'totp_recovery_codes'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
uuid = sqlalchemy.Column(
sqlalchemy.String(36),
nullable=False,
default=lambda: str(uuid_lib.uuid4()),
)
account_uuid = sqlalchemy.Column(
sqlalchemy.String(36),
sqlalchemy.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
)
code_id = sqlalchemy.Column(sqlalchemy.String(16), nullable=False)
salt = sqlalchemy.Column(sqlalchemy.String(64), nullable=False)
digest = sqlalchemy.Column(sqlalchemy.String(128), nullable=False)
algorithm = sqlalchemy.Column(sqlalchemy.String(32), nullable=False, server_default='pbkdf2_hmac_sha256')
iterations = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
__table_args__ = (
sqlalchemy.Index('uq_totp_recovery_codes_uuid', 'uuid', unique=True),
sqlalchemy.Index('uq_totp_recovery_codes_code_id', 'code_id', unique=True),
sqlalchemy.Index('ix_totp_recovery_codes_account', 'account_uuid'),
)
@@ -0,0 +1,138 @@
"""add TOTP credentials and recovery codes
Revision ID: 0025_totp_credentials
Revises: 0024_passkey_credentials
Create Date: 2026-09-22
The TOTP shared secret is stored only as a Fernet token keyed off the instance
JWT secret (HKDF-SHA256), and recovery codes are stored only as salted
PBKDF2-HMAC-SHA256 digests. No plaintext second-factor material is persisted.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0025_totp_credentials'
down_revision = '0024_passkey_credentials'
branch_labels = None
depends_on = None
_CREDENTIALS_TABLE = 'totp_credentials'
_RECOVERY_CODES_TABLE = 'totp_recovery_codes'
_LEGACY_CREDENTIALS_TABLE = 'totp_credentials_legacy_pre_0025'
# Columns this migration guarantees. A pre-existing table missing any of them is
# an incompatible abandoned shape and must not be silently reused.
_REQUIRED_CREDENTIAL_COLUMNS = frozenset(
{'uuid', 'account_uuid', 'secret_ciphertext', 'key_version', 'algorithm', 'digits', 'period'}
)
def _retire_abandoned_credentials_table() -> None:
"""Move an incompatible `totp_credentials` aside without losing its data.
The table is retained under a clearly labelled name for forensic recovery,
but its indexes are dropped because they occupy the very names the supported
schema needs (``uq_totp_credentials_uuid`` in particular).
"""
inspector = sa.inspect(op.get_bind())
existing_tables = set(inspector.get_table_names())
if _LEGACY_CREDENTIALS_TABLE in existing_tables:
op.drop_table(_LEGACY_CREDENTIALS_TABLE)
op.rename_table(_CREDENTIALS_TABLE, _LEGACY_CREDENTIALS_TABLE)
for index in sa.inspect(op.get_bind()).get_indexes(_LEGACY_CREDENTIALS_TABLE):
name = index.get('name')
if name:
op.drop_index(name, table_name=_LEGACY_CREDENTIALS_TABLE)
def _create_credentials_table() -> None:
op.create_table(
_CREDENTIALS_TABLE,
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('uuid', sa.String(36), nullable=False),
sa.Column(
'account_uuid',
sa.String(36),
sa.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
),
sa.Column('secret_ciphertext', sa.Text(), nullable=False),
sa.Column('key_version', sa.Integer(), nullable=False, server_default='1'),
sa.Column('algorithm', sa.String(16), nullable=False, server_default='SHA1'),
sa.Column('digits', sa.Integer(), nullable=False, server_default='6'),
sa.Column('period', sa.Integer(), nullable=False, server_default='30'),
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
sa.Column('last_used_counter', sa.BigInteger(), nullable=True),
sa.Column('disabled_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
)
op.create_index('uq_totp_credentials_uuid', _CREDENTIALS_TABLE, ['uuid'], unique=True)
op.create_index('ix_totp_credentials_account', _CREDENTIALS_TABLE, ['account_uuid'], unique=False)
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = set(inspector.get_table_names())
if _CREDENTIALS_TABLE not in existing_tables:
_create_credentials_table()
else:
columns = {column['name'] for column in inspector.get_columns(_CREDENTIALS_TABLE)}
if not _REQUIRED_CREDENTIAL_COLUMNS.issubset(columns):
# An abandoned table from an unrelated feature occupies the name and
# cannot store a Fernet-wrapped secret. Preserve it under a clearly
# labelled name (no data loss) and install the supported schema.
_retire_abandoned_credentials_table()
_create_credentials_table()
if _RECOVERY_CODES_TABLE not in existing_tables:
op.create_table(
_RECOVERY_CODES_TABLE,
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('uuid', sa.String(36), nullable=False),
sa.Column(
'account_uuid',
sa.String(36),
sa.ForeignKey('users.uuid', ondelete='CASCADE'),
nullable=False,
),
sa.Column('code_id', sa.String(16), nullable=False),
sa.Column('salt', sa.String(64), nullable=False),
sa.Column('digest', sa.String(128), nullable=False),
sa.Column(
'algorithm',
sa.String(32),
nullable=False,
server_default='pbkdf2_hmac_sha256',
),
sa.Column('iterations', sa.Integer(), nullable=False),
sa.Column('used_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
op.create_index('uq_totp_recovery_codes_uuid', _RECOVERY_CODES_TABLE, ['uuid'], unique=True)
op.create_index('uq_totp_recovery_codes_code_id', _RECOVERY_CODES_TABLE, ['code_id'], unique=True)
op.create_index('ix_totp_recovery_codes_account', _RECOVERY_CODES_TABLE, ['account_uuid'], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
existing_tables = set(inspector.get_table_names())
if _RECOVERY_CODES_TABLE in existing_tables:
op.drop_index('ix_totp_recovery_codes_account', table_name=_RECOVERY_CODES_TABLE)
op.drop_index('uq_totp_recovery_codes_code_id', table_name=_RECOVERY_CODES_TABLE)
op.drop_index('uq_totp_recovery_codes_uuid', table_name=_RECOVERY_CODES_TABLE)
op.drop_table(_RECOVERY_CODES_TABLE)
if _CREDENTIALS_TABLE in existing_tables:
op.drop_index('ix_totp_credentials_account', table_name=_CREDENTIALS_TABLE)
op.drop_index('uq_totp_credentials_uuid', table_name=_CREDENTIALS_TABLE)
op.drop_table(_CREDENTIALS_TABLE)
@@ -0,0 +1,21 @@
"""merge the TOTP credential branch with the RAG document identity branch
Revision ID: 0026_merge_totp_and_rag_identity
Revises: 0025_totp_credentials, 0025_rag_document_identity
Create Date: 2026-09-22
"""
from __future__ import annotations
revision = '0026_merge_totp_and_rag_identity'
down_revision = ('0025_totp_credentials', '0025_rag_document_identity')
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
Generated
+73 -59
View File
@@ -1066,7 +1066,7 @@ name = "cuda-bindings"
version = "13.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "cuda-pathfinder" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" },
@@ -1099,34 +1099,34 @@ wheels = [
[package.optional-dependencies]
cudart = [
{ name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-runtime" },
]
cufft = [
{ name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cufft" },
]
cufile = [
{ name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cufile" },
]
cupti = [
{ name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-cupti" },
]
curand = [
{ name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-curand" },
]
cusolver = [
{ name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cusolver" },
]
cusparse = [
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cusparse" },
]
nvjitlink = [
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-nvjitlink" },
]
nvrtc = [
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cuda-nvrtc" },
]
nvtx = [
{ name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-nvtx" },
]
[[package]]
@@ -1747,6 +1747,9 @@ wheels = [
http2 = [
{ name = "h2" },
]
socks = [
{ name = "socksio" },
]
[[package]]
name = "httpx-sse"
@@ -2083,6 +2086,7 @@ dependencies = [
{ name = "ebooklib" },
{ name = "gewechat-client" },
{ name = "html2text" },
{ name = "httpx", extra = ["socks"] },
{ name = "langbot-plugin" },
{ name = "langchain" },
{ name = "langchain-core" },
@@ -2180,6 +2184,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
{ name = "langbot-plugin", specifier = "==0.6.0b5" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
@@ -3301,7 +3306,7 @@ name = "nvidia-cublas"
version = "13.1.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cuda-nvrtc" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
@@ -3340,7 +3345,7 @@ name = "nvidia-cudnn-cu13"
version = "9.20.0.48"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cublas" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
@@ -3352,7 +3357,7 @@ name = "nvidia-cufft"
version = "12.0.0.61"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
@@ -3382,9 +3387,9 @@ name = "nvidia-cusolver"
version = "12.0.4.66"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-cublas" },
{ name = "nvidia-cusparse" },
{ name = "nvidia-nvjitlink" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
@@ -3396,7 +3401,7 @@ name = "nvidia-cusparse"
version = "12.6.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "nvidia-nvjitlink" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
@@ -4489,7 +4494,7 @@ name = "pylibseekdb"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pymysql", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
{ name = "pymysql" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/a8/7413d33218aff55a14ec9d20532b49243ffd0579e7a92244922c1885444e/pylibseekdb-1.4.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5cb2efab9f1321cdb4b034d3a2bd92e41a402fc95e7dc9579c7473a426f96e24", size = 52173499, upload-time = "2026-08-27T13:05:09.347Z" },
@@ -5257,10 +5262,10 @@ name = "scikit-learn"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "joblib", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "scipy", marker = "python_full_version >= '3.14'" },
{ name = "threadpoolctl", marker = "python_full_version >= '3.14'" },
{ name = "joblib" },
{ name = "numpy" },
{ name = "scipy" },
{ name = "threadpoolctl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
wheels = [
@@ -5307,7 +5312,7 @@ name = "scipy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
@@ -5378,14 +5383,14 @@ name = "sentence-transformers"
version = "5.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "scikit-learn", marker = "python_full_version >= '3.14'" },
{ name = "scipy", marker = "python_full_version >= '3.14'" },
{ name = "torch", marker = "python_full_version >= '3.14'" },
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
{ name = "transformers", marker = "python_full_version >= '3.14'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
{ name = "huggingface-hub" },
{ name = "numpy" },
{ name = "scikit-learn" },
{ name = "scipy" },
{ name = "torch" },
{ name = "tqdm" },
{ name = "transformers" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" }
wheels = [
@@ -5437,6 +5442,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "socksio"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" },
]
[[package]]
name = "soupsieve"
version = "2.8.3"
@@ -5758,21 +5772,21 @@ name = "torch"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cuda-bindings", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "filelock", marker = "python_full_version >= '3.14'" },
{ name = "fsspec", marker = "python_full_version >= '3.14'" },
{ name = "jinja2", marker = "python_full_version >= '3.14'" },
{ name = "networkx", marker = "python_full_version >= '3.14'" },
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu13", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "setuptools", marker = "python_full_version >= '3.14'" },
{ name = "sympy", marker = "python_full_version >= '3.14'" },
{ name = "triton", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
{ name = "cuda-bindings", marker = "sys_platform == 'linux'" },
{ name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" },
{ name = "filelock" },
{ name = "fsspec" },
{ name = "jinja2" },
{ name = "networkx" },
{ name = "nvidia-cublas", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" },
{ name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" },
{ name = "setuptools" },
{ name = "sympy" },
{ name = "triton", marker = "sys_platform == 'linux'" },
{ name = "typing-extensions" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/38/7028d3be540f1dcdf41660a2b01d0c51d2cb73915fe370d84e4d277a6d47/torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2", size = 87975425, upload-time = "2026-06-17T21:08:34.094Z" },
@@ -5814,15 +5828,15 @@ name = "transformers"
version = "5.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "packaging", marker = "python_full_version >= '3.14'" },
{ name = "pyyaml", marker = "python_full_version >= '3.14'" },
{ name = "regex", marker = "python_full_version >= '3.14'" },
{ name = "safetensors", marker = "python_full_version >= '3.14'" },
{ name = "tokenizers", marker = "python_full_version >= '3.14'" },
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
{ name = "typer", marker = "python_full_version >= '3.14'" },
{ name = "huggingface-hub" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "regex" },
{ name = "safetensors" },
{ name = "tokenizers" },
{ name = "tqdm" },
{ name = "typer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
wheels = [
@@ -6083,9 +6097,9 @@ name = "valkey-glide"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform != 'win32'" },
{ name = "protobuf", marker = "sys_platform != 'win32'" },
{ name = "sniffio", marker = "sys_platform != 'win32'" },
{ name = "anyio" },
{ name = "protobuf" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
wheels = [
@@ -21,9 +21,13 @@ import {
Plus,
Trash2,
Pencil,
ShieldCheck,
ShieldOff,
} from 'lucide-react';
import { startRegistration } from '@simplewebauthn/browser';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
import TotpEnrollDialog, { type TotpDialogMode } from './TotpEnrollDialog';
import TotpAdminResetDialog from './TotpAdminResetDialog';
import { PanelBody } from '../settings-dialog/panel-layout';
interface AccountSettingsPanelProps {
@@ -32,6 +36,15 @@ interface AccountSettingsPanelProps {
onEmailResolved?: (email: string) => void;
}
interface TotpAccountRow {
account_uuid: string;
user: string;
status?: string;
enabled: boolean;
last_used_at?: string | null;
recovery_codes_remaining: number;
}
interface PasskeyItem {
uuid: string;
name: string;
@@ -56,6 +69,23 @@ export default function AccountSettingsPanel({
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [registeringPasskey, setRegisteringPasskey] = useState(false);
const [totpDialogOpen, setTotpDialogOpen] = useState(false);
// Latched when the dialog opens so a status refresh cannot swap the flow.
const [totpDialogMode, setTotpDialogMode] = useState<TotpDialogMode>('enroll');
// Owner/admin re-binding flow: the target Account is latched on open.
const [adminResetOpen, setAdminResetOpen] = useState(false);
const [adminResetTarget, setAdminResetTarget] = useState<TotpAccountRow | null>(
null,
);
const [totpRows, setTotpRows] = useState<TotpAccountRow[]>([]);
const [isManager, setIsManager] = useState(false);
const [accountUuid, setAccountUuid] = useState('');
const [totpStatus, setTotpStatus] = useState<{
enabled: boolean;
pending: boolean;
recovery_codes_remaining: number;
last_used_at?: string | null;
} | null>(null);
useEffect(() => {
if (active) {
@@ -64,6 +94,59 @@ export default function AccountSettingsPanel({
}
}, [active]);
// Depends on `accountUuid`: the self row is keyed by it, so it must load after
// the Account is resolved rather than in the same pass.
useEffect(() => {
if (active && accountUuid) {
void loadTotpStatus();
void loadTotpAccounts();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, accountUuid]);
async function loadTotpStatus() {
try {
const own = await httpClient.getTotpStatus();
setTotpStatus(own);
setTotpRows((prev) => {
const rest = prev.filter((row) => row.account_uuid !== accountUuid);
return [
{
account_uuid: accountUuid,
user: userEmail,
enabled: own.enabled,
last_used_at: own.last_used_at ?? null,
recovery_codes_remaining: own.recovery_codes_remaining,
},
...rest,
];
});
} catch {
// A disabled or unavailable second factor must not break the panel.
setTotpStatus(null);
}
}
// Owners and admins may see and manage every Account's second factor.
async function loadTotpAccounts() {
try {
const res = await httpClient.getTotpAccounts();
const list = res.accounts || [];
setIsManager(list.length > 1);
setTotpRows(list);
} catch {
// A non-manager receives 403 here; the panel keeps working on own state.
setIsManager(false);
}
}
// Owners/admins re-bind the Account by walking them through a fresh QR scan
// rather than silently revoking, so the Account is never left locked out.
function handleRevokeTotp(row: TotpAccountRow) {
setAdminResetTarget(row);
setAdminResetOpen(true);
}
async function loadUserInfo() {
setLoading(true);
try {
@@ -71,6 +154,7 @@ export default function AccountSettingsPanel({
setAccountType(info.account_type);
setHasPassword(info.has_password);
setUserEmail(info.user);
setAccountUuid(info.account_uuid);
onEmailResolved?.(info.user);
} catch {
toast.error(t('common.error'));
@@ -332,6 +416,129 @@ export default function AccountSettingsPanel({
</div>
)}
</div>
{/* TOTP second factor. The card is informational: changing the factor
happens from the action on the Account's own row. */}
<div className="pt-4 space-y-3">
<div>
<h4 className="text-sm font-medium flex items-center gap-1.5">
<ShieldCheck className="h-4 w-4" />
{t('account.totpSectionTitle')}
</h4>
<p className="text-xs text-muted-foreground">
{isManager
? t('account.totpManagerSectionDesc')
: totpStatus?.enabled
? t('account.totpEnabledDesc', {
count: totpStatus.recovery_codes_remaining,
})
: t('account.totpSectionDesc')}
</p>
</div>
{totpRows.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
{t('account.noAccounts')}
</div>
) : (
<div className="space-y-2">
{totpRows.map((row) => {
// Managers re-bind someone else's factor through a dialog that
// shows a server-rendered QR code: the Account scans it and
// reads the code back, so the secret still only reaches their
// authenticator.
const isSelf = row.account_uuid === accountUuid;
return (
<Item
key={row.account_uuid}
size="sm"
variant="muted"
className="rounded-lg"
>
<ItemMedia variant="icon">
{row.enabled ? (
<ShieldCheck className="h-4 w-4" />
) : (
<ShieldOff className="h-4 w-4" />
)}
</ItemMedia>
<ItemContent>
<ItemTitle>
{row.user}
{isSelf && (
<span className="ml-1 text-xs font-normal text-muted-foreground">
({t('account.you')})
</span>
)}
</ItemTitle>
<ItemDescription>
{row.enabled
? `${t('account.totpStatusEnabled')} · ${t(
'account.totpCodesRemaining',
{ count: row.recovery_codes_remaining },
)}`
: t('account.totpStatusDisabled')}
{row.last_used_at && (
<span className="ml-2">
·{' '}
{t('account.totpLastUsed', {
date: new Date(
row.last_used_at,
).toLocaleDateString(),
})}
</span>
)}
</ItemDescription>
</ItemContent>
<ItemActions>
{isSelf ? (
<Button
variant={row.enabled ? 'outline' : 'default'}
size="sm"
className="h-8 cursor-pointer"
onClick={() => {
setTotpDialogMode(row.enabled ? 'manage' : 'enroll');
setTotpDialogOpen(true);
}}
disabled={!systemInfo.allow_modify_login_info}
>
{row.enabled
? t('account.manageTotp')
: t('account.enableTotp')}
</Button>
) : (
isManager && (
// Managers can both re-bind an enabled Account and
// force-enable one that never had a second factor.
<Button
variant={row.enabled ? 'ghost' : 'default'}
size="sm"
className={
row.enabled
? 'h-8 cursor-pointer text-destructive hover:text-destructive'
: 'h-8 cursor-pointer'
}
onClick={() => handleRevokeTotp(row)}
disabled={!systemInfo.allow_modify_login_info}
>
{row.enabled ? (
<>
<Trash2 className="mr-1 h-3.5 w-3.5" />
{t('account.revokeTotp')}
</>
) : (
t('account.enableTotp')
)}
</Button>
)
)}
</ItemActions>
</Item>
);
})}
</div>
)}
</div>
</div>
)}
@@ -340,6 +547,27 @@ export default function AccountSettingsPanel({
onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword}
/>
<TotpEnrollDialog
open={totpDialogOpen}
onOpenChange={setTotpDialogOpen}
onChanged={() => {
void loadTotpStatus();
void loadTotpAccounts();
}}
mode={totpDialogMode}
/>
<TotpAdminResetDialog
open={adminResetOpen}
onOpenChange={setAdminResetOpen}
accountUuid={adminResetTarget?.account_uuid ?? ''}
accountUser={adminResetTarget?.user ?? ''}
onChanged={() => {
void loadTotpStatus();
void loadTotpAccounts();
}}
/>
</PanelBody>
);
}
@@ -0,0 +1,294 @@
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
Loader2,
ShieldCheck,
Copy,
Check,
Download,
AlertTriangle,
RefreshCw,
} from 'lucide-react';
/**
* Owner/admin driven re-binding of another Account's second factor.
*
* The manager walks the Account through a fresh enrolment: the QR code is
* rendered server-side (the shared secret never reaches the browser), the
* Account scans it and reads back the 6-digit code, and the manager enters that
* code to activate the credential. Recovery codes are then handed over.
*
* The previous authenticator stops working as soon as a new enrolment starts.
*/
type Step = 'confirm' | 'recovery';
interface TotpAdminResetDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The Account whose second factor is being re-bound. */
accountUuid: string;
/** Display name of that Account, used in the copy. */
accountUser: string;
/** Called when a change was made so the panel can refresh its status. */
onChanged?: () => void;
}
export default function TotpAdminResetDialog({
open,
onOpenChange,
accountUuid,
accountUser,
onChanged,
}: TotpAdminResetDialogProps) {
const { t } = useTranslation();
const [step, setStep] = useState<Step>('confirm');
const [loading, setLoading] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState('');
const [code, setCode] = useState('');
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const changedRef = useRef(false);
async function startReset() {
try {
const res = await httpClient.adminBeginTotpEnroll(accountUuid);
setQrDataUrl(res.qr_code_data_url);
setCode('');
} catch (error) {
const apiError = error as { msg?: string };
toast.error(apiError?.msg || t('common.error'));
}
}
// Reset the wizard each time it is opened for a (possibly new) Account.
useEffect(() => {
if (!open) {
return;
}
changedRef.current = false;
setStep('confirm');
setQrDataUrl('');
setCode('');
setRecoveryCodes([]);
setCopiedKey(null);
void startReset();
// Intentionally keyed on `open`/`accountUuid` only: `startReset` mutates state.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, accountUuid]);
function closeDialog() {
if (changedRef.current) {
changedRef.current = false;
onChanged?.();
}
onOpenChange(false);
}
async function handleConfirm() {
if (!code.trim()) {
return;
}
setLoading(true);
try {
const res = await httpClient.adminConfirmTotpEnroll(
accountUuid,
code.trim(),
);
setRecoveryCodes(res.recovery_codes || []);
changedRef.current = true;
setStep('recovery');
toast.success(t('account.totpEnabledSuccess'));
} catch {
toast.error(t('account.totpInvalidCode'));
} finally {
setLoading(false);
}
}
async function copyText(value: string, key: string) {
try {
await navigator.clipboard.writeText(value);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 1500);
} catch {
toast.error(t('common.error'));
}
}
function downloadRecoveryCodes() {
const blob = new Blob(
[
`${t('account.totpRecoveryCodesTitle')} - ${accountUser}\n\n${recoveryCodes.join('\n')}\n`,
],
{ type: 'text/plain' },
);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'langbot-recovery-codes.txt';
link.click();
URL.revokeObjectURL(url);
}
return (
<Dialog
open={open}
onOpenChange={(next) => (next ? onOpenChange(true) : closeDialog())}
>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />
{step === 'confirm'
? t('account.totpAdminResetTitle', { user: accountUser })
: t('account.totpRecoveryCodesTitle')}
</DialogTitle>
<DialogDescription>
{step === 'confirm'
? t('account.totpAdminResetDesc')
: t('account.totpRecoveryCodesDesc')}
</DialogDescription>
</DialogHeader>
{step === 'confirm' && (
<div className="space-y-4">
{!qrDataUrl ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('account.totpGeneratingSecret')}
</div>
) : (
<>
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<p className="text-xs text-muted-foreground">
{t('account.totpAdminResetWarning', { user: accountUser })}
</p>
</div>
<div className="flex justify-center">
<img
src={qrDataUrl}
alt={t('account.totpQrAlt')}
className="h-[200px] w-[200px] rounded-md bg-white p-2"
/>
</div>
<p className="text-xs text-muted-foreground">
{t('account.totpAdminResetHint')}
</p>
<div className="space-y-2">
<Label htmlFor="totp-admin-code">
{t('account.totpEnterCode')}
</Label>
<Input
id="totp-admin-code"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('account.enterCode')}
inputMode="numeric"
spellCheck={false}
autoComplete="off"
className="tracking-widest"
/>
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button
variant="outline"
onClick={() => void startReset()}
disabled={loading}
className="cursor-pointer"
>
<RefreshCw className="mr-2 h-4 w-4" />
{t('account.totpRefreshSecret')}
</Button>
<Button
onClick={handleConfirm}
disabled={loading || !code.trim()}
className="cursor-pointer"
>
{loading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t('account.totpVerifyAndEnable')}
</Button>
</DialogFooter>
</>
)}
</div>
)}
{step === 'recovery' && (
<div className="space-y-4">
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<p className="text-xs text-muted-foreground">
{t('account.totpAdminHandOverCodes', { user: accountUser })}
</p>
</div>
<div className="grid grid-cols-2 gap-2">
{recoveryCodes.map((value) => (
<code
key={value}
className="rounded-md bg-muted px-2 py-1.5 text-center font-mono text-xs"
>
{value}
</code>
))}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => copyText(recoveryCodes.join('\n'), 'all')}
>
{copiedKey === 'all' ? (
<Check className="mr-2 h-3.5 w-3.5" />
) : (
<Copy className="mr-2 h-3.5 w-3.5" />
)}
{t('common.copy')}
</Button>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={downloadRecoveryCodes}
>
<Download className="mr-2 h-3.5 w-3.5" />
{t('common.download')}
</Button>
</div>
<Button
size="sm"
className="cursor-pointer"
onClick={closeDialog}
>
{t('account.totpSavedCodes')}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,406 @@
import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
Loader2,
ShieldCheck,
ShieldOff,
Copy,
Check,
Download,
AlertTriangle,
RefreshCw,
} from 'lucide-react';
/**
* 'enroll' — the Account has no second factor: scan a server-rendered QR code,
* then confirm a code.
* 'manage' — the Account already has one: regenerate codes or disable it.
*
* The mode is chosen by the caller when the dialog opens and is intentionally
* NOT re-derived from live status, otherwise confirming an enrolment would flip
* the dialog straight into the disable view and hide the recovery codes.
*/
export type TotpDialogMode = 'enroll' | 'manage';
type Step = 'confirm' | 'recovery' | 'manage' | 'regenerate' | 'disable';
interface TotpEnrollDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called when a change was made so the panel can refresh its status. */
onChanged?: () => void;
mode: TotpDialogMode;
}
export default function TotpEnrollDialog({
open,
onOpenChange,
onChanged,
mode,
}: TotpEnrollDialogProps) {
const { t } = useTranslation();
const [step, setStep] = useState<Step>('confirm');
const [loading, setLoading] = useState(false);
// Server-rendered PNG data URL; the shared secret never reaches the browser.
const [qrDataUrl, setQrDataUrl] = useState('');
const [code, setCode] = useState('');
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
// Latched per opening so a status refresh cannot re-route an in-flight flow.
const modeRef = useRef<TotpDialogMode>(mode);
const changedRef = useRef(false);
// `rotate: true` is the explicit refresh action. Leaving it false reuses the
// server-side pending enrolment, so React StrictMode's double effect and any
// request retry cannot invalidate the QR code already on screen.
async function startEnroll(rotate = false) {
try {
const res = await httpClient.beginTotpEnroll(rotate);
setQrDataUrl(res.qr_code_data_url);
setCode('');
} catch (error) {
const apiError = error as { msg?: string };
toast.error(apiError?.msg || t('common.error'));
}
}
// Reset the wizard each time it is opened, then act on the latched mode.
useEffect(() => {
if (!open) {
return;
}
modeRef.current = mode;
changedRef.current = false;
setQrDataUrl('');
setCode('');
setRecoveryCodes([]);
setCopiedKey(null);
if (mode === 'manage') {
setStep('manage');
return;
}
setStep('confirm');
void startEnroll();
// Intentionally keyed on `open`/`mode` only: `startEnroll` mutates local state.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, mode]);
function closeDialog() {
// Flush any change once, on the way out, so the panel refreshes.
if (changedRef.current) {
changedRef.current = false;
onChanged?.();
}
onOpenChange(false);
}
async function handleConfirm() {
if (!code.trim()) {
return;
}
setLoading(true);
try {
const res = await httpClient.confirmTotpEnroll(code.trim());
setRecoveryCodes(res.recovery_codes || []);
changedRef.current = true;
// Stay on the recovery step: the codes are shown exactly once.
setStep('recovery');
toast.success(t('account.totpEnabledSuccess'));
} catch {
toast.error(t('account.totpInvalidCode'));
} finally {
setLoading(false);
}
}
async function handleRegenerate() {
if (!code.trim()) {
return;
}
setLoading(true);
try {
const res = await httpClient.regenerateTotpRecoveryCodes(code.trim());
setRecoveryCodes(res.recovery_codes || []);
changedRef.current = true;
setStep('recovery');
toast.success(t('account.totpRecoveryCodesRegenerated'));
} catch {
toast.error(t('account.totpInvalidCode'));
} finally {
setLoading(false);
}
}
async function handleDisable() {
if (!code.trim()) {
return;
}
setLoading(true);
try {
await httpClient.disableTotp(code.trim());
changedRef.current = true;
toast.success(t('account.totpDisabledSuccess'));
closeDialog();
} catch {
toast.error(t('account.totpInvalidCode'));
} finally {
setLoading(false);
}
}
async function copyText(value: string, key: string) {
try {
await navigator.clipboard.writeText(value);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 1500);
} catch {
toast.error(t('common.error'));
}
}
function downloadRecoveryCodes() {
const blob = new Blob(
[
`${t('account.totpRecoveryCodesTitle')}\n\n${recoveryCodes.join('\n')}\n`,
],
{ type: 'text/plain' },
);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'langbot-recovery-codes.txt';
link.click();
URL.revokeObjectURL(url);
}
const titleByStep: Record<Step, string> = {
confirm: t('account.totpEnrollTitle'),
recovery: t('account.totpRecoveryCodesTitle'),
manage: t('account.totpManageTitle'),
regenerate: t('account.totpRegenerateCodes'),
disable: t('account.disableTotp'),
};
return (
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : closeDialog())}>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />
{titleByStep[step]}
</DialogTitle>
<DialogDescription>
{step === 'confirm' && t('account.totpEnrollDesc')}
{step === 'recovery' && t('account.totpRecoveryCodesDesc')}
{step === 'manage' && t('account.totpManageDesc')}
{step === 'regenerate' && t('account.totpRegenerateDesc')}
{step === 'disable' && t('account.disableTotpDesc')}
</DialogDescription>
</DialogHeader>
{step === 'confirm' && (
<div className="space-y-4">
{!qrDataUrl ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('account.totpGeneratingSecret')}
</div>
) : (
<>
<div className="flex justify-center">
<img
src={qrDataUrl}
alt={t('account.totpQrAlt')}
className="h-[200px] w-[200px] rounded-md bg-white p-2"
/>
</div>
<div className="space-y-2">
<Label htmlFor="totp-code">
{t('account.totpEnterCode')}
</Label>
<Input
id="totp-code"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('account.enterCode')}
inputMode="numeric"
spellCheck={false}
autoComplete="off"
className="tracking-widest"
/>
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button
variant="outline"
onClick={() => void startEnroll(true)}
disabled={loading}
className="cursor-pointer"
>
<RefreshCw className="mr-2 h-4 w-4" />
{t('account.totpRefreshSecret')}
</Button>
<Button
onClick={handleConfirm}
disabled={loading || !code.trim()}
className="cursor-pointer"
>
{loading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t('account.totpVerifyAndEnable')}
</Button>
</DialogFooter>
</>
)}
</div>
)}
{step === 'recovery' && (
<div className="space-y-4">
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<p className="text-xs text-muted-foreground">
{t('account.totpRecoveryCodesWarning')}
</p>
</div>
<div className="grid grid-cols-2 gap-2">
{recoveryCodes.map((value) => (
<code
key={value}
className="rounded-md bg-muted px-2 py-1.5 text-center font-mono text-xs"
>
{value}
</code>
))}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => copyText(recoveryCodes.join('\n'), 'all')}
>
{copiedKey === 'all' ? (
<Check className="mr-2 h-3.5 w-3.5" />
) : (
<Copy className="mr-2 h-3.5 w-3.5" />
)}
{t('common.copy')}
</Button>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={downloadRecoveryCodes}
>
<Download className="mr-2 h-3.5 w-3.5" />
{t('common.download')}
</Button>
</div>
<Button
size="sm"
className="cursor-pointer"
onClick={closeDialog}
>
{t('account.totpSavedCodes')}
</Button>
</DialogFooter>
</div>
)}
{step === 'manage' && (
<div className="space-y-3">
<Button
variant="outline"
className="w-full justify-start cursor-pointer"
onClick={() => {
setCode('');
setStep('regenerate');
}}
>
<RefreshCw className="mr-2 h-4 w-4" />
{t('account.totpRegenerateCodes')}
</Button>
<Button
variant="outline"
className="w-full justify-start text-destructive hover:text-destructive cursor-pointer"
onClick={() => {
setCode('');
setStep('disable');
}}
>
<ShieldOff className="mr-2 h-4 w-4" />
{t('account.disableTotp')}
</Button>
<DialogFooter>
<Button
variant="ghost"
className="cursor-pointer"
onClick={closeDialog}
>
{t('common.cancel')}
</Button>
</DialogFooter>
</div>
)}
{(step === 'regenerate' || step === 'disable') && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="totp-manage-code">
{t('account.totpOrRecoveryCode')}
</Label>
<Input
id="totp-manage-code"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('account.enterCode')}
spellCheck={false}
autoComplete="off"
/>
</div>
<DialogFooter>
<Button
variant="outline"
className="cursor-pointer"
onClick={() => setStep('manage')}
>
{t('common.back')}
</Button>
<Button
variant={step === 'disable' ? 'destructive' : 'default'}
onClick={step === 'disable' ? handleDisable : handleRegenerate}
disabled={loading || !code.trim()}
className="cursor-pointer"
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{step === 'disable'
? t('account.disableTotp')
: t('account.totpRegenerateCodes')}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}
+144 -4
View File
@@ -1241,14 +1241,146 @@ export class BackendClient extends BaseHttpClient {
);
}
public authUser(user: string, password: string): Promise<ApiRespUserToken> {
public authUser(
user: string,
password: string,
totpCode?: string,
): Promise<ApiRespUserToken> {
return this.post(
'/api/v1/user/auth',
{ user, password },
{ user, password, totp_code: totpCode },
{ skipWorkspace: true },
);
}
// ============ TOTP second factor (login) ============
public requestTotpChallenge(
user: string,
): Promise<{ challenge_token: string }> {
return this.post(
'/api/v1/user/totp/challenge',
{ user },
{ skipWorkspace: true },
);
}
public verifyTotpLogin(
user: string,
code: string,
challengeToken: string,
): Promise<{ token: string; user: string }> {
// The challenge token proves the password step already ran for this
// Account; without it the backend refuses to mint a session.
return this.post(
'/api/v1/user/totp/verify',
{ user, code, challenge_token: challengeToken },
{ skipWorkspace: true },
);
}
// ============ TOTP second factor (account settings) ============
public getTotpStatus(): Promise<{
enabled: boolean;
pending: boolean;
confirmed_at?: string | null;
last_used_at?: string | null;
recovery_codes_remaining: number;
}> {
return this.get('/api/v1/user/totp/status', undefined, {
skipWorkspace: true,
});
}
public beginTotpEnroll(rotate = false): Promise<{
uuid: string;
// A server-rendered PNG data URL. The shared secret is never returned so it
// cannot be read out of the browser.
qr_code_data_url: string;
algorithm: string;
digits: number;
period: number;
}> {
// rotate=true is the explicit "refresh" action; the default reuses any
// pending enrolment so duplicate calls cannot invalidate the shown QR.
return this.post(
'/api/v1/user/totp/enroll',
{ rotate },
{ skipWorkspace: true },
);
}
public confirmTotpEnroll(code: string): Promise<{ recovery_codes: string[] }> {
return this.post(
'/api/v1/user/totp/enroll/confirm',
{ 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<void> {
return this.post('/api/v1/user/totp/disable', { code }, {
skipWorkspace: true,
});
}
// ============ TOTP oversight (Workspace owner/admin only) ============
public getTotpAccounts(): Promise<{
accounts: Array<{
account_uuid: string;
user: string;
status?: string;
enabled: boolean;
last_used_at?: string | null;
recovery_codes_remaining: number;
}>;
}> {
return this.get('/api/v1/user/totp/accounts');
}
public revokeTotpForAccount(accountUuid: string): Promise<void> {
return this.delete(
`/api/v1/user/totp/accounts/${encodeURIComponent(accountUuid)}`,
);
}
/**
* Force a re-binding of another Account's second factor (owner/admin only).
* Returns a server-rendered QR code; the shared secret is never returned.
*/
public adminBeginTotpEnroll(accountUuid: string): Promise<{
uuid: string;
qr_code_data_url: string;
algorithm: string;
digits: number;
period: number;
}> {
return this.post(
`/api/v1/user/totp/accounts/${encodeURIComponent(accountUuid)}/enroll`,
{},
);
}
/** Activate a forced re-binding; recovery codes are returned exactly once. */
public adminConfirmTotpEnroll(
accountUuid: string,
code: string,
): Promise<{ recovery_codes: string[] }> {
return this.post(
`/api/v1/user/totp/accounts/${encodeURIComponent(accountUuid)}/enroll/confirm`,
{ code },
);
}
public checkUserToken(): Promise<ApiRespUserToken> {
return this.get('/api/v1/user/check-token', undefined, {
skipWorkspace: true,
@@ -1257,15 +1389,23 @@ export class BackendClient extends BaseHttpClient {
public resetPassword(
user: string,
recoveryKey: string,
newPassword: string,
options: {
// 'recovery_key' is the instance-wide key; 'totp' and 'recovery_code'
// consume an Account-scoped second factor instead.
method?: 'recovery_key' | 'totp' | 'recovery_code';
recoveryKey?: string;
totpCode?: string;
} = {},
): Promise<{ user: string }> {
return this.post(
'/api/v1/user/reset-password',
{
user,
recovery_key: recoveryKey,
new_password: newPassword,
method: options.method ?? 'recovery_key',
recovery_key: options.recoveryKey,
totp_code: options.totpCode,
},
{ skipWorkspace: true },
);
+9 -1
View File
@@ -117,12 +117,20 @@ export abstract class BaseHttpClient {
switch (status) {
case 401:
if (typeof window !== 'undefined') {
// Only an existing authenticated session should be torn down and
// redirected. A 401 from a sign-in attempt (bad credentials or a
// pending second factor) must not reload the page, otherwise the
// TOTP challenge step would be lost.
const hadSession = Boolean(localStorage.getItem('token'));
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
clearActiveWorkspaceUuid();
setCurrentWorkspaceSnapshot(null);
clearWorkspaceBootstrapSnapshot();
if (!error.request.responseURL.includes('/check-token')) {
if (
hadSession &&
!error.request.responseURL.includes('/check-token')
) {
window.location.href = '/login';
}
}
+144 -1
View File
@@ -36,6 +36,9 @@ import {
RefreshCw,
Layers,
Fingerprint,
ShieldCheck,
KeyRound,
ArrowLeft,
} from 'lucide-react';
import { startAuthentication } from '@simplewebauthn/browser';
import langbotIcon from '@/app/assets/langbot-logo.webp';
@@ -67,6 +70,14 @@ export default function Login() {
const [showSpaceLogin, setShowSpaceLogin] = useState(false);
const [showPasskeyLogin, setShowPasskeyLogin] = useState(false);
const [passkeyLoading, setPasskeyLoading] = useState(false);
// Second-factor step: primary credentials passed, awaiting a TOTP or recovery code.
const [totpStep, setTotpStep] = useState(false);
const [pendingEmail, setPendingEmail] = useState('');
// Issued alongside `totp_required`; required to complete the second factor.
const [totpChallengeToken, setTotpChallengeToken] = useState('');
const [totpCode, setTotpCode] = useState('');
const [totpLoading, setTotpLoading] = useState(false);
const [useRecoveryCode, setUseRecoveryCode] = useState(false);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [retrying, setRetrying] = useState(false);
@@ -223,11 +234,66 @@ export default function Login() {
toast.success(t('common.loginSuccess'));
}
})
.catch(() => {
.catch((error: { code?: string; msg?: string; data?: { challenge_token?: string } }) => {
// The backend answers `totp_required` when the password was correct but
// a second factor is still outstanding. It also hands back the
// challenge token that must accompany the code.
if (error?.code === 'totp_required') {
setPendingEmail(username);
setTotpChallengeToken(error?.data?.challenge_token || '');
setTotpStep(true);
setUseRecoveryCode(false);
setTotpCode('');
return;
}
if (error?.code === 'totp_invalid_code') {
toast.error(t('common.totpInvalidCode'));
return;
}
toast.error(t('common.loginFailed'));
});
}
async function handleTotpSubmit(event: React.FormEvent) {
event.preventDefault();
const code = totpCode.trim();
if (!code) {
return;
}
if (!totpChallengeToken) {
toast.error(t('common.totpVerifyFailed'));
return;
}
setTotpLoading(true);
try {
// The same endpoint accepts both authenticator codes and recovery codes.
const res = await httpClient.verifyTotpLogin(
pendingEmail,
code,
totpChallengeToken,
);
if (await finishLogin(res.token, res.user || pendingEmail)) {
toast.success(t('common.loginSuccess'));
}
} catch (error) {
const apiError = error as { code?: string };
toast.error(
apiError?.code === 'totp_invalid_code'
? t('common.totpInvalidCode')
: t('common.totpVerifyFailed'),
);
} finally {
setTotpLoading(false);
}
}
function handleBackToPassword() {
setTotpStep(false);
setTotpChallengeToken('');
setTotpCode('');
setUseRecoveryCode(false);
}
const handleSpaceLoginClick = useCallback(async () => {
setSpaceLoading(true);
try {
@@ -336,6 +402,81 @@ export default function Login() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Second-factor challenge: shown once the password has been accepted. */}
{totpStep ? (
<form onSubmit={handleTotpSubmit} className="space-y-4">
<div className="flex items-start gap-2">
<ShieldCheck className="h-5 w-5 mt-0.5 text-primary" />
<div>
<p className="text-sm font-medium">
{t('common.totpChallengeTitle')}
</p>
<p className="text-xs text-muted-foreground">
{useRecoveryCode
? t('common.totpUseRecoveryCode')
: t('common.totpChallengeDesc')}
</p>
</div>
</div>
<div className="relative">
{useRecoveryCode ? (
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
) : (
<ShieldCheck className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
)}
<Input
autoFocus
inputMode={useRecoveryCode ? 'text' : 'numeric'}
autoComplete="one-time-code"
spellCheck={false}
placeholder={
useRecoveryCode
? t('common.enterRecoveryCode')
: t('common.enterTotpCode')
}
className={`pl-10 ${useRecoveryCode ? 'font-mono' : 'tracking-widest'}`}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
/>
</div>
<Button
type="submit"
className="w-full cursor-pointer"
disabled={totpLoading || !totpCode.trim()}
>
{totpLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t('common.verify')}
</Button>
<div className="flex items-center justify-between text-xs">
<button
type="button"
className="text-blue-500 cursor-pointer"
onClick={() => {
setUseRecoveryCode((prev) => !prev);
setTotpCode('');
}}
>
{useRecoveryCode
? t('common.useTotpCode')
: t('common.useRecoveryCode')}
</button>
<button
type="button"
className="flex items-center text-muted-foreground cursor-pointer"
onClick={handleBackToPassword}
>
<ArrowLeft className="mr-1 h-3 w-3" />
{t('common.back')}
</button>
</div>
</form>
) : (
<>
{/* Space and password login are per-account capabilities. */}
{showSpaceLogin && (
<div className="space-y-3">
@@ -487,6 +628,8 @@ export default function Login() {
{t('common.dataCollectionPolicy')}
</a>
</p>
</>
)}
</CardContent>
</Card>
</div>
+165 -32
View File
@@ -22,16 +22,30 @@ import {
import { 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,
LifeBuoy,
} 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';
// The reset flow accepts three second-factor methods:
// * recovery_key — the instance-wide key from data/config.yaml (default);
// * totp — a code from the Account's authenticator app;
// * recovery_code — one of the Account's single-use recovery codes.
type ResetMethod = 'recovery_key' | 'totp' | 'recovery_code';
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(),
newPassword: z.string().min(1, t('resetPassword.newPasswordRequired')),
});
@@ -39,40 +53,92 @@ export default function ResetPassword() {
const navigate = useNavigate();
const { t } = useTranslation();
const [isResetting, setIsResetting] = useState(false);
const [method, setMethod] = useState<ResetMethod>('recovery_key');
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
resolver: zodResolver(formSchema(t)),
defaultValues: {
email: '',
recoveryKey: '',
totpCode: '',
newPassword: '',
},
});
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
handleResetPassword(values.email, values.recoveryKey, values.newPassword);
// Validate the second factor for the selected method before calling out.
if (method === 'recovery_key' && !values.recoveryKey?.trim()) {
form.setError('recoveryKey', {
message: t('resetPassword.recoveryKeyRequired'),
});
return;
}
if (method !== 'recovery_key' && !values.totpCode?.trim()) {
form.setError('totpCode', {
message:
method === 'totp'
? t('resetPassword.totpCodeRequired')
: t('resetPassword.recoveryCodeRequired'),
});
return;
}
handleResetPassword(
values.email,
values.newPassword,
method,
values.recoveryKey,
values.totpCode,
);
}
function handleResetPassword(
email: string,
recoveryKey: string,
newPassword: string,
selectedMethod: ResetMethod,
recoveryKey?: string,
totpCode?: string,
) {
setIsResetting(true);
httpClient
.resetPassword(email, recoveryKey, newPassword)
.resetPassword(email, newPassword, {
method: selectedMethod,
recoveryKey,
totpCode,
})
.then(() => {
toast.success(t('resetPassword.resetSuccess'));
navigate('/login');
})
.catch(() => {
toast.error(t('resetPassword.resetFailed'));
toast.error(
selectedMethod === 'recovery_key'
? t('resetPassword.resetFailed')
: t('resetPassword.secondFactorFailed'),
);
})
.finally(() => {
setIsResetting(false);
});
}
const methodButton = (value: ResetMethod, label: string, Icon: typeof KeyRound) => (
<button
type="button"
onClick={() => {
setMethod(value);
form.clearErrors();
}}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md border px-2 py-1.5 text-xs transition-colors cursor-pointer ${
method === value
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-border text-muted-foreground hover:bg-muted'
}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
<Card className="w-[375px] shadow-lg dark:shadow-white/10">
@@ -118,32 +184,99 @@ 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>
)}
/>
{/* Second-factor method selector */}
<div className="space-y-2">
<FormLabel>{t('resetPassword.verifyWith')}</FormLabel>
<div className="flex gap-2">
{methodButton(
'recovery_key',
t('resetPassword.methodRecoveryKey'),
KeyRound,
)}
{methodButton(
'totp',
t('resetPassword.methodTotp'),
ShieldCheck,
)}
{methodButton(
'recovery_code',
t('resetPassword.methodRecoveryCode'),
LifeBuoy,
)}
</div>
</div>
{method === 'recovery_key' ? (
<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>
)}
/>
) : (
<FormField
control={form.control}
name="totpCode"
render={({ field }) => (
<FormItem>
<FormLabel>
{method === 'totp'
? t('resetPassword.totpCode')
: t('resetPassword.recoveryCode')}
</FormLabel>
<FormDescription>
{method === 'totp'
? t('resetPassword.totpCodeDescription')
: t('resetPassword.recoveryCodeDescription')}
</FormDescription>
<FormControl>
<div className="relative">
{method === 'totp' ? (
<ShieldCheck className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
) : (
<LifeBuoy className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
)}
<Input
inputMode={method === 'totp' ? 'numeric' : 'text'}
placeholder={
method === 'totp'
? t('resetPassword.enterTotpCode')
: t('resetPassword.enterRecoveryCodeValue')
}
className={`pl-10 ${
method === 'totp' ? 'tracking-widest' : 'font-mono'
}`}
autoComplete="off"
spellCheck={false}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
+83
View File
@@ -68,6 +68,7 @@ const enUS = {
deleteError: 'Delete failed: ',
addRound: 'Add Round',
copy: 'Copy',
download: 'Download',
copySuccess: 'Copy Successfully',
copyFailed: 'Copy Failed',
test: 'Test',
@@ -91,6 +92,19 @@ 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',
verify: 'Verify',
back: 'Back',
totpChallengeTitle: 'Two-factor verification',
totpChallengeDesc:
'Enter the 6-digit code from your authenticator app to continue',
totpUseRecoveryCode:
'Enter one of your single-use recovery codes to continue',
enterTotpCode: 'Enter 6-digit code',
enterRecoveryCode: 'Enter recovery code',
useRecoveryCode: 'Use a recovery code',
useTotpCode: 'Use an authenticator code',
totpInvalidCode: 'Invalid or already-used code, please try again',
totpVerifyFailed: 'Two-factor verification failed, please try again',
spaceLoginTitle: 'Login with LangBot Account',
spaceLoginDescription:
'Scan the QR code or visit the link below to authorize',
@@ -1303,7 +1317,23 @@ const enUS = {
resetSuccess: 'Password reset successfully, please login',
resetFailed:
'Password reset failed, please check your email and recovery key',
secondFactorFailed:
'Verification failed, please check your code and try again',
backToLogin: 'Back to Login',
verifyWith: 'Verify with',
methodRecoveryKey: 'Recovery Key',
methodTotp: 'Authenticator',
methodRecoveryCode: 'Recovery Code',
totpCode: 'Authenticator Code',
totpCodeDescription:
'Enter the 6-digit code shown in your authenticator app',
totpCodeRequired: 'Authenticator code cannot be empty',
enterTotpCode: 'Enter 6-digit code',
recoveryCode: 'Recovery Code',
recoveryCodeDescription:
'Enter one of the single-use recovery codes you saved when enabling two-factor authentication',
recoveryCodeRequired: 'Recovery code cannot be empty',
enterRecoveryCodeValue: 'Enter recovery code',
},
embedding: {
description: 'Manage Embedding models for text vectorization',
@@ -1363,6 +1393,59 @@ const enUS = {
passkeyAddedSuccess: 'Passkey added successfully',
passkeyDeleteSuccess: 'Passkey deleted',
passkeyRenameSuccess: 'Passkey renamed successfully',
totpSectionTitle: 'Two-Factor Authentication',
totpSectionDesc:
'Add a time-based one-time password as a second sign-in factor',
totpEnabledDesc:
'Two-factor authentication is enabled · {{count}} recovery codes remaining',
enableTotp: 'Enable',
manageTotp: 'Manage',
totpEnrollTitle: 'Enable Two-Factor Authentication',
totpEnrollDesc:
'Scan the QR code with your authenticator app, then confirm the generated code',
totpStartEnroll: 'Generate Secret',
totpGeneratingSecret: 'Generating a new secret...',
totpManageTitle: 'Two-factor authentication',
totpManageDesc: 'Regenerate your recovery codes or turn the second factor off.',
totpRegenerateCodes: 'Regenerate recovery codes',
totpRegenerateDesc: 'Enter a current authenticator or recovery code to issue a fresh set of codes.',
totpRecoveryCodesRegenerated: 'New recovery codes generated',
totpStatusDisabled: 'Not enabled',
totpCodesRemaining: '{{count}} recovery codes left',
totpManagerSectionDesc:
'Owners and admins can review and revoke the second factor of any Account.',
revokeTotp: 'Re-bind',
totpAdminResetTitle: 'Re-bind two-factor authentication for {{user}}',
totpAdminResetDesc: 'Have the Account scan the QR code with their authenticator, then enter the 6-digit code below to finish binding.',
totpAdminResetWarning: 'Once re-binding starts, {{user}} existing authenticator stops working immediately.',
totpAdminResetHint: 'If the Account cannot sign in right now, they can scan this QR code in any authenticator app.',
totpAdminHandOverCodes: 'Hand these recovery codes over to {{user}}. They are shown only once.',
revokeTotpConfirm: 'Turn off two-factor authentication for {{user}}? They will be able to sign in with only a password afterwards.',
revokeTotpSuccess: 'Two-factor authentication revoked',
you: 'you',
noAccounts: 'No accounts to show',
totpRefreshSecret: 'Refresh QR code',
totpQrAlt: 'Two-factor authentication QR code',
totpEnterCode: 'Verification code',
enterCode: 'Enter code',
totpVerifyAndEnable: 'Verify and Enable',
totpOrRecoveryCode: 'Authenticator or recovery code',
totpStatusEnabled: 'Two-factor authentication enabled',
totpLastUsed: 'Last verified: {{date}}',
totpNeverUsed: 'Not used yet',
disableTotp: 'Disable Two-Factor Authentication',
disableTotpDesc:
'Enter a current authenticator code or a recovery code to turn off two-factor authentication',
totpEnabledSuccess: 'Two-factor authentication enabled',
totpDisabledSuccess: 'Two-factor authentication disabled',
totpInvalidCode: 'Invalid code, please check and try again',
totpRecoveryCodesTitle: 'Recovery codes',
totpRecoveryCodesDesc:
'Save these single-use recovery codes in a safe place. They are shown only once.',
totpRecoveryCodesWarning:
'Each code works once. If you lose your authenticator and these codes, you will lose access to sign-in.',
totpSavedCodes: 'I have saved these codes',
regenerateRecoveryCodes: 'Regenerate recovery codes',
bindSpaceFailed: 'Failed to bind LangBot Account',
bindSpaceInvalidState:
'Invalid bind request. Please try again from account settings.',
+71
View File
@@ -71,6 +71,7 @@ const esES = {
deleteError: 'Error al eliminar: ',
addRound: 'Añadir ronda',
copy: 'Copiar',
download: 'Descargar',
copySuccess: 'Copiado correctamente',
copyFailed: 'Error al copiar',
test: 'Probar',
@@ -95,6 +96,17 @@ const esES = {
passkeyLoginFailed: 'Error al iniciar sesión con Passkey',
passkeyNotSupported:
'Passkey no es compatible en este navegador o dispositivo',
verify: 'Verificar',
back: 'Atrás',
totpChallengeTitle: 'Verificación en dos pasos',
totpChallengeDesc: 'Introduce el código de 6 dígitos de tu aplicación de autenticación para continuar',
totpUseRecoveryCode: 'Introduce uno de tus códigos de recuperación de un solo uso para continuar',
enterTotpCode: 'Introduce el código de 6 dígitos',
enterRecoveryCode: 'Introduce el código de recuperación',
useRecoveryCode: 'Usar un código de recuperación',
useTotpCode: 'Usar un código de autenticación',
totpInvalidCode: 'Código no válido o ya usado, inténtalo de nuevo',
totpVerifyFailed: 'La verificación en dos pasos falló, inténtalo de nuevo',
spaceLoginTitle: 'Iniciar sesión con una cuenta de LangBot',
spaceLoginDescription:
'Escanea el código QR o visita el enlace para autorizar',
@@ -1341,6 +1353,19 @@ const esES = {
resetFailed:
'Error al restablecer la contraseña, por favor verifica tu correo y clave de recuperación',
backToLogin: 'Volver al inicio de sesión',
secondFactorFailed: 'La verificación falló, comprueba el código e inténtalo de nuevo',
verifyWith: 'Verificar con',
methodRecoveryKey: 'Clave de recuperación',
methodTotp: 'Autenticador',
methodRecoveryCode: 'Código de recuperación',
totpCode: 'Código del autenticador',
totpCodeDescription: 'Introduce el código de 6 dígitos que muestra tu aplicación de autenticación',
totpCodeRequired: 'El código del autenticador no puede estar vacío',
enterTotpCode: 'Introduce el código de 6 dígitos',
recoveryCode: 'Código de recuperación',
recoveryCodeDescription: 'Introduce uno de los códigos de recuperación de un solo uso que guardaste al activar la verificación en dos pasos',
recoveryCodeRequired: 'El código de recuperación no puede estar vacío',
enterRecoveryCodeValue: 'Introduce el código de recuperación',
},
embedding: {
description: 'Gestionar modelos Embedding para la vectorización de texto',
@@ -1401,6 +1426,52 @@ const esES = {
passkeyAddedSuccess: 'Llave de acceso añadida con éxito',
passkeyDeleteSuccess: 'Llave de acceso eliminada',
passkeyRenameSuccess: 'Nombre de llave de acceso modificado con éxito',
totpSectionTitle: 'Verificación en dos pasos',
totpSectionDesc: 'Añade una contraseña de un solo uso basada en tiempo como segundo factor de inicio de sesión',
totpEnabledDesc: 'Verificación en dos pasos activada · quedan {{count}} códigos de recuperación',
enableTotp: 'Activar',
manageTotp: 'Gestionar',
totpEnrollTitle: 'Activar la verificación en dos pasos',
totpEnrollDesc: 'Escanea el código QR con tu aplicación de autenticación y confirma el código generado',
totpStartEnroll: 'Generar secreto',
totpGeneratingSecret: 'Generando un nuevo secreto…',
totpManageTitle: 'Verificación en dos pasos',
totpManageDesc: 'Regenera tus códigos de recuperación o desactiva el segundo factor.',
totpRegenerateCodes: 'Regenerar códigos de recuperación',
totpRegenerateDesc: 'Introduce un código actual del autenticador o de recuperación para emitir un conjunto nuevo.',
totpRecoveryCodesRegenerated: 'Nuevos códigos de recuperación generados',
totpStatusDisabled: 'No activada',
totpCodesRemaining: 'Quedan {{count}} códigos de recuperación',
totpManagerSectionDesc: 'Los propietarios y administradores pueden revisar y restablecer el segundo factor de cualquier cuenta.',
revokeTotp: 'Reasignar',
totpAdminResetTitle: 'Reasignar la verificación en dos pasos de {{user}}',
totpAdminResetDesc: 'Pide a la cuenta que escanee el código QR con su autenticador y que introduzca abajo el código de 6 dígitos para finalizar.',
totpAdminResetWarning: 'Al iniciar la reasignación, el autenticador actual de {{user}} deja de funcionar de inmediato.',
totpAdminResetHint: 'Si la cuenta no puede iniciar sesión ahora, puede escanear este código QR en cualquier aplicación de autenticación.',
totpAdminHandOverCodes: 'Entrega estos códigos de recuperación a {{user}}. Solo se muestran una vez.',
revokeTotpConfirm: '¿Desactivar la verificación en dos pasos de {{user}}? Después podrá iniciar sesión solo con la contraseña.',
revokeTotpSuccess: 'Verificación en dos pasos restablecida',
you: 'tú',
noAccounts: 'No hay cuentas que mostrar',
totpRefreshSecret: 'Regenerar código QR',
totpQrAlt: 'Código QR de verificación en dos pasos',
totpEnterCode: 'Código de verificación',
enterCode: 'Introduce el código',
totpVerifyAndEnable: 'Verificar y activar',
totpOrRecoveryCode: 'Código del autenticador o de recuperación',
totpStatusEnabled: 'Verificación en dos pasos activada',
totpLastUsed: 'Última verificación: {{date}}',
totpNeverUsed: 'Aún no usado',
disableTotp: 'Desactivar la verificación en dos pasos',
disableTotpDesc: 'Introduce un código actual del autenticador o un código de recuperación para desactivar la verificación en dos pasos',
totpEnabledSuccess: 'Verificación en dos pasos activada',
totpDisabledSuccess: 'Verificación en dos pasos desactivada',
totpInvalidCode: 'Código no válido, compruébalo e inténtalo de nuevo',
totpRecoveryCodesTitle: 'Códigos de recuperación',
totpRecoveryCodesDesc: 'Guarda estos códigos de recuperación de un solo uso en un lugar seguro. Solo se muestran una vez.',
totpRecoveryCodesWarning: 'Cada código funciona una sola vez. Si pierdes el autenticador y estos códigos, perderás el acceso al inicio de sesión.',
totpSavedCodes: 'He guardado estos códigos',
regenerateRecoveryCodes: 'Regenerar códigos de recuperación',
bindSpaceFailed: 'Error al vincular la cuenta de LangBot',
bindSpaceInvalidState:
'Solicitud de vinculación no válida. Por favor, inténtalo de nuevo desde la configuración de la cuenta.',
+71
View File
@@ -69,6 +69,7 @@ const jaJP = {
deleteError: '削除に失敗しました:',
addRound: 'ラウンドを追加',
copy: 'コピー',
download: 'ダウンロード',
copySuccess: 'コピーに成功しました',
copyFailed: 'コピーに失敗しました',
test: 'テスト',
@@ -93,6 +94,17 @@ const jaJP = {
passkeyLoginFailed: 'パスキーでのログインに失敗しました',
passkeyNotSupported:
'お使いのブラウザまたはデバイスはパスキーをサポートしていません',
verify: '確認',
back: '戻る',
totpChallengeTitle: '二段階認証',
totpChallengeDesc: '認証アプリに表示される6桁のコードを入力してください',
totpUseRecoveryCode: '一度だけ使用できるリカバリーコードを入力してください',
enterTotpCode: '6桁のコードを入力',
enterRecoveryCode: 'リカバリーコードを入力',
useRecoveryCode: 'リカバリーコードを使用',
useTotpCode: '認証アプリのコードを使用',
totpInvalidCode: 'コードが無効か使用済みです。もう一度お試しください',
totpVerifyFailed: '二段階認証に失敗しました。もう一度お試しください',
spaceLoginTitle: 'LangBot アカウントでログイン',
spaceLoginDescription:
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
@@ -1311,6 +1323,19 @@ const jaJP = {
resetFailed:
'パスワードのリセットに失敗しました。メールアドレスと復旧キーを確認してください',
backToLogin: 'ログインに戻る',
secondFactorFailed: '確認に失敗しました。コードを確認して再試行してください',
verifyWith: '確認方法',
methodRecoveryKey: 'リカバリーキー',
methodTotp: '認証アプリ',
methodRecoveryCode: 'リカバリーコード',
totpCode: '認証コード',
totpCodeDescription: '認証アプリに表示される6桁のコードを入力してください',
totpCodeRequired: '認証コードを入力してください',
enterTotpCode: '6桁のコードを入力',
recoveryCode: 'リカバリーコード',
recoveryCodeDescription: '二段階認証を有効にしたときに保存した一度限りのリカバリーコードを入力してください',
recoveryCodeRequired: 'リカバリーコードを入力してください',
enterRecoveryCodeValue: 'リカバリーコードを入力',
},
embedding: {
description: 'テキストのベクトル化に使用する埋め込みモデルを管理します',
@@ -1370,6 +1395,52 @@ const jaJP = {
passkeyAddedSuccess: 'パスキーが正常に追加されました',
passkeyDeleteSuccess: 'パスキーを削除しました',
passkeyRenameSuccess: 'パスキー名を変更しました',
totpSectionTitle: '二段階認証',
totpSectionDesc: 'ログインの第二要素として時間ベースのワンタイムパスワードを追加します',
totpEnabledDesc: '二段階認証は有効です · 残りリカバリーコード {{count}} 個',
enableTotp: '有効化',
manageTotp: '管理',
totpEnrollTitle: '二段階認証を有効にする',
totpEnrollDesc: '認証アプリでQRコードをスキャンし、生成されたコードを入力して確認します',
totpStartEnroll: 'シークレットを生成',
totpGeneratingSecret: '新しいシークレットを生成しています…',
totpManageTitle: '二段階認証',
totpManageDesc: 'リカバリーコードを再生成するか、二段階認証を無効にできます。',
totpRegenerateCodes: 'リカバリーコードを再生成',
totpRegenerateDesc: '現在の認証コードまたはリカバリーコードを入力すると、新しいコードを発行します。',
totpRecoveryCodesRegenerated: '新しいリカバリーコードを生成しました',
totpStatusDisabled: '未設定',
totpCodesRemaining: 'リカバリーコード残り {{count}} 個',
totpManagerSectionDesc: 'オーナーと管理者はすべてのアカウントの二段階認証を確認・解除できます。',
revokeTotp: '再バインド',
totpAdminResetTitle: '{{user}} の二段階認証を再バインド',
totpAdminResetDesc: '対象アカウントに認証アプリでQRコードを読み取ってもらい、表示される6桁のコードを下に入力して完了します。',
totpAdminResetWarning: '再バインドを開始すると、{{user}} の既存の認証アプリは直ちに無効になります。',
totpAdminResetHint: '対象アカウントが今ログインできない場合、任意の認証アプリでこのQRコードを読み取ってもらえます。',
totpAdminHandOverCodes: 'これらのリカバリーコードを {{user}} に渡してください。表示は一度だけです。',
revokeTotpConfirm: '{{user}} の二段階認証を無効にしますか?以降はパスワードのみでログインできます。',
revokeTotpSuccess: '二段階認証を解除しました',
you: '自分',
noAccounts: '表示するアカウントがありません',
totpRefreshSecret: 'QRコードを再生成',
totpQrAlt: '二段階認証のQRコード',
totpEnterCode: '確認コード',
enterCode: 'コードを入力',
totpVerifyAndEnable: '確認して有効化',
totpOrRecoveryCode: '認証アプリまたはリカバリーコード',
totpStatusEnabled: '二段階認証が有効です',
totpLastUsed: '最終確認: {{date}}',
totpNeverUsed: '未使用',
disableTotp: '二段階認証を無効にする',
disableTotpDesc: '現在の認証コードまたはリカバリーコードを入力して、二段階認証を無効にします',
totpEnabledSuccess: '二段階認証を有効にしました',
totpDisabledSuccess: '二段階認証を無効にしました',
totpInvalidCode: 'コードが無効です。確認してもう一度お試しください',
totpRecoveryCodesTitle: 'リカバリーコード',
totpRecoveryCodesDesc: 'これらの一度限りのリカバリーコードを安全な場所に保存してください。表示は一度だけです。',
totpRecoveryCodesWarning: '各コードは一度だけ使用できます。認証アプリとこれらのコードを失うと、ログインできなくなります。',
totpSavedCodes: 'コードを保存しました',
regenerateRecoveryCodes: 'リカバリーコードを再生成',
bindSpaceFailed: 'LangBot アカウントの連携に失敗しました',
bindSpaceInvalidState:
'無効な連携リクエストです。アカウント設定から再度お試しください。',
+71
View File
@@ -69,6 +69,7 @@ const ruRU = {
deleteError: 'Ошибка удаления: ',
addRound: 'Добавить раунд',
copy: 'Копировать',
download: 'Скачать',
copySuccess: 'Скопировано',
copyFailed: 'Ошибка копирования',
test: 'Тест',
@@ -92,6 +93,17 @@ const ruRU = {
passkeyLoginFailed: 'Не удалось войти с помощью Passkey',
passkeyNotSupported:
'Passkey не поддерживается в этом браузере или на устройстве',
verify: 'Подтвердить',
back: 'Назад',
totpChallengeTitle: 'Двухфакторная проверка',
totpChallengeDesc: 'Введите 6-значный код из приложения-аутентификатора, чтобы продолжить',
totpUseRecoveryCode: 'Введите один из одноразовых кодов восстановления, чтобы продолжить',
enterTotpCode: 'Введите 6-значный код',
enterRecoveryCode: 'Введите код восстановления',
useRecoveryCode: 'Использовать код восстановления',
useTotpCode: 'Использовать код аутентификатора',
totpInvalidCode: 'Код неверный или уже использован, попробуйте снова',
totpVerifyFailed: 'Двухфакторная проверка не удалась, попробуйте снова',
spaceLoginTitle: 'Войти с аккаунтом LangBot',
spaceLoginDescription:
'Отсканируйте QR-код или перейдите по ссылке ниже для авторизации',
@@ -1315,6 +1327,19 @@ const ruRU = {
resetSuccess: 'Пароль успешно сброшен, пожалуйста, войдите',
resetFailed: 'Ошибка сброса пароля, проверьте email и ключ восстановления',
backToLogin: 'Вернуться к входу',
secondFactorFailed: 'Проверка не удалась, проверьте код и попробуйте снова',
verifyWith: 'Способ проверки',
methodRecoveryKey: 'Ключ восстановления',
methodTotp: 'Аутентификатор',
methodRecoveryCode: 'Код восстановления',
totpCode: 'Код аутентификатора',
totpCodeDescription: 'Введите 6-значный код из приложения-аутентификатора',
totpCodeRequired: 'Код аутентификатора не может быть пустым',
enterTotpCode: 'Введите 6-значный код',
recoveryCode: 'Код восстановления',
recoveryCodeDescription: 'Введите один из одноразовых кодов восстановления, сохранённых при включении двухфакторной аутентификации',
recoveryCodeRequired: 'Код восстановления не может быть пустым',
enterRecoveryCodeValue: 'Введите код восстановления',
},
embedding: {
description: 'Управление моделями Embedding для векторизации текста',
@@ -1375,6 +1400,52 @@ const ruRU = {
passkeyAddedSuccess: 'Ключ доступа успешно добавлен',
passkeyDeleteSuccess: 'Ключ доступа удален',
passkeyRenameSuccess: 'Ключ доступа успешно переименован',
totpSectionTitle: 'Двухфакторная аутентификация',
totpSectionDesc: 'Добавьте одноразовый пароль на основе времени как второй фактор входа',
totpEnabledDesc: 'Двухфакторная аутентификация включена · осталось кодов восстановления: {{count}}',
enableTotp: 'Включить',
manageTotp: 'Управление',
totpEnrollTitle: 'Включить двухфакторную аутентификацию',
totpEnrollDesc: 'Отсканируйте QR-код в приложении-аутентификаторе и подтвердите сгенерированный код',
totpStartEnroll: 'Создать секрет',
totpGeneratingSecret: 'Создание нового секрета…',
totpManageTitle: 'Двухфакторная аутентификация',
totpManageDesc: 'Перегенерируйте коды восстановления или отключите второй фактор.',
totpRegenerateCodes: 'Перегенерировать коды восстановления',
totpRegenerateDesc: 'Введите текущий код аутентификатора или код восстановления, чтобы получить новый набор.',
totpRecoveryCodesRegenerated: 'Новые коды восстановления созданы',
totpStatusDisabled: 'Не включено',
totpCodesRemaining: 'Осталось кодов восстановления: {{count}}',
totpManagerSectionDesc: 'Владельцы и администраторы могут просматривать и сбрасывать второй фактор любого аккаунта.',
revokeTotp: 'Перепривязать',
totpAdminResetTitle: 'Перепривязать двухфакторную аутентификацию для {{user}}',
totpAdminResetDesc: 'Попросите аккаунт отсканировать QR-код в приложении-аутентификаторе и ввести ниже 6-значный код для завершения.',
totpAdminResetWarning: 'После начала перепривязки текущий аутентификатор {{user}} сразу перестанет работать.',
totpAdminResetHint: 'Если аккаунт сейчас не может войти, он может отсканировать этот QR-код в любом приложении-аутентификаторе.',
totpAdminHandOverCodes: 'Передайте эти коды восстановления {{user}}. Они показываются только один раз.',
revokeTotpConfirm: 'Отключить двухфакторную аутентификацию для {{user}}? После этого вход будет возможен только по паролю.',
revokeTotpSuccess: 'Двухфакторная аутентификация сброшена',
you: 'вы',
noAccounts: 'Нет аккаунтов для отображения',
totpRefreshSecret: 'Обновить QR-код',
totpQrAlt: 'QR-код двухфакторной аутентификации',
totpEnterCode: 'Код подтверждения',
enterCode: 'Введите код',
totpVerifyAndEnable: 'Подтвердить и включить',
totpOrRecoveryCode: 'Код аутентификатора или восстановления',
totpStatusEnabled: 'Двухфакторная аутентификация включена',
totpLastUsed: 'Последняя проверка: {{date}}',
totpNeverUsed: 'Ещё не использовалось',
disableTotp: 'Отключить двухфакторную аутентификацию',
disableTotpDesc: 'Введите текущий код аутентификатора или код восстановления, чтобы отключить двухфакторную аутентификацию',
totpEnabledSuccess: 'Двухфакторная аутентификация включена',
totpDisabledSuccess: 'Двухфакторная аутентификация отключена',
totpInvalidCode: 'Неверный код, проверьте и попробуйте снова',
totpRecoveryCodesTitle: 'Коды восстановления',
totpRecoveryCodesDesc: 'Сохраните эти одноразовые коды восстановления в надёжном месте. Они показываются только один раз.',
totpRecoveryCodesWarning: 'Каждый код работает один раз. Если вы потеряете аутентификатор и эти коды, вы потеряете доступ к входу.',
totpSavedCodes: 'Я сохранил эти коды',
regenerateRecoveryCodes: 'Перегенерировать коды восстановления',
bindSpaceFailed: 'Не удалось привязать аккаунт LangBot',
bindSpaceInvalidState:
'Недействительный запрос привязки. Повторите попытку из настроек аккаунта.',
+71
View File
@@ -68,6 +68,7 @@ const thTH = {
deleteError: 'ลบล้มเหลว: ',
addRound: 'เพิ่มรอบ',
copy: 'คัดลอก',
download: 'ดาวน์โหลด',
copySuccess: 'คัดลอกสำเร็จ',
copyFailed: 'คัดลอกล้มเหลว',
test: 'ทดสอบ',
@@ -91,6 +92,17 @@ const thTH = {
passkeyLoginSuccess: 'ยืนยัน Passkey สำเร็จ กำลังเข้าสู่ระบบ...',
passkeyLoginFailed: 'เข้าสู่ระบบด้วย Passkey ล้มเหลว',
passkeyNotSupported: 'เบราว์เซอร์หรืออุปกรณ์นี้ไม่รองรับ Passkey',
verify: 'ยืนยัน',
back: 'ย้อนกลับ',
totpChallengeTitle: 'การยืนยันสองขั้นตอน',
totpChallengeDesc: 'ป้อนรหัส 6 หลักจากแอปยืนยันตัวตนเพื่อดำเนินการต่อ',
totpUseRecoveryCode: 'ป้อนรหัสกู้คืนแบบใช้ครั้งเดียวเพื่อดำเนินการต่อ',
enterTotpCode: 'ป้อนรหัส 6 หลัก',
enterRecoveryCode: 'ป้อนรหัสกู้คืน',
useRecoveryCode: 'ใช้รหัสกู้คืน',
useTotpCode: 'ใช้รหัสจากแอปยืนยันตัวตน',
totpInvalidCode: 'รหัสไม่ถูกต้องหรือถูกใช้แล้ว กรุณาลองใหม่',
totpVerifyFailed: 'การยืนยันสองขั้นตอนล้มเหลว กรุณาลองใหม่',
spaceLoginTitle: 'เข้าสู่ระบบด้วยบัญชี LangBot',
spaceLoginDescription:
'สแกน QR code หรือเข้าชมลิงก์ด้านล่างเพื่อยืนยันสิทธิ์',
@@ -1286,6 +1298,19 @@ const thTH = {
resetSuccess: 'รีเซ็ตรหัสผ่านสำเร็จ กรุณาเข้าสู่ระบบ',
resetFailed: 'รีเซ็ตรหัสผ่านล้มเหลว กรุณาตรวจสอบอีเมลและคีย์กู้คืน',
backToLogin: 'กลับไปหน้าเข้าสู่ระบบ',
secondFactorFailed: 'การยืนยันล้มเหลว กรุณาตรวจสอบรหัสแล้วลองใหม่',
verifyWith: 'ยืนยันด้วย',
methodRecoveryKey: 'คีย์กู้คืน',
methodTotp: 'แอปยืนยันตัวตน',
methodRecoveryCode: 'รหัสกู้คืน',
totpCode: 'รหัสจากแอปยืนยันตัวตน',
totpCodeDescription: 'ป้อนรหัส 6 หลักที่แสดงในแอปยืนยันตัวตน',
totpCodeRequired: 'รหัสจากแอปยืนยันตัวตนต้องไม่ว่าง',
enterTotpCode: 'ป้อนรหัส 6 หลัก',
recoveryCode: 'รหัสกู้คืน',
recoveryCodeDescription: 'ป้อนรหัสกู้คืนแบบใช้ครั้งเดียวที่บันทึกไว้เมื่อเปิดใช้การยืนยันสองขั้นตอน',
recoveryCodeRequired: 'รหัสกู้คืนต้องไม่ว่าง',
enterRecoveryCodeValue: 'ป้อนรหัสกู้คืน',
},
embedding: {
description: 'จัดการโมเดล Embedding สำหรับการแปลงข้อความเป็นเวกเตอร์',
@@ -1345,6 +1370,52 @@ const thTH = {
passkeyAddedSuccess: 'เพิ่มพาสคีย์สำเร็จ',
passkeyDeleteSuccess: 'ลบพาสคีย์แล้ว',
passkeyRenameSuccess: 'เปลี่ยนชื่อพาสคีย์สำเร็จ',
totpSectionTitle: 'การยืนยันสองขั้นตอน',
totpSectionDesc: 'เพิ่มรหัสผ่านใช้ครั้งเดียวตามเวลาเป็นปัจจัยที่สองในการเข้าสู่ระบบ',
totpEnabledDesc: 'เปิดใช้การยืนยันสองขั้นตอนแล้ว · เหลือรหัสกู้คืน {{count}} รหัส',
enableTotp: 'เปิดใช้',
manageTotp: 'จัดการ',
totpEnrollTitle: 'เปิดใช้การยืนยันสองขั้นตอน',
totpEnrollDesc: 'สแกนคิวอาร์โค้ดด้วยแอปยืนยันตัวตน แล้วยืนยันรหัสที่สร้างขึ้น',
totpStartEnroll: 'สร้างรหัสลับ',
totpGeneratingSecret: 'กำลังสร้างรหัสลับใหม่…',
totpManageTitle: 'การยืนยันสองขั้นตอน',
totpManageDesc: 'สร้างรหัสกู้คืนใหม่ หรือปิดการยืนยันสองขั้นตอน',
totpRegenerateCodes: 'สร้างรหัสกู้คืนใหม่',
totpRegenerateDesc: 'ป้อนรหัสจากแอปหรือรหัสกู้คืนปัจจุบันเพื่อออกชุดรหัสใหม่',
totpRecoveryCodesRegenerated: 'สร้างรหัสกู้คืนใหม่แล้ว',
totpStatusDisabled: 'ยังไม่เปิดใช้',
totpCodesRemaining: 'เหลือรหัสกู้คืน {{count}} รหัส',
totpManagerSectionDesc: 'เจ้าของและผู้ดูแลสามารถตรวจสอบและรีเซ็ตการยืนยันสองขั้นตอนของบัญชีใดก็ได้',
revokeTotp: 'ผูกใหม่',
totpAdminResetTitle: 'ผูกการยืนยันสองขั้นตอนใหม่ให้ {{user}}',
totpAdminResetDesc: 'ให้บัญชีนั้นสแกนคิวอาร์โค้ดด้วยแอปยืนยันตัวตน แล้วกรอกรหัส 6 หลักด้านล่างเพื่อเสร็จสิ้นการผูก',
totpAdminResetWarning: 'เมื่อเริ่มผูกใหม่ แอปยืนยันตัวตนเดิมของ {{user}} จะใช้งานไม่ได้ทันที',
totpAdminResetHint: 'หากบัญชีนั้นยังเข้าสู่ระบบไม่ได้ในตอนนี้ สามารถสแกนคิวอาร์โค้ดนี้ในแอปยืนยันตัวตนใดก็ได้',
totpAdminHandOverCodes: 'ส่งรหัสกู้คืนเหล่านี้ให้ {{user}} โดยจะแสดงเพียงครั้งเดียว',
revokeTotpConfirm: 'ปิดการยืนยันสองขั้นตอนของ {{user}} หรือไม่ หลังจากนั้นจะเข้าสู่ระบบด้วยรหัสผ่านเท่านั้น',
revokeTotpSuccess: 'รีเซ็ตการยืนยันสองขั้นตอนแล้ว',
you: 'คุณ',
noAccounts: 'ไม่มีบัญชีที่จะแสดง',
totpRefreshSecret: 'สร้างคิวอาร์โค้ดใหม่',
totpQrAlt: 'คิวอาร์โค้ดการยืนยันสองขั้นตอน',
totpEnterCode: 'รหัสยืนยัน',
enterCode: 'ป้อนรหัส',
totpVerifyAndEnable: 'ยืนยันและเปิดใช้',
totpOrRecoveryCode: 'รหัสจากแอปหรือรหัสกู้คืน',
totpStatusEnabled: 'เปิดใช้การยืนยันสองขั้นตอนแล้ว',
totpLastUsed: 'ยืนยันล่าสุด: {{date}}',
totpNeverUsed: 'ยังไม่เคยใช้',
disableTotp: 'ปิดใช้การยืนยันสองขั้นตอน',
disableTotpDesc: 'ป้อนรหัสจากแอปยืนยันตัวตนปัจจุบันหรือรหัสกู้คืนเพื่อปิดการยืนยันสองขั้นตอน',
totpEnabledSuccess: 'เปิดใช้การยืนยันสองขั้นตอนแล้ว',
totpDisabledSuccess: 'ปิดใช้การยืนยันสองขั้นตอนแล้ว',
totpInvalidCode: 'รหัสไม่ถูกต้อง กรุณาตรวจสอบแล้วลองใหม่',
totpRecoveryCodesTitle: 'รหัสกู้คืน',
totpRecoveryCodesDesc: 'เก็บรหัสกู้คืนแบบใช้ครั้งเดียวเหล่านี้ไว้ในที่ปลอดภัย จะแสดงเพียงครั้งเดียว',
totpRecoveryCodesWarning: 'รหัสแต่ละรหัสใช้ได้ครั้งเดียว หากคุณทำแอปยืนยันตัวตนและรหัสเหล่านี้หาย คุณจะไม่สามารถเข้าสู่ระบบได้',
totpSavedCodes: 'ฉันบันทึกรหัสเหล่านี้แล้ว',
regenerateRecoveryCodes: 'สร้างรหัสกู้คืนใหม่',
bindSpaceFailed: 'ผูกบัญชี LangBot ล้มเหลว',
bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี',
setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
+71
View File
@@ -69,6 +69,7 @@ const viVN = {
deleteError: 'Xóa thất bại: ',
addRound: 'Thêm lượt',
copy: 'Sao chép',
download: 'Tải xuống',
copySuccess: 'Sao chép thành công',
copyFailed: 'Sao chép thất bại',
test: 'Kiểm tra',
@@ -92,6 +93,17 @@ const viVN = {
passkeyLoginSuccess: 'Xác thực Passkey thành công, đang đăng nhập...',
passkeyLoginFailed: 'Đăng nhập bằng Passkey thất bại',
passkeyNotSupported: 'Trình duyệt hoặc thiết bị này không hỗ trợ Passkey',
verify: 'Xác minh',
back: 'Quay lại',
totpChallengeTitle: 'Xác minh hai bước',
totpChallengeDesc: 'Nhập mã 6 chữ số từ ứng dụng xác thực để tiếp tục',
totpUseRecoveryCode: 'Nhập một mã khôi phục dùng một lần để tiếp tục',
enterTotpCode: 'Nhập mã 6 chữ số',
enterRecoveryCode: 'Nhập mã khôi phục',
useRecoveryCode: 'Dùng mã khôi phục',
useTotpCode: 'Dùng mã từ ứng dụng xác thực',
totpInvalidCode: 'Mã không hợp lệ hoặc đã được dùng, vui lòng thử lại',
totpVerifyFailed: 'Xác minh hai bước thất bại, vui lòng thử lại',
spaceLoginTitle: 'Đăng nhập bằng tài khoản LangBot',
spaceLoginDescription:
'Quét mã QR hoặc truy cập liên kết bên dưới để ủy quyền',
@@ -1308,6 +1320,19 @@ const viVN = {
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',
backToLogin: 'Quay lại đăng nhập',
secondFactorFailed: 'Xác minh thất bại, vui lòng kiểm tra mã và thử lại',
verifyWith: 'Xác minh bằng',
methodRecoveryKey: 'Khóa khôi phục',
methodTotp: 'Ứng dụng xác thực',
methodRecoveryCode: 'Mã khôi phục',
totpCode: 'Mã xác thực',
totpCodeDescription: 'Nhập mã 6 chữ số hiển thị trong ứng dụng xác thực',
totpCodeRequired: 'Mã xác thực không được để trống',
enterTotpCode: 'Nhập mã 6 chữ số',
recoveryCode: 'Mã khôi phục',
recoveryCodeDescription: 'Nhập một trong các mã khôi phục dùng một lần bạn đã lưu khi bật xác minh hai bước',
recoveryCodeRequired: 'Mã khôi phục không được để trống',
enterRecoveryCodeValue: 'Nhập mã khôi phục',
},
embedding: {
description: 'Quản lý mô hình Embedding để véc tơ hóa văn bản',
@@ -1368,6 +1393,52 @@ const viVN = {
passkeyAddedSuccess: 'Đã thêm mã khóa truy cập thành công',
passkeyDeleteSuccess: 'Đã xóa mã khóa truy cập',
passkeyRenameSuccess: 'Đã đổi tên mã khóa truy cập thành công',
totpSectionTitle: 'Xác minh hai bước',
totpSectionDesc: 'Thêm mật khẩu dùng một lần theo thời gian làm yếu tố đăng nhập thứ hai',
totpEnabledDesc: 'Đã bật xác minh hai bước · còn {{count}} mã khôi phục',
enableTotp: 'Bật',
manageTotp: 'Quản lý',
totpEnrollTitle: 'Bật xác minh hai bước',
totpEnrollDesc: 'Quét mã QR bằng ứng dụng xác thực, sau đó xác nhận mã được tạo',
totpStartEnroll: 'Tạo khóa bí mật',
totpGeneratingSecret: 'Đang tạo khóa bí mật mới…',
totpManageTitle: 'Xác minh hai bước',
totpManageDesc: 'Tạo lại mã khôi phục hoặc tắt yếu tố xác minh thứ hai.',
totpRegenerateCodes: 'Tạo lại mã khôi phục',
totpRegenerateDesc: 'Nhập mã xác thực hoặc mã khôi phục hiện tại để tạo bộ mã mới.',
totpRecoveryCodesRegenerated: 'Đã tạo mã khôi phục mới',
totpStatusDisabled: 'Chưa bật',
totpCodesRemaining: 'Còn {{count}} mã khôi phục',
totpManagerSectionDesc: 'Chủ sở hữu và quản trị viên có thể xem và đặt lại yếu tố thứ hai của bất kỳ tài khoản nào.',
revokeTotp: 'Liên kết lại',
totpAdminResetTitle: 'Liên kết lại xác thực hai bước cho {{user}}',
totpAdminResetDesc: 'Yêu cầu tài khoản đó quét mã QR bằng ứng dụng xác thực, rồi nhập mã 6 chữ số bên dưới để hoàn tất.',
totpAdminResetWarning: 'Khi bắt đầu liên kết lại, ứng dụng xác thực hiện tại của {{user}} sẽ ngừng hoạt động ngay.',
totpAdminResetHint: 'Nếu tài khoản đó hiện không thể đăng nhập, họ có thể quét mã QR này bằng bất kỳ ứng dụng xác thực nào.',
totpAdminHandOverCodes: 'Hãy chuyển các mã khôi phục này cho {{user}}. Chúng chỉ hiển thị một lần.',
revokeTotpConfirm: 'Tắt xác minh hai bước cho {{user}}? Sau đó họ chỉ cần mật khẩu để đăng nhập.',
revokeTotpSuccess: 'Đã đặt lại xác minh hai bước',
you: 'bạn',
noAccounts: 'Không có tài khoản để hiển thị',
totpRefreshSecret: 'Tạo lại mã QR',
totpQrAlt: 'Mã QR xác minh hai bước',
totpEnterCode: 'Mã xác minh',
enterCode: 'Nhập mã',
totpVerifyAndEnable: 'Xác minh và bật',
totpOrRecoveryCode: 'Mã ứng dụng xác thực hoặc mã khôi phục',
totpStatusEnabled: 'Đã bật xác minh hai bước',
totpLastUsed: 'Xác minh gần nhất: {{date}}',
totpNeverUsed: 'Chưa sử dụng',
disableTotp: 'Tắt xác minh hai bước',
disableTotpDesc: 'Nhập mã từ ứng dụng xác thực hiện tại hoặc mã khôi phục để tắt xác minh hai bước',
totpEnabledSuccess: 'Đã bật xác minh hai bước',
totpDisabledSuccess: 'Đã tắt xác minh hai bước',
totpInvalidCode: 'Mã không hợp lệ, vui lòng kiểm tra và thử lại',
totpRecoveryCodesTitle: 'Mã khôi phục',
totpRecoveryCodesDesc: 'Lưu các mã khôi phục dùng một lần này ở nơi an toàn. Chúng chỉ hiển thị một lần.',
totpRecoveryCodesWarning: 'Mỗi mã chỉ dùng được một lần. Nếu bạn mất ứng dụng xác thực và các mã này, bạn sẽ mất quyền truy cập đăng nhập.',
totpSavedCodes: 'Tôi đã lưu các mã này',
regenerateRecoveryCodes: 'Tạo lại mã khôi phục',
bindSpaceFailed: 'Liên kết tài khoản LangBot thất bại',
bindSpaceInvalidState:
'Yêu cầu liên kết không hợp lệ. Vui lòng thử lại từ cài đặt tài khoản.',
+71
View File
@@ -67,6 +67,7 @@ const zhHans = {
deleteError: '删除失败:',
addRound: '添加回合',
copy: '复制',
download: '下载',
copySuccess: '复制成功',
copyFailed: '复制失败',
test: '测试',
@@ -89,6 +90,17 @@ const zhHans = {
passkeyLoginSuccess: 'Passkey 验证成功,正在登录...',
passkeyLoginFailed: 'Passkey 登录失败',
passkeyNotSupported: '当前浏览器或设备不支持 Passkey',
verify: '验证',
back: '返回',
totpChallengeTitle: '两步验证',
totpChallengeDesc: '请输入身份验证器中的 6 位验证码以继续',
totpUseRecoveryCode: '请输入一个一次性恢复代码以继续',
enterTotpCode: '输入 6 位验证码',
enterRecoveryCode: '输入恢复代码',
useRecoveryCode: '使用恢复代码',
useTotpCode: '使用验证器验证码',
totpInvalidCode: '验证码无效或已被使用,请重试',
totpVerifyFailed: '两步验证失败,请重试',
spaceLoginTitle: '通过 LangBot 账号登录',
spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
spaceLoginUserCode: '您的验证码',
@@ -1242,6 +1254,19 @@ const zhHans = {
resetSuccess: '密码重置成功,请登录',
resetFailed: '密码重置失败,请检查邮箱和恢复密钥是否正确',
backToLogin: '返回登录',
secondFactorFailed: '验证失败,请检查验证码后重试',
verifyWith: '验证方式',
methodRecoveryKey: '恢复密钥',
methodTotp: '身份验证器',
methodRecoveryCode: '恢复代码',
totpCode: '身份验证器验证码',
totpCodeDescription: '请输入身份验证器中显示的 6 位验证码',
totpCodeRequired: '身份验证器验证码不能为空',
enterTotpCode: '输入 6 位验证码',
recoveryCode: '恢复代码',
recoveryCodeDescription: '请输入启用两步验证时保存的一次性恢复代码',
recoveryCodeRequired: '恢复代码不能为空',
enterRecoveryCodeValue: '输入恢复代码',
},
embedding: {
description: '管理嵌入模型,用于向量化文本',
@@ -1297,6 +1322,52 @@ const zhHans = {
passkeyAddedSuccess: '通行密钥添加成功',
passkeyDeleteSuccess: '通行密钥已删除',
passkeyRenameSuccess: '通行密钥重命名成功',
totpSectionTitle: '两步验证',
totpSectionDesc: '添加基于时间的一次性密码作为登录第二重验证',
totpEnabledDesc: '两步验证已启用 · 剩余 {{count}} 个恢复代码',
enableTotp: '启用',
manageTotp: '管理',
totpEnrollTitle: '启用两步验证',
totpEnrollDesc: '使用身份验证器扫描二维码,然后输入生成的验证码进行确认',
totpStartEnroll: '生成密钥',
totpGeneratingSecret: '正在生成新密钥…',
totpManageTitle: '两步验证',
totpManageDesc: '重新生成恢复代码,或关闭两步验证。',
totpRegenerateCodes: '重新生成恢复代码',
totpRegenerateDesc: '请输入当前验证器验证码或恢复代码,以生成一组新的恢复代码。',
totpRecoveryCodesRegenerated: '已生成新的恢复代码',
totpStatusDisabled: '未启用',
totpCodesRemaining: '剩余 {{count}} 个恢复代码',
totpManagerSectionDesc: '所有者和管理员可以查看并重置任意账户的两步验证。',
revokeTotp: '重新绑定',
totpAdminResetTitle: '重新绑定 {{user}} 的两步验证',
totpAdminResetDesc: '请让该账户用身份验证器扫描下方二维码,再把生成的 6 位验证码填入下方完成绑定。',
totpAdminResetWarning: '开始绑定后,{{user}} 原来的身份验证器会立即失效。',
totpAdminResetHint: '如果该账户当前无法登录,可让其在任意身份验证器中扫描此二维码。',
totpAdminHandOverCodes: '请把这些恢复代码转交给 {{user}},它们只会显示一次。',
revokeTotpConfirm: '确定要关闭 {{user}} 的两步验证吗?关闭后该账户仅凭密码即可登录。',
revokeTotpSuccess: '已重置两步验证',
you: '你',
noAccounts: '暂无账户',
totpRefreshSecret: '刷新二维码',
totpQrAlt: '两步验证二维码',
totpEnterCode: '验证码',
enterCode: '输入验证码',
totpVerifyAndEnable: '验证并启用',
totpOrRecoveryCode: '身份验证器或恢复代码',
totpStatusEnabled: '两步验证已启用',
totpLastUsed: '上次验证:{{date}}',
totpNeverUsed: '尚未使用',
disableTotp: '停用两步验证',
disableTotpDesc: '请输入当前验证器验证码或恢复代码以关闭两步验证',
totpEnabledSuccess: '两步验证已启用',
totpDisabledSuccess: '两步验证已停用',
totpInvalidCode: '验证码无效,请检查后重试',
totpRecoveryCodesTitle: '恢复代码',
totpRecoveryCodesDesc: '请将这些一次性恢复代码保存在安全的地方,它们只会显示一次。',
totpRecoveryCodesWarning: '每个代码只能使用一次。如果验证器和这些代码都丢失,您将无法登录。',
totpSavedCodes: '我已保存这些代码',
regenerateRecoveryCodes: '重新生成恢复代码',
bindSpaceFailed: '绑定 LangBot 账号失败',
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
setPasswordHint: '设置密码后可使用邮箱密码登录',
+71
View File
@@ -67,6 +67,7 @@ const zhHant = {
deleteError: '刪除失敗:',
addRound: '新增回合',
copy: '複製',
download: '下載',
copySuccess: '複製成功',
copyFailed: '複製失敗',
test: '測試',
@@ -89,6 +90,17 @@ const zhHant = {
passkeyLoginSuccess: 'Passkey 驗證成功,正在登入...',
passkeyLoginFailed: 'Passkey 登入失敗',
passkeyNotSupported: '目前瀏覽器或裝置不支援 Passkey',
verify: '驗證',
back: '返回',
totpChallengeTitle: '兩步驗證',
totpChallengeDesc: '請輸入驗證器中的 6 位驗證碼以繼續',
totpUseRecoveryCode: '請輸入一個一次性恢復代碼以繼續',
enterTotpCode: '輸入 6 位驗證碼',
enterRecoveryCode: '輸入恢復代碼',
useRecoveryCode: '使用恢復代碼',
useTotpCode: '使用驗證器驗證碼',
totpInvalidCode: '驗證碼無效或已被使用,請重試',
totpVerifyFailed: '兩步驗證失敗,請重試',
spaceLoginTitle: '透過 LangBot 帳號登入',
spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權',
spaceLoginUserCode: '您的驗證碼',
@@ -1243,6 +1255,19 @@ const zhHant = {
resetSuccess: '密碼重設成功,請登入',
resetFailed: '密碼重設失敗,請檢查電子郵件和恢復金鑰是否正確',
backToLogin: '返回登入',
secondFactorFailed: '驗證失敗,請檢查驗證碼後重試',
verifyWith: '驗證方式',
methodRecoveryKey: '恢復金鑰',
methodTotp: '驗證器',
methodRecoveryCode: '恢復代碼',
totpCode: '驗證器驗證碼',
totpCodeDescription: '請輸入驗證器中顯示的 6 位驗證碼',
totpCodeRequired: '驗證器驗證碼不能為空',
enterTotpCode: '輸入 6 位驗證碼',
recoveryCode: '恢復代碼',
recoveryCodeDescription: '請輸入啟用兩步驗證時儲存的一次性恢復代碼',
recoveryCodeRequired: '恢復代碼不能為空',
enterRecoveryCodeValue: '輸入恢復代碼',
},
embedding: {
description: '管理嵌入模型,用於向量化文字',
@@ -1298,6 +1323,52 @@ const zhHant = {
passkeyAddedSuccess: '通行密鑰新增成功',
passkeyDeleteSuccess: '通行密鑰已刪除',
passkeyRenameSuccess: '通行密鑰重新命名成功',
totpSectionTitle: '兩步驗證',
totpSectionDesc: '新增基於時間的一次性密碼作為登入第二重驗證',
totpEnabledDesc: '兩步驗證已啟用 · 剩餘 {{count}} 個恢復代碼',
enableTotp: '啟用',
manageTotp: '管理',
totpEnrollTitle: '啟用兩步驗證',
totpEnrollDesc: '使用驗證器掃描二維碼,然後輸入產生的驗證碼進行確認',
totpStartEnroll: '產生金鑰',
totpGeneratingSecret: '正在產生新金鑰…',
totpManageTitle: '兩步驗證',
totpManageDesc: '重新產生恢復代碼,或關閉兩步驗證。',
totpRegenerateCodes: '重新產生恢復代碼',
totpRegenerateDesc: '請輸入目前驗證器驗證碼或恢復代碼,以產生一組新的恢復代碼。',
totpRecoveryCodesRegenerated: '已產生新的恢復代碼',
totpStatusDisabled: '未啟用',
totpCodesRemaining: '剩餘 {{count}} 個恢復代碼',
totpManagerSectionDesc: '擁有者與管理員可以檢視並重設任意帳號的兩步驗證。',
revokeTotp: '重新綁定',
totpAdminResetTitle: '重新綁定 {{user}} 的兩步驗證',
totpAdminResetDesc: '請讓該帳號用驗證器掃描下方二維碼,再把產生的 6 位驗證碼填入下方完成綁定。',
totpAdminResetWarning: '開始綁定後,{{user}} 原本的驗證器會立即失效。',
totpAdminResetHint: '如果該帳號目前無法登入,可讓其在任意驗證器中掃描此二維碼。',
totpAdminHandOverCodes: '請將這些恢復代碼轉交給 {{user}},它們只會顯示一次。',
revokeTotpConfirm: '確定要關閉 {{user}} 的兩步驗證嗎?關閉後該帳號僅憑密碼即可登入。',
revokeTotpSuccess: '已重設兩步驗證',
you: '你',
noAccounts: '暫無帳號',
totpRefreshSecret: '重新整理二維碼',
totpQrAlt: '兩步驗證二維碼',
totpEnterCode: '驗證碼',
enterCode: '輸入驗證碼',
totpVerifyAndEnable: '驗證並啟用',
totpOrRecoveryCode: '驗證器或恢復代碼',
totpStatusEnabled: '兩步驗證已啟用',
totpLastUsed: '上次驗證:{{date}}',
totpNeverUsed: '尚未使用',
disableTotp: '停用兩步驗證',
disableTotpDesc: '請輸入目前驗證器驗證碼或恢復代碼以關閉兩步驗證',
totpEnabledSuccess: '兩步驗證已啟用',
totpDisabledSuccess: '兩步驗證已停用',
totpInvalidCode: '驗證碼無效,請檢查後重試',
totpRecoveryCodesTitle: '恢復代碼',
totpRecoveryCodesDesc: '請將這些一次性恢復代碼保存在安全的地方,它們只會顯示一次。',
totpRecoveryCodesWarning: '每個代碼只能使用一次。如果驗證器和這些代碼都遺失,您將無法登入。',
totpSavedCodes: '我已儲存這些代碼',
regenerateRecoveryCodes: '重新產生恢復代碼',
bindSpaceFailed: '綁定 LangBot 帳號失敗',
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
setPasswordHint: '設定密碼後可使用電子郵件密碼登入',