mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-13 21:37:13 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a40051daf1 | |||
| d26d0635c5 | |||
| 58cde8c022 |
+2
-1
@@ -110,7 +110,8 @@ classifiers = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
seekdb = [
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"pyseekdb==1.4.0.post1",
|
||||
"pylibseekdb==1.4.0; sys_platform == 'linux' or (sys_platform == 'darwin' and platform_machine == 'arm64')",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
"""Account, authentication, passkey and TOTP HTTP routes.
|
||||
|
||||
Exposes the unauthenticated login/recovery surface as well as the authenticated
|
||||
account-management, passkey (WebAuthn) and TOTP second-factor endpoints under
|
||||
``/api/v1/user``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
@@ -15,6 +22,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.totp import TotpAlreadyEnabledError, TotpInvalidCodeError, TotpNotEnabledError
|
||||
|
||||
# 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
|
||||
@@ -46,7 +54,10 @@ def _admit_reset_attempt(now: float) -> bool:
|
||||
|
||||
@group.group_class('user', '/api/v1/user')
|
||||
class UserRouterGroup(group.RouterGroup):
|
||||
"""``/api/v1/user`` routes for accounts, auth, passkeys and TOTP."""
|
||||
|
||||
def _validate_space_redirect_uri(self, redirect_uri: str, *, bind: bool) -> str:
|
||||
"""Validate a Space OAuth redirect URI against the expected callback shape."""
|
||||
parsed = urlsplit(redirect_uri)
|
||||
if (
|
||||
parsed.scheme not in {'http', 'https'}
|
||||
@@ -67,7 +78,11 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
return redirect_uri
|
||||
|
||||
def _extract_origin_and_rp_id(self, json_data: dict[str, typing.Any] | None = None) -> tuple[str, str]:
|
||||
def _extract_origin_and_rp_id(
|
||||
self,
|
||||
json_data: dict[str, typing.Any] | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Resolve the WebAuthn origin and relying-party ID for a request."""
|
||||
origin = ''
|
||||
if json_data and isinstance(json_data, dict):
|
||||
origin = json_data.get('origin', '')
|
||||
@@ -80,14 +95,21 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
parsed = urlsplit(origin)
|
||||
rp_id = parsed.hostname or 'localhost'
|
||||
clean_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else origin.rstrip('/')
|
||||
if parsed.scheme and parsed.netloc:
|
||||
clean_origin = f'{parsed.scheme}://{parsed.netloc}'
|
||||
else:
|
||||
clean_origin = origin.rstrip('/')
|
||||
return clean_origin, rp_id
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Register every ``/api/v1/user`` route on this router group."""
|
||||
|
||||
@self.route('/init', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Report initialization state, or create the first account (POST)."""
|
||||
if quart.request.method == 'GET':
|
||||
return self.success(data={'initialized': await self.ap.user_service.is_initialized()})
|
||||
initialized = await self.ap.user_service.is_initialized()
|
||||
return self.success(data={'initialized': initialized})
|
||||
|
||||
if await self.ap.user_service.is_initialized():
|
||||
return self.fail(1, 'System already initialized')
|
||||
@@ -108,27 +130,56 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
@self.route('/auth', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
|
||||
return self.http_status(403, 'password_login_disabled', 'Password login is disabled on LangBot Cloud')
|
||||
"""Authenticate a local Account, requiring a TOTP factor when enabled."""
|
||||
deployment = getattr(self.ap, 'deployment', None)
|
||||
if getattr(deployment, 'mode', 'oss') == 'cloud':
|
||||
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']
|
||||
try:
|
||||
token = await self.ap.user_service.authenticate(json_data['user'], json_data['password'])
|
||||
token = await self.ap.user_service.authenticate(user_email, json_data['password'])
|
||||
except argon2.exceptions.VerifyMismatchError:
|
||||
return self.fail(1, 'Invalid username or password')
|
||||
except ValueError as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
# Second factor: an enabled TOTP credential makes the password alone
|
||||
# insufficient. The client retries the same request with a code.
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is not None and await self.ap.totp_service.is_enabled(user_obj.uuid):
|
||||
totp_code = json_data.get('totp_code')
|
||||
recovery_code = json_data.get('recovery_code')
|
||||
verified = False
|
||||
if totp_code:
|
||||
verified = await self.ap.totp_service.verify_for_account(
|
||||
user_obj.uuid,
|
||||
str(totp_code),
|
||||
)
|
||||
elif recovery_code:
|
||||
verified = await self.ap.totp_service.redeem_recovery_code(
|
||||
user_obj.uuid,
|
||||
str(recovery_code),
|
||||
)
|
||||
if not verified:
|
||||
return self.http_status(401, 'totp_required', 'TOTP verification required')
|
||||
|
||||
return self.success(data={'token': token})
|
||||
|
||||
@self.route('/check-token', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
|
||||
async def _(account) -> str:
|
||||
"""Issue a fresh user token for an already-authenticated Account."""
|
||||
token = await self.ap.user_service.generate_jwt_token(account)
|
||||
|
||||
return self.success(data={'token': token})
|
||||
|
||||
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Reset a password using the recovery key, TOTP, or a recovery code."""
|
||||
# Admit (or reject) BEFORE touching the body or any service call (#2392):
|
||||
# rejecting requests never reach the slow path, and quota accounting happens
|
||||
# synchronously at entry, closing the post-await race of burst requests.
|
||||
@@ -138,7 +189,11 @@ class UserRouterGroup(group.RouterGroup):
|
||||
json_data = await quart.request.json
|
||||
|
||||
user_email = json_data['user']
|
||||
recovery_key = json_data['recovery_key']
|
||||
# Recovery accepts either the instance recovery key, or (for accounts
|
||||
# that enrolled one) a TOTP code or a one-time TOTP recovery code.
|
||||
recovery_key = json_data.get('recovery_key')
|
||||
totp_code = json_data.get('totp_code')
|
||||
recovery_code = json_data.get('recovery_code')
|
||||
new_password = json_data['new_password']
|
||||
|
||||
# hard sleep 3s for security
|
||||
@@ -152,19 +207,39 @@ 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 totp_code or recovery_code:
|
||||
if not await self.ap.totp_service.is_enabled(user_obj.uuid):
|
||||
return self.http_status(
|
||||
403,
|
||||
'totp_not_enabled',
|
||||
'TOTP is not enabled for this account',
|
||||
)
|
||||
if totp_code:
|
||||
authorized = await self.ap.totp_service.verify_for_account(
|
||||
user_obj.uuid,
|
||||
str(totp_code),
|
||||
)
|
||||
else:
|
||||
authorized = await self.ap.totp_service.redeem_recovery_code(
|
||||
user_obj.uuid,
|
||||
str(recovery_code),
|
||||
)
|
||||
if not authorized:
|
||||
return self.http_status(403, 'totp_invalid_code', 'Invalid TOTP code')
|
||||
else:
|
||||
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')
|
||||
if not key_matches:
|
||||
return self.http_status(403, -1, 'Invalid recovery key')
|
||||
|
||||
await self.ap.user_service.reset_password(user_email, new_password)
|
||||
|
||||
@@ -172,6 +247,7 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
@self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Change the current Account password after verifying the old one."""
|
||||
# Check if password change is allowed
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_modify_login_info', True
|
||||
@@ -185,7 +261,11 @@ class UserRouterGroup(group.RouterGroup):
|
||||
new_password = json_data['new_password']
|
||||
|
||||
try:
|
||||
await self.ap.user_service.change_password(user_email, current_password, new_password)
|
||||
await self.ap.user_service.change_password(
|
||||
user_email,
|
||||
current_password,
|
||||
new_password,
|
||||
)
|
||||
except argon2.exceptions.VerifyMismatchError:
|
||||
return self.http_status(400, -1, 'Current password is incorrect')
|
||||
except ValueError as e:
|
||||
@@ -209,7 +289,8 @@ class UserRouterGroup(group.RouterGroup):
|
||||
redirect_uri = self._validate_space_redirect_uri(redirect_uri, bind=False)
|
||||
launch_workspace_uuid = quart.request.args.get('launch_workspace_uuid')
|
||||
if launch_workspace_uuid:
|
||||
if not getattr(getattr(self.ap, 'deployment', None), 'multi_workspace_enabled', False):
|
||||
deployment = getattr(self.ap, 'deployment', None)
|
||||
if not getattr(deployment, 'multi_workspace_enabled', False):
|
||||
return self.fail(1, 'Space launch requires Cloud mode')
|
||||
try:
|
||||
uuid.UUID(launch_workspace_uuid)
|
||||
@@ -226,7 +307,11 @@ class UserRouterGroup(group.RouterGroup):
|
||||
except ValueError as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/space/bind-authorize-url', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
@self.route(
|
||||
'/space/bind-authorize-url',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Issue an account-bound, one-time Space OAuth redirect."""
|
||||
redirect_uri = quart.request.args.get('redirect_uri', '')
|
||||
@@ -272,19 +357,24 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
try:
|
||||
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=False)
|
||||
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
||||
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(
|
||||
state,
|
||||
'login',
|
||||
)
|
||||
# Exchange code for tokens
|
||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
||||
workspace_uuids = [launch_workspace_uuid] if launch_workspace_uuid else []
|
||||
workspace_created_ats: dict[str, int] = {}
|
||||
if not workspace_uuids and getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') != 'cloud':
|
||||
deployment = getattr(self.ap, 'deployment', None)
|
||||
if not workspace_uuids and getattr(deployment, 'mode', 'oss') != 'cloud':
|
||||
binding = await self.ap.workspace_service.get_execution_binding()
|
||||
workspace_uuids = [binding.workspace_uuid]
|
||||
workspace_created_at = binding.workspace_created_at
|
||||
if workspace_created_at is not None:
|
||||
if workspace_created_at.tzinfo is None:
|
||||
workspace_created_at = workspace_created_at.replace(tzinfo=datetime.UTC)
|
||||
workspace_created_ats[binding.workspace_uuid] = int(workspace_created_at.timestamp())
|
||||
created_at_epoch = int(workspace_created_at.timestamp())
|
||||
workspace_created_ats[binding.workspace_uuid] = created_at_epoch
|
||||
token_data = await self.ap.space_service.exchange_oauth_code(
|
||||
code,
|
||||
workspace_uuids,
|
||||
@@ -299,14 +389,20 @@ class UserRouterGroup(group.RouterGroup):
|
||||
if not access_token:
|
||||
return self.fail(1, 'Failed to get access token from Space')
|
||||
|
||||
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||
if cloud_mode and launch_workspace_uuid and launch_workspace_uuid != cloud_workspace_uuid:
|
||||
deployment = getattr(self.ap, 'deployment', None)
|
||||
cloud_mode = getattr(deployment, 'mode', 'oss') == 'cloud'
|
||||
launch_mismatch = launch_workspace_uuid != cloud_workspace_uuid
|
||||
if cloud_mode and launch_workspace_uuid and launch_mismatch:
|
||||
return self.fail(1, 'Space OAuth Workspace binding mismatch')
|
||||
target_workspace_uuid = launch_workspace_uuid or cloud_workspace_uuid
|
||||
if cloud_mode:
|
||||
if not target_workspace_uuid:
|
||||
return self.fail(1, 'Space OAuth response is missing the Cloud Workspace binding')
|
||||
await self.ap.directory_projection_service.reconcile_workspaces((target_workspace_uuid,))
|
||||
return self.fail(
|
||||
1,
|
||||
'Space OAuth response is missing the Cloud Workspace binding',
|
||||
)
|
||||
projection_service = self.ap.directory_projection_service
|
||||
await projection_service.reconcile_workspaces((target_workspace_uuid,))
|
||||
|
||||
# Authenticate only after the signed, exact Workspace delta has
|
||||
# established the Account and membership runtime shadow rows.
|
||||
@@ -316,12 +412,15 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
if target_workspace_uuid:
|
||||
try:
|
||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||
collab_service = self.ap.workspace_collaboration_service
|
||||
access = await collab_service.resolve_account_workspace(
|
||||
user_obj.uuid,
|
||||
target_workspace_uuid,
|
||||
)
|
||||
except Exception:
|
||||
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
|
||||
self.ap.logger.warning(
|
||||
'Rejected Space OAuth launch for unauthorized Workspace',
|
||||
)
|
||||
return self.fail(1, 'Space OAuth failed')
|
||||
return self.success(
|
||||
data={
|
||||
@@ -356,6 +455,7 @@ class UserRouterGroup(group.RouterGroup):
|
||||
'user': account.user,
|
||||
'account_type': account.account_type,
|
||||
'has_password': bool(account.password and account.password.strip()),
|
||||
'totp_enabled': await self.ap.totp_service.is_enabled(account.uuid),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -408,6 +508,7 @@ class UserRouterGroup(group.RouterGroup):
|
||||
capabilities['invitation_registration_enabled'] = not cloud_mode
|
||||
capabilities['passkey_login_enabled'] = True
|
||||
capabilities['passkey_supported'] = True
|
||||
capabilities['totp_supported'] = True
|
||||
return self.success(data={'initialized': True, **capabilities})
|
||||
|
||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
@@ -498,7 +599,11 @@ class UserRouterGroup(group.RouterGroup):
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
@self.route('/passkey/register/options', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
@self.route(
|
||||
'/passkey/register/options',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Generate WebAuthn registration options for current account."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
@@ -515,7 +620,9 @@ class UserRouterGroup(group.RouterGroup):
|
||||
origin, rp_id = self._extract_origin_and_rp_id(json_data)
|
||||
|
||||
try:
|
||||
options, challenge_token = await self.ap.user_service.generate_passkey_registration_options(
|
||||
user_service = self.ap.user_service
|
||||
reg_options = user_service.generate_passkey_registration_options
|
||||
options, challenge_token = await reg_options(
|
||||
account_uuid=user_obj.uuid,
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
@@ -525,7 +632,11 @@ class UserRouterGroup(group.RouterGroup):
|
||||
except Exception as e:
|
||||
return self.fail(1, str(e))
|
||||
|
||||
@self.route('/passkey/register/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
@self.route(
|
||||
'/passkey/register/verify',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Verify WebAuthn registration response and save credential."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
@@ -570,7 +681,9 @@ class UserRouterGroup(group.RouterGroup):
|
||||
origin, rp_id = self._extract_origin_and_rp_id(json_data)
|
||||
|
||||
try:
|
||||
options, challenge_token = await self.ap.user_service.generate_passkey_authentication_options(
|
||||
user_service = self.ap.user_service
|
||||
auth_options = user_service.generate_passkey_authentication_options
|
||||
options, challenge_token = await auth_options(
|
||||
rp_id=rp_id,
|
||||
origin=origin,
|
||||
email=email,
|
||||
@@ -626,7 +739,11 @@ class UserRouterGroup(group.RouterGroup):
|
||||
]
|
||||
)
|
||||
|
||||
@self.route('/passkey/<passkey_uuid>', methods=['PATCH'], auth_type=group.AuthType.USER_TOKEN)
|
||||
@self.route(
|
||||
'/passkey/<passkey_uuid>',
|
||||
methods=['PATCH'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
)
|
||||
async def _(user_email: str, passkey_uuid: str) -> str:
|
||||
"""Rename a registered passkey."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
@@ -653,7 +770,11 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.http_status(404, -1, 'Passkey not found')
|
||||
return self.success(data={'uuid': updated.uuid, 'name': updated.name})
|
||||
|
||||
@self.route('/passkey/<passkey_uuid>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||
@self.route(
|
||||
'/passkey/<passkey_uuid>',
|
||||
methods=['DELETE'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
)
|
||||
async def _(user_email: str, passkey_uuid: str) -> str:
|
||||
"""Delete/revoke a registered passkey."""
|
||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||
@@ -674,6 +795,160 @@ class UserRouterGroup(group.RouterGroup):
|
||||
return self.http_status(404, -1, 'Passkey not found')
|
||||
return self.success()
|
||||
|
||||
@self.route('/totp/check', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> str:
|
||||
"""Report whether TOTP is enabled for a given Account (unauthenticated).
|
||||
|
||||
Used by the password-recovery page to decide whether the TOTP and
|
||||
recovery-code verification methods are selectable. Only the boolean
|
||||
capability is disclosed; no account details leak.
|
||||
"""
|
||||
if not await self.ap.user_service.is_initialized():
|
||||
return self.http_status(400, -1, 'System not initialized')
|
||||
|
||||
json_data = await quart.request.json
|
||||
user_email = json_data.get('user')
|
||||
if not isinstance(user_email, str) or not user_email:
|
||||
return self.fail(1, 'User is required')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
enabled = user_obj is not None and await self.ap.totp_service.is_enabled(user_obj.uuid)
|
||||
|
||||
return self.success(data={'totp_enabled': enabled})
|
||||
|
||||
@self.route('/totp/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Report whether the current Account has TOTP enabled."""
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'enabled': await self.ap.totp_service.is_enabled(user_obj.uuid),
|
||||
'remaining_recovery_codes': await self.ap.totp_service.remaining_recovery_codes(
|
||||
user_obj.uuid,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/totp/enroll', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Start TOTP enrolment and return the QR payload plus recovery codes.
|
||||
|
||||
The secret is not enforced until ``/totp/enroll/verify`` confirms the
|
||||
authenticator app can produce a valid 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')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
try:
|
||||
enrollment, recovery_codes = await self.ap.totp_service.begin_enrollment(user_obj)
|
||||
except TotpAlreadyEnabledError as e:
|
||||
return self.http_status(409, e.code, str(e))
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'secret': enrollment.secret,
|
||||
'otpauth_uri': enrollment.otpauth_uri,
|
||||
'qr_svg': self.ap.totp_service.build_qr_svg(enrollment.otpauth_uri),
|
||||
'recovery_codes': recovery_codes,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/totp/enroll/verify', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Confirm enrolment with the first code from the authenticator app."""
|
||||
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')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
if not code:
|
||||
return self.fail(1, 'Verification code is required')
|
||||
|
||||
try:
|
||||
await self.ap.totp_service.confirm_enrollment(user_obj.uuid, str(code))
|
||||
except TotpNotEnabledError as e:
|
||||
return self.http_status(400, e.code, str(e))
|
||||
except TotpAlreadyEnabledError as e:
|
||||
return self.http_status(409, e.code, str(e))
|
||||
except TotpInvalidCodeError as e:
|
||||
return self.http_status(400, e.code, str(e))
|
||||
|
||||
return self.success(data={'enabled': True})
|
||||
|
||||
@self.route('/totp/recovery-codes', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Regenerate one-time recovery codes after proving a valid TOTP 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')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
if not code:
|
||||
return self.fail(1, 'Verification code is required')
|
||||
|
||||
if not await self.ap.totp_service.verify_for_account(user_obj.uuid, str(code)):
|
||||
return self.http_status(400, TotpInvalidCodeError.code, 'Invalid verification code')
|
||||
|
||||
try:
|
||||
_, recovery_codes = await self.ap.totp_service.regenerate_recovery_codes(
|
||||
user_obj.uuid,
|
||||
)
|
||||
except TotpNotEnabledError as e:
|
||||
return self.http_status(400, e.code, str(e))
|
||||
|
||||
return self.success(data={'recovery_codes': recovery_codes})
|
||||
|
||||
@self.route('/totp/disable', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
"""Disable TOTP for the current Account after a valid code check."""
|
||||
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')
|
||||
|
||||
user_obj = await self.ap.user_service.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
return self.http_status(404, -1, 'User not found')
|
||||
|
||||
json_data = await quart.request.json
|
||||
code = json_data.get('code')
|
||||
if not code:
|
||||
return self.fail(1, 'Verification code is required')
|
||||
|
||||
try:
|
||||
await self.ap.totp_service.disable(user_obj.uuid, str(code))
|
||||
except TotpNotEnabledError as e:
|
||||
return self.http_status(400, e.code, str(e))
|
||||
except TotpInvalidCodeError as e:
|
||||
return self.http_status(400, e.code, str(e))
|
||||
|
||||
return self.success(data={'enabled': False})
|
||||
|
||||
async def _handle_space_direct_launch(
|
||||
self,
|
||||
launch_assertion: str,
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
"""Second-factor TOTP (RFC 6238) enrolment, verification and recovery.
|
||||
|
||||
This service backs the optional TOTP second factor for LangBot Accounts:
|
||||
|
||||
* the shared secret is encrypted at rest with a Fernet key derived from the
|
||||
instance JWT secret via HKDF, and is never persisted in plaintext;
|
||||
* recovery codes are stored only as salted PBKDF2-HMAC-SHA256 digests;
|
||||
* the plaintext secret and recovery codes leave the server exactly once, in the
|
||||
enrolment response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import dataclasses
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
import typing
|
||||
import uuid
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from ....entity.persistence import totp
|
||||
from ....entity.persistence import user
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core.app import Application
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# RFC 6238 parameters. Six digits and a 30 second step are what every common
|
||||
# authenticator app (Google Authenticator, Authy, 1Password, ...) defaults to.
|
||||
_TOTP_DIGITS = 6
|
||||
_TOTP_STEP_SECONDS = 30
|
||||
# Accept one step of clock skew in either direction, which tolerates small
|
||||
# device clock drift without materially widening the brute-force window.
|
||||
_TOTP_WINDOW_STEPS = 1
|
||||
_RECOVERY_CODE_COUNT = 10
|
||||
# 10 groups drawn from 32 symbols provide 50 bits of entropy per recovery code.
|
||||
_RECOVERY_CODE_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||
_RECOVERY_CODE_LENGTH = 10
|
||||
# Recovery codes are stored only as salted PBKDF2-HMAC-SHA256 digests. The work
|
||||
# factor is intentionally high: guessing is already infeasible against 50 bits of
|
||||
# entropy, and the slow KDF keeps a dumped database from being attacked cheaply.
|
||||
# Hashing runs off the event loop, so this is a latency cost paid only at
|
||||
# enrolment / regeneration / redemption.
|
||||
_RECOVERY_CODE_KDF_ITERATIONS = 300_000
|
||||
|
||||
|
||||
class TotpAlreadyEnabledError(ValueError):
|
||||
"""Raised when enrolling an Account that already has TOTP enabled."""
|
||||
|
||||
code = 'totp_already_enabled'
|
||||
|
||||
|
||||
class TotpNotEnabledError(ValueError):
|
||||
"""Raised when an operation requires an enabled TOTP credential."""
|
||||
|
||||
code = 'totp_not_enabled'
|
||||
|
||||
|
||||
class TotpInvalidCodeError(ValueError):
|
||||
"""Raised when a supplied TOTP or recovery code fails verification."""
|
||||
|
||||
code = 'totp_invalid_code'
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, slots=True)
|
||||
class TotpEnrollment:
|
||||
"""Result of starting (or restarting) TOTP enrolment for an Account."""
|
||||
|
||||
secret: str
|
||||
otpauth_uri: str
|
||||
|
||||
|
||||
class TotpService:
|
||||
"""Second-factor TOTP enrolment, verification and recovery for Accounts.
|
||||
|
||||
Nothing usable is persisted in plaintext:
|
||||
|
||||
* The shared TOTP secret is encrypted at rest with a Fernet key derived from
|
||||
the instance JWT secret via HKDF, so a leaked database file alone does not
|
||||
expose live secrets (the attacker additionally needs ``config.yaml``).
|
||||
* Recovery codes are stored only as salted PBKDF2-HMAC-SHA256 digests and are
|
||||
consumed one at a time.
|
||||
* The plaintext secret / recovery codes leave the server exactly once, in the
|
||||
enrolment response, and are never stored or logged server-side.
|
||||
"""
|
||||
|
||||
ap: Application
|
||||
|
||||
def __init__(self, ap: Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
# -- storage helpers -------------------------------------------------
|
||||
|
||||
def _session_factory(self) -> async_sessionmaker[AsyncSession]:
|
||||
return async_sessionmaker(self.ap.persistence_mgr.get_db_engine(), expire_on_commit=False)
|
||||
|
||||
def _encryption_key(self) -> bytes:
|
||||
"""Derive a stable 32-byte Fernet key from the instance JWT secret.
|
||||
|
||||
HKDF-SHA256 with a fixed domain-separation salt keeps the key stable
|
||||
across restarts and distinct from the JWT signing secret. The key
|
||||
material is NOT stored in the database, so a leaked ``langbot.db`` alone
|
||||
cannot decrypt the TOTP secrets.
|
||||
"""
|
||||
secret = ''
|
||||
try:
|
||||
secret = self.ap.instance_config.data['system']['jwt']['secret'] or ''
|
||||
except (KeyError, TypeError):
|
||||
secret = ''
|
||||
if not secret:
|
||||
# Defence in depth: a missing JWT secret must not silently produce a
|
||||
# well-known encryption key. This should never happen because
|
||||
# GenKeysStage seeds it, but failing closed is safer than encrypting
|
||||
# with a predictable key. The caller maps this to an invalid-code
|
||||
# failure, so no plaintext is ever persisted.
|
||||
raise TotpInvalidCodeError('Instance JWT secret unavailable')
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
# HKDF enforces the label internally, so include it as `info`.
|
||||
derived = HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=b'langbot-totp-v1',
|
||||
info=b'langbot-totp-secret-encryption',
|
||||
).derive(secret.encode('utf-8'))
|
||||
return base64.urlsafe_b64encode(derived)
|
||||
|
||||
def _encrypt_secret(self, secret: str) -> str:
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
return Fernet(self._encryption_key()).encrypt(secret.encode('utf-8')).decode('ascii')
|
||||
|
||||
def _decrypt_secret(self, token: str) -> str:
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
try:
|
||||
return Fernet(self._encryption_key()).decrypt(token.encode('ascii')).decode('utf-8')
|
||||
except (InvalidToken, ValueError) as exc:
|
||||
raise TotpInvalidCodeError('Stored TOTP secret cannot be decrypted') from exc
|
||||
|
||||
# -- RFC 6238 primitives ---------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def generate_secret() -> str:
|
||||
"""Return a fresh base32 secret (160 bits, the RFC 4226 recommendation)."""
|
||||
return base64.b32encode(secrets.token_bytes(20)).decode('ascii').rstrip('=')
|
||||
|
||||
@staticmethod
|
||||
def _hotp(secret: str, counter: int) -> str:
|
||||
padding = '=' * (-len(secret) % 8)
|
||||
key = base64.b32decode(secret.upper() + padding)
|
||||
msg = struct.pack('>Q', counter)
|
||||
digest = hmac.new(key, msg, hashlib.sha1).digest()
|
||||
offset = digest[-1] & 0x0F
|
||||
binary = struct.unpack('>I', digest[offset : offset + 4])[0] & 0x7FFFFFFF
|
||||
return str(binary % (10**_TOTP_DIGITS)).zfill(_TOTP_DIGITS)
|
||||
|
||||
@classmethod
|
||||
def generate_code(cls, secret: str, at: float | None = None) -> str:
|
||||
"""Return the TOTP code for ``secret`` at the given (or current) time."""
|
||||
counter = int((at if at is not None else time.time()) // _TOTP_STEP_SECONDS)
|
||||
return cls._hotp(secret, counter)
|
||||
|
||||
@classmethod
|
||||
def verify_code(cls, secret: str, code: str, at: float | None = None) -> bool:
|
||||
"""Constant-time check of a user-supplied code within the skew window."""
|
||||
candidate = (code or '').strip().replace(' ', '')
|
||||
if not candidate.isdigit() or len(candidate) != _TOTP_DIGITS:
|
||||
return False
|
||||
now = at if at is not None else time.time()
|
||||
counter = int(now // _TOTP_STEP_SECONDS)
|
||||
for offset in range(-_TOTP_WINDOW_STEPS, _TOTP_WINDOW_STEPS + 1):
|
||||
expected = cls._hotp(secret, counter + offset)
|
||||
if hmac.compare_digest(expected, candidate):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def build_otpauth_uri(secret: str, account_name: str, issuer: str = 'LangBot') -> str:
|
||||
"""Build the otpauth:// URI an authenticator app scans from the QR code."""
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
label = quote(f'{issuer}:{account_name}')
|
||||
params = urlencode(
|
||||
{
|
||||
'secret': secret,
|
||||
'issuer': issuer,
|
||||
'algorithm': 'SHA1',
|
||||
'digits': _TOTP_DIGITS,
|
||||
'period': _TOTP_STEP_SECONDS,
|
||||
}
|
||||
)
|
||||
return f'otpauth://totp/{label}?{params}'
|
||||
|
||||
@staticmethod
|
||||
def build_qr_svg(otpauth_uri: str) -> str:
|
||||
"""Render the otpauth URI to an inline SVG QR code.
|
||||
|
||||
SVG keeps the response text-only so the frontend can drop it straight
|
||||
into a dialog without byte-encoding a PNG data URL.
|
||||
"""
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
|
||||
qr = qrcode.QRCode(
|
||||
version=None,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=10,
|
||||
border=2,
|
||||
image_factory=qrcode.image.svg.SvgPathImage,
|
||||
)
|
||||
qr.add_data(otpauth_uri)
|
||||
qr.make(fit=True)
|
||||
image = qr.make_image()
|
||||
import io
|
||||
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer)
|
||||
return buffer.getvalue().decode('utf-8')
|
||||
|
||||
# -- recovery codes ---------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _normalise_recovery_code(code: str) -> str:
|
||||
return (code or '').strip().upper().replace('-', '').replace(' ', '')
|
||||
|
||||
@classmethod
|
||||
def _hash_recovery_code(cls, code: str, *, salt: bytes | None = None) -> str:
|
||||
"""Return a self-describing PBKDF2-HMAC-SHA256 digest of a recovery code.
|
||||
|
||||
The format is ``pbkdf2_sha256$<iterations>$<salt_hex>$<digest_hex>`` so the
|
||||
work factor is stored alongside the digest and can be raised later
|
||||
without invalidating existing codes. Salted and slow, so a database dump
|
||||
does not allow offline brute-forcing of recovery codes.
|
||||
"""
|
||||
if salt is None:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
'sha256',
|
||||
cls._normalise_recovery_code(code).encode('utf-8'),
|
||||
salt,
|
||||
_RECOVERY_CODE_KDF_ITERATIONS,
|
||||
)
|
||||
return f'pbkdf2_sha256${_RECOVERY_CODE_KDF_ITERATIONS}${salt.hex()}${digest.hex()}'
|
||||
|
||||
@staticmethod
|
||||
def _split_recovery_digest(stored: str) -> tuple[int, bytes, bytes] | None:
|
||||
parts = (stored or '').split('$')
|
||||
if len(parts) != 4 or parts[0] != 'pbkdf2_sha256':
|
||||
return None
|
||||
try:
|
||||
iterations = int(parts[1])
|
||||
salt = bytes.fromhex(parts[2])
|
||||
digest = bytes.fromhex(parts[3])
|
||||
except ValueError:
|
||||
return None
|
||||
return iterations, salt, digest
|
||||
|
||||
@staticmethod
|
||||
def _random_recovery_code() -> str:
|
||||
"""Return one random recovery code from the unambiguous alphabet."""
|
||||
alphabet = _RECOVERY_CODE_ALPHABET
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(_RECOVERY_CODE_LENGTH))
|
||||
|
||||
@classmethod
|
||||
async def generate_recovery_codes(cls) -> tuple[list[str], list[str]]:
|
||||
"""Return ``(plaintext_codes, hashed_codes)`` for one enrolment."""
|
||||
plaintext: list[str] = []
|
||||
hashed: list[str] = []
|
||||
for _ in range(_RECOVERY_CODE_COUNT):
|
||||
code = cls._random_recovery_code()
|
||||
plaintext.append(code)
|
||||
# Offload the expensive KDF so 10 codes do not stall the event loop.
|
||||
hashed.append(await asyncio.to_thread(cls._hash_recovery_code, code))
|
||||
return plaintext, hashed
|
||||
|
||||
# -- persistence ------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _credential_statement(account_uuid: str) -> typing.Any:
|
||||
"""Build the SELECT that loads an Account's TOTP credential row."""
|
||||
entity = totp.TotpCredential
|
||||
return sqlalchemy.select(entity).where(entity.account_uuid == account_uuid)
|
||||
|
||||
async def get_credential(self, account_uuid: str) -> totp.TotpCredential | None:
|
||||
"""Load the (single) TOTP credential row for an Account, if any."""
|
||||
statement = self._credential_statement(account_uuid)
|
||||
async with self._session_factory()() as session:
|
||||
return await session.scalar(statement)
|
||||
|
||||
async def is_enabled(self, account_uuid: str) -> bool:
|
||||
"""Return whether the Account has a confirmed, enabled TOTP credential."""
|
||||
credential = await self.get_credential(account_uuid)
|
||||
return bool(credential and credential.enabled)
|
||||
|
||||
async def begin_enrollment(self, account: user.User) -> tuple[TotpEnrollment, list[str]]:
|
||||
"""Create or replace a pending TOTP secret and return recovery codes.
|
||||
|
||||
A previous *enabled* credential is left untouched until the new secret
|
||||
is confirmed, so a failed re-enrolment cannot lock the account out.
|
||||
"""
|
||||
secret = self.generate_secret()
|
||||
uri = self.build_otpauth_uri(secret, account_name=account.user)
|
||||
plaintext_codes, hashed_codes = await self.generate_recovery_codes()
|
||||
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
credential = await session.scalar(self._credential_statement(account.uuid))
|
||||
if credential is None:
|
||||
credential = totp.TotpCredential(
|
||||
uuid=str(uuid.uuid4()),
|
||||
account_uuid=account.uuid,
|
||||
secret_encrypted=self._encrypt_secret(secret),
|
||||
account_name=account.user,
|
||||
enabled=False,
|
||||
recovery_codes=json.dumps(hashed_codes),
|
||||
)
|
||||
session.add(credential)
|
||||
elif not credential.enabled:
|
||||
credential.secret_encrypted = self._encrypt_secret(secret)
|
||||
credential.account_name = account.user
|
||||
credential.recovery_codes = json.dumps(hashed_codes)
|
||||
else:
|
||||
raise TotpAlreadyEnabledError('TOTP is already enabled for this account')
|
||||
await session.flush()
|
||||
|
||||
return TotpEnrollment(secret=secret, otpauth_uri=uri), plaintext_codes
|
||||
|
||||
async def confirm_enrollment(self, account_uuid: str, code: str) -> None:
|
||||
"""Verify the first code and flip the credential to enabled."""
|
||||
credential = await self.get_credential(account_uuid)
|
||||
if credential is None:
|
||||
raise TotpNotEnabledError('No pending TOTP enrolment found')
|
||||
if credential.enabled:
|
||||
raise TotpAlreadyEnabledError('TOTP is already enabled for this account')
|
||||
|
||||
try:
|
||||
code_matches = self.verify_code(self._decrypt_secret(credential.secret_encrypted), code)
|
||||
except TotpInvalidCodeError:
|
||||
code_matches = False
|
||||
if not code_matches:
|
||||
raise TotpInvalidCodeError('Invalid verification code')
|
||||
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
record = await session.scalar(self._credential_statement(account_uuid))
|
||||
if record is None:
|
||||
raise TotpNotEnabledError('No pending TOTP enrolment found')
|
||||
record.enabled = True
|
||||
record.last_used_at = datetime.datetime.now()
|
||||
|
||||
async def verify_for_account(self, account_uuid: str, code: str) -> bool:
|
||||
"""Validate a live TOTP code for an enabled credential."""
|
||||
credential = await self.get_credential(account_uuid)
|
||||
if credential is None or not credential.enabled:
|
||||
return False
|
||||
try:
|
||||
secret = self._decrypt_secret(credential.secret_encrypted)
|
||||
except TotpInvalidCodeError:
|
||||
return False
|
||||
if not self.verify_code(secret, code):
|
||||
return False
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
record = await session.scalar(self._credential_statement(account_uuid))
|
||||
if record is not None:
|
||||
record.last_used_at = datetime.datetime.now()
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _match_recovery_code(cls, code: str, hashed_codes: list[str]) -> int:
|
||||
"""Return the index of the matching digest, or -1. Constant-time per entry."""
|
||||
candidate = cls._normalise_recovery_code(code)
|
||||
for index, stored in enumerate(hashed_codes):
|
||||
parsed = cls._split_recovery_digest(stored)
|
||||
if parsed is None:
|
||||
continue
|
||||
iterations, salt, expected = parsed
|
||||
digest = hashlib.pbkdf2_hmac('sha256', candidate.encode('utf-8'), salt, iterations)
|
||||
if hmac.compare_digest(digest, expected):
|
||||
return index
|
||||
return -1
|
||||
|
||||
async def redeem_recovery_code(self, account_uuid: str, code: str) -> bool:
|
||||
"""Consume a one-time recovery code for password reset fallback."""
|
||||
credential = await self.get_credential(account_uuid)
|
||||
if credential is None:
|
||||
return False
|
||||
|
||||
hashed_codes: list[str] = []
|
||||
if credential.recovery_codes:
|
||||
try:
|
||||
parsed = json.loads(credential.recovery_codes)
|
||||
if isinstance(parsed, list):
|
||||
hashed_codes = [str(item) for item in parsed]
|
||||
except (ValueError, TypeError):
|
||||
hashed_codes = []
|
||||
|
||||
# Recomputing PBKDF2 for up to 10 salted digests is CPU-bound; keep it
|
||||
# off the event loop so a recovery attempt cannot stall other requests.
|
||||
matched_index = await asyncio.to_thread(self._match_recovery_code, code or '', hashed_codes)
|
||||
if matched_index < 0:
|
||||
return False
|
||||
|
||||
remaining = hashed_codes[:matched_index] + hashed_codes[matched_index + 1 :]
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
record = await session.scalar(self._credential_statement(account_uuid))
|
||||
if record is not None:
|
||||
record.recovery_codes = json.dumps(remaining)
|
||||
record.last_used_at = datetime.datetime.now()
|
||||
return True
|
||||
|
||||
async def regenerate_recovery_codes(self, account_uuid: str) -> tuple[None, list[str]]:
|
||||
"""Replace the recovery codes for an enabled credential.
|
||||
|
||||
The caller is responsible for proving possession of a valid TOTP code
|
||||
first; this method only swaps the stored digests for a fresh set and
|
||||
returns the plaintext codes for one-time display.
|
||||
"""
|
||||
credential = await self.get_credential(account_uuid)
|
||||
if credential is None or not credential.enabled:
|
||||
raise TotpNotEnabledError('TOTP is not enabled for this account')
|
||||
|
||||
plaintext_codes, hashed_codes = await self.generate_recovery_codes()
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
record = await session.scalar(self._credential_statement(account_uuid))
|
||||
if record is None:
|
||||
raise TotpNotEnabledError('TOTP is not enabled for this account')
|
||||
record.recovery_codes = json.dumps(hashed_codes)
|
||||
record.updated_at = datetime.datetime.now()
|
||||
return None, plaintext_codes
|
||||
|
||||
async def disable(self, account_uuid: str, code: str) -> bool:
|
||||
"""Remove TOTP after the caller proves possession of a valid factor."""
|
||||
credential = await self.get_credential(account_uuid)
|
||||
if credential is None or not credential.enabled:
|
||||
raise TotpNotEnabledError('TOTP is not enabled for this account')
|
||||
|
||||
try:
|
||||
secret = self._decrypt_secret(credential.secret_encrypted)
|
||||
code_matches = self.verify_code(secret, code)
|
||||
except TotpInvalidCodeError:
|
||||
code_matches = False
|
||||
if not code_matches:
|
||||
raise TotpInvalidCodeError('Invalid verification code')
|
||||
|
||||
async with self._session_factory()() as session:
|
||||
async with session.begin():
|
||||
record = await session.scalar(self._credential_statement(account_uuid))
|
||||
if record is not None:
|
||||
await session.delete(record)
|
||||
return True
|
||||
|
||||
async def remaining_recovery_codes(self, account_uuid: str) -> int:
|
||||
"""Return how many unused recovery codes remain for the Account."""
|
||||
credential = await self.get_credential(account_uuid)
|
||||
if credential is None or not credential.recovery_codes:
|
||||
return 0
|
||||
try:
|
||||
parsed = json.loads(credential.recovery_codes)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
return len(parsed) if isinstance(parsed, list) else 0
|
||||
@@ -34,6 +34,7 @@ from ..api.http.service import apikey as apikey_service
|
||||
from ..api.http.service import webhook as webhook_service
|
||||
from ..api.http.service import monitoring as monitoring_service
|
||||
from ..api.http.service import skill as skill_service
|
||||
from ..api.http.service import totp as totp_service
|
||||
from ..api.http.service import maintenance as maintenance_service
|
||||
from ..discover import engine as discover_engine
|
||||
from ..storage import mgr as storagemgr
|
||||
@@ -161,6 +162,8 @@ class Application:
|
||||
|
||||
user_service: user_service.UserService = None
|
||||
|
||||
totp_service: totp_service.TotpService = None
|
||||
|
||||
space_service: space_service.SpaceService = None
|
||||
|
||||
llm_model_service: model_service.LLMModelsService = None
|
||||
|
||||
@@ -28,6 +28,7 @@ from ...api.http.service import apikey as apikey_service
|
||||
from ...api.http.service import webhook as webhook_service
|
||||
from ...api.http.service import monitoring as monitoring_service
|
||||
from ...api.http.service import skill as skill_service
|
||||
from ...api.http.service import totp as totp_service
|
||||
from ...skill import manager as skill_mgr
|
||||
from ...api.http.service import maintenance as maintenance_service
|
||||
from ...discover import engine as discover_engine
|
||||
@@ -198,6 +199,9 @@ class BuildAppStage(stage.BootingStage):
|
||||
user_service_inst = user_service.UserService(ap)
|
||||
ap.user_service = user_service_inst
|
||||
|
||||
totp_service_inst = totp_service.TotpService(ap)
|
||||
ap.totp_service = totp_service_inst
|
||||
|
||||
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,60 @@
|
||||
"""Persistence entity for per-Account TOTP (RFC 6238) second factors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as uuid_lib
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class TotpCredential(Base):
|
||||
"""Per-Account TOTP (RFC 6238) second factor and its recovery codes.
|
||||
|
||||
A single row is kept per Account. The shared secret is stored encrypted
|
||||
(``secret_encrypted``, Fernet keyed off the instance JWT secret via HKDF)
|
||||
rather than in plaintext, and remains unenforced until the owner confirms
|
||||
possession by submitting a valid code (``enabled``). Recovery codes are
|
||||
stored only as salted PBKDF2-HMAC-SHA256 digests, so a database leak does
|
||||
not hand out account recovery. No plaintext secret or recovery code is ever
|
||||
persisted; both leave the server exactly once, in the enrolment response.
|
||||
"""
|
||||
|
||||
__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,
|
||||
)
|
||||
# Fernet-encrypted base32 secret; never exposed to the client after enrol.
|
||||
secret_encrypted = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
|
||||
# Issuer label shown inside the authenticator app (e.g. the account email).
|
||||
account_name = sqlalchemy.Column(sqlalchemy.String(320), nullable=False)
|
||||
enabled = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, server_default='0')
|
||||
# JSON-encoded list of salted PBKDF2 hashes for the one-time recovery codes.
|
||||
recovery_codes = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
last_used_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
created_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
nullable=False,
|
||||
server_default=sqlalchemy.func.now(),
|
||||
)
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
nullable=False,
|
||||
server_default=sqlalchemy.func.now(),
|
||||
onupdate=sqlalchemy.func.now(),
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('uq_totp_credentials_uuid', 'uuid', unique=True),
|
||||
sqlalchemy.Index('uq_totp_credentials_account', 'account_uuid', unique=True),
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""add totp credentials table
|
||||
|
||||
Revision ID: 0025_totp_credentials
|
||||
Revises: 0024_passkey_credentials
|
||||
Create Date: 2026-09-12
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
_TABLE_NAME = 'totp_credentials'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
existing_tables = set(sa.inspect(conn).get_table_names())
|
||||
if _TABLE_NAME not in existing_tables:
|
||||
op.create_table(
|
||||
_TABLE_NAME,
|
||||
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_encrypted', sa.Text(), nullable=False),
|
||||
sa.Column('account_name', sa.String(320), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('recovery_codes', sa.Text(), nullable=True),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('uq_totp_credentials_uuid', _TABLE_NAME, ['uuid'], unique=True)
|
||||
op.create_index('uq_totp_credentials_account', _TABLE_NAME, ['account_uuid'], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('uq_totp_credentials_account', table_name=_TABLE_NAME)
|
||||
op.drop_index('uq_totp_credentials_uuid', table_name=_TABLE_NAME)
|
||||
op.drop_table(_TABLE_NAME)
|
||||
@@ -101,18 +101,6 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
self._collection_configs: Dict[str, HNSWConfiguration] = {}
|
||||
self._runtime_cache_limit = runtime_cache_limit(ap)
|
||||
|
||||
self._escape_table = str.maketrans(
|
||||
{
|
||||
'\x00': '',
|
||||
'\\': '\\\\',
|
||||
"'": "''", # Standard SQL escaping (OceanBase NO_BACKSLASH_ESCAPES)
|
||||
'"': '\\"',
|
||||
'\n': '\\n',
|
||||
'\r': '\\r',
|
||||
'\t': '\\t',
|
||||
}
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
self._collections.clear()
|
||||
self._collection_configs.clear()
|
||||
@@ -192,16 +180,22 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
return coll
|
||||
|
||||
def _clean_metadata(self, meta: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""SeekDB metadata doesn't support \\ and ", insert will error 3104"""
|
||||
return {
|
||||
k: v.translate(self._escape_table)
|
||||
if isinstance(v, str)
|
||||
else v
|
||||
if v is None or isinstance(v, (int, float, bool))
|
||||
else str(v)
|
||||
for k, v in meta.items()
|
||||
if v is not None
|
||||
}
|
||||
"""Keep supported scalar metadata values without altering strings."""
|
||||
return {k: v if isinstance(v, (str, int, float, bool)) else str(v) for k, v in meta.items() if v is not None}
|
||||
|
||||
@staticmethod
|
||||
def _relevance_scores_to_distances(results: Dict[str, Any]) -> None:
|
||||
"""Convert SeekDB hybrid relevance scores to lower-is-better distances."""
|
||||
distances = results.get('distances')
|
||||
if not isinstance(distances, list):
|
||||
return
|
||||
|
||||
results['distances'] = [
|
||||
[1.0 - float(score) if isinstance(score, (int, float)) else score for score in batch]
|
||||
if isinstance(batch, list)
|
||||
else batch
|
||||
for batch in distances
|
||||
]
|
||||
|
||||
async def get_or_create_collection(self, collection: str):
|
||||
"""Get or create collection (without vector size - will use default)."""
|
||||
@@ -236,10 +230,10 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
|
||||
kwargs: Dict[str, Any] = dict(ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas)
|
||||
if documents is not None:
|
||||
kwargs['documents'] = [doc.translate(self._escape_table) for doc in documents]
|
||||
await asyncio.to_thread(coll.add, **kwargs)
|
||||
kwargs['documents'] = documents
|
||||
await asyncio.to_thread(coll.upsert, **kwargs)
|
||||
|
||||
self.ap.logger.info(f"Added {len(ids)} embeddings to SeekDB collection '{collection}'")
|
||||
self.ap.logger.info(f"Upserted {len(ids)} embeddings into SeekDB collection '{collection}'")
|
||||
|
||||
async def search(
|
||||
self,
|
||||
@@ -287,7 +281,8 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
# Route by search type.
|
||||
# pyseekdb's query() always requires embeddings, so full-text and
|
||||
# hybrid modes use hybrid_search() which supports text-only queries
|
||||
# and returns the same nested-list format with distances.
|
||||
# and returns relevance scores in the nested ``distances`` field.
|
||||
returns_relevance_scores = False
|
||||
if search_type == SearchType.FULL_TEXT:
|
||||
if not query_text:
|
||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
|
||||
@@ -309,6 +304,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
n_results=k,
|
||||
include=['documents', 'metadatas'],
|
||||
)
|
||||
returns_relevance_scores = True
|
||||
|
||||
elif search_type == SearchType.HYBRID:
|
||||
if not query_text:
|
||||
@@ -352,6 +348,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
n_results=k,
|
||||
include=['documents', 'metadatas'],
|
||||
)
|
||||
returns_relevance_scores = True
|
||||
self.ap.logger.info(
|
||||
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
|
||||
)
|
||||
@@ -363,6 +360,8 @@ class SeekDBVectorDatabase(VectorDatabase):
|
||||
results = await asyncio.to_thread(coll.query, **query_kwargs)
|
||||
|
||||
results = self._json_safe(results)
|
||||
if returns_relevance_scores:
|
||||
self._relevance_scores_to_distances(results)
|
||||
self.ap.logger.info(
|
||||
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Real embedded SeekDB regression tests.
|
||||
|
||||
Install the optional dependency before running these slow tests::
|
||||
|
||||
uv sync --dev --extra seekdb
|
||||
uv run pytest tests/integration/vector/test_seekdb.py -m slow -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip('pyseekdb')
|
||||
|
||||
from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.slow]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def backend(tmp_path):
|
||||
app = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={
|
||||
'vdb': {
|
||||
'runtime_cache_limit': 16,
|
||||
'seekdb': {
|
||||
'mode': 'embedded',
|
||||
'path': str(tmp_path),
|
||||
'database': 'langbot_test',
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
logger=SimpleNamespace(
|
||||
info=lambda *args, **kwargs: None,
|
||||
warning=lambda *args, **kwargs: None,
|
||||
),
|
||||
)
|
||||
database = SeekDBVectorDatabase(app)
|
||||
collection = f'test_{uuid.uuid4().hex}'
|
||||
yield database, collection
|
||||
|
||||
await database.delete_collection(collection)
|
||||
await database.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_and_text_round_trip(backend) -> None:
|
||||
database, collection = backend
|
||||
original = 'He said "hello".\nC:\\notes\\file.txt isn\'t empty. 中文'
|
||||
updated = f'Updated: {original}'
|
||||
|
||||
await database.add_embeddings(
|
||||
collection,
|
||||
['document-a'],
|
||||
[[1.0, 0.0, 0.0]],
|
||||
[{'file_id': 'file-a', 'text': original}],
|
||||
[original],
|
||||
)
|
||||
await database.add_embeddings(
|
||||
collection,
|
||||
['document-a'],
|
||||
[[0.0, 1.0, 0.0]],
|
||||
[{'file_id': 'file-a', 'text': updated}],
|
||||
[updated],
|
||||
)
|
||||
|
||||
items, _ = await database.list_by_filter(collection, {'file_id': 'file-a'})
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0]['id'] == 'document-a'
|
||||
assert items[0]['document'] == updated
|
||||
assert items[0]['metadata']['text'] == updated
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_text_and_hybrid_results_keep_relevance_order(backend) -> None:
|
||||
database, collection = backend
|
||||
documents = [
|
||||
'orchid orchid orchid flower',
|
||||
'orchid grows in a garden with many other beautiful plants',
|
||||
'a completely unrelated topic',
|
||||
]
|
||||
|
||||
await database.add_embeddings(
|
||||
collection,
|
||||
['best', 'weak', 'noise'],
|
||||
[[1.0, 0.0, 0.0], [0.9, 0.1, 0.0], [0.0, 0.0, 1.0]],
|
||||
[
|
||||
{'file_id': item_id, 'document_id': item_id, 'text': document}
|
||||
for item_id, document in zip(['best', 'weak', 'noise'], documents, strict=True)
|
||||
],
|
||||
documents,
|
||||
)
|
||||
seekdb_collection = await database.get_or_create_collection(collection)
|
||||
await asyncio.to_thread(seekdb_collection.refresh_index)
|
||||
|
||||
full_text = await database.search(
|
||||
collection,
|
||||
[1.0, 0.0, 0.0],
|
||||
k=3,
|
||||
search_type='full_text',
|
||||
query_text='orchid',
|
||||
)
|
||||
hybrid = await database.search(
|
||||
collection,
|
||||
[1.0, 0.0, 0.0],
|
||||
k=3,
|
||||
search_type='hybrid',
|
||||
query_text='orchid',
|
||||
vector_weight=0.65,
|
||||
)
|
||||
|
||||
assert full_text['ids'][0][:2] == ['best', 'weak']
|
||||
assert full_text['distances'][0] == sorted(full_text['distances'][0])
|
||||
assert hybrid['ids'][0] == ['best', 'weak', 'noise']
|
||||
assert hybrid['distances'][0] == sorted(hybrid['distances'][0])
|
||||
@@ -12,4 +12,7 @@ def test_seekdb_is_only_declared_as_an_optional_dependency() -> None:
|
||||
project = pyproject['project']
|
||||
base_dependencies = project['dependencies']
|
||||
assert not any(dependency.lower().startswith('pyseekdb') for dependency in base_dependencies)
|
||||
assert project['optional-dependencies']['seekdb'] == ['pyseekdb==1.1.0.post3']
|
||||
assert project['optional-dependencies']['seekdb'] == [
|
||||
'pyseekdb==1.4.0.post1',
|
||||
"pylibseekdb==1.4.0; sys_platform == 'linux' or (sys_platform == 'darwin' and platform_machine == 'arm64')",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase
|
||||
|
||||
|
||||
def _adapter_with_collection(collection: MagicMock) -> SeekDBVectorDatabase:
|
||||
adapter = SeekDBVectorDatabase.__new__(SeekDBVectorDatabase)
|
||||
adapter.ap = SimpleNamespace(logger=MagicMock())
|
||||
adapter.client = MagicMock()
|
||||
adapter.client.has_collection.return_value = True
|
||||
adapter._collections = {'knowledge_base': collection}
|
||||
adapter._runtime_cache_limit = 16
|
||||
return adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_embeddings_upserts_and_preserves_text() -> None:
|
||||
collection = MagicMock()
|
||||
adapter = _adapter_with_collection(collection)
|
||||
adapter._get_or_create_collection_internal = AsyncMock(return_value=collection)
|
||||
original = 'He said "hello".\nC:\\notes\\file.txt isn\'t empty. 中文'
|
||||
|
||||
await adapter.add_embeddings(
|
||||
collection='knowledge_base',
|
||||
ids=['document-a'],
|
||||
embeddings_list=[[1.0, 0.0, 0.0]],
|
||||
metadatas=[{'text': original}],
|
||||
documents=[original],
|
||||
)
|
||||
|
||||
collection.upsert.assert_called_once_with(
|
||||
ids=['document-a'],
|
||||
embeddings=[[1.0, 0.0, 0.0]],
|
||||
metadatas=[{'text': original}],
|
||||
documents=[original],
|
||||
)
|
||||
collection.add.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('search_type', 'scores', 'expected_distances'),
|
||||
[
|
||||
('full_text', [0.4508196721, 0.25], [0.5491803279, 0.75]),
|
||||
('hybrid', [0.0328, 0.0323, 0.0159], [0.9672, 0.9677, 0.9841]),
|
||||
],
|
||||
)
|
||||
async def test_search_converts_relevance_scores_to_distances(
|
||||
search_type: str,
|
||||
scores: list[float],
|
||||
expected_distances: list[float],
|
||||
) -> None:
|
||||
collection = MagicMock()
|
||||
collection.hybrid_search.return_value = {
|
||||
'ids': [['best', 'weak', 'noise'][: len(scores)]],
|
||||
'metadatas': [[{} for _ in scores]],
|
||||
'distances': [scores],
|
||||
}
|
||||
adapter = _adapter_with_collection(collection)
|
||||
|
||||
results = await adapter.search(
|
||||
collection='knowledge_base',
|
||||
query_embedding=[1.0, 0.0, 0.0],
|
||||
k=len(scores),
|
||||
search_type=search_type,
|
||||
query_text='orchid',
|
||||
vector_weight=0.65,
|
||||
)
|
||||
|
||||
assert results['distances'][0] == pytest.approx(expected_distances)
|
||||
assert results['distances'][0] == sorted(results['distances'][0])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vector_search_keeps_seekdb_cosine_distances() -> None:
|
||||
collection = MagicMock()
|
||||
collection.query.return_value = {
|
||||
'ids': [['best', 'weak']],
|
||||
'metadatas': [[{}, {}]],
|
||||
'distances': [[0.1, 0.25]],
|
||||
}
|
||||
adapter = _adapter_with_collection(collection)
|
||||
|
||||
results = await adapter.search(
|
||||
collection='knowledge_base',
|
||||
query_embedding=[1.0, 0.0, 0.0],
|
||||
k=2,
|
||||
search_type='vector',
|
||||
)
|
||||
|
||||
assert results['distances'] == [[0.1, 0.25]]
|
||||
@@ -1066,7 +1066,7 @@ name = "cuda-bindings"
|
||||
version = "13.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cuda-pathfinder" },
|
||||
{ name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
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" },
|
||||
{ name = "nvidia-cuda-runtime", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
cufft = [
|
||||
{ name = "nvidia-cufft" },
|
||||
{ name = "nvidia-cufft", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
cufile = [
|
||||
{ name = "nvidia-cufile" },
|
||||
{ name = "nvidia-cufile", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
cupti = [
|
||||
{ name = "nvidia-cuda-cupti" },
|
||||
{ name = "nvidia-cuda-cupti", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
curand = [
|
||||
{ name = "nvidia-curand" },
|
||||
{ name = "nvidia-curand", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
cusolver = [
|
||||
{ name = "nvidia-cusolver" },
|
||||
{ name = "nvidia-cusolver", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
cusparse = [
|
||||
{ name = "nvidia-cusparse" },
|
||||
{ name = "nvidia-cusparse", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
nvjitlink = [
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
nvrtc = [
|
||||
{ name = "nvidia-cuda-nvrtc" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
nvtx = [
|
||||
{ name = "nvidia-nvtx" },
|
||||
{ name = "nvidia-nvtx", marker = "python_full_version >= '3.14' and sys_platform == 'linux'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2139,6 +2139,7 @@ dependencies = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
seekdb = [
|
||||
{ name = "pylibseekdb", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or sys_platform == 'linux'" },
|
||||
{ name = "pyseekdb" },
|
||||
]
|
||||
|
||||
@@ -2203,10 +2204,11 @@ requires-dist = [
|
||||
{ name = "pycryptodome", specifier = ">=3.22.0" },
|
||||
{ name = "pydantic", specifier = ">2.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.12.0" },
|
||||
{ name = "pylibseekdb", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'seekdb') or (sys_platform == 'linux' and extra == 'seekdb')", specifier = "==1.4.0" },
|
||||
{ name = "pymilvus", specifier = ">=2.6.4" },
|
||||
{ name = "pynacl", specifier = ">=1.5.0" },
|
||||
{ name = "pypdf2", specifier = ">=3.0.1" },
|
||||
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.1.0.post3" },
|
||||
{ name = "pyseekdb", marker = "extra == 'seekdb'", specifier = "==1.4.0.post1" },
|
||||
{ name = "python-docx", specifier = ">=1.1.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.27" },
|
||||
{ name = "python-socks", specifier = ">=2.7.1" },
|
||||
@@ -3297,7 +3299,7 @@ name = "nvidia-cublas"
|
||||
version = "13.1.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cuda-nvrtc" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
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" },
|
||||
@@ -3336,7 +3338,7 @@ name = "nvidia-cudnn-cu13"
|
||||
version = "9.20.0.48"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas" },
|
||||
{ name = "nvidia-cublas", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
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" },
|
||||
@@ -3348,7 +3350,7 @@ name = "nvidia-cufft"
|
||||
version = "12.0.0.61"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
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" },
|
||||
@@ -3378,9 +3380,9 @@ name = "nvidia-cusolver"
|
||||
version = "12.0.4.66"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas" },
|
||||
{ name = "nvidia-cusparse" },
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ 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'" },
|
||||
]
|
||||
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" },
|
||||
@@ -3392,7 +3394,7 @@ name = "nvidia-cusparse"
|
||||
version = "12.6.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-nvjitlink", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
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" },
|
||||
@@ -4482,21 +4484,18 @@ crypto = [
|
||||
|
||||
[[package]]
|
||||
name = "pylibseekdb"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pymysql", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/1e/5d971387d4bcdcf0f6f3c85d681a207c49f20715cf566a88d2222e5cd4c0/pylibseekdb-1.3.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:1d33cf82f34339bc58ac160688fc7d15ac2f7cbb226338d3887fe8350f65b762", size = 142749176, upload-time = "2026-05-25T08:59:18.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/9e/47f4a1ebad7e95169cfff1b87433b38623cc68426b3dfaac244c2492e5d4/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:77ba6786908cd8ab320ed4e5d5ef352759ef8990d72aff913467db5fe32542c4", size = 140878003, upload-time = "2026-05-25T06:11:51.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b1/c772c15444ddec07365c5728624824b7b2137c319398c3cfc44d2e6b09a3/pylibseekdb-1.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4b127c21ac1178ab903735041b6afe25295731d7bcee9813e5e1576c9d384937", size = 160132660, upload-time = "2026-05-25T06:12:02.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/e8/d53bb80f6ed27f19dfb5b2f996cf9bef0e054442d473493e4f2425265762/pylibseekdb-1.3.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:23cd6ad60a80543dfccb4dc9500401347b82fddb8cef10f5503e5eb816adb39f", size = 142736028, upload-time = "2026-05-25T08:59:41.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/e6/3811303e0740e45dd475e6cf8ccea2abb706f047e50455ec1834bdeb6068/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ec2465e206574f5dee7870bde2434a5ab9a03c2001786b1765fcb5dd790d6f98", size = 140881851, upload-time = "2026-05-25T06:12:11.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/29/856ea807cbe997c9fe2df6257106b2b2924ef9458bf87db7e4bd0b8dec03/pylibseekdb-1.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1b78f26dfbb80157169b81f22ebb80957e3c6ee7b33e5ff35beaa4d628c33915", size = 160133328, upload-time = "2026-05-25T06:12:22.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/f1/5ec7782810746e9c065a419e8105a5925b3b04f495296b507706da9dc3b3/pylibseekdb-1.3.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:f6f739454aff786beeccfe71b66a0d89d01b5a8a260e0b8c5c30f8e9184bd88a", size = 142743219, upload-time = "2026-05-25T09:00:08.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/8a/4d8150f6ad5f11dca40a6d42df9e2a41ed47125735a49afc7d2528460cd3/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:89069e1aeeb51f61aeaa0cf5d94bedb918f46c3476d7b30183dde7b2101e5954", size = 140884366, upload-time = "2026-05-25T06:12:31.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/29/0583f2e00dbad80efffd7cb7df6431bd086b01a94d8b69688bae15a52e84/pylibseekdb-1.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2515ea14bbac59e6f9f90a43bbaf179050ad7f8ab683d1cb9fd7fe225ccdca4e", size = 160137143, upload-time = "2026-05-25T06:12:43.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/5d/8c9afc77d32adbb1f7af85c3131419bcc9860677c5d6efb2d8d0ae9a7a66/pylibseekdb-1.3.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:a4177a3a6369699c9791cef3a7bfe7b472af301352237ed6e4cea42034fc0047", size = 142739982, upload-time = "2026-05-25T09:00:26.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/91/bd3f9dea464cc22b454bbe384df3423e36e9fcbe7b1779c861f7ca9721e3/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:8651b8e0324fa78a5ed93b9952f4140c968655c344ef11fdb20d754077efeb05", size = 140896377, upload-time = "2026-05-25T06:12:53.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/f4/fcf930ed8c6d40154f41edfb2054794c786dd66deced3a8cc3fef5898af7/pylibseekdb-1.3.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e6e58bce51e709c46aae3891e723b786132da925b9b6362db4486c07044d99e8", size = 160135373, upload-time = "2026-05-25T06:13:03.535Z" },
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/93/e9a13b996b5561f89c9a4f1b62796f8a6230a5dce215869e3cfef8adc4f1/pylibseekdb-1.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e37b931417b7fc7fc88d15fd8b9b0dad499cd05e693cc483aa3743840e85f0c0", size = 49442143, upload-time = "2026-08-27T13:03:41.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/cd/e54bb304512042cac0514fd607175f5425bd0924330e3eb74937c2afe827/pylibseekdb-1.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6aaa3c9e4865d32f533af04eb8eab06d2c10fc581ac38097d618efb44c05dc5b", size = 53975703, upload-time = "2026-08-27T13:04:41.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/03/4380094699cbd4539971c0b943701f776408c94c28cc3ecdaed7c217bb29/pylibseekdb-1.4.0-cp312-abi3-macosx_15_0_arm64.whl", hash = "sha256:2fee55af299f2992dd5d61c9e239ef8855629f4117ee8b4c21dc877160707004", size = 52171602, upload-time = "2026-08-27T13:05:15.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b5/ea71acbee58925a51cb1a7137afd2d1c4e3fbb5bf2a0144cd53d299a20df/pylibseekdb-1.4.0-cp312-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f606579904a19bcd7ec96bc117251db9d4202485fc7355f2a289804d1b3b2c1b", size = 49438937, upload-time = "2026-08-27T13:03:48.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/f3/452485e45676a7d738720a7a3f7abbf4d24a3d272cbacabef41ae8b8e52c/pylibseekdb-1.4.0-cp312-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2d32d6f0d3b92b0c719b6c4230a24748ce10666f67052c696359e69138d8fbe7", size = 53972507, upload-time = "2026-08-27T13:04:46.946Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4621,7 +4620,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyseekdb"
|
||||
version = "1.1.0.post3"
|
||||
version = "1.4.0.post1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", marker = "python_full_version < '3.14'" },
|
||||
@@ -4635,7 +4634,7 @@ dependencies = [
|
||||
{ name = "tqdm", marker = "python_full_version < '3.14'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/6e/2373239ab80c35a17aa14e8219727f06567e91d3b7f1b8c36d28ce94d04b/pyseekdb-1.1.0.post3-py3-none-any.whl", hash = "sha256:0437c9a4de72be44eb24b070b2b8099086467c08af10a57191498a61257a4bfb", size = 110985, upload-time = "2026-02-12T14:19:05.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/87/d5dd862faa3d4adf3847c1ce19c3ea5ecd0dcfda9c2584a95bfd2b0fac0f/pyseekdb-1.4.0.post1-py3-none-any.whl", hash = "sha256:a3379f6962a0c01aa029d3e5a8f0c0f5a59b27a689b8aae1931d9ce5563f252c", size = 158375, upload-time = "2026-08-03T08:56:59.501Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5256,10 +5255,10 @@ name = "scikit-learn"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "joblib" },
|
||||
{ name = "numpy" },
|
||||
{ name = "scipy" },
|
||||
{ name = "threadpoolctl" },
|
||||
{ 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'" },
|
||||
]
|
||||
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 = [
|
||||
@@ -5306,7 +5305,7 @@ name = "scipy"
|
||||
version = "1.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||
]
|
||||
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 = [
|
||||
@@ -5377,14 +5376,14 @@ name = "sentence-transformers"
|
||||
version = "5.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "numpy" },
|
||||
{ name = "scikit-learn" },
|
||||
{ name = "scipy" },
|
||||
{ name = "torch" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "transformers" },
|
||||
{ name = "typing-extensions" },
|
||||
{ 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'" },
|
||||
]
|
||||
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 = [
|
||||
@@ -5757,21 +5756,21 @@ name = "torch"
|
||||
version = "2.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ 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" },
|
||||
{ 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'" },
|
||||
]
|
||||
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" },
|
||||
@@ -5813,15 +5812,15 @@ name = "transformers"
|
||||
version = "5.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huggingface-hub" },
|
||||
{ name = "numpy" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "regex" },
|
||||
{ name = "safetensors" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "typer" },
|
||||
{ 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'" },
|
||||
]
|
||||
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 = [
|
||||
@@ -6082,9 +6081,9 @@ name = "valkey-glide"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "anyio", marker = "sys_platform != 'win32'" },
|
||||
{ name = "protobuf", marker = "sys_platform != 'win32'" },
|
||||
{ name = "sniffio", marker = "sys_platform != 'win32'" },
|
||||
]
|
||||
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,11 @@ import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Pencil,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
|
||||
import TotpEnrollDialog from './TotpEnrollDialog';
|
||||
import { PanelBody } from '../settings-dialog/panel-layout';
|
||||
|
||||
interface AccountSettingsPanelProps {
|
||||
@@ -56,11 +58,16 @@ export default function AccountSettingsPanel({
|
||||
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]);
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const [registeringPasskey, setRegisteringPasskey] = useState(false);
|
||||
const [totpEnabled, setTotpEnabled] = useState(false);
|
||||
const [remainingRecoveryCodes, setRemainingRecoveryCodes] = useState(0);
|
||||
const [totpLoading, setTotpLoading] = useState(false);
|
||||
const [totpDialogOpen, setTotpDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
loadUserInfo();
|
||||
loadPasskeys();
|
||||
loadTotpStatus();
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
@@ -91,6 +98,19 @@ export default function AccountSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTotpStatus() {
|
||||
setTotpLoading(true);
|
||||
try {
|
||||
const status = await httpClient.getTotpStatus();
|
||||
setTotpEnabled(status.enabled);
|
||||
setRemainingRecoveryCodes(status.remaining_recovery_codes);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setTotpLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
setRegisteringPasskey(true);
|
||||
try {
|
||||
@@ -332,6 +352,56 @@ export default function AccountSettingsPanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TOTP (2FA) Section */}
|
||||
<div className="pt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">
|
||||
{t('account.totpSectionTitle')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('account.totpSectionDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setTotpDialogOpen(true)}
|
||||
disabled={totpLoading || !systemInfo.allow_modify_login_info}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{totpLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{totpEnabled
|
||||
? t('account.disableTotp')
|
||||
: t('account.enableTotp')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Item size="sm" variant="muted" className="rounded-lg">
|
||||
<ItemMedia variant="icon">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
{totpEnabled
|
||||
? t('account.totpEnabled')
|
||||
: t('account.totpDisabled')}
|
||||
</ItemTitle>
|
||||
<ItemDescription>
|
||||
{totpEnabled
|
||||
? t('account.totpRecoveryCodesRemaining', {
|
||||
count: remainingRecoveryCodes,
|
||||
})
|
||||
: t('account.totpSectionDesc')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -340,6 +410,13 @@ export default function AccountSettingsPanel({
|
||||
onOpenChange={handlePasswordDialogClose}
|
||||
hasPassword={hasPassword}
|
||||
/>
|
||||
|
||||
<TotpEnrollDialog
|
||||
open={totpDialogOpen}
|
||||
onOpenChange={setTotpDialogOpen}
|
||||
enabled={totpEnabled}
|
||||
onChanged={loadTotpStatus}
|
||||
/>
|
||||
</PanelBody>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1241,10 +1241,21 @@ export class BackendClient extends BaseHttpClient {
|
||||
);
|
||||
}
|
||||
|
||||
public authUser(user: string, password: string): Promise<ApiRespUserToken> {
|
||||
public authUser(
|
||||
user: string,
|
||||
password: string,
|
||||
secondFactor?: { totpCode?: string; recoveryCode?: string },
|
||||
): Promise<ApiRespUserToken> {
|
||||
return this.post(
|
||||
'/api/v1/user/auth',
|
||||
{ user, password },
|
||||
{
|
||||
user,
|
||||
password,
|
||||
...(secondFactor?.totpCode ? { totp_code: secondFactor.totpCode } : {}),
|
||||
...(secondFactor?.recoveryCode
|
||||
? { recovery_code: secondFactor.recoveryCode }
|
||||
: {}),
|
||||
},
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
@@ -1257,15 +1268,25 @@ export class BackendClient extends BaseHttpClient {
|
||||
|
||||
public resetPassword(
|
||||
user: string,
|
||||
recoveryKey: string,
|
||||
newPassword: string,
|
||||
factor:
|
||||
| { recoveryKey: string }
|
||||
| { totpCode: string }
|
||||
| { recoveryCode: string },
|
||||
): Promise<{ user: string }> {
|
||||
return this.post(
|
||||
'/api/v1/user/reset-password',
|
||||
{
|
||||
user,
|
||||
recovery_key: recoveryKey,
|
||||
new_password: newPassword,
|
||||
// Exactly one proof-of-ownership factor is accepted by the backend.
|
||||
...('recoveryKey' in factor
|
||||
? { recovery_key: factor.recoveryKey }
|
||||
: {}),
|
||||
...('totpCode' in factor ? { totp_code: factor.totpCode } : {}),
|
||||
...('recoveryCode' in factor
|
||||
? { recovery_code: factor.recoveryCode }
|
||||
: {}),
|
||||
},
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
@@ -1290,6 +1311,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
user: string;
|
||||
account_type: 'local' | 'space';
|
||||
has_password: boolean;
|
||||
totp_enabled?: boolean;
|
||||
}> {
|
||||
return this.get('/api/v1/user/info', undefined, { skipWorkspace: true });
|
||||
}
|
||||
@@ -1306,12 +1328,28 @@ export class BackendClient extends BaseHttpClient {
|
||||
space_login_enabled?: boolean;
|
||||
passkey_login_enabled?: boolean;
|
||||
passkey_supported?: boolean;
|
||||
totp_supported?: boolean;
|
||||
}> {
|
||||
return this.get('/api/v1/user/account-info', undefined, {
|
||||
skipWorkspace: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account identified by the given email has TOTP enabled.
|
||||
*
|
||||
* This endpoint is unauthenticated so the password-recovery page can decide
|
||||
* whether to offer the TOTP / recovery-code verification methods. The
|
||||
* response only exposes the boolean capability.
|
||||
*/
|
||||
public checkTotpForEmail(user: string): Promise<{ totp_enabled: boolean }> {
|
||||
return this.post(
|
||||
'/api/v1/user/totp/check',
|
||||
{ user },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Passkey (WebAuthn) API ============
|
||||
public getPasskeyAuthOptions(
|
||||
email?: string,
|
||||
@@ -1390,6 +1428,51 @@ export class BackendClient extends BaseHttpClient {
|
||||
});
|
||||
}
|
||||
|
||||
// ============ TOTP (2FA) API ============
|
||||
public getTotpStatus(): Promise<{
|
||||
enabled: boolean;
|
||||
remaining_recovery_codes: number;
|
||||
}> {
|
||||
return this.get('/api/v1/user/totp/status', undefined, {
|
||||
skipWorkspace: true,
|
||||
});
|
||||
}
|
||||
|
||||
public beginTotpEnrollment(): Promise<{
|
||||
secret: string;
|
||||
otpauth_uri: string;
|
||||
qr_svg: string;
|
||||
recovery_codes: string[];
|
||||
}> {
|
||||
return this.post('/api/v1/user/totp/enroll', {}, { skipWorkspace: true });
|
||||
}
|
||||
|
||||
public verifyTotpEnrollment(code: string): Promise<{ enabled: boolean }> {
|
||||
return this.post(
|
||||
'/api/v1/user/totp/enroll/verify',
|
||||
{ code },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public regenerateTotpRecoveryCodes(
|
||||
code: string,
|
||||
): Promise<{ recovery_codes: string[] }> {
|
||||
return this.post(
|
||||
'/api/v1/user/totp/recovery-codes',
|
||||
{ code },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public disableTotp(code: string): Promise<{ success: boolean }> {
|
||||
return this.post(
|
||||
'/api/v1/user/totp/disable',
|
||||
{ code },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Workspace API ============
|
||||
public getWorkspaceBootstrap(): Promise<WorkspaceBootstrapResponse> {
|
||||
return this.get('/api/v1/workspaces/bootstrap', undefined, {
|
||||
|
||||
+124
-15
@@ -36,6 +36,7 @@ import {
|
||||
RefreshCw,
|
||||
Layers,
|
||||
Fingerprint,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
@@ -71,6 +72,15 @@ export default function Login() {
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const autoSpaceLoginStarted = useRef(false);
|
||||
// Second-factor state: when /auth replies with totp_required we keep the
|
||||
// credentials and ask for a TOTP or recovery code instead of a password.
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [totpSubmitting, setTotpSubmitting] = useState(false);
|
||||
const [pendingCredentials, setPendingCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
|
||||
resolver: zodResolver(formSchema(t)),
|
||||
@@ -223,11 +233,49 @@ export default function Login() {
|
||||
toast.success(t('common.loginSuccess'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error: unknown) => {
|
||||
const apiError = error as { code?: string };
|
||||
if (apiError?.code === 'totp_required') {
|
||||
// Password was accepted; the account additionally requires TOTP.
|
||||
setPendingCredentials({ username, password });
|
||||
setTotpCode('');
|
||||
setTotpRequired(true);
|
||||
return;
|
||||
}
|
||||
toast.error(t('common.loginFailed'));
|
||||
});
|
||||
}
|
||||
|
||||
async function handleTotpSubmit() {
|
||||
if (!pendingCredentials || !totpCode.trim()) {
|
||||
return;
|
||||
}
|
||||
setTotpSubmitting(true);
|
||||
try {
|
||||
const code = totpCode.trim();
|
||||
// A recovery code is longer than six digits; treat it as such so users
|
||||
// can sign in even when the authenticator is unavailable.
|
||||
const isRecoveryCode = code.replace(/\s/g, '').length !== 6;
|
||||
const res = await httpClient.authUser(
|
||||
pendingCredentials.username,
|
||||
pendingCredentials.password,
|
||||
isRecoveryCode ? { recoveryCode: code } : { totpCode: code },
|
||||
);
|
||||
setTotpRequired(false);
|
||||
setPendingCredentials(null);
|
||||
if (await finishLogin(res.token, pendingCredentials.username)) {
|
||||
toast.success(t('common.loginSuccess'));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const apiError = error as { code?: string; message?: string };
|
||||
// Keep the second-factor step open so the user can retry; surface the
|
||||
// server message when available.
|
||||
toast.error(apiError?.message || t('common.loginTotpInvalid'));
|
||||
} finally {
|
||||
setTotpSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSpaceLoginClick = useCallback(async () => {
|
||||
setSpaceLoading(true);
|
||||
try {
|
||||
@@ -336,8 +384,67 @@ export default function Login() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* TOTP second-factor step: shown after the password is accepted. */}
|
||||
{totpRequired && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<ShieldCheck className="h-8 w-8 text-primary" />
|
||||
<p className="text-sm font-medium">
|
||||
{t('common.loginTotpTitle')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('common.loginTotpDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<ShieldCheck className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value)}
|
||||
placeholder={t('common.loginTotpPlaceholder')}
|
||||
className="pl-10 font-mono tracking-widest"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleTotpSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full cursor-pointer"
|
||||
onClick={handleTotpSubmit}
|
||||
disabled={totpSubmitting || !totpCode.trim()}
|
||||
>
|
||||
{totpSubmitting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{totpSubmitting
|
||||
? t('common.loginTotpVerifying')
|
||||
: t('common.loginTotpVerify')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full cursor-pointer"
|
||||
onClick={() => {
|
||||
setTotpRequired(false);
|
||||
setPendingCredentials(null);
|
||||
setTotpCode('');
|
||||
}}
|
||||
>
|
||||
{t('common.backToLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Space and password login are per-account capabilities. */}
|
||||
{showSpaceLogin && (
|
||||
{!totpRequired && showSpaceLogin && (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -355,7 +462,7 @@ export default function Login() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPasskeyLogin && (
|
||||
{!totpRequired && showPasskeyLogin && (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -375,21 +482,23 @@ export default function Login() {
|
||||
)}
|
||||
|
||||
{/* Divider - only show if both login methods are available */}
|
||||
{(showSpaceLogin || showPasskeyLogin) && showLocalLogin && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
{!totpRequired &&
|
||||
(showSpaceLogin || showPasskeyLogin) &&
|
||||
showLocalLogin && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
|
||||
{t('common.or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
|
||||
{t('common.or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Password login remains available to every account with a password. */}
|
||||
{showLocalLogin && (
|
||||
{!totpRequired && showLocalLogin && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Mail, Lock, Loader2, Info, Layers } from 'lucide-react';
|
||||
import { Mail, Lock, Loader2, Info, Layers, ShieldCheck } from 'lucide-react';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -236,6 +236,11 @@ export default function Register() {
|
||||
>
|
||||
{t('register.registerWithPassword')}
|
||||
</Button>
|
||||
{/* Recommend enabling TOTP once the account exists */}
|
||||
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
<span>{t('register.totpHint')}</span>
|
||||
</p>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
|
||||
@@ -19,19 +19,24 @@ import {
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from '@/components/ui/form';
|
||||
import { useState } from 'react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Mail, Lock, ArrowLeft, KeyRound } from 'lucide-react';
|
||||
import { Mail, Lock, ArrowLeft, KeyRound, ShieldCheck } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
|
||||
type RecoveryMethod = 'recoveryKey' | 'totp' | 'recoveryCode';
|
||||
|
||||
const formSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
email: z.string().email(t('common.invalidEmail')),
|
||||
recoveryKey: z.string().min(1, t('resetPassword.recoveryKeyRequired')),
|
||||
recoveryKey: z.string().optional(),
|
||||
totpCode: z.string().optional(),
|
||||
recoveryCode: z.string().optional(),
|
||||
newPassword: z.string().min(1, t('resetPassword.newPasswordRequired')),
|
||||
});
|
||||
|
||||
@@ -39,34 +44,129 @@ export default function ResetPassword() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [method, setMethod] = useState<RecoveryMethod>('recoveryKey');
|
||||
// Whether TOTP is enabled for the email currently entered. `null` means we have
|
||||
// not yet resolved it (empty/invalid email), so the TOTP methods stay disabled
|
||||
// until we can confirm the account actually enrolled one.
|
||||
const [totpEnabledForEmail, setTotpEnabledForEmail] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
|
||||
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
|
||||
resolver: zodResolver(formSchema(t)),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
recoveryKey: '',
|
||||
totpCode: '',
|
||||
recoveryCode: '',
|
||||
newPassword: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Watch the email so we can resolve, per account, whether TOTP is enabled.
|
||||
const email = form.watch('email');
|
||||
|
||||
// Resolve whether the entered email has TOTP enabled; only then may the user
|
||||
// pick the TOTP / recovery-code verification methods. While unresolved (empty
|
||||
// or invalid email) both TOTP methods stay disabled, so an account without
|
||||
// TOTP can never select them.
|
||||
useEffect(() => {
|
||||
if (!email || !z.string().email().safeParse(email).success) {
|
||||
setTotpEnabledForEmail(null);
|
||||
setMethod('recoveryKey');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
// Debounce so we only query once the user pauses typing.
|
||||
const timer = setTimeout(() => {
|
||||
httpClient
|
||||
.checkTotpForEmail(email)
|
||||
.then((res) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setTotpEnabledForEmail(res.totp_enabled);
|
||||
if (!res.totp_enabled) {
|
||||
setMethod('recoveryKey');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
// Fail closed: if we cannot confirm TOTP, only the recovery key is
|
||||
// offered rather than letting an unverified TOTP path through.
|
||||
setTotpEnabledForEmail(null);
|
||||
setMethod('recoveryKey');
|
||||
}
|
||||
});
|
||||
}, 400);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [email]);
|
||||
|
||||
const totpMethodsDisabled = totpEnabledForEmail !== true;
|
||||
|
||||
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
|
||||
handleResetPassword(values.email, values.recoveryKey, values.newPassword);
|
||||
if (method === 'recoveryKey') {
|
||||
if (!values.recoveryKey || !values.recoveryKey.trim()) {
|
||||
toast.error(t('resetPassword.recoveryKeyRequired'));
|
||||
return;
|
||||
}
|
||||
handleResetPassword(
|
||||
values.email,
|
||||
{ recoveryKey: values.recoveryKey.trim() },
|
||||
values.newPassword,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (method === 'totp') {
|
||||
if (!values.totpCode || !values.totpCode.trim()) {
|
||||
toast.error(t('resetPassword.totpCodeRequired'));
|
||||
return;
|
||||
}
|
||||
handleResetPassword(
|
||||
values.email,
|
||||
{ totpCode: values.totpCode.trim() },
|
||||
values.newPassword,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!values.recoveryCode || !values.recoveryCode.trim()) {
|
||||
toast.error(t('resetPassword.recoveryCodeRequired'));
|
||||
return;
|
||||
}
|
||||
handleResetPassword(
|
||||
values.email,
|
||||
{ recoveryCode: values.recoveryCode.trim() },
|
||||
values.newPassword,
|
||||
);
|
||||
}
|
||||
|
||||
function handleResetPassword(
|
||||
email: string,
|
||||
recoveryKey: string,
|
||||
factor:
|
||||
| { recoveryKey: string }
|
||||
| { totpCode: string }
|
||||
| { recoveryCode: string },
|
||||
newPassword: string,
|
||||
) {
|
||||
setIsResetting(true);
|
||||
httpClient
|
||||
.resetPassword(email, recoveryKey, newPassword)
|
||||
.resetPassword(email, newPassword, factor)
|
||||
.then(() => {
|
||||
toast.success(t('resetPassword.resetSuccess'));
|
||||
navigate('/login');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t('resetPassword.resetFailed'));
|
||||
.catch((error: unknown) => {
|
||||
const apiError = error as { code?: string };
|
||||
if (apiError?.code === 'totp_not_enabled') {
|
||||
toast.error(t('resetPassword.totpNotEnabled'));
|
||||
} else if (apiError?.code === 'totp_invalid_code') {
|
||||
toast.error(t('resetPassword.invalidTotpCode'));
|
||||
} else {
|
||||
toast.error(t('resetPassword.resetFailed'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsResetting(false);
|
||||
@@ -118,32 +218,124 @@ export default function ResetPassword() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recoveryKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('resetPassword.recoveryKey')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('resetPassword.recoveryKeyDescription')}
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
{/* Recovery keys are case-sensitive base64url strings; send them verbatim */}
|
||||
<div className="relative">
|
||||
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('resetPassword.enterRecoveryKey')}
|
||||
className="pl-10 font-mono"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
{/* Recovery method selector: recovery key, TOTP, or recovery code.
|
||||
The TOTP-based methods are only selectable once we have
|
||||
confirmed the entered account actually enrolled TOTP. */}
|
||||
<div className="space-y-3">
|
||||
<FormLabel>{t('resetPassword.verifyMethod')}</FormLabel>
|
||||
<Tabs
|
||||
value={method}
|
||||
onValueChange={(v) => setMethod(v as RecoveryMethod)}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="recoveryKey" className="flex-1">
|
||||
{t('resetPassword.recoveryKey')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="totp"
|
||||
className="flex-1"
|
||||
disabled={totpMethodsDisabled}
|
||||
>
|
||||
{t('resetPassword.totpMethod')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="recoveryCode"
|
||||
className="flex-1"
|
||||
disabled={totpMethodsDisabled}
|
||||
>
|
||||
{t('resetPassword.recoveryCodeMethod')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{totpMethodsDisabled && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('resetPassword.totpMethodsUnavailable')}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{method === 'recoveryKey' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recoveryKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('resetPassword.recoveryKey')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('resetPassword.recoveryKeyDescription')}
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
{/* Recovery keys are case-sensitive base64url strings; send them verbatim */}
|
||||
<div className="relative">
|
||||
<KeyRound className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('resetPassword.enterRecoveryKey')}
|
||||
className="pl-10 font-mono"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{method === 'totp' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="totpCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('resetPassword.totpCode')}</FormLabel>
|
||||
<FormDescription>
|
||||
{t('resetPassword.totpMethodDescription')}
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<ShieldCheck className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('resetPassword.enterTotpCode')}
|
||||
className="pl-10 font-mono tracking-widest"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{method === 'recoveryCode' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recoveryCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('resetPassword.recoveryCode')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<ShieldCheck className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('resetPassword.enterRecoveryCode')}
|
||||
className="pl-10 font-mono"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -90,6 +90,13 @@ const enUS = {
|
||||
passkeyLoginSuccess: 'Passkey verified successfully, signing in...',
|
||||
passkeyLoginFailed: 'Failed to sign in with Passkey',
|
||||
passkeyNotSupported: 'Passkey is not supported on this browser or device',
|
||||
loginTotpTitle: 'Two-Factor Verification',
|
||||
loginTotpDesc:
|
||||
'Enter the 6-digit code from your authenticator app, or a recovery code',
|
||||
loginTotpPlaceholder: 'Authenticator or recovery code',
|
||||
loginTotpVerify: 'Verify',
|
||||
loginTotpVerifying: 'Verifying...',
|
||||
loginTotpInvalid: 'Invalid code, please try again',
|
||||
spaceLoginTitle: 'Login with LangBot Account',
|
||||
spaceLoginDescription:
|
||||
'Scan the QR code or visit the link below to authorize',
|
||||
@@ -1279,6 +1286,8 @@ const enUS = {
|
||||
registerWithPassword: 'Register with email and password',
|
||||
initSuccess: 'Initialization successful, please login',
|
||||
initFailed: 'Initialization failed: ',
|
||||
totpHint:
|
||||
'Recommended: enable two-factor authentication (TOTP) after signing in to secure your account.',
|
||||
},
|
||||
resetPassword: {
|
||||
title: 'Reset Password 🔐',
|
||||
@@ -1298,6 +1307,22 @@ const enUS = {
|
||||
resetFailed:
|
||||
'Password reset failed, please check your email and recovery key',
|
||||
backToLogin: 'Back to Login',
|
||||
totpMethod: 'TOTP Authenticator',
|
||||
recoveryCodeMethod: 'Recovery Code',
|
||||
verifyMethod: 'Verification Method',
|
||||
totpMethodsUnavailable:
|
||||
'TOTP is not enabled for this account; only the recovery key can be used.',
|
||||
totpCode: 'Authenticator Code',
|
||||
enterTotpCode: 'Enter the 6-digit code from your authenticator app',
|
||||
recoveryCode: 'Recovery Code',
|
||||
enterRecoveryCode: 'Enter one of your recovery codes',
|
||||
totpCodeRequired: 'Authenticator code cannot be empty',
|
||||
recoveryCodeRequired: 'Recovery code cannot be empty',
|
||||
totpNotEnabled:
|
||||
'TOTP is not enabled for this account, use the recovery key instead',
|
||||
invalidTotpCode: 'Invalid verification code, please try again',
|
||||
totpMethodDescription:
|
||||
'Verify with a TOTP authenticator app or one of your recovery codes',
|
||||
},
|
||||
embedding: {
|
||||
description: 'Manage Embedding models for text vectorization',
|
||||
@@ -1357,6 +1382,38 @@ const enUS = {
|
||||
passkeyAddedSuccess: 'Passkey added successfully',
|
||||
passkeyDeleteSuccess: 'Passkey deleted',
|
||||
passkeyRenameSuccess: 'Passkey renamed successfully',
|
||||
totpSectionTitle: 'Two-Factor Authentication (TOTP)',
|
||||
totpSectionDesc:
|
||||
'Scan a QR code to add a TOTP authenticator for extra login security',
|
||||
totpEnabled: 'Enabled',
|
||||
totpDisabled: 'Disabled',
|
||||
enableTotp: 'Enable TOTP',
|
||||
disableTotp: 'Disable TOTP',
|
||||
totpEnabledSuccess: 'Two-factor authentication enabled',
|
||||
totpDisabledSuccess: 'Two-factor authentication disabled',
|
||||
totpEnrollTitle: 'Add TOTP Authenticator',
|
||||
totpEnrollDesc:
|
||||
'Scan the QR code with your authenticator app, then enter the 6-digit code to confirm',
|
||||
totpScanHint: 'Scan this QR code with your authenticator app',
|
||||
totpManualSecret: 'Or enter this key manually',
|
||||
totpCodeLabel: 'Authenticator Code',
|
||||
totpCodePlaceholder: '6-digit code',
|
||||
totpVerify: 'Verify and Enable',
|
||||
totpVerifying: 'Verifying...',
|
||||
totpRecoveryCodesTitle: 'Recovery Codes',
|
||||
totpRecoveryCodesDesc:
|
||||
'Store these codes somewhere safe. Each code can be used once if you lose access to your authenticator.',
|
||||
totpRecoveryCodesRemaining: '{{count}} recovery codes remaining',
|
||||
totpRegenerateRecoveryCodes: 'Regenerate Recovery Codes',
|
||||
totpRecoveryCodesRegenerated: 'Recovery codes regenerated',
|
||||
totpDisableTitle: 'Disable Two-Factor Authentication',
|
||||
totpDisableDesc:
|
||||
'Enter a valid authenticator code to disable two-factor authentication',
|
||||
totpConfirmDisable: 'Disable',
|
||||
totpInvalidCode: 'Invalid code, please try again',
|
||||
totpLoadFailed: 'Failed to load two-factor authentication status',
|
||||
totpCopySecret: 'Copy key',
|
||||
totpCopied: 'Copied to clipboard',
|
||||
bindSpaceFailed: 'Failed to bind LangBot Account',
|
||||
bindSpaceInvalidState:
|
||||
'Invalid bind request. Please try again from account settings.',
|
||||
|
||||
@@ -89,6 +89,11 @@ const esES = {
|
||||
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
|
||||
loginLocal: 'Iniciar sesión con cuenta local',
|
||||
loginWithPassword: 'Iniciar sesión con contraseña',
|
||||
loginWithPasskey: 'Iniciar sesión con Passkey',
|
||||
passkeyLoginSuccess: 'Passkey verificada con éxito, iniciando sesión...',
|
||||
passkeyLoginFailed: 'Error al iniciar sesión con Passkey',
|
||||
passkeyNotSupported:
|
||||
'Passkey no es compatible en este navegador o dispositivo',
|
||||
spaceLoginTitle: 'Iniciar sesión con una cuenta de LangBot',
|
||||
spaceLoginDescription:
|
||||
'Escanea el código QR o visita el enlace para autorizar',
|
||||
@@ -1325,6 +1330,8 @@ const esES = {
|
||||
newPasswordRequired: 'La nueva contraseña no puede estar vacía',
|
||||
resetPassword: 'Restablecer contraseña',
|
||||
resetting: 'Restableciendo...',
|
||||
totpMethodsUnavailable:
|
||||
'TOTP no está habilitado para esta cuenta; solo se puede usar la clave de recuperación.',
|
||||
resetSuccess:
|
||||
'Contraseña restablecida correctamente, por favor inicia sesión',
|
||||
resetFailed:
|
||||
@@ -1376,6 +1383,20 @@ const esES = {
|
||||
bindSpaceWarning:
|
||||
'Después de vincular, tu correo de inicio de sesión se cambiará de {{localEmail}} al correo de la cuenta de LangBot.',
|
||||
bindSpaceSuccess: 'Cuenta de LangBot vinculada correctamente',
|
||||
passkeySectionTitle: 'Llaves de acceso (Passkeys)',
|
||||
passkeySectionDesc:
|
||||
'Inicia sesión de forma segura sin contraseñas usando biometría o llaves de seguridad',
|
||||
addPasskey: 'Añadir llave de acceso',
|
||||
passkeyName: 'Nombre de la llave',
|
||||
passkeyNamePlaceholder: 'p. ej., MacBook Touch ID, YubiKey',
|
||||
passkeyCreated: 'Creada el {{date}}',
|
||||
passkeyLastUsed: 'Último uso: {{date}}',
|
||||
noPasskeys: 'No hay llaves de acceso registradas',
|
||||
deletePasskeyConfirm:
|
||||
'¿Seguro que deseas eliminar esta llave de acceso? Ya no podrás usarla para iniciar sesión.',
|
||||
passkeyAddedSuccess: 'Llave de acceso añadida con éxito',
|
||||
passkeyDeleteSuccess: 'Llave de acceso eliminada',
|
||||
passkeyRenameSuccess: 'Nombre de llave de acceso modificado con éxito',
|
||||
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.',
|
||||
|
||||
@@ -92,6 +92,13 @@ const jaJP = {
|
||||
passkeyLoginFailed: 'パスキーでのログインに失敗しました',
|
||||
passkeyNotSupported:
|
||||
'お使いのブラウザまたはデバイスはパスキーをサポートしていません',
|
||||
loginTotpTitle: '二要素認証',
|
||||
loginTotpDesc:
|
||||
'認証アプリの6桁のコード、またはリカバリーコードを入力してください',
|
||||
loginTotpPlaceholder: '認証コードまたはリカバリーコード',
|
||||
loginTotpVerify: '確認',
|
||||
loginTotpVerifying: '確認中...',
|
||||
loginTotpInvalid: 'コードが無効です。もう一度お試しください',
|
||||
spaceLoginTitle: 'LangBot アカウントでログイン',
|
||||
spaceLoginDescription:
|
||||
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
|
||||
@@ -1286,6 +1293,8 @@ const jaJP = {
|
||||
registerWithPassword: 'メールアドレスとパスワードで登録',
|
||||
initSuccess: '初期化に成功しました。ログインしてください',
|
||||
initFailed: '初期化に失敗しました:',
|
||||
totpHint:
|
||||
'推奨:ログイン後、アカウント設定で二要素認証(TOTP)を有効にしてアカウントを保護してください。',
|
||||
},
|
||||
resetPassword: {
|
||||
title: 'パスワードをリセット 🔐',
|
||||
@@ -1305,6 +1314,21 @@ const jaJP = {
|
||||
resetFailed:
|
||||
'パスワードのリセットに失敗しました。メールアドレスと復旧キーを確認してください',
|
||||
backToLogin: 'ログインに戻る',
|
||||
totpMethod: 'TOTP 認証アプリ',
|
||||
recoveryCodeMethod: 'リカバリーコード',
|
||||
verifyMethod: '確認方法',
|
||||
totpMethodsUnavailable:
|
||||
'このアカウントでは TOTP が有効になっていません。リカバリーキーのみ使用できます。',
|
||||
totpCode: '認証コード',
|
||||
enterTotpCode: '認証アプリに表示される6桁のコードを入力',
|
||||
recoveryCode: 'リカバリーコード',
|
||||
enterRecoveryCode: 'リカバリーコードのいずれかを入力',
|
||||
totpCodeRequired: '認証コードは必須です',
|
||||
recoveryCodeRequired: 'リカバリーコードは必須です',
|
||||
totpNotEnabled:
|
||||
'このアカウントでは TOTP が有効になっていません。復旧キーを使用してください',
|
||||
invalidTotpCode: '認証コードが無効です。もう一度お試しください',
|
||||
totpMethodDescription: 'TOTP 認証アプリまたはリカバリーコードで確認します',
|
||||
},
|
||||
embedding: {
|
||||
description: 'テキストのベクトル化に使用する埋め込みモデルを管理します',
|
||||
@@ -1364,6 +1388,37 @@ const jaJP = {
|
||||
passkeyAddedSuccess: 'パスキーが正常に追加されました',
|
||||
passkeyDeleteSuccess: 'パスキーを削除しました',
|
||||
passkeyRenameSuccess: 'パスキー名を変更しました',
|
||||
totpSectionTitle: '二要素認証 (TOTP)',
|
||||
totpSectionDesc:
|
||||
'QR コードをスキャンして TOTP 認証アプリを追加し、ログインの安全性を高めます',
|
||||
totpEnabled: '有効',
|
||||
totpDisabled: '無効',
|
||||
enableTotp: 'TOTP を有効化',
|
||||
disableTotp: 'TOTP を無効化',
|
||||
totpEnabledSuccess: '二要素認証を有効にしました',
|
||||
totpDisabledSuccess: '二要素認証を無効にしました',
|
||||
totpEnrollTitle: 'TOTP 認証アプリを追加',
|
||||
totpEnrollDesc:
|
||||
'認証アプリで QR コードをスキャンし、6桁のコードを入力して確認します',
|
||||
totpScanHint: '認証アプリでこの QR コードをスキャンしてください',
|
||||
totpManualSecret: 'またはこのキーを手動で入力',
|
||||
totpCodeLabel: '認証コード',
|
||||
totpCodePlaceholder: '6桁のコード',
|
||||
totpVerify: '確認して有効化',
|
||||
totpVerifying: '確認中...',
|
||||
totpRecoveryCodesTitle: 'リカバリーコード',
|
||||
totpRecoveryCodesDesc:
|
||||
'これらのコードは安全な場所に保管してください。認証アプリが使えない場合、各コードは一度だけ使用できます。',
|
||||
totpRecoveryCodesRemaining: '残り {{count}} 個のリカバリーコード',
|
||||
totpRegenerateRecoveryCodes: 'リカバリーコードを再生成',
|
||||
totpRecoveryCodesRegenerated: 'リカバリーコードを再生成しました',
|
||||
totpDisableTitle: '二要素認証を無効化',
|
||||
totpDisableDesc: '有効な認証コードを入力して二要素認証を無効化します',
|
||||
totpConfirmDisable: '無効化',
|
||||
totpInvalidCode: 'コードが無効です。もう一度お試しください',
|
||||
totpLoadFailed: '二要素認証の状態の読み込みに失敗しました',
|
||||
totpCopySecret: 'キーをコピー',
|
||||
totpCopied: 'クリップボードにコピーしました',
|
||||
bindSpaceFailed: 'LangBot アカウントの連携に失敗しました',
|
||||
bindSpaceInvalidState:
|
||||
'無効な連携リクエストです。アカウント設定から再度お試しください。',
|
||||
|
||||
@@ -86,6 +86,11 @@ const ruRU = {
|
||||
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
|
||||
loginLocal: 'Войти с локальной учётной записью',
|
||||
loginWithPassword: 'Войти с паролем',
|
||||
loginWithPasskey: 'Войти с помощью Passkey',
|
||||
passkeyLoginSuccess: 'Passkey успешно подтверждён, вход...',
|
||||
passkeyLoginFailed: 'Не удалось войти с помощью Passkey',
|
||||
passkeyNotSupported:
|
||||
'Passkey не поддерживается в этом браузере или на устройстве',
|
||||
spaceLoginTitle: 'Войти с аккаунтом LangBot',
|
||||
spaceLoginDescription:
|
||||
'Отсканируйте QR-код или перейдите по ссылке ниже для авторизации',
|
||||
@@ -1301,6 +1306,8 @@ const ruRU = {
|
||||
newPasswordRequired: 'Новый пароль не может быть пустым',
|
||||
resetPassword: 'Сбросить пароль',
|
||||
resetting: 'Сброс...',
|
||||
totpMethodsUnavailable:
|
||||
'TOTP не включён для этой учётной записи; доступен только ключ восстановления.',
|
||||
resetSuccess: 'Пароль успешно сброшен, пожалуйста, войдите',
|
||||
resetFailed: 'Ошибка сброса пароля, проверьте email и ключ восстановления',
|
||||
backToLogin: 'Вернуться к входу',
|
||||
@@ -1350,6 +1357,20 @@ const ruRU = {
|
||||
bindSpaceWarning:
|
||||
'После привязки ваш email для входа будет изменён с {{localEmail}} на email аккаунта LangBot.',
|
||||
bindSpaceSuccess: 'Аккаунт LangBot успешно привязан',
|
||||
passkeySectionTitle: 'Ключи доступа (Passkey)',
|
||||
passkeySectionDesc:
|
||||
'Безопасный вход без пароля с помощью биометрии или аппаратного ключа',
|
||||
addPasskey: 'Добавить ключ доступа',
|
||||
passkeyName: 'Название ключа',
|
||||
passkeyNamePlaceholder: 'например, MacBook Touch ID, YubiKey',
|
||||
passkeyCreated: 'Создан {{date}}',
|
||||
passkeyLastUsed: 'Последнее использование: {{date}}',
|
||||
noPasskeys: 'Нет зарегистрированных ключей доступа',
|
||||
deletePasskeyConfirm:
|
||||
'Вы уверены, что хотите удалить этот ключ доступа? Вы больше не сможете использовать его для входа.',
|
||||
passkeyAddedSuccess: 'Ключ доступа успешно добавлен',
|
||||
passkeyDeleteSuccess: 'Ключ доступа удален',
|
||||
passkeyRenameSuccess: 'Ключ доступа успешно переименован',
|
||||
bindSpaceFailed: 'Не удалось привязать аккаунт LangBot',
|
||||
bindSpaceInvalidState:
|
||||
'Недействительный запрос привязки. Повторите попытку из настроек аккаунта.',
|
||||
|
||||
@@ -86,6 +86,10 @@ const thTH = {
|
||||
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
|
||||
loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น',
|
||||
loginWithPassword: 'เข้าสู่ระบบด้วยรหัสผ่าน',
|
||||
loginWithPasskey: 'เข้าสู่ระบบด้วย Passkey',
|
||||
passkeyLoginSuccess: 'ยืนยัน Passkey สำเร็จ กำลังเข้าสู่ระบบ...',
|
||||
passkeyLoginFailed: 'เข้าสู่ระบบด้วย Passkey ล้มเหลว',
|
||||
passkeyNotSupported: 'เบราว์เซอร์หรืออุปกรณ์นี้ไม่รองรับ Passkey',
|
||||
spaceLoginTitle: 'เข้าสู่ระบบด้วยบัญชี LangBot',
|
||||
spaceLoginDescription:
|
||||
'สแกน QR code หรือเข้าชมลิงก์ด้านล่างเพื่อยืนยันสิทธิ์',
|
||||
@@ -1273,6 +1277,8 @@ const thTH = {
|
||||
newPasswordRequired: 'รหัสผ่านใหม่ต้องไม่ว่างเปล่า',
|
||||
resetPassword: 'รีเซ็ตรหัสผ่าน',
|
||||
resetting: 'กำลังรีเซ็ต...',
|
||||
totpMethodsUnavailable:
|
||||
'บัญชีนี้ยังไม่ได้เปิดใช้ TOTP ใช้ได้เฉพาะคีย์กู้คืนเท่านั้น',
|
||||
resetSuccess: 'รีเซ็ตรหัสผ่านสำเร็จ กรุณาเข้าสู่ระบบ',
|
||||
resetFailed: 'รีเซ็ตรหัสผ่านล้มเหลว กรุณาตรวจสอบอีเมลและคีย์กู้คืน',
|
||||
backToLogin: 'กลับไปหน้าเข้าสู่ระบบ',
|
||||
@@ -1321,6 +1327,20 @@ const thTH = {
|
||||
bindSpaceWarning:
|
||||
'หลังจากผูกแล้ว อีเมลเข้าสู่ระบบของคุณจะเปลี่ยนจาก {{localEmail}} เป็นอีเมลบัญชี LangBot',
|
||||
bindSpaceSuccess: 'ผูกบัญชี LangBot สำเร็จ',
|
||||
passkeySectionTitle: 'พาสคีย์ (Passkey)',
|
||||
passkeySectionDesc:
|
||||
'เข้าสู่ระบบอย่างปลอดภัยโดยไม่ต้องใช้รหัสผ่านด้วยไบโอเมตริกซ์หรือคีย์ความปลอดภัย',
|
||||
addPasskey: 'เพิ่มพาสคีย์',
|
||||
passkeyName: 'ชื่อคีย์',
|
||||
passkeyNamePlaceholder: 'เช่น MacBook Touch ID, YubiKey',
|
||||
passkeyCreated: 'สร้างเมื่อ {{date}}',
|
||||
passkeyLastUsed: 'ใช้งานล่าสุด: {{date}}',
|
||||
noPasskeys: 'ยังไม่มีพาสคีย์ที่ลงทะเบียน',
|
||||
deletePasskeyConfirm:
|
||||
'คุณแน่ใจหรือไม่ว่าต้องการลบพาสคีย์นี้? คุณจะไม่สามารถใช้คีย์นี้เข้าสู่ระบบได้อีก',
|
||||
passkeyAddedSuccess: 'เพิ่มพาสคีย์สำเร็จ',
|
||||
passkeyDeleteSuccess: 'ลบพาสคีย์แล้ว',
|
||||
passkeyRenameSuccess: 'เปลี่ยนชื่อพาสคีย์สำเร็จ',
|
||||
bindSpaceFailed: 'ผูกบัญชี LangBot ล้มเหลว',
|
||||
bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี',
|
||||
setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
|
||||
|
||||
@@ -87,6 +87,10 @@ const viVN = {
|
||||
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
|
||||
loginLocal: 'Đăng nhập với tài khoản cục bộ',
|
||||
loginWithPassword: 'Đăng nhập bằng mật khẩu',
|
||||
loginWithPasskey: 'Đăng nhập bằng Passkey',
|
||||
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',
|
||||
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',
|
||||
@@ -1294,6 +1298,8 @@ const viVN = {
|
||||
newPasswordRequired: 'Mật khẩu mới không được để trống',
|
||||
resetPassword: 'Đặt lại mật khẩu',
|
||||
resetting: 'Đang đặt lại...',
|
||||
totpMethodsUnavailable:
|
||||
'TOTP chưa được bật cho tài khoản này; chỉ có thể dùng khóa khôi phục.',
|
||||
resetSuccess: 'Đặt lại mật khẩu thành công, vui lòng đăng nhập',
|
||||
resetFailed:
|
||||
'Đặt lại mật khẩu thất bại, vui lòng kiểm tra email và khóa khôi phục',
|
||||
@@ -1344,6 +1350,20 @@ const viVN = {
|
||||
bindSpaceWarning:
|
||||
'Sau khi liên kết, email đăng nhập của bạn sẽ được đổi từ {{localEmail}} sang email tài khoản LangBot.',
|
||||
bindSpaceSuccess: 'Liên kết tài khoản LangBot thành công',
|
||||
passkeySectionTitle: 'Mã khóa truy cập (Passkey)',
|
||||
passkeySectionDesc:
|
||||
'Đăng nhập an toàn không cần mật khẩu bằng sinh trắc học hoặc khóa bảo mật',
|
||||
addPasskey: 'Thêm mã khóa truy cập',
|
||||
passkeyName: 'Tên khóa',
|
||||
passkeyNamePlaceholder: 'ví dụ: MacBook Touch ID, YubiKey',
|
||||
passkeyCreated: 'Được tạo vào {{date}}',
|
||||
passkeyLastUsed: 'Sử dụng lần cuối: {{date}}',
|
||||
noPasskeys: 'Chưa có mã khóa truy cập nào được đăng ký',
|
||||
deletePasskeyConfirm:
|
||||
'Bạn có chắc chắn muốn xóa mã khóa truy cập này? Bạn sẽ không thể sử dụng nó để đăng nhập nữa.',
|
||||
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',
|
||||
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.',
|
||||
|
||||
@@ -88,6 +88,12 @@ const zhHans = {
|
||||
passkeyLoginSuccess: 'Passkey 验证成功,正在登录...',
|
||||
passkeyLoginFailed: 'Passkey 登录失败',
|
||||
passkeyNotSupported: '当前浏览器或设备不支持 Passkey',
|
||||
loginTotpTitle: '两步验证',
|
||||
loginTotpDesc: '请输入验证器应用中的 6 位验证码,或使用恢复码',
|
||||
loginTotpPlaceholder: '验证码或恢复码',
|
||||
loginTotpVerify: '验证',
|
||||
loginTotpVerifying: '验证中...',
|
||||
loginTotpInvalid: '验证码无效,请重试',
|
||||
spaceLoginTitle: '通过 LangBot 账号登录',
|
||||
spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
|
||||
spaceLoginUserCode: '您的验证码',
|
||||
@@ -1219,6 +1225,8 @@ const zhHans = {
|
||||
registerWithPassword: '通过邮箱密码组合注册',
|
||||
initSuccess: '初始化成功 请登录',
|
||||
initFailed: '初始化失败:',
|
||||
totpHint:
|
||||
'推荐:登录后在账户设置中开启两步验证(TOTP)以保护您的账户安全。',
|
||||
},
|
||||
resetPassword: {
|
||||
title: '重置密码 🔐',
|
||||
@@ -1236,6 +1244,19 @@ const zhHans = {
|
||||
resetSuccess: '密码重置成功,请登录',
|
||||
resetFailed: '密码重置失败,请检查邮箱和恢复密钥是否正确',
|
||||
backToLogin: '返回登录',
|
||||
totpMethod: 'TOTP 验证器',
|
||||
recoveryCodeMethod: '恢复码',
|
||||
verifyMethod: '验证方式',
|
||||
totpCode: '验证器验证码',
|
||||
enterTotpCode: '输入验证器应用中的 6 位验证码',
|
||||
recoveryCode: '恢复码',
|
||||
enterRecoveryCode: '输入您的其中一个恢复码',
|
||||
totpCodeRequired: '验证码不能为空',
|
||||
recoveryCodeRequired: '恢复码不能为空',
|
||||
totpNotEnabled: '该账户未开启 TOTP,请改用恢复密钥',
|
||||
invalidTotpCode: '验证码无效,请重试',
|
||||
totpMethodDescription: '使用 TOTP 验证器应用或恢复码进行验证',
|
||||
totpMethodsUnavailable: '该账户未开启 TOTP 验证,仅可使用恢复密钥重置密码',
|
||||
},
|
||||
embedding: {
|
||||
description: '管理嵌入模型,用于向量化文本',
|
||||
@@ -1291,6 +1312,35 @@ const zhHans = {
|
||||
passkeyAddedSuccess: '通行密钥添加成功',
|
||||
passkeyDeleteSuccess: '通行密钥已删除',
|
||||
passkeyRenameSuccess: '通行密钥重命名成功',
|
||||
totpSectionTitle: '两步验证 (TOTP)',
|
||||
totpSectionDesc: '扫描二维码添加 TOTP 验证器,提升登录安全性',
|
||||
totpEnabled: '已开启',
|
||||
totpDisabled: '未开启',
|
||||
enableTotp: '开启 TOTP',
|
||||
disableTotp: '关闭 TOTP',
|
||||
totpEnabledSuccess: '两步验证已开启',
|
||||
totpDisabledSuccess: '两步验证已关闭',
|
||||
totpEnrollTitle: '添加 TOTP 验证器',
|
||||
totpEnrollDesc: '使用验证器应用扫描二维码,然后输入 6 位验证码完成确认',
|
||||
totpScanHint: '使用验证器应用扫描此二维码',
|
||||
totpManualSecret: '或手动输入此密钥',
|
||||
totpCodeLabel: '验证码',
|
||||
totpCodePlaceholder: '6 位验证码',
|
||||
totpVerify: '验证并开启',
|
||||
totpVerifying: '验证中...',
|
||||
totpRecoveryCodesTitle: '恢复码',
|
||||
totpRecoveryCodesDesc:
|
||||
'请妥善保存这些恢复码。当您无法使用验证器时,每个恢复码可使用一次。',
|
||||
totpRecoveryCodesRemaining: '剩余 {{count}} 个恢复码',
|
||||
totpRegenerateRecoveryCodes: '重新生成恢复码',
|
||||
totpRecoveryCodesRegenerated: '恢复码已重新生成',
|
||||
totpDisableTitle: '关闭两步验证',
|
||||
totpDisableDesc: '输入有效的验证器验证码以关闭两步验证',
|
||||
totpConfirmDisable: '关闭',
|
||||
totpInvalidCode: '验证码无效,请重试',
|
||||
totpLoadFailed: '加载两步验证状态失败',
|
||||
totpCopySecret: '复制密钥',
|
||||
totpCopied: '已复制到剪贴板',
|
||||
bindSpaceFailed: '绑定 LangBot 账号失败',
|
||||
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
|
||||
setPasswordHint: '设置密码后可使用邮箱密码登录',
|
||||
|
||||
@@ -84,6 +84,10 @@ const zhHant = {
|
||||
spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
|
||||
loginLocal: '使用本地帳號登入',
|
||||
loginWithPassword: '透過密碼登入',
|
||||
loginWithPasskey: '使用 Passkey 登入',
|
||||
passkeyLoginSuccess: 'Passkey 驗證成功,正在登入...',
|
||||
passkeyLoginFailed: 'Passkey 登入失敗',
|
||||
passkeyNotSupported: '目前瀏覽器或裝置不支援 Passkey',
|
||||
spaceLoginTitle: '透過 LangBot 帳號登入',
|
||||
spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權',
|
||||
spaceLoginUserCode: '您的驗證碼',
|
||||
@@ -1230,6 +1234,7 @@ const zhHant = {
|
||||
newPasswordRequired: '新密碼不能為空',
|
||||
resetPassword: '重設密碼',
|
||||
resetting: '重設中...',
|
||||
totpMethodsUnavailable: '此帳戶未開啟 TOTP 驗證,僅可使用恢復金鑰重設密碼',
|
||||
resetSuccess: '密碼重設成功,請登入',
|
||||
resetFailed: '密碼重設失敗,請檢查電子郵件和恢復金鑰是否正確',
|
||||
backToLogin: '返回登入',
|
||||
@@ -1275,6 +1280,19 @@ const zhHant = {
|
||||
bindSpaceWarning:
|
||||
'綁定後,您的登入電子郵件將從 {{localEmail}} 更改為 LangBot 帳號的電子郵件。',
|
||||
bindSpaceSuccess: 'LangBot 帳號綁定成功',
|
||||
passkeySectionTitle: '通行密鑰 (Passkey)',
|
||||
passkeySectionDesc: '使用指紋、面容或硬體安全金鑰免密安全登入',
|
||||
addPasskey: '新增通行密鑰',
|
||||
passkeyName: '金鑰名稱',
|
||||
passkeyNamePlaceholder: '例如:MacBook Touch ID、YubiKey',
|
||||
passkeyCreated: '建立於 {{date}}',
|
||||
passkeyLastUsed: '上次使用: {{date}}',
|
||||
noPasskeys: '尚未綁定任何通行密鑰',
|
||||
deletePasskeyConfirm:
|
||||
'確定要刪除此通行密鑰嗎?刪除後將無法使用該金鑰登入。',
|
||||
passkeyAddedSuccess: '通行密鑰新增成功',
|
||||
passkeyDeleteSuccess: '通行密鑰已刪除',
|
||||
passkeyRenameSuccess: '通行密鑰重新命名成功',
|
||||
bindSpaceFailed: '綁定 LangBot 帳號失敗',
|
||||
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
|
||||
setPasswordHint: '設定密碼後可使用電子郵件密碼登入',
|
||||
|
||||
Reference in New Issue
Block a user