Просмотр исходного кода

fix(users): show password rules in form + match FE check to BE (#1303)

  Create/Edit User modal previously had no hint about backend password
  complexity, and the FE pre-check was only length>=6 — so any weak
  password got bounced as a bare "HTTP 422" toast after the round-trip.

  - New checkPasswordComplexity util mirroring backend's validator order
  - Helper text under password inputs in both modals
  - Submit disabled until every rule passes
  - client.ts 422 parser falls back to JSON.stringify(detail) when the
    mapped array is empty, so a bare status code never masks the real
    Pydantic detail again
  - i18n keys added in all 8 locales (EN/DE fully translated, others
    per project's English-seed convention)
  - 7 unit tests pinning the validator contract, including the
    reporter's "12345678" input
maziggy 3 месяцев назад
Родитель
Сommit
d08183278f

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 43 - 0
frontend/src/__tests__/utils/password.test.ts

@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { checkPasswordComplexity } from '../../utils/password';
+
+describe('checkPasswordComplexity', () => {
+  it('rejects passwords shorter than 8 characters', () => {
+    expect(checkPasswordComplexity('Ab1!def')).toBe('tooShort');
+    expect(checkPasswordComplexity('')).toBe('tooShort');
+  });
+
+  it('flags missing uppercase first (matches backend validator order)', () => {
+    // Matches backend/app/schemas/auth.py:_validate_password_complexity which
+    // returns the uppercase error before checking lowercase/digit/special.
+    expect(checkPasswordComplexity('abcdefgh')).toBe('needsUppercase');
+    expect(checkPasswordComplexity('abcdefg1')).toBe('needsUppercase');
+    expect(checkPasswordComplexity('abcdefg!')).toBe('needsUppercase');
+  });
+
+  it('flags missing lowercase when uppercase is present', () => {
+    expect(checkPasswordComplexity('ABCDEFGH')).toBe('needsLowercase');
+    expect(checkPasswordComplexity('ABCDEFG1')).toBe('needsLowercase');
+  });
+
+  it('flags missing digit when letters are present', () => {
+    expect(checkPasswordComplexity('Abcdefgh')).toBe('needsDigit');
+    expect(checkPasswordComplexity('Abcdefg!')).toBe('needsDigit');
+  });
+
+  it('flags missing special character', () => {
+    expect(checkPasswordComplexity('Abcdefg1')).toBe('needsSpecial');
+  });
+
+  it('returns null for a password that meets every rule', () => {
+    expect(checkPasswordComplexity('Bambuddy1!')).toBeNull();
+    expect(checkPasswordComplexity('LongerP@ssw0rd!')).toBeNull();
+  });
+
+  it('handles a password from the #1303 user (8 digits) — the original failure mode', () => {
+    // The reporter typed an 8-character all-digits password and the backend
+    // returned 422 "Password must contain at least one uppercase letter".
+    // The FE check now produces the same verdict locally without a round-trip.
+    expect(checkPasswordComplexity('12345678')).toBe('needsUppercase');
+  });
+});

+ 16 - 5
frontend/src/api/client.ts

@@ -104,11 +104,22 @@ async function request<T>(
   if (!response.ok) {
     const error = await response.json().catch(() => ({}));
     const detail = error.detail;
-    const message = typeof detail === 'string'
-      ? detail
-      : Array.isArray(detail)
-        ? detail.map((e: { msg?: string }) => (e.msg ?? '').replace(/^Value error,\s*/i, '')).filter(Boolean).join('; ')
-        : `HTTP ${response.status}`;
+    let message: string;
+    if (typeof detail === 'string') {
+      message = detail;
+    } else if (Array.isArray(detail)) {
+      // FastAPI 422 shape: each entry has `msg` like "Value error, <real msg>".
+      // Strip the prefix and join. Fall back to raw JSON if every entry has an
+      // empty msg (defensive — shouldn't happen with stock Pydantic, but the
+      // previous fallback masked the real cause as a bare "HTTP 422" toast).
+      const joined = detail
+        .map((e: { msg?: string }) => (e.msg ?? '').replace(/^Value error,\s*/i, ''))
+        .filter(Boolean)
+        .join('; ');
+      message = joined || JSON.stringify(detail) || `HTTP ${response.status}`;
+    } else {
+      message = `HTTP ${response.status}`;
+    }
 
     // Handle 401 Unauthorized - only clear token if it's actually invalid
     // Don't clear on "Authentication required" which might be a timing issue

+ 7 - 2
frontend/src/i18n/locales/de.ts

@@ -1839,7 +1839,8 @@ export default {
     username: 'Benutzername',
     enterUsername: 'Benutzername eingeben',
     password: 'Passwort',
-    enterPassword: 'Passwort eingeben (min. 6 Zeichen)',
+    enterPassword: 'Passwort eingeben',
+    passwordRequirements: 'Mindestens 8 Zeichen, davon ein Großbuchstabe, ein Kleinbuchstabe, eine Ziffer und ein Sonderzeichen.',
     confirmPassword: 'Passwort bestätigen',
     confirmPasswordPlaceholder: 'Passwort bestätigen',
     // Title tooltips
@@ -1943,7 +1944,11 @@ export default {
       groupDeleted: 'Gruppe erfolgreich gelöscht',
       fillRequiredFields: 'Bitte füllen Sie alle erforderlichen Felder aus',
       passwordsDoNotMatch: 'Passwörter stimmen nicht überein',
-      passwordTooShort: 'Passwort muss mindestens 6 Zeichen lang sein',
+      passwordTooShort: 'Passwort muss mindestens 8 Zeichen lang sein',
+      passwordNeedsUppercase: 'Passwort muss mindestens einen Großbuchstaben enthalten',
+      passwordNeedsLowercase: 'Passwort muss mindestens einen Kleinbuchstaben enthalten',
+      passwordNeedsDigit: 'Passwort muss mindestens eine Ziffer enthalten',
+      passwordNeedsSpecial: 'Passwort muss mindestens ein Sonderzeichen enthalten',
       enterGroupName: 'Bitte geben Sie einen Gruppennamen ein',
       settingsSaved: 'Einstellungen gespeichert',
       noPermissionUpdate: 'Sie haben keine Berechtigung, Einstellungen zu ändern',

+ 7 - 2
frontend/src/i18n/locales/en.ts

@@ -1842,7 +1842,8 @@ export default {
     username: 'Username',
     enterUsername: 'Enter username',
     password: 'Password',
-    enterPassword: 'Enter password (min 6 characters)',
+    enterPassword: 'Enter password',
+    passwordRequirements: 'At least 8 characters, with one uppercase, one lowercase, one digit, and one special character.',
     confirmPassword: 'Confirm Password',
     confirmPasswordPlaceholder: 'Confirm password',
     // Title tooltips
@@ -1946,7 +1947,11 @@ export default {
       groupDeleted: 'Group deleted successfully',
       fillRequiredFields: 'Please fill in all required fields',
       passwordsDoNotMatch: 'Passwords do not match',
-      passwordTooShort: 'Password must be at least 6 characters',
+      passwordTooShort: 'Password must be at least 8 characters',
+      passwordNeedsUppercase: 'Password must contain at least one uppercase letter',
+      passwordNeedsLowercase: 'Password must contain at least one lowercase letter',
+      passwordNeedsDigit: 'Password must contain at least one digit',
+      passwordNeedsSpecial: 'Password must contain at least one special character',
       enterGroupName: 'Please enter a group name',
       settingsSaved: 'Settings saved',
       noPermissionUpdate: 'You do not have permission to change settings',

+ 7 - 2
frontend/src/i18n/locales/fr.ts

@@ -1796,7 +1796,8 @@ export default {
     username: 'Nom d\'utilisateur',
     enterUsername: 'Entrez l\'utilisateur',
     password: 'Mot de passe',
-    enterPassword: 'Mot de passe (min 6 char)',
+    enterPassword: 'Entrez le mot de passe',
+    passwordRequirements: 'Au moins 8 caractères, avec une majuscule, une minuscule, un chiffre et un caractère spécial.',
     confirmPassword: 'Confirmer le mot de passe',
     confirmPasswordPlaceholder: 'Confirmez le mot de passe',
     // Title tooltips
@@ -1900,7 +1901,11 @@ export default {
       groupDeleted: 'Groupe supprimé',
       fillRequiredFields: 'Remplissez les champs requis',
       passwordsDoNotMatch: 'Les mots de passe ne correspondent pas',
-      passwordTooShort: 'Minimum 6 caractères',
+      passwordTooShort: 'Le mot de passe doit contenir au moins 8 caractères',
+      passwordNeedsUppercase: 'Le mot de passe doit contenir au moins une majuscule',
+      passwordNeedsLowercase: 'Le mot de passe doit contenir au moins une minuscule',
+      passwordNeedsDigit: 'Le mot de passe doit contenir au moins un chiffre',
+      passwordNeedsSpecial: 'Le mot de passe doit contenir au moins un caractère spécial',
       enterGroupName: 'Entrez un nom de groupe',
       settingsSaved: 'Paramètres enregistrés',
       noPermissionUpdate: "Vous n'avez pas l'autorisation de modifier les paramètres",

+ 7 - 2
frontend/src/i18n/locales/it.ts

@@ -1796,7 +1796,8 @@ export default {
     username: 'Nome utente',
     enterUsername: 'Inserisci nome utente',
     password: 'Password',
-    enterPassword: 'Inserisci password (min 6 caratteri)',
+    enterPassword: 'Inserisci password',
+    passwordRequirements: 'Almeno 8 caratteri, con una maiuscola, una minuscola, una cifra e un carattere speciale.',
     confirmPassword: 'Conferma password',
     confirmPasswordPlaceholder: 'Conferma password',
     // Title tooltips
@@ -1900,7 +1901,11 @@ export default {
       groupDeleted: 'Gruppo eliminato con successo',
       fillRequiredFields: 'Compila tutti i campi obbligatori',
       passwordsDoNotMatch: 'Le password non coincidono',
-      passwordTooShort: 'La password deve essere di almeno 6 caratteri',
+      passwordTooShort: 'La password deve essere di almeno 8 caratteri',
+      passwordNeedsUppercase: 'La password deve contenere almeno una lettera maiuscola',
+      passwordNeedsLowercase: 'La password deve contenere almeno una lettera minuscola',
+      passwordNeedsDigit: 'La password deve contenere almeno una cifra',
+      passwordNeedsSpecial: 'La password deve contenere almeno un carattere speciale',
       enterGroupName: 'Inserisci un nome gruppo',
       settingsSaved: 'Impostazioni salvate',
       noPermissionUpdate: 'Non hai il permesso di modificare le impostazioni',

+ 7 - 2
frontend/src/i18n/locales/ja.ts

@@ -1838,7 +1838,8 @@ export default {
     username: 'ユーザー名',
     enterUsername: 'ユーザー名を入力',
     password: 'パスワード',
-    enterPassword: 'パスワードを入力(6文字以上)',
+    enterPassword: 'パスワードを入力',
+    passwordRequirements: 'At least 8 characters, with one uppercase, one lowercase, one digit, and one special character.',
     confirmPassword: 'パスワードの確認',
     confirmPasswordPlaceholder: 'パスワードを確認',
     // Title tooltips
@@ -1942,7 +1943,11 @@ export default {
       groupDeleted: 'グループを削除しました',
       fillRequiredFields: '必須項目をすべて入力してください',
       passwordsDoNotMatch: 'パスワードが一致しません',
-      passwordTooShort: 'パスワードは6文字以上必要です',
+      passwordTooShort: 'パスワードは8文字以上必要です',
+      passwordNeedsUppercase: 'Password must contain at least one uppercase letter',
+      passwordNeedsLowercase: 'Password must contain at least one lowercase letter',
+      passwordNeedsDigit: 'Password must contain at least one digit',
+      passwordNeedsSpecial: 'Password must contain at least one special character',
       enterGroupName: 'グループ名を入力',
       settingsSaved: '設定を保存しました',
       noPermissionUpdate: '設定を変更する権限がありません',

+ 7 - 2
frontend/src/i18n/locales/pt-BR.ts

@@ -1796,7 +1796,8 @@ export default {
     username: 'Nome de Usuário',
     enterUsername: 'Digite o nome de usuário',
     password: 'Senha',
-    enterPassword: 'Digite a senha (mínimo 6 caracteres)',
+    enterPassword: 'Digite a senha',
+    passwordRequirements: 'Pelo menos 8 caracteres, com uma maiúscula, uma minúscula, um dígito e um caractere especial.',
     confirmPassword: 'Confirmar Senha',
     confirmPasswordPlaceholder: 'Confirme a senha',
     // Title tooltips
@@ -1900,7 +1901,11 @@ export default {
       groupDeleted: 'Grupo excluído com sucesso',
       fillRequiredFields: 'Por favor, preencha todos os campos obrigatórios',
       passwordsDoNotMatch: 'As senhas não coincidem',
-      passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',
+      passwordTooShort: 'A senha deve ter pelo menos 8 caracteres',
+      passwordNeedsUppercase: 'A senha deve conter pelo menos uma letra maiúscula',
+      passwordNeedsLowercase: 'A senha deve conter pelo menos uma letra minúscula',
+      passwordNeedsDigit: 'A senha deve conter pelo menos um dígito',
+      passwordNeedsSpecial: 'A senha deve conter pelo menos um caractere especial',
       enterGroupName: 'Por favor, insira um nome de grupo',
       settingsSaved: 'Configurações salvas',
       noPermissionUpdate: 'Você não tem permissão para alterar as configurações',

+ 7 - 2
frontend/src/i18n/locales/zh-CN.ts

@@ -1840,7 +1840,8 @@ export default {
     username: '用户名',
     enterUsername: '输入用户名',
     password: '密码',
-    enterPassword: '输入密码(至少 6 个字符)',
+    enterPassword: '输入密码',
+    passwordRequirements: 'At least 8 characters, with one uppercase, one lowercase, one digit, and one special character.',
     confirmPassword: '确认密码',
     confirmPasswordPlaceholder: '确认密码',
     // Title tooltips
@@ -1944,7 +1945,11 @@ export default {
       groupDeleted: '组删除成功',
       fillRequiredFields: '请填写所有必填字段',
       passwordsDoNotMatch: '密码不匹配',
-      passwordTooShort: '密码至少需要 6 个字符',
+      passwordTooShort: '密码至少需要 8 个字符',
+      passwordNeedsUppercase: 'Password must contain at least one uppercase letter',
+      passwordNeedsLowercase: 'Password must contain at least one lowercase letter',
+      passwordNeedsDigit: 'Password must contain at least one digit',
+      passwordNeedsSpecial: 'Password must contain at least one special character',
       enterGroupName: '请输入组名称',
       settingsSaved: '设置已保存',
       noPermissionUpdate: '您没有权限更改设置',

+ 7 - 2
frontend/src/i18n/locales/zh-TW.ts

@@ -1840,7 +1840,8 @@ export default {
     username: '使用者名稱',
     enterUsername: '輸入使用者名稱',
     password: '密碼',
-    enterPassword: '輸入密碼(至少 6 個字元)',
+    enterPassword: '輸入密碼',
+    passwordRequirements: 'At least 8 characters, with one uppercase, one lowercase, one digit, and one special character.',
     confirmPassword: '確認密碼',
     confirmPasswordPlaceholder: '確認密碼',
     // Title tooltips
@@ -1944,7 +1945,11 @@ export default {
       groupDeleted: '群組刪除成功',
       fillRequiredFields: '請填寫所有必填欄位',
       passwordsDoNotMatch: '密碼不符',
-      passwordTooShort: '密碼至少需要 6 個字元',
+      passwordTooShort: '密碼至少需要 8 個字元',
+      passwordNeedsUppercase: 'Password must contain at least one uppercase letter',
+      passwordNeedsLowercase: 'Password must contain at least one lowercase letter',
+      passwordNeedsDigit: 'Password must contain at least one digit',
+      passwordNeedsSpecial: 'Password must contain at least one special character',
       enterGroupName: '請輸入群組名稱',
       settingsSaved: '設定已儲存',
       noPermissionUpdate: '您沒有權限變更設定',

+ 27 - 8
frontend/src/pages/SettingsPage.tsx

@@ -6,6 +6,7 @@ import { api } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDateOnly } from '../utils/date';
 import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
+import { checkPasswordComplexity } from '../utils/password';
 import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
@@ -708,8 +709,16 @@ export function SettingsPage() {
         showToast(t('settings.toast.passwordsDoNotMatch'), 'error');
         return;
       }
-      if (userFormData.password.length < 6) {
-        showToast(t('settings.toast.passwordTooShort'), 'error');
+      const complexityIssue = checkPasswordComplexity(userFormData.password);
+      if (complexityIssue) {
+        const issueToKey = {
+          tooShort: 'settings.toast.passwordTooShort',
+          needsUppercase: 'settings.toast.passwordNeedsUppercase',
+          needsLowercase: 'settings.toast.passwordNeedsLowercase',
+          needsDigit: 'settings.toast.passwordNeedsDigit',
+          needsSpecial: 'settings.toast.passwordNeedsSpecial',
+        } as const;
+        showToast(t(issueToKey[complexityIssue]), 'error');
         return;
       }
     }
@@ -729,8 +738,16 @@ export function SettingsPage() {
         showToast(t('settings.toast.passwordsDoNotMatch'), 'error');
         return;
       }
-      if (userFormData.password.length < 6) {
-        showToast(t('settings.toast.passwordTooShort'), 'error');
+      const complexityIssue = checkPasswordComplexity(userFormData.password);
+      if (complexityIssue) {
+        const issueToKey = {
+          tooShort: 'settings.toast.passwordTooShort',
+          needsUppercase: 'settings.toast.passwordNeedsUppercase',
+          needsLowercase: 'settings.toast.passwordNeedsLowercase',
+          needsDigit: 'settings.toast.passwordNeedsDigit',
+          needsSpecial: 'settings.toast.passwordNeedsSpecial',
+        } as const;
+        showToast(t(issueToKey[complexityIssue]), 'error');
         return;
       }
     }
@@ -5308,8 +5325,9 @@ export function SettingsPage() {
                     className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
                     placeholder={t('settings.enterPassword')}
                     autoComplete="new-password"
-                    minLength={6}
+                    minLength={8}
                   />
+                  <p className="text-bambu-gray text-xs mt-1">{t('settings.passwordRequirements')}</p>
                 </div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">{t('settings.confirmPassword')}</label>
@@ -5368,7 +5386,7 @@ export function SettingsPage() {
                 </Button>
                 <Button
                   onClick={handleCreateUser}
-                  disabled={createUserMutation.isPending || !userFormData.username || !userFormData.password || userFormData.password !== userFormData.confirmPassword || userFormData.password.length < 6}
+                  disabled={createUserMutation.isPending || !userFormData.username || !userFormData.password || userFormData.password !== userFormData.confirmPassword || checkPasswordComplexity(userFormData.password) !== null}
                 >
                   {createUserMutation.isPending ? (
                     <>
@@ -5483,8 +5501,9 @@ export function SettingsPage() {
                         className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
                         placeholder={t('settings.enterNewPassword')}
                         autoComplete="new-password"
-                        minLength={6}
+                        minLength={8}
                       />
+                      <p className="text-bambu-gray text-xs mt-1">{t('settings.passwordRequirements')}</p>
                     </div>
                     {userFormData.password && (
                       <div>
@@ -5579,7 +5598,7 @@ export function SettingsPage() {
                     updateUserMutation.isPending ||
                     !userFormData.username ||
                     (advancedAuthStatus?.advanced_auth_enabled && !userFormData.email) ||
-                    Boolean(!advancedAuthStatus?.advanced_auth_enabled && userFormData.password && (userFormData.password !== userFormData.confirmPassword || userFormData.password.length < 6))
+                    Boolean(!advancedAuthStatus?.advanced_auth_enabled && userFormData.password && (userFormData.password !== userFormData.confirmPassword || checkPasswordComplexity(userFormData.password) !== null))
                   }
                 >
                   {updateUserMutation.isPending ? (

+ 26 - 0
frontend/src/utils/password.ts

@@ -0,0 +1,26 @@
+/**
+ * Password complexity check matching the backend rules in
+ * `backend/app/schemas/auth.py:_validate_password_complexity` plus the
+ * implicit `min_length=8` that most server-side schemas enforce.
+ *
+ * Returning the FIRST unmet requirement as a translation-key suffix keeps the
+ * UI message order identical to what the backend would have returned — the
+ * user sees the same rule fail whether the check happens client- or server-
+ * side, which avoids the confusion of fixing one issue only to immediately
+ * trip another after the round-trip.
+ */
+export type PasswordRequirementKey =
+  | 'tooShort'
+  | 'needsUppercase'
+  | 'needsLowercase'
+  | 'needsDigit'
+  | 'needsSpecial';
+
+export function checkPasswordComplexity(password: string): PasswordRequirementKey | null {
+  if (password.length < 8) return 'tooShort';
+  if (!/[A-Z]/.test(password)) return 'needsUppercase';
+  if (!/[a-z]/.test(password)) return 'needsLowercase';
+  if (!/\d/.test(password)) return 'needsDigit';
+  if (!/[^A-Za-z0-9]/.test(password)) return 'needsSpecial';
+  return null;
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BfJWmysU.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C-E97wE_.js"></script>
+    <script type="module" crossorigin src="/assets/index-BfJWmysU.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BkYu3kLs.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов