mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 12:40:18 +00:00
rebase #3581 onto master (v0.51.546) for review+ship-gate — require current password before disabling auth
starship-s: sudo-mode re-auth on POST /api/settings before any change/clear/passwordless transition of password auth, + cosmetic auth-disabled-acknowledged nav badge. Operator-hardening (rubric-named). Security-boundary + visible UI -> Nathan visual sign-off. Co-authored-by: starship-s <starship-s@users.noreply.github.com>
This commit is contained in:
@@ -7168,6 +7168,7 @@ _SETTINGS_DEFAULTS = {
|
||||
"auto_title_refresh_every": "0", # adaptive title refresh: 0=off, 5/10/20=every N exchanges
|
||||
"busy_input_mode": "queue", # behavior when sending while agent is running: queue | interrupt | steer
|
||||
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
|
||||
"auth_disabled_acknowledged": False, # user acknowledged unauthenticated risk
|
||||
}
|
||||
_SETTINGS_LEGACY_DROP_KEYS = {
|
||||
"assistant_language",
|
||||
@@ -7368,6 +7369,7 @@ _SETTINGS_BOOL_KEYS = {
|
||||
"session_endless_scroll",
|
||||
"auto_scroll_follow",
|
||||
"worklog_details_expanded_default",
|
||||
"auth_disabled_acknowledged",
|
||||
}
|
||||
# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr')
|
||||
_SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$")
|
||||
|
||||
@@ -8594,6 +8594,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
"passkeys_enabled": bool(passkeys),
|
||||
"passkeys_count": len(passkeys),
|
||||
"passkey_feature_flag": passkey_flag,
|
||||
"auth_disabled_acknowledged": bool(load_settings().get("auth_disabled_acknowledged")) if not auth_enabled else False,
|
||||
})
|
||||
|
||||
if parsed.path in ("/manifest.json", "/manifest.webmanifest"):
|
||||
@@ -8840,6 +8841,21 @@ def handle_get(handler, parsed) -> bool:
|
||||
settings["password_env_var"] = bool(
|
||||
os.getenv("HERMES_WEBUI_PASSWORD", "").strip()
|
||||
)
|
||||
# Auth-state fields for frontend safety badge / confirmation flows
|
||||
from api.auth import get_password_hash, is_auth_enabled
|
||||
settings["auth_enabled"] = is_auth_enabled()
|
||||
settings["password_auth_enabled"] = get_password_hash() is not None
|
||||
try:
|
||||
from api.auth import _passkey_feature_flag_enabled as _pffe
|
||||
from api.passkeys import registered_credentials as _rc
|
||||
if _pffe():
|
||||
settings["passkeys_enabled"] = bool(_rc())
|
||||
settings["passwordless_enabled"] = bool(_rc()) and not settings["password_auth_enabled"]
|
||||
else:
|
||||
settings["passkeys_enabled"] = False
|
||||
settings["passwordless_enabled"] = False
|
||||
except Exception:
|
||||
pass
|
||||
# Inject the running version so the UI badge stays in sync with git tags
|
||||
# without any manual release step.
|
||||
try:
|
||||
@@ -11346,9 +11362,11 @@ def handle_post(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/settings":
|
||||
from api.auth import (
|
||||
create_session,
|
||||
get_password_hash,
|
||||
is_auth_enabled,
|
||||
parse_cookie,
|
||||
set_auth_cookie,
|
||||
verify_password,
|
||||
verify_session,
|
||||
)
|
||||
|
||||
@@ -11356,6 +11374,7 @@ def handle_post(handler, parsed) -> bool:
|
||||
body["bot_name"] = (str(body["bot_name"]) or "").strip() or "Hermes"
|
||||
|
||||
auth_enabled_before = is_auth_enabled()
|
||||
password_auth_enabled_before = auth_enabled_before and get_password_hash() is not None
|
||||
current_cookie = parse_cookie(handler)
|
||||
logged_in_before = bool(current_cookie and verify_session(current_cookie))
|
||||
requested_password = bool(
|
||||
@@ -11367,6 +11386,8 @@ def handle_post(handler, parsed) -> bool:
|
||||
if requested_passwordless:
|
||||
body["_clear_password"] = True
|
||||
|
||||
current_password = body.pop("_current_password", None)
|
||||
|
||||
# #1560: HERMES_WEBUI_PASSWORD env var takes precedence in
|
||||
# api.auth.get_password_hash(), so writing password_hash to settings.json
|
||||
# has no effect on auth. Refuse loudly with 409 instead of silently
|
||||
@@ -11395,6 +11416,22 @@ def handle_post(handler, parsed) -> bool:
|
||||
403,
|
||||
)
|
||||
|
||||
# Auth-disable safety: when password auth is currently enabled, require
|
||||
# the current password to change, clear, or switch to passwordless.
|
||||
if auth_enabled_before and password_auth_enabled_before and (requested_password or requested_clear_password):
|
||||
if not isinstance(current_password, str) or not current_password:
|
||||
return bad(
|
||||
handler,
|
||||
"Current password is required to change or disable authentication.",
|
||||
403,
|
||||
)
|
||||
if not verify_password(current_password):
|
||||
return bad(
|
||||
handler,
|
||||
"Current password is incorrect.",
|
||||
403,
|
||||
)
|
||||
|
||||
if requested_passwordless:
|
||||
from api.auth import _passkey_feature_flag_enabled
|
||||
from api.passkeys import registered_credentials
|
||||
@@ -11408,6 +11445,13 @@ def handle_post(handler, parsed) -> bool:
|
||||
|
||||
clear_credentials()
|
||||
|
||||
# Handle auth_disabled_acknowledged setting
|
||||
ack = body.pop("_auth_disabled_acknowledged", None)
|
||||
if ack is not None and not is_auth_enabled():
|
||||
body["auth_disabled_acknowledged"] = bool(ack)
|
||||
elif is_auth_enabled() or requested_password:
|
||||
body["auth_disabled_acknowledged"] = False
|
||||
|
||||
saved = save_settings(body)
|
||||
saved.pop("password_hash", None) # never expose hash to client
|
||||
|
||||
@@ -11450,8 +11494,20 @@ def handle_post(handler, parsed) -> bool:
|
||||
logged_in_after = True
|
||||
|
||||
saved["auth_enabled"] = auth_enabled_after
|
||||
saved["password_auth_enabled"] = get_password_hash() is not None
|
||||
saved["logged_in"] = logged_in_after
|
||||
saved["auth_just_enabled"] = auth_just_enabled
|
||||
try:
|
||||
from api.auth import _passkey_feature_flag_enabled as _pffe
|
||||
from api.passkeys import registered_credentials as _rc
|
||||
if _pffe():
|
||||
saved["passkeys_enabled"] = bool(_rc())
|
||||
saved["passwordless_enabled"] = bool(_rc()) and not saved["password_auth_enabled"]
|
||||
else:
|
||||
saved["passkeys_enabled"] = False
|
||||
saved["passwordless_enabled"] = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not new_cookie:
|
||||
return j(handler, saved)
|
||||
|
||||
+160
-2
@@ -1433,6 +1433,18 @@ const LOCALES = {
|
||||
disable_auth_confirm_message: 'Anyone will be able to access this instance.',
|
||||
auth_disabled: 'Auth disabled — password protection removed',
|
||||
disable_auth_failed: 'Failed to disable auth: ',
|
||||
current_password_label: 'Current Password',
|
||||
current_password_placeholder: 'Enter current password\u2026',
|
||||
current_password_required: 'Current password is required to change or disable authentication.',
|
||||
current_password_incorrect: 'Current password is incorrect.',
|
||||
disable_auth_typed_confirm: 'Type DISABLE AUTH to confirm',
|
||||
auth_status_password: 'Password Auth',
|
||||
auth_status_passkey_only: 'Passkey Only',
|
||||
auth_status_unauthenticated: 'Unauthenticated',
|
||||
auth_warning_badge: 'Auth disabled',
|
||||
auth_disabled_warning_message: 'This instance is accessible without authentication.',
|
||||
auth_acknowledged_label: 'I’ve reviewed this risk; show a less-urgent nav warning.',
|
||||
auth_ack_save_failed: 'Failed to update acknowledgement: ',
|
||||
bg_error_single: (title) => `"${title}" has encountered an error`,
|
||||
bg_error_multi: (count) => `${count} sessions have encountered an error`,
|
||||
// skill form
|
||||
@@ -2953,6 +2965,18 @@ const LOCALES = {
|
||||
disable_auth_confirm_message: 'Chiunque potrà accedere a questa istanza.',
|
||||
auth_disabled: 'Auth disabilitata — protezione password rimossa',
|
||||
disable_auth_failed: 'Disabilitazione auth fallita: ',
|
||||
current_password_label: 'Password attuale',
|
||||
current_password_placeholder: 'Inserisci password attuale…',
|
||||
current_password_required: 'La password attuale è necessaria per modificare o disabilitare l\'autenticazione.',
|
||||
current_password_incorrect: 'Password attuale non corretta.',
|
||||
disable_auth_typed_confirm: 'Digita DISABLE AUTH per confermare',
|
||||
auth_status_password: 'Autenticazione password',
|
||||
auth_status_passkey_only: 'Solo passkey',
|
||||
auth_status_unauthenticated: 'Non autenticato',
|
||||
auth_warning_badge: 'Auth disabilitata',
|
||||
auth_disabled_warning_message: 'Questa istanza è accessibile senza autenticazione.',
|
||||
auth_acknowledged_label: 'Ho esaminato questo rischio; mostra un avviso di navigazione meno urgente.',
|
||||
auth_ack_save_failed: 'Aggiornamento del riconoscimento non riuscito: ',
|
||||
bg_error_single: (title) => `"${title}" ha incontrato un errore`,
|
||||
bg_error_multi: (count) => `${count} sessioni hanno incontrato un errore`,
|
||||
// skill form
|
||||
@@ -4481,6 +4505,18 @@ const LOCALES = {
|
||||
disable_auth_confirm_message: '誰でもこのインスタンスにアクセスできるようになります。',
|
||||
auth_disabled: '認証無効化 — パスワード保護を解除しました',
|
||||
disable_auth_failed: '認証の無効化に失敗: ',
|
||||
current_password_label: '現在のパスワード',
|
||||
current_password_placeholder: '現在のパスワードを入力…',
|
||||
current_password_required: '認証の変更または無効化には現在のパスワードが必要です。',
|
||||
current_password_incorrect: '現在のパスワードが正しくありません。',
|
||||
disable_auth_typed_confirm: '確認のため DISABLE AUTH と入力',
|
||||
auth_status_password: 'パスワード認証',
|
||||
auth_status_passkey_only: 'パスキーのみ',
|
||||
auth_status_unauthenticated: '未認証',
|
||||
auth_warning_badge: '認証無効',
|
||||
auth_disabled_warning_message: 'このインスタンスは認証なしでアクセスできます。',
|
||||
auth_acknowledged_label: 'このリスクを確認しました。ナビゲーション警告を控えめに表示します。',
|
||||
auth_ack_save_failed: '確認状態の更新に失敗しました: ',
|
||||
bg_error_single: (title) => `"${title}" でエラーが発生しました`,
|
||||
bg_error_multi: (count) => `${count} 件のセッションでエラーが発生しました`,
|
||||
// skill form
|
||||
@@ -5704,6 +5740,18 @@ const LOCALES = {
|
||||
disable_auth_confirm_message: 'Любой сможет получить доступ к этому экземпляру.',
|
||||
auth_disabled: 'Авторизация отключена — защита паролем снята',
|
||||
disable_auth_failed: 'Не удалось отключить авторизацию: ',
|
||||
current_password_label: 'Текущий пароль',
|
||||
current_password_placeholder: 'Введите текущий пароль…',
|
||||
current_password_required: 'Для изменения или отключения авторизации требуется текущий пароль.',
|
||||
current_password_incorrect: 'Текущий пароль неверный.',
|
||||
disable_auth_typed_confirm: 'Введите DISABLE AUTH для подтверждения',
|
||||
auth_status_password: 'Парольная авторизация',
|
||||
auth_status_passkey_only: 'Только ключи доступа',
|
||||
auth_status_unauthenticated: 'Без авторизации',
|
||||
auth_warning_badge: 'Авторизация отключена',
|
||||
auth_disabled_warning_message: 'Этот экземпляр доступен без авторизации.',
|
||||
auth_acknowledged_label: 'Я ознакомился(ась) с этим риском; показывать менее срочное предупреждение в навигации.',
|
||||
auth_ack_save_failed: 'Не удалось обновить подтверждение: ',
|
||||
bg_error_single: (title) => `В "${title}" возникла ошибка`,
|
||||
bg_error_multi: (count) => `${count} сеансов столкнулись с ошибкой`,
|
||||
skill_deleted: 'Навык удалён',
|
||||
@@ -7164,6 +7212,18 @@ const LOCALES = {
|
||||
disable_auth_confirm_message: 'Anyone will be able to access this instance.',
|
||||
auth_disabled: 'Autenticación desactivada — protección por contraseña eliminada',
|
||||
disable_auth_failed: 'Failed to disable auth: ',
|
||||
current_password_label: 'Contraseña actual',
|
||||
current_password_placeholder: 'Ingrese contraseña actual…',
|
||||
current_password_required: 'Se requiere la contraseña actual para cambiar o desactivar la autenticación.',
|
||||
current_password_incorrect: 'La contraseña actual es incorrecta.',
|
||||
disable_auth_typed_confirm: 'Escriba DISABLE AUTH para confirmar',
|
||||
auth_status_password: 'Autenticación por contraseña',
|
||||
auth_status_passkey_only: 'Solo clave de acceso',
|
||||
auth_status_unauthenticated: 'Sin autenticación',
|
||||
auth_warning_badge: 'Auth desactivada',
|
||||
auth_disabled_warning_message: 'Esta instancia es accesible sin autenticación.',
|
||||
auth_acknowledged_label: 'He revisado este riesgo; muestra un aviso de navegación menos urgente.',
|
||||
auth_ack_save_failed: 'Error al actualizar el reconocimiento: ',
|
||||
bg_error_single: (title) => `"${title}" has encountered an error`,
|
||||
bg_error_multi: (count) => `${count} sessions have encountered an error`,
|
||||
// skill form
|
||||
@@ -8845,6 +8905,18 @@ const LOCALES = {
|
||||
disable_auth_confirm_message: 'Zugriff ohne Authentifizierung?',
|
||||
auth_disabled: 'Passwortschutz deaktiviert.',
|
||||
disable_auth_failed: 'Deaktivieren fehlgeschlagen.',
|
||||
current_password_label: 'Aktuelles Passwort',
|
||||
current_password_placeholder: 'Aktuelles Passwort eingeben…',
|
||||
current_password_required: 'Das aktuelle Passwort ist erforderlich, um die Authentifizierung zu ändern oder zu deaktivieren.',
|
||||
current_password_incorrect: 'Das aktuelle Passwort ist falsch.',
|
||||
disable_auth_typed_confirm: 'Geben Sie DISABLE AUTH ein, um zu bestätigen',
|
||||
auth_status_password: 'Passwort-Authentifizierung',
|
||||
auth_status_passkey_only: 'Nur Passkey',
|
||||
auth_status_unauthenticated: 'Nicht authentifiziert',
|
||||
auth_warning_badge: 'Auth deaktiviert',
|
||||
auth_disabled_warning_message: 'Diese Instanz ist ohne Authentifizierung zugänglich.',
|
||||
auth_acknowledged_label: 'Ich habe dieses Risiko geprüft; Navigationswarnung weniger dringend anzeigen.',
|
||||
auth_ack_save_failed: 'Bestätigung konnte nicht aktualisiert werden: ',
|
||||
bg_error_single: 'Hinterfrage {count} fehlgeschlagen.',
|
||||
bg_error_multi: '{count} Hinterfragen fehlgeschlagen.',
|
||||
media_audio_label: 'Audio',
|
||||
@@ -10081,6 +10153,18 @@ const LOCALES = {
|
||||
disable_auth_confirm_message: '任何人都可以访问此实例。',
|
||||
auth_disabled: '认证已停用,密码保护已移除',
|
||||
disable_auth_failed: '停用认证失败:',
|
||||
current_password_label: '当前密码',
|
||||
current_password_placeholder: '输入当前密码…',
|
||||
current_password_required: '更改或禁用认证需要当前密码。',
|
||||
current_password_incorrect: '当前密码不正确。',
|
||||
disable_auth_typed_confirm: '输入 DISABLE AUTH 以确认',
|
||||
auth_status_password: '密码认证',
|
||||
auth_status_passkey_only: '仅通行密钥',
|
||||
auth_status_unauthenticated: '未认证',
|
||||
auth_warning_badge: '认证已禁用',
|
||||
auth_disabled_warning_message: '此实例无需认证即可访问。',
|
||||
auth_acknowledged_label: '我已查看此风险;显示较低优先级的导航警告。',
|
||||
auth_ack_save_failed: '更新确认状态失败:',
|
||||
bg_error_single: (title) => `“${title}”出现错误`,
|
||||
bg_error_multi: (count) => `${count} 个会话出现错误`,
|
||||
skill_deleted: '技能已删除',
|
||||
@@ -11850,6 +11934,18 @@ const LOCALES = {
|
||||
disable_auth_confirm_message: '任何人都可以存取此執行個體。',
|
||||
auth_disabled: '驗證已停用 — 密碼保護已關閉',
|
||||
disable_auth_failed: '停用驗證失敗:',
|
||||
current_password_label: '目前密碼',
|
||||
current_password_placeholder: '輸入目前密碼…',
|
||||
current_password_required: '變更或停用驗證需要目前密碼。',
|
||||
current_password_incorrect: '目前密碼不正確。',
|
||||
disable_auth_typed_confirm: '輸入 DISABLE AUTH 以確認',
|
||||
auth_status_password: '密碼驗證',
|
||||
auth_status_passkey_only: '僅限通行金鑰',
|
||||
auth_status_unauthenticated: '未驗證',
|
||||
auth_warning_badge: '驗證已停用',
|
||||
auth_disabled_warning_message: '此執行個體無需驗證即可存取。',
|
||||
auth_acknowledged_label: '我已檢視此風險;顯示較低急迫性的導覽警告。',
|
||||
auth_ack_save_failed: '更新確認狀態失敗:',
|
||||
bg_error_single: (title) => `「${title}」發生錯誤`,
|
||||
bg_error_multi: (count) => `${count} 個對話發生錯誤`,
|
||||
// skill form
|
||||
@@ -13290,8 +13386,22 @@ const LOCALES = {
|
||||
subagent_children: 'Subagent sessions', // TODO: translate
|
||||
// login-flow keys (issue #1442)
|
||||
sign_out_failed: 'Falha ao sair: ',
|
||||
auth_disabled: 'Autenticação desativada — proteção por senha removida',
|
||||
disable_auth_confirm_title: 'Desativar proteção por senha',
|
||||
disable_auth_confirm_message: 'Qualquer pessoa poderá acessar esta instância.',
|
||||
auth_disabled: 'Autenticação desativada — proteção por senha removida',
|
||||
disable_auth_failed: 'Falha ao desativar autenticação: ',
|
||||
current_password_label: 'Senha atual',
|
||||
current_password_placeholder: 'Digite a senha atual…',
|
||||
current_password_required: 'A senha atual é necessária para alterar ou desativar a autenticação.',
|
||||
current_password_incorrect: 'A senha atual está incorreta.',
|
||||
disable_auth_typed_confirm: 'Digite DISABLE AUTH para confirmar',
|
||||
auth_status_password: 'Autenticação por senha',
|
||||
auth_status_passkey_only: 'Somente passkey',
|
||||
auth_status_unauthenticated: 'Sem autenticação',
|
||||
auth_warning_badge: 'Auth desativada',
|
||||
auth_disabled_warning_message: 'Esta instância está acessível sem autenticação.',
|
||||
auth_acknowledged_label: 'Revisei este risco; mostre um aviso de navegação menos urgente.',
|
||||
auth_ack_save_failed: 'Falha ao atualizar o reconhecimento: ',
|
||||
outline_title: 'Esboço',
|
||||
outline_empty: 'Ainda não há perguntas.',
|
||||
outline_loading: 'Carregando…',
|
||||
@@ -14617,6 +14727,18 @@ const LOCALES = {
|
||||
disable_auth_confirm_message: 'Anyone will be able to access this instance.',
|
||||
auth_disabled: '인증 비활성화 — 비밀번호 보호가 제거되었습니다',
|
||||
disable_auth_failed: 'Failed to disable auth: ',
|
||||
current_password_label: '현재 비밀번호',
|
||||
current_password_placeholder: '현재 비밀번호 입력…',
|
||||
current_password_required: '인증을 변경하거나 비활성화하려면 현재 비밀번호가 필요합니다.',
|
||||
current_password_incorrect: '현재 비밀번호가 올바르지 않습니다.',
|
||||
disable_auth_typed_confirm: '확인하려면 DISABLE AUTH를 입력하세요',
|
||||
auth_status_password: '비밀번호 인증',
|
||||
auth_status_passkey_only: '패스키 전용',
|
||||
auth_status_unauthenticated: '미인증',
|
||||
auth_warning_badge: '인증 비활성화됨',
|
||||
auth_disabled_warning_message: '이 인스턴스는 인증 없이 접근할 수 있습니다.',
|
||||
auth_acknowledged_label: '이 위험을 확인했습니다. 덜 긴급한 탐색 경고를 표시합니다.',
|
||||
auth_ack_save_failed: '승인 상태를 업데이트하지 못했습니다: ',
|
||||
bg_error_single: (title) => `"${title}" has encountered an error`,
|
||||
bg_error_multi: (count) => `${count} sessions have encountered an error`,
|
||||
// skill form
|
||||
@@ -16079,7 +16201,19 @@ const LOCALES = {
|
||||
disable_auth_confirm_title: 'Désactiver la protection par mot de passe',
|
||||
disable_auth_confirm_message: 'Tout le monde pourra accéder à cette instance.',
|
||||
auth_disabled: 'Authentification désactivée – protection par mot de passe supprimée',
|
||||
disable_auth_failed: 'Échec de la désactivation de l\'authentification :',
|
||||
disable_auth_failed: 'Échec de la désactivation de l\'authentification :',
|
||||
current_password_label: 'Mot de passe actuel',
|
||||
current_password_placeholder: 'Entrez le mot de passe actuel…',
|
||||
current_password_required: 'Le mot de passe actuel est requis pour modifier ou désactiver l\'authentification.',
|
||||
current_password_incorrect: 'Le mot de passe actuel est incorrect.',
|
||||
disable_auth_typed_confirm: 'Tapez DISABLE AUTH pour confirmer',
|
||||
auth_status_password: 'Authentification par mot de passe',
|
||||
auth_status_passkey_only: 'Passkey uniquement',
|
||||
auth_status_unauthenticated: 'Non authentifié',
|
||||
auth_warning_badge: 'Auth désactivée',
|
||||
auth_disabled_warning_message: 'Cette instance est accessible sans authentification.',
|
||||
auth_acknowledged_label: 'J’ai examiné ce risque ; afficher un avertissement de navigation moins urgent.',
|
||||
auth_ack_save_failed: 'Échec de la mise à jour de l’accusé de réception : ',
|
||||
skill_name: 'Nom',
|
||||
skill_category: 'Catégorie',
|
||||
skill_category_placeholder: 'Facultatif, par ex. développeurs',
|
||||
@@ -17603,6 +17737,18 @@ const LOCALES = {
|
||||
disable_auth_confirm_message: 'Bu örneğe herkes erişebilecek.',
|
||||
auth_disabled: 'Kimlik doğrulama devre dışı bırakıldı — şifre koruması kaldırıldı',
|
||||
disable_auth_failed: 'Kimlik doğrulama devre dışı bırakılamadı:',
|
||||
current_password_label: 'Mevcut şifre',
|
||||
current_password_placeholder: 'Mevcut şifreyi girin…',
|
||||
current_password_required: 'Kimlik doğrulamasını değiştirmek veya devre dışı bırakmak için mevcut şifre gereklidir.',
|
||||
current_password_incorrect: 'Mevcut şifre yanlış.',
|
||||
disable_auth_typed_confirm: 'Onaylamak için DISABLE AUTH yazın',
|
||||
auth_status_password: 'Şifre kimlik doğrulaması',
|
||||
auth_status_passkey_only: 'Yalnızca passkey',
|
||||
auth_status_unauthenticated: 'Kimlik doğrulaması yok',
|
||||
auth_warning_badge: 'Kimlik doğrulaması devre dışı',
|
||||
auth_disabled_warning_message: 'Bu örneğe kimlik doğrulaması olmadan erişilebilir.',
|
||||
auth_acknowledged_label: 'Bu riski gözden geçirdim; daha az acil bir gezinme uyarısı göster.',
|
||||
auth_ack_save_failed: 'Onay durumu güncellenemedi: ',
|
||||
bg_error_single: (title) => `"${title}" bir hatayla karşılaştı`,
|
||||
bg_error_multi: (count) => `${count} oturum bir hatayla karşılaştı`,
|
||||
// skill form
|
||||
@@ -19209,6 +19355,18 @@ const LOCALES = {
|
||||
disable_auth_confirm_message: 'Każdy będzie mógł uzyskać dostęp do tej instancji.',
|
||||
auth_disabled: 'Uwierzytelnianie wyłączone — usunięto ochronę hasłem',
|
||||
disable_auth_failed: 'Wyłączenie uwierzytelniania nie powiodło się: ',
|
||||
current_password_label: 'Obecne hasło',
|
||||
current_password_placeholder: 'Wpisz obecne hasło…',
|
||||
current_password_required: 'Obecne hasło jest wymagane do zmiany lub wyłączenia uwierzytelniania.',
|
||||
current_password_incorrect: 'Obecne hasło jest nieprawidłowe.',
|
||||
disable_auth_typed_confirm: 'Wpisz DISABLE AUTH, aby potwierdzić',
|
||||
auth_status_password: 'Uwierzytelnianie hasłem',
|
||||
auth_status_passkey_only: 'Tylko passkey',
|
||||
auth_status_unauthenticated: 'Bez uwierzytelniania',
|
||||
auth_warning_badge: 'Uwierzytelnianie wyłączone',
|
||||
auth_disabled_warning_message: 'Ta instancja jest dostępna bez uwierzytelniania.',
|
||||
auth_acknowledged_label: 'Znam to ryzyko; pokaż mniej pilne ostrzeżenie w nawigacji.',
|
||||
auth_ack_save_failed: 'Nie udało się zaktualizować potwierdzenia: ',
|
||||
bg_error_single: (title) => `W „${title}” wystąpił błąd`,
|
||||
bg_error_multi: (count) => `W ${count} sesjach wystąpił błąd`,
|
||||
// skill form
|
||||
|
||||
+15
-2
@@ -167,7 +167,7 @@
|
||||
<button class="rail-btn nav-tab dashboard-link has-tooltip" id="dashboardRailBtn" data-dashboard-link style="display:none" onclick="openHermesDashboard(event)" data-tooltip="Hermes Dashboard" data-i18n-title="tab_dashboard" aria-label="Hermes Dashboard"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg><span class="dashboard-external-badge" aria-hidden="true"></span></button>
|
||||
<button class="rail-btn nav-tab has-tooltip" data-panel="logs" onclick="switchPanel('logs',{fromRailClick:true})" data-tooltip="Logs" data-i18n-title="tab_logs" aria-label="Logs"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/><path d="M8 9h2"/></svg></button>
|
||||
<div class="rail-spacer"></div>
|
||||
<button class="rail-btn nav-tab has-tooltip" data-panel="settings" onclick="switchPanel('settings',{fromRailClick:true})" data-tooltip="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
<button class="rail-btn nav-tab has-tooltip" data-panel="settings" onclick="switchPanel('settings',{fromRailClick:true})" data-tooltip="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg><span class="auth-warning-badge" id="authWarningBadgeDesktop" style="display:none;position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:#e05"></span></button>
|
||||
</nav>
|
||||
<aside class="sidebar">
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
<button class="nav-tab dashboard-link has-tooltip has-tooltip--bottom" id="dashboardMobileBtn" data-dashboard-link data-label="Dashboard" style="display:none" onclick="openHermesDashboard(event)" data-tooltip="Hermes Dashboard" data-i18n-title="tab_dashboard"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg><span class="dashboard-external-badge" aria-hidden="true"></span></button>
|
||||
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="logs" data-label="Logs" onclick="switchPanel('logs',{fromRailClick:true})" data-tooltip="Logs" data-i18n-title="tab_logs"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h8"/><path d="M8 9h2"/></svg></button>
|
||||
<!-- Settings button mirrored here for mobile (rail is desktop-only via @media >=768px). Keep in sync with rail entry. -->
|
||||
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="settings" onclick="switchPanel('settings',{fromRailClick:true})" data-tooltip="Settings" data-i18n-title="tab_settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="settings" onclick="switchPanel('settings',{fromRailClick:true})" data-tooltip="Settings" data-i18n-title="tab_settings" style="position:relative"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg><span class="auth-warning-badge" id="authWarningBadgeMobile" style="display:none;position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:#e05"></span></button>
|
||||
</div>
|
||||
<!-- Flash-prevention: apply hidden tab visibility from localStorage synchronously
|
||||
before any paint. Mirrors the theme/skin/font-size inline scripts in <head>.
|
||||
@@ -1381,9 +1381,22 @@
|
||||
<div class="settings-field">
|
||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px" data-i18n="settings_desc_password">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||
<div id="settingsAuthStatus" style="margin-bottom:8px;display:none"></div>
|
||||
<input type="password" id="settingsPassword" placeholder="Enter new password…" data-i18n-placeholder="password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
<div id="settingsCurrentPasswordBlock" style="display:none;margin-top:6px">
|
||||
<label for="settingsCurrentPassword" style="font-size:11px;display:block;margin-bottom:4px" data-i18n="current_password_label">Current Password</label>
|
||||
<input type="password" id="settingsCurrentPassword" placeholder="Enter current password…" data-i18n-placeholder="current_password_placeholder" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
</div>
|
||||
<div id="settingsPasswordEnvLock" data-i18n="password_env_var_locked" style="display:none;margin-top:6px;padding:8px 10px;font-size:11px;color:var(--muted);background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;line-height:1.45">The HERMES_WEBUI_PASSWORD environment variable is currently set and takes precedence. Unset it and restart the server to manage the password from here.</div>
|
||||
</div>
|
||||
<div id="settingsAuthDisabledWarning" style="display:none;margin-top:8px;padding:10px 12px;border-radius:8px;border:1px solid rgba(220,80,80,.35);background:rgba(220,80,80,.08)">
|
||||
<div style="font-size:12px;font-weight:700;color:rgba(220,80,80,.95);margin-bottom:6px" data-i18n="auth_status_unauthenticated">Unauthenticated</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:8px;line-height:1.45" data-i18n="auth_disabled_warning_message">This instance is accessible without authentication.</div>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text);cursor:pointer">
|
||||
<input type="checkbox" id="settingsAuthDisabledAck" onchange="_setAuthDisabledAck(this.checked)">
|
||||
<span data-i18n="auth_acknowledged_label">I’ve reviewed this risk; show a less-urgent nav warning.</span>
|
||||
</label>
|
||||
</div>
|
||||
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
|
||||
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
|
||||
<div class="settings-field" id="shutdownServerBlock" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
|
||||
|
||||
+117
-6
@@ -7467,8 +7467,13 @@ async function loadSettingsPanel(){
|
||||
// Show auth buttons only when auth is active
|
||||
try{
|
||||
const authStatus=await api('/api/auth/status');
|
||||
_settingsPasswordAuthEnabled=!!authStatus.password_auth_enabled;
|
||||
_setSettingsAuthButtonsVisible(!!authStatus.auth_enabled);
|
||||
_syncPasswordlessButton(authStatus);
|
||||
_renderSettingsAuthStatus(authStatus);
|
||||
_updateCurrentPasswordVisibility();
|
||||
_updateAuthWarningBadge(authStatus);
|
||||
_updateAuthDisabledWarning(authStatus);
|
||||
}catch(e){}
|
||||
loadPasskeys();
|
||||
// #1560: env-var-locked password also disables the Disable Auth button —
|
||||
@@ -8293,6 +8298,7 @@ async function _refreshProviderModels(providerId, btn){
|
||||
}
|
||||
|
||||
let _settingsPasswordEnvLocked=false;
|
||||
let _settingsPasswordAuthEnabled=false;
|
||||
function _setSettingsAuthButtonsVisible(active){
|
||||
const signOutBtn=$('btnSignOut');
|
||||
if(signOutBtn) signOutBtn.style.display=active?'':'none';
|
||||
@@ -8309,6 +8315,63 @@ function _syncPasswordlessButton(authStatus){
|
||||
btn.disabled=!can;
|
||||
}
|
||||
|
||||
function _renderSettingsAuthStatus(authStatus){
|
||||
const el=$('settingsAuthStatus');
|
||||
if(!el) return;
|
||||
if(!authStatus) { el.style.display='none'; return; }
|
||||
el.style.display='block';
|
||||
let label='',cls='detail-badge ok';
|
||||
if(authStatus.auth_enabled && authStatus.password_auth_enabled){
|
||||
label=t('auth_status_password'); cls='detail-badge ok';
|
||||
}else if(authStatus.auth_enabled && !authStatus.password_auth_enabled){
|
||||
label=t('auth_status_passkey_only'); cls='detail-badge warn';
|
||||
}else{
|
||||
label=t('auth_status_unauthenticated'); cls='detail-badge err';
|
||||
}
|
||||
el.innerHTML='<span class="'+cls+'" style="font-size:11px">'+label+'</span>';
|
||||
}
|
||||
|
||||
function _updateCurrentPasswordVisibility(){
|
||||
const block=$('settingsCurrentPasswordBlock');
|
||||
if(!block) return;
|
||||
block.style.display=_settingsPasswordAuthEnabled?'block':'none';
|
||||
}
|
||||
|
||||
function _updateAuthWarningBadge(authStatus){
|
||||
const badges=['authWarningBadgeDesktop','authWarningBadgeMobile'];
|
||||
const authDisabled=!authStatus||!authStatus.auth_enabled;
|
||||
const acknowledged=!!(authStatus&&authStatus.auth_disabled_acknowledged);
|
||||
badges.forEach(function(id){
|
||||
const el=$(id);
|
||||
if(!el) return;
|
||||
if(!authDisabled){ el.style.display='none'; return; }
|
||||
el.style.display='block';
|
||||
el.style.background=acknowledged?'#e8a030':'#e05';
|
||||
});
|
||||
}
|
||||
|
||||
function _updateAuthDisabledWarning(authStatus){
|
||||
const el=$('settingsAuthDisabledWarning');
|
||||
if(!el) return;
|
||||
const authDisabled=!authStatus||!authStatus.auth_enabled;
|
||||
if(!authDisabled){ el.style.display='none'; return; }
|
||||
el.style.display='block';
|
||||
const cb=$('settingsAuthDisabledAck');
|
||||
if(cb) cb.checked=!!(authStatus&&authStatus.auth_disabled_acknowledged);
|
||||
}
|
||||
|
||||
async function _setAuthDisabledAck(checked){
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify({_auth_disabled_acknowledged:!!checked})});
|
||||
try{
|
||||
const authStatus=await api('/api/auth/status');
|
||||
_updateAuthWarningBadge(authStatus);
|
||||
}catch(e){}
|
||||
}catch(e){
|
||||
showToast(t('auth_ack_save_failed')+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function _b64uToBytes(s){
|
||||
s=String(s||'').replace(/-/g,'+').replace(/_/g,'/');
|
||||
while(s.length%4) s+='=';
|
||||
@@ -8957,8 +9020,17 @@ async function saveSettings(andClose){
|
||||
body.bot_name=botName||'Hermes';
|
||||
// Password: only act if the field has content; blank = leave auth unchanged
|
||||
if(pw && pw.trim()){
|
||||
const currentPwField=$('settingsCurrentPassword');
|
||||
const currentPw=(currentPwField||{}).value||'';
|
||||
if(_settingsPasswordAuthEnabled && !currentPw.trim()){
|
||||
if(currentPwField) currentPwField.focus();
|
||||
showToast(t('current_password_required'));
|
||||
return;
|
||||
}
|
||||
const payload={...body,_set_password:pw.trim()};
|
||||
if(_settingsPasswordAuthEnabled) payload._current_password=currentPw;
|
||||
try{
|
||||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
|
||||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
|
||||
if(modelChanged && model){
|
||||
try{
|
||||
await api('/api/default-model',{method:'POST',body:JSON.stringify({model})});
|
||||
@@ -8969,6 +9041,16 @@ async function saveSettings(andClose){
|
||||
}
|
||||
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showQuotaChip,showConversationOutline,showTps,fadeTextEffect,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
|
||||
showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated'));
|
||||
const cpField=$('settingsCurrentPassword'); if(cpField) cpField.value='';
|
||||
const pwField=$('settingsPassword'); if(pwField) pwField.value='';
|
||||
_settingsPasswordAuthEnabled=!!saved.password_auth_enabled;
|
||||
_updateCurrentPasswordVisibility();
|
||||
try{
|
||||
const authStatus=await api('/api/auth/status');
|
||||
_renderSettingsAuthStatus(authStatus);
|
||||
_updateAuthWarningBadge(authStatus);
|
||||
_updateAuthDisabledWarning(authStatus);
|
||||
}catch(e){}
|
||||
_settingsDirty=false;
|
||||
_resetSettingsPanelState();
|
||||
if(!andClose) _pendingSettingsTargetPanel = null;
|
||||
@@ -9009,28 +9091,57 @@ async function signOut(){
|
||||
async function goPasswordless(){
|
||||
const ok=await showConfirmDialog({title:'Go passwordless?',message:'This removes the password and keeps passkey sign-in enabled. Keep at least one passkey registered or you could lose access.',confirmLabel:'Go passwordless',danger:false,focusCancel:true});
|
||||
if(!ok) return;
|
||||
const currentPw=($('settingsCurrentPassword')||{}).value;
|
||||
const payload={_passwordless:true};
|
||||
if(_settingsPasswordAuthEnabled && currentPw) payload._current_password=currentPw;
|
||||
try{
|
||||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify({_passwordless:true})});
|
||||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
|
||||
showToast('Password removed. Passkey sign-in remains enabled.');
|
||||
_setSettingsAuthButtonsVisible(!!saved.auth_enabled);
|
||||
_syncPasswordlessButton({auth_enabled:saved.auth_enabled,password_auth_enabled:false,passkeys_count:1});
|
||||
const pwField=$('settingsPassword'); if(pwField) pwField.value='';
|
||||
const cpField=$('settingsCurrentPassword'); if(cpField) cpField.value='';
|
||||
_settingsPasswordAuthEnabled=false;
|
||||
_updateCurrentPasswordVisibility();
|
||||
try{
|
||||
const authStatus=await api('/api/auth/status');
|
||||
_renderSettingsAuthStatus(authStatus);
|
||||
_updateAuthWarningBadge(authStatus);
|
||||
}catch(e){}
|
||||
}catch(e){showToast('Failed to go passwordless: '+e.message);}
|
||||
}
|
||||
|
||||
async function disableAuth(){
|
||||
const _disAuth=await showConfirmDialog({title:t('disable_auth_confirm_title'),message:t('disable_auth_confirm_message'),confirmLabel:t('disable'),danger:true,focusCancel:true});
|
||||
if(!_disAuth) return;
|
||||
const currentPwField=$('settingsCurrentPassword');
|
||||
const currentPw=(currentPwField||{}).value||'';
|
||||
if(_settingsPasswordAuthEnabled && !currentPw.trim()){
|
||||
if(currentPwField) currentPwField.focus();
|
||||
showToast(t('current_password_required'));
|
||||
return;
|
||||
}
|
||||
const confirmText='DISABLE AUTH';
|
||||
const userInput=await showPromptDialog({title:t('disable_auth_confirm_title'),message:t('disable_auth_confirm_message')+' '+t('disable_auth_typed_confirm'),placeholder:confirmText,confirmLabel:t('disable_auth'),danger:true});
|
||||
if(!userInput || userInput.trim()!==confirmText) return;
|
||||
const payload={_clear_password:true};
|
||||
if(_settingsPasswordAuthEnabled) payload._current_password=currentPw;
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})});
|
||||
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
|
||||
showToast(t('auth_disabled'));
|
||||
// Hide auth controls since auth is now off
|
||||
const disableBtn=$('btnDisableAuth');
|
||||
if(disableBtn) disableBtn.style.display='none';
|
||||
const signOutBtn=$('btnSignOut');
|
||||
if(signOutBtn) signOutBtn.style.display='none';
|
||||
_syncPasswordlessButton({auth_enabled:false,password_auth_enabled:false,passkeys_count:0});
|
||||
_settingsPasswordAuthEnabled=false;
|
||||
_updateCurrentPasswordVisibility();
|
||||
const cpField=$('settingsCurrentPassword'); if(cpField) cpField.value='';
|
||||
loadPasskeys();
|
||||
try{
|
||||
const authStatus=await api('/api/auth/status');
|
||||
_renderSettingsAuthStatus(authStatus);
|
||||
_updateAuthWarningBadge(authStatus);
|
||||
_updateAuthDisabledWarning(authStatus);
|
||||
}catch(e){}
|
||||
}catch(e){
|
||||
showToast(t('disable_auth_failed')+e.message);
|
||||
}
|
||||
|
||||
@@ -5267,6 +5267,8 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
|
||||
.detail-alert{border:2px solid rgba(245,158,11,.45);background:rgba(245,158,11,.1);border-radius:8px;padding:12px 14px;margin-bottom:16px;color:var(--text);}
|
||||
.detail-alert-title{font-size:12px;font-weight:700;color:var(--error,#e05);text-transform:uppercase;letter-spacing:0;margin-bottom:6px;}
|
||||
.detail-alert p{margin:0 0 8px;font-size:13px;line-height:1.45;color:var(--text);}
|
||||
.auth-warning-badge{display:none;position:absolute;top:4px;right:4px;width:8px;height:8px;border-radius:50%;background:#e05;pointer-events:none;}
|
||||
.nav-tab{position:relative;}
|
||||
.detail-alert p:last-child{margin-bottom:0;}
|
||||
.detail-alert-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;}
|
||||
|
||||
|
||||
+5
-2
@@ -5888,8 +5888,11 @@ function showPromptDialog(opts={}){
|
||||
input.autocomplete='off';input.spellcheck=false;
|
||||
}
|
||||
if(cancelBtn) cancelBtn.textContent=opts.cancelLabel||t('cancel');
|
||||
if(confirmBtn){confirmBtn.textContent=opts.confirmLabel||t('create');confirmBtn.classList.remove('danger');}
|
||||
if(dialog) dialog.setAttribute('role','dialog');
|
||||
if(confirmBtn){
|
||||
confirmBtn.textContent=opts.confirmLabel||t('create');
|
||||
confirmBtn.classList.toggle('danger',!!opts.danger);
|
||||
}
|
||||
if(dialog) dialog.setAttribute('role',opts.danger?'alertdialog':'dialog');
|
||||
if(overlay){overlay.style.display='flex';overlay.setAttribute('aria-hidden','false');}
|
||||
return new Promise(resolve=>{
|
||||
APP_DIALOG.resolve=resolve;
|
||||
|
||||
@@ -16,6 +16,7 @@ the cached result.
|
||||
"""
|
||||
import importlib
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -57,6 +58,10 @@ class TestPasswordHashCache(unittest.TestCase):
|
||||
# doesn't cascade across test boundaries
|
||||
os.environ.pop('HERMES_WEBUI_PASSWORD', None)
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.pop('HERMES_WEBUI_PASSWORD', None)
|
||||
auth._invalidate_password_hash_cache()
|
||||
|
||||
def _set_env_pw(self, pw: str) -> None:
|
||||
os.environ['HERMES_WEBUI_PASSWORD'] = pw
|
||||
|
||||
@@ -166,6 +171,10 @@ class TestPasswordHashCacheConcurrency(unittest.TestCase):
|
||||
auth._AUTH_HASH_CACHE = None
|
||||
os.environ.pop('HERMES_WEBUI_PASSWORD', None)
|
||||
|
||||
def tearDown(self):
|
||||
os.environ.pop('HERMES_WEBUI_PASSWORD', None)
|
||||
auth._invalidate_password_hash_cache()
|
||||
|
||||
def _set_env_pw(self, pw: str) -> None:
|
||||
os.environ['HERMES_WEBUI_PASSWORD'] = pw
|
||||
|
||||
@@ -251,18 +260,18 @@ class TestPasswordCacheInvalidation(unittest.TestCase):
|
||||
auth._AUTH_HASH_COMPUTED = False
|
||||
auth._AUTH_HASH_CACHE = None
|
||||
os.environ.pop('HERMES_WEBUI_PASSWORD', None)
|
||||
# Start with a clean settings.json so write tests are isolated
|
||||
self._sf = config.SETTINGS_FILE
|
||||
self._backup = None
|
||||
if self._sf.exists():
|
||||
self._backup = self._sf.read_text(encoding='utf-8')
|
||||
self._sf.unlink()
|
||||
# Start with a clean temporary settings.json so write tests are isolated.
|
||||
# The full pytest shard may already have the test server subprocess
|
||||
# running. Mutating the shared TEST_STATE_DIR/settings.json here can make
|
||||
# that server observe/cache a password and then return 401 to unrelated
|
||||
# later HTTP tests. Keep this class fully in-process instead.
|
||||
self._orig_settings_file = config.SETTINGS_FILE
|
||||
self._tmpdir = Path(tempfile.mkdtemp())
|
||||
config.SETTINGS_FILE = self._tmpdir / 'settings.json'
|
||||
|
||||
def tearDown(self):
|
||||
if self._backup is not None:
|
||||
self._sf.write_text(self._backup, encoding='utf-8')
|
||||
elif self._sf.exists():
|
||||
self._sf.unlink()
|
||||
config.SETTINGS_FILE = self._orig_settings_file
|
||||
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
||||
auth._invalidate_password_hash_cache()
|
||||
os.environ.pop('HERMES_WEBUI_PASSWORD', None)
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Auth-disable safety tests.
|
||||
|
||||
Verify that POST /api/settings requires _current_password when password auth
|
||||
is already enabled and the user wants to change, clear, or go passwordless.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_auth_settings_state(tmp_path, monkeypatch):
|
||||
"""Keep these unit tests from mutating the shared pytest server state.
|
||||
|
||||
The full CI shard may already have the test server running. If these tests
|
||||
write password_hash into the shared test settings.json, the server process can
|
||||
observe/cache it and unrelated later API tests start returning 401. Point the
|
||||
in-process config module at a per-test settings file instead.
|
||||
"""
|
||||
import api.config as cfg
|
||||
from api.auth import _invalidate_password_hash_cache
|
||||
|
||||
monkeypatch.setattr(cfg, "SETTINGS_FILE", tmp_path / "settings.json")
|
||||
_invalidate_password_hash_cache()
|
||||
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
|
||||
yield
|
||||
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
|
||||
_invalidate_password_hash_cache()
|
||||
|
||||
|
||||
class _FakeHandler:
|
||||
def __init__(self, body_bytes: bytes = b"", cookie: str = ""):
|
||||
self.status = None
|
||||
self.sent_headers = []
|
||||
self.body = bytearray()
|
||||
self.wfile = self
|
||||
self.rfile = io.BytesIO(body_bytes)
|
||||
self.headers = {
|
||||
"Content-Length": str(len(body_bytes)),
|
||||
}
|
||||
if cookie:
|
||||
self.headers["Cookie"] = cookie
|
||||
self.request = None
|
||||
# First-password setup is gated to local/private clients when auth is
|
||||
# disabled. These in-process settings tests exercise the local bootstrap
|
||||
# path, so model a loopback request explicitly.
|
||||
self.client_address = ("127.0.0.1", 0)
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, name, value):
|
||||
self.sent_headers.append((name, value))
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
def write(self, data):
|
||||
self.body.extend(data)
|
||||
|
||||
def header(self, name):
|
||||
for key, value in self.sent_headers:
|
||||
if key.lower() == name.lower():
|
||||
return value
|
||||
return None
|
||||
|
||||
def json_body(self):
|
||||
return json.loads(bytes(self.body).decode("utf-8"))
|
||||
|
||||
|
||||
def _post_settings(body_dict, cookie=""):
|
||||
from api.routes import handle_post
|
||||
raw = json.dumps(body_dict).encode("utf-8")
|
||||
handler = _FakeHandler(body_bytes=raw, cookie=cookie)
|
||||
parsed = urlparse("http://example.com/api/settings")
|
||||
handle_post(handler, parsed)
|
||||
return handler
|
||||
|
||||
|
||||
def _get_settings():
|
||||
from api.routes import handle_get
|
||||
handler = _FakeHandler()
|
||||
parsed = urlparse("http://example.com/api/settings")
|
||||
handle_get(handler, parsed)
|
||||
return handler.json_body(), handler.status
|
||||
|
||||
|
||||
def _set_password_raw(pw):
|
||||
from api.config import save_settings
|
||||
from api.auth import _invalidate_password_hash_cache
|
||||
save_settings({"_set_password": pw})
|
||||
_invalidate_password_hash_cache()
|
||||
|
||||
|
||||
def _clear_password_raw():
|
||||
from api.config import save_settings
|
||||
from api.auth import _invalidate_password_hash_cache
|
||||
save_settings({"_clear_password": True})
|
||||
_invalidate_password_hash_cache()
|
||||
|
||||
|
||||
class TestChangePasswordRequiresCurrentPassword:
|
||||
def test_cannot_change_password_without_current_password(self):
|
||||
_set_password_raw("oldpassword")
|
||||
handler = _post_settings({"_set_password": "newpassword"})
|
||||
assert handler.status == 403
|
||||
payload = handler.json_body()
|
||||
assert "current password" in payload.get("error", "").lower()
|
||||
|
||||
def test_cannot_change_password_with_wrong_current_password(self):
|
||||
_set_password_raw("oldpassword")
|
||||
handler = _post_settings({"_set_password": "newpassword", "_current_password": "wrongpw"})
|
||||
assert handler.status == 403
|
||||
payload = handler.json_body()
|
||||
assert "incorrect" in payload.get("error", "").lower()
|
||||
|
||||
def test_can_change_password_with_correct_current_password(self):
|
||||
_set_password_raw("oldpassword")
|
||||
handler = _post_settings({"_set_password": "newpassword", "_current_password": "oldpassword"})
|
||||
assert handler.status == 200
|
||||
payload = handler.json_body()
|
||||
assert payload.get("auth_enabled") is True
|
||||
_clear_password_raw()
|
||||
|
||||
|
||||
class TestClearPasswordRequiresCurrentPassword:
|
||||
def test_cannot_clear_password_without_current_password(self):
|
||||
_set_password_raw("oldpassword")
|
||||
handler = _post_settings({"_clear_password": True})
|
||||
assert handler.status == 403
|
||||
payload = handler.json_body()
|
||||
assert "current password" in payload.get("error", "").lower()
|
||||
|
||||
def test_cannot_clear_password_with_wrong_current_password(self):
|
||||
_set_password_raw("oldpassword")
|
||||
handler = _post_settings({"_clear_password": True, "_current_password": "wrongpw"})
|
||||
assert handler.status == 403
|
||||
|
||||
def test_cannot_clear_password_with_non_string_current_password(self):
|
||||
_set_password_raw("oldpassword")
|
||||
handler = _post_settings({"_clear_password": True, "_current_password": 123})
|
||||
assert handler.status == 403
|
||||
payload = handler.json_body()
|
||||
assert "current password" in payload.get("error", "").lower()
|
||||
|
||||
def test_can_clear_password_with_correct_current_password(self):
|
||||
_set_password_raw("oldpassword")
|
||||
handler = _post_settings({"_clear_password": True, "_current_password": "oldpassword"})
|
||||
assert handler.status == 200
|
||||
payload = handler.json_body()
|
||||
assert payload.get("auth_enabled") is False
|
||||
|
||||
|
||||
class TestFirstTimePasswordNoCurrentRequired:
|
||||
def test_first_time_password_works_without_current_password(self):
|
||||
_clear_password_raw()
|
||||
from api.auth import _invalidate_password_hash_cache
|
||||
_invalidate_password_hash_cache()
|
||||
handler = _post_settings({"_set_password": "firstpw"})
|
||||
assert handler.status == 200
|
||||
payload = handler.json_body()
|
||||
assert payload.get("auth_enabled") is True
|
||||
_clear_password_raw()
|
||||
_invalidate_password_hash_cache()
|
||||
|
||||
def test_first_time_password_uses_auth_enabled_state_not_stale_hash(self, monkeypatch):
|
||||
"""Disabled-auth bootstrap must not be blocked by stale password-hash state."""
|
||||
monkeypatch.setattr("api.auth.is_auth_enabled", lambda: False)
|
||||
monkeypatch.setattr("api.auth.get_password_hash", lambda: "stale-hash")
|
||||
handler = _post_settings({"_set_password": "firstpw"})
|
||||
assert handler.status == 200
|
||||
payload = handler.json_body()
|
||||
assert payload.get("auth_enabled") is False
|
||||
|
||||
|
||||
class TestEnvVarPasswordLockStillRejects:
|
||||
def test_env_var_lock_rejects_password_change(self):
|
||||
os.environ["HERMES_WEBUI_PASSWORD"] = "envpw"
|
||||
handler = _post_settings({"_set_password": "newpw", "_current_password": "envpw"})
|
||||
assert handler.status == 409
|
||||
|
||||
def test_env_var_lock_rejects_password_clear(self):
|
||||
os.environ["HERMES_WEBUI_PASSWORD"] = "envpw"
|
||||
handler = _post_settings({"_clear_password": True, "_current_password": "envpw"})
|
||||
assert handler.status == 409
|
||||
|
||||
|
||||
class TestCurrentPasswordNotPersisted:
|
||||
def test_current_password_not_in_settings_response(self):
|
||||
_set_password_raw("oldpassword")
|
||||
handler = _post_settings({"_set_password": "newpassword", "_current_password": "oldpassword"})
|
||||
assert handler.status == 200
|
||||
payload = handler.json_body()
|
||||
assert "_current_password" not in payload
|
||||
assert "current_password" not in payload
|
||||
_clear_password_raw()
|
||||
|
||||
def test_current_password_not_in_get_settings(self):
|
||||
payload, status = _get_settings()
|
||||
assert status == 200
|
||||
assert "_current_password" not in payload
|
||||
assert "current_password" not in payload
|
||||
|
||||
|
||||
class TestSettingsExposesAuthStateFields:
|
||||
def test_get_settings_has_auth_enabled(self):
|
||||
payload, status = _get_settings()
|
||||
assert status == 200
|
||||
assert "auth_enabled" in payload
|
||||
assert "password_auth_enabled" in payload
|
||||
|
||||
def test_get_settings_never_exposes_password_hash(self):
|
||||
payload, status = _get_settings()
|
||||
assert status == 200
|
||||
assert "password_hash" not in payload
|
||||
|
||||
def test_post_settings_includes_auth_state_fields(self):
|
||||
_clear_password_raw()
|
||||
from api.auth import _invalidate_password_hash_cache
|
||||
_invalidate_password_hash_cache()
|
||||
handler = _post_settings({"send_key": "enter"})
|
||||
assert handler.status == 200
|
||||
payload = handler.json_body()
|
||||
assert "auth_enabled" in payload
|
||||
assert "password_auth_enabled" in payload
|
||||
|
||||
|
||||
class TestAuthDisabledAcknowledged:
|
||||
def test_acknowledged_can_be_set_when_auth_disabled(self):
|
||||
_clear_password_raw()
|
||||
from api.auth import _invalidate_password_hash_cache
|
||||
_invalidate_password_hash_cache()
|
||||
handler = _post_settings({"_auth_disabled_acknowledged": True})
|
||||
assert handler.status == 200
|
||||
payload = handler.json_body()
|
||||
assert payload.get("auth_disabled_acknowledged") is True
|
||||
|
||||
def test_acknowledged_resets_when_auth_enabled(self):
|
||||
_set_password_raw("testpw")
|
||||
handler = _post_settings({"_auth_disabled_acknowledged": True, "_current_password": "testpw"})
|
||||
assert handler.status == 200
|
||||
payload = handler.json_body()
|
||||
assert payload.get("auth_disabled_acknowledged") is False
|
||||
_clear_password_raw()
|
||||
from api.auth import _invalidate_password_hash_cache
|
||||
_invalidate_password_hash_cache()
|
||||
|
||||
def test_acknowledged_resets_when_setting_first_password(self):
|
||||
_clear_password_raw()
|
||||
handler = _post_settings({"_auth_disabled_acknowledged": True})
|
||||
assert handler.status == 200
|
||||
assert handler.json_body().get("auth_disabled_acknowledged") is True
|
||||
|
||||
handler = _post_settings({"_set_password": "firstpw"})
|
||||
assert handler.status == 200
|
||||
payload = handler.json_body()
|
||||
assert payload.get("auth_enabled") is True
|
||||
assert payload.get("auth_disabled_acknowledged") is False
|
||||
_clear_password_raw()
|
||||
from api.auth import _invalidate_password_hash_cache
|
||||
_invalidate_password_hash_cache()
|
||||
|
||||
def test_auth_status_exposes_acknowledgement_only_while_disabled(self):
|
||||
_clear_password_raw()
|
||||
handler = _post_settings({"_auth_disabled_acknowledged": True})
|
||||
assert handler.status == 200
|
||||
|
||||
from api.routes import handle_get
|
||||
status_handler = _FakeHandler()
|
||||
handle_get(status_handler, urlparse("http://example.com/api/auth/status"))
|
||||
status_payload = status_handler.json_body()
|
||||
assert status_payload.get("auth_enabled") is False
|
||||
assert status_payload.get("auth_disabled_acknowledged") is True
|
||||
|
||||
_set_password_raw("testpw")
|
||||
status_handler = _FakeHandler()
|
||||
handle_get(status_handler, urlparse("http://example.com/api/auth/status"))
|
||||
status_payload = status_handler.json_body()
|
||||
assert status_payload.get("auth_enabled") is True
|
||||
assert status_payload.get("auth_disabled_acknowledged") is False
|
||||
_clear_password_raw()
|
||||
from api.auth import _invalidate_password_hash_cache
|
||||
_invalidate_password_hash_cache()
|
||||
@@ -47,6 +47,125 @@ def test_ui_js_exposes_shared_dialog_helpers():
|
||||
assert "document.addEventListener('keydown'" in src
|
||||
|
||||
|
||||
def test_prompt_dialog_honors_custom_label_and_danger_state():
|
||||
src = read("static/ui.js")
|
||||
assert "confirmBtn.textContent=opts.confirmLabel||t('create')" in src
|
||||
assert "confirmBtn.classList.toggle('danger',!!opts.danger)" in src
|
||||
assert "opts.danger?'alertdialog':'dialog'" in src
|
||||
|
||||
|
||||
def test_disable_auth_prompt_uses_destructive_label_not_create():
|
||||
src = read("static/panels.js")
|
||||
match = re.search(r"async function disableAuth\(\)\{(.*?)\n\}", src, re.DOTALL)
|
||||
assert match, "disableAuth() not found"
|
||||
body = match.group(1)
|
||||
assert "const confirmText='DISABLE AUTH'" in body
|
||||
assert "currentPwField=$('settingsCurrentPassword')" in body
|
||||
assert "showToast(t('current_password_required'))" in body
|
||||
assert body.index("showToast(t('current_password_required'))") < body.index("showPromptDialog")
|
||||
assert "showPromptDialog" in body
|
||||
assert "confirmLabel:t('disable_auth')" in body
|
||||
assert "danger:true" in body
|
||||
assert "t('create')" not in body
|
||||
|
||||
|
||||
def test_auth_disabled_warning_uses_status_payload_without_extra_settings_fetch():
|
||||
src = read("static/panels.js")
|
||||
match = re.search(r"function _updateAuthDisabledWarning\(authStatus\)\{(.*?)\n\}", src, re.DOTALL)
|
||||
assert match, "_updateAuthDisabledWarning(authStatus) not found"
|
||||
body = match.group(1)
|
||||
assert "authStatus&&authStatus.auth_disabled_acknowledged" in body
|
||||
assert "api('/api/settings')" not in body
|
||||
|
||||
|
||||
def test_acknowledgement_save_failure_uses_i18n_toast():
|
||||
src = read("static/panels.js")
|
||||
match = re.search(r"async function _setAuthDisabledAck\(checked\)\{(.*?)\n\}", src, re.DOTALL)
|
||||
assert match, "_setAuthDisabledAck(checked) not found"
|
||||
body = match.group(1)
|
||||
assert "showToast(t('auth_ack_save_failed')+e.message)" in body
|
||||
assert "Failed to update acknowledgement" not in body
|
||||
|
||||
|
||||
def test_save_settings_password_change_preflights_current_password_before_api():
|
||||
src = read("static/panels.js")
|
||||
match = re.search(r"async function saveSettings\([^)]*\)\{(.*?)\n\}", src, re.DOTALL)
|
||||
assert match, "saveSettings() not found"
|
||||
body = match.group(1)
|
||||
assert "currentPwField=$('settingsCurrentPassword')" in body
|
||||
assert "showToast(t('current_password_required'))" in body
|
||||
assert body.index("showToast(t('current_password_required'))") < body.index("api('/api/settings'")
|
||||
|
||||
|
||||
def test_disable_auth_typed_confirm_locales_show_literal_phrase():
|
||||
src = read("static/i18n.js")
|
||||
values = re.findall(r"disable_auth_typed_confirm:\s*'([^']*)'", src)
|
||||
assert values, "disable_auth_typed_confirm keys missing"
|
||||
assert len(values) == len(_i18n_locale_blocks(src)), (
|
||||
"Every locale must define disable_auth_typed_confirm exactly once"
|
||||
)
|
||||
missing_literal = [value for value in values if "DISABLE AUTH" not in value]
|
||||
assert not missing_literal, (
|
||||
"Disable-auth prompt must display the exact phrase accepted by "
|
||||
f"disableAuth(), but these translations do not: {missing_literal}"
|
||||
)
|
||||
|
||||
|
||||
AUTH_SAFETY_LOCALE_KEYS = (
|
||||
"current_password_label",
|
||||
"current_password_placeholder",
|
||||
"current_password_required",
|
||||
"current_password_incorrect",
|
||||
"disable_auth_typed_confirm",
|
||||
"auth_status_password",
|
||||
"auth_status_passkey_only",
|
||||
"auth_status_unauthenticated",
|
||||
"auth_warning_badge",
|
||||
"auth_disabled_warning_message",
|
||||
"auth_acknowledged_label",
|
||||
"auth_ack_save_failed",
|
||||
)
|
||||
|
||||
|
||||
def _i18n_locale_blocks(src):
|
||||
heads = list(re.finditer(r"^ (?:(?:'([^']+)')|([A-Za-z][A-Za-z0-9_]*)):\s*\{", src, re.M))
|
||||
blocks = {}
|
||||
for i, head in enumerate(heads):
|
||||
locale = head.group(1) or head.group(2)
|
||||
end = heads[i + 1].start() if i + 1 < len(heads) else src.find("\n};", head.end())
|
||||
assert end != -1, f"could not find end of locale block {locale}"
|
||||
blocks[locale] = src[head.end():end]
|
||||
return blocks
|
||||
|
||||
|
||||
def test_auth_safety_keys_exist_once_per_locale():
|
||||
src = read("static/i18n.js")
|
||||
blocks = _i18n_locale_blocks(src)
|
||||
assert "pt" in blocks
|
||||
assert "zh-Hant" in blocks
|
||||
for locale, block in blocks.items():
|
||||
missing = [key for key in AUTH_SAFETY_LOCALE_KEYS if f"{key}:" not in block]
|
||||
duplicated = [
|
||||
key for key in AUTH_SAFETY_LOCALE_KEYS
|
||||
if len(re.findall(rf"\b{re.escape(key)}\s*:", block)) != 1
|
||||
]
|
||||
assert not missing, f"{locale} missing auth-safety locale keys: {missing}"
|
||||
assert not duplicated, f"{locale} has duplicated auth-safety locale keys: {duplicated}"
|
||||
|
||||
|
||||
def test_auth_safety_pt_and_zh_hant_strings_stay_in_correct_locale_blocks():
|
||||
src = read("static/i18n.js")
|
||||
blocks = _i18n_locale_blocks(src)
|
||||
zh_hant = blocks["zh-Hant"]
|
||||
pt = blocks["pt"]
|
||||
assert "目前密碼" in zh_hant
|
||||
assert "輸入 DISABLE AUTH" in zh_hant
|
||||
assert "Senha atual" not in zh_hant
|
||||
assert "Digite DISABLE AUTH" not in zh_hant
|
||||
assert "Senha atual" in pt
|
||||
assert "Digite DISABLE AUTH" in pt
|
||||
|
||||
|
||||
def test_no_native_confirm_calls_remain_in_static_js():
|
||||
for path in (REPO / "static").glob("*.js"):
|
||||
src = path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -101,10 +101,15 @@ def test_first_password_enablement_returns_cookie_and_keeps_browser_logged_in():
|
||||
_get_settings_file().write_text(_json.dumps(clean, indent=2), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
# Then: tell the server to clear auth via API (must use the session cookie)
|
||||
# Then: tell the server to clear auth via API (must use the session cookie
|
||||
# and prove possession of the current password under auth-disable safety).
|
||||
try:
|
||||
_headers = {"Cookie": cookie_header} if cookie_header else {}
|
||||
post("/api/settings", {"_clear_password": True}, headers=_headers)
|
||||
post(
|
||||
"/api/settings",
|
||||
{"_clear_password": True, "_current_password": "sprint45-secret"},
|
||||
headers=_headers,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_restore_settings_file(original_settings)
|
||||
|
||||
Reference in New Issue
Block a user