mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-30 21:36:47 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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.',
|
||||
|
||||
@@ -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:
|
||||
'無効な連携リクエストです。アカウント設定から再度お試しください。',
|
||||
|
||||
@@ -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:
|
||||
'Недействительный запрос привязки. Повторите попытку из настроек аккаунта.',
|
||||
|
||||
@@ -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: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: '设置密码后可使用邮箱密码登录',
|
||||
|
||||
@@ -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: '設定密碼後可使用電子郵件密碼登入',
|
||||
|
||||
Reference in New Issue
Block a user