diff --git a/client/src/components/Settings/AccountTab.tsx b/client/src/components/Settings/AccountTab.tsx index 126d296d6..9045024bc 100644 --- a/client/src/components/Settings/AccountTab.tsx +++ b/client/src/components/Settings/AccountTab.tsx @@ -10,6 +10,8 @@ import { getApiErrorMessage } from '../../types' import type { UserWithOidc } from '../../types' import Section from './Section' import PasskeysSection from './PasskeysSection' +import PasswordChecklist from '../shared/PasswordChecklist' +import { passwordErrorKey } from '../../utils/passwordError' const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending' @@ -199,6 +201,7 @@ export default function AccountTab(): React.ReactElement { placeholder={t('settings.newPassword')} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" /> + { if (!currentPassword) return toast.error(t('settings.currentPasswordRequired')) if (!newPassword) return toast.error(t('settings.passwordRequired')) - if (newPassword.length < 8) return toast.error(t('settings.passwordTooShort')) + const weak = passwordErrorKey(newPassword) + if (weak) return toast.error(t(weak)) if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch')) try { await authApi.changePassword({ current_password: currentPassword, new_password: newPassword }) diff --git a/client/src/components/shared/PasswordChecklist.test.tsx b/client/src/components/shared/PasswordChecklist.test.tsx new file mode 100644 index 000000000..1f85a0df3 --- /dev/null +++ b/client/src/components/shared/PasswordChecklist.test.tsx @@ -0,0 +1,37 @@ +// FE-SHARED-PWCHECK-001 to FE-SHARED-PWCHECK-004 +import { describe, expect, it } from 'vitest' +import { render, screen } from '../../../tests/helpers/render' +import PasswordChecklist from './PasswordChecklist' +import { passwordErrorKey } from '../../utils/passwordError' + +describe('PasswordChecklist (#1200)', () => { + it('FE-SHARED-PWCHECK-001: stays out of the way until something is typed', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('FE-SHARED-PWCHECK-002: ticks off each rule the password meets', () => { + render() + const list = screen.getByRole('list', { name: 'Password requirements' }) + const met = Array.from(list.querySelectorAll('li[data-met]')).map(li => li.textContent) + expect(met).toEqual(['At least 8 characters', 'A lowercase letter', 'A number']) + expect(list.querySelectorAll('li')).toHaveLength(5) + }) + + it('FE-SHARED-PWCHECK-003: the always-light sign-in card keeps fixed colours', () => { + render() + const upper = screen.getByText('An uppercase letter').closest('li') as HTMLElement + expect(upper.getAttribute('data-met')).toBe('true') + expect(upper.style.background).not.toBe('') + }) +}) + +describe('passwordErrorKey (#1200)', () => { + it('FE-SHARED-PWCHECK-004: names the problem the server would answer with, or nothing', () => { + expect(passwordErrorKey('Ab1!')).toBe('settings.passwordTooShort') + expect(passwordErrorKey('zzzzzzzzzz')).toBe('settings.passwordRepetitive') + expect(passwordErrorKey('Password')).toBe('settings.passwordCommon') + expect(passwordErrorKey('longenough1')).toBe('settings.passwordWeak') + expect(passwordErrorKey('Longenough1!')).toBeNull() + }) +}) diff --git a/client/src/components/shared/PasswordChecklist.tsx b/client/src/components/shared/PasswordChecklist.tsx new file mode 100644 index 000000000..9dbc99fc1 --- /dev/null +++ b/client/src/components/shared/PasswordChecklist.tsx @@ -0,0 +1,33 @@ +import { Check, Circle } from 'lucide-react' +import { PASSWORD_RULES, passwordRuleStates } from '@trek/shared' +import { useTranslation } from '../../i18n' + +// The sign-in and reset pages are always light, whatever the theme, so their +// list keeps fixed colours instead of the theme tokens. +const LIGHT_MET = { background: '#dcfce7', color: '#15803d' } // theme-lint-disable: the always-light sign-in card +const LIGHT_OPEN = { background: '#f3f4f6', color: '#6b7280' } // theme-lint-disable: the always-light sign-in card + +/** + * The rules under a new-password field, each ticked off as the password meets + * it (#1200). Hidden until something is typed, so an empty form stays calm. + */ +export default function PasswordChecklist({ password, className = '', tone = 'theme' }: { password: string; className?: string; tone?: 'theme' | 'light' }) { + const { t } = useTranslation() + if (!password) return null + const states = passwordRuleStates(password) + return ( +
    + {PASSWORD_RULES.map(rule => { + const met = states[rule] + return ( +
  • + {met ? : } + {t(`settings.passwordRule.${rule}`)} +
  • + ) + })} +
+ ) +} diff --git a/client/src/mobile/screens/settings/MSettingsAccount.tsx b/client/src/mobile/screens/settings/MSettingsAccount.tsx index caa559e66..835fff019 100644 --- a/client/src/mobile/screens/settings/MSettingsAccount.tsx +++ b/client/src/mobile/screens/settings/MSettingsAccount.tsx @@ -27,6 +27,8 @@ import { getApiErrorMessage } from '../../../types' import type { UserWithOidc } from '../../../types' import { MSetCard, MSetEyebrow, MSetInput, MSetButton, MSetHint } from './MSettingsUi' import MConfirmSheet from './MConfirmSheet' +import PasswordChecklist from '../../../components/shared/PasswordChecklist' +import { passwordErrorKey } from '../../../utils/passwordError' const MFA_BACKUP_SESSION_KEY = 'trek_mfa_backup_codes_pending' @@ -190,7 +192,8 @@ export default function MSettingsAccount() { const changePassword = async () => { if (!currentPassword) return toast.error(t('settings.currentPasswordRequired')) if (!newPassword) return toast.error(t('settings.passwordRequired')) - if (newPassword.length < 8) return toast.error(t('settings.passwordTooShort')) + const weak = passwordErrorKey(newPassword) + if (weak) return toast.error(t(weak)) if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch')) try { await authApi.changePassword({ current_password: currentPassword, new_password: newPassword }) @@ -369,6 +372,7 @@ export default function MSettingsAccount() { onChange={(e) => setNewPassword(e.target.value)} placeholder={t('settings.newPassword')} /> + { }); }); + describe('FE-PAGE-LOGIN-007: Remember me sends remember_me to the API', () => { + it('renders an off toggle and forwards remember_me: true when toggled on', async () => { + let capturedBody: Record | null = null; + server.use( + http.post('/api/auth/login', async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ user: { id: 1, username: 'test', email: 'test@example.com', role: 'user' } }); + }), + ); + + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument(); + }); + + const toggle = screen.getByRole('button', { name: /remember me/i }); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + + await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com'); + await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123'); + await user.click(toggle); + expect(toggle).toHaveAttribute('aria-pressed', 'true'); + await user.click(screen.getByRole('button', { name: /sign in/i })); + + await waitFor(() => { + expect(capturedBody).toEqual(expect.objectContaining({ remember_me: true })); + }); + }); + }); + + describe('FE-PAGE-LOGIN-005: Registration toggle visible', () => { + it('shows a Register button to switch to registration mode', async () => { + // Default appConfig has allow_registration: true, has_users: true + render(); + + await waitFor(() => { + // The register toggle link text appears + expect(screen.getByRole('button', { name: /^register$/i })).toBeInTheDocument(); + }); + }); + }); + describe('FE-PAGE-LOGIN-006: Register creates account', () => { it('switches to register mode and submits registration form', async () => { const user = userEvent.setup(); @@ -172,7 +216,7 @@ describe('LoginPage', () => { await user.type(screen.getByPlaceholderText('admin'), 'newuser'); await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'new@example.com'); - await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123'); + await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'Passw0rd!23'); await user.click(screen.getByRole('button', { name: /create account/i })); @@ -374,8 +418,8 @@ describe('LoginPage', () => { expect(screen.getByPlaceholderText('New password')).toBeInTheDocument(); }); - await user.type(screen.getByPlaceholderText('New password'), 'newpassword123'); - await user.type(screen.getByPlaceholderText('Confirm new password'), 'differentpassword123'); + await user.type(screen.getByPlaceholderText('New password'), 'NewPassw0rd!23'); + await user.type(screen.getByPlaceholderText('Confirm new password'), 'DifferentPassw0rd!23'); await user.click(screen.getByRole('button', { name: /update password/i })); await waitFor(() => { @@ -412,8 +456,8 @@ describe('LoginPage', () => { expect(screen.getByPlaceholderText('New password')).toBeInTheDocument(); }); - await user.type(screen.getByPlaceholderText('New password'), 'newpassword123'); - await user.type(screen.getByPlaceholderText('Confirm new password'), 'newpassword123'); + await user.type(screen.getByPlaceholderText('New password'), 'NewPassw0rd!23'); + await user.type(screen.getByPlaceholderText('Confirm new password'), 'NewPassw0rd!23'); await user.click(screen.getByRole('button', { name: /update password/i })); await waitFor(() => { diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 013d784cd..1ee9c8848 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -18,6 +18,7 @@ import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'; import { IDP_LOGIN_URL, useLogin } from './login/useLogin'; import LoginWorld from './login/LoginWorld'; import { clearSignedOut } from '../utils/signedOut' +import PasswordChecklist from '../components/shared/PasswordChecklist'; /** Fixed so the sky does not reshuffle on every render. */ const STARFIELD = [ @@ -694,6 +695,7 @@ export default function LoginPage(): React.ReactElement { onBlur={(e: React.FocusEvent) => (e.target.style.borderColor = '#e5e7eb')} /> +
+ {mode === 'register' && !passwordChangeStep && } {mode === 'login' && (
{showPw ? : }
+
+
@@ -195,6 +197,7 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea {showEditPw ? : }
+
diff --git a/client/src/pages/admin/useAdmin.test.tsx b/client/src/pages/admin/useAdmin.test.tsx index a37d8b51b..8a00e4603 100644 --- a/client/src/pages/admin/useAdmin.test.tsx +++ b/client/src/pages/admin/useAdmin.test.tsx @@ -518,7 +518,7 @@ describe('useAdmin', () => { const { result } = await mountAdmin(); act(() => - result.current.setCreateForm({ username: 'bob', email: 'b@e.com', password: 'longenough1', role: 'admin' }) + result.current.setCreateForm({ username: 'bob', email: 'b@e.com', password: 'Longenough1!', role: 'admin' }) ); act(() => result.current.setShowCreateUser(true)); await act(async () => { @@ -536,7 +536,7 @@ describe('useAdmin', () => { const { result } = await mountAdmin(); act(() => - result.current.setCreateForm({ username: 'bob', email: 'b@e.com', password: 'longenough1', role: 'user' }) + result.current.setCreateForm({ username: 'bob', email: 'b@e.com', password: 'Longenough1!', role: 'user' }) ); await act(async () => { await result.current.handleCreateUser(); @@ -733,13 +733,13 @@ describe('useAdmin', () => { act(() => result.current.handleEditUser(result.current.users[1])); act(() => - result.current.setEditForm({ username: 'alice', email: 'a@e.com', role: 'user', password: ' longenough1 ' }) + result.current.setEditForm({ username: 'alice', email: 'a@e.com', role: 'user', password: ' Longenough1! ' }) ); await act(async () => { await result.current.handleSaveUser(); }); - expect(body).toMatchObject({ password: 'longenough1' }); + expect(body).toMatchObject({ password: 'Longenough1!' }); }); it('FE-ADMHOOK-039: handleSaveUser surfaces the server error', async () => { diff --git a/client/src/pages/admin/useAdmin.ts b/client/src/pages/admin/useAdmin.ts index bbe85c32d..717062185 100644 --- a/client/src/pages/admin/useAdmin.ts +++ b/client/src/pages/admin/useAdmin.ts @@ -10,6 +10,7 @@ import { useToast } from '../../components/shared/Toast' import { managedAdminTabs } from '../../managed' import type { AdminUser, AdminStats, OidcConfig, UpdateInfo } from './adminModel' import type { TransitProvider, TransitKeySource } from '@trek/shared' +import { passwordErrorKey } from '../../utils/passwordError' /** * Every tab id AdminPage can render a panel for, whatever this install offers. @@ -412,8 +413,9 @@ export function useAdmin() { toast.error(t('admin.toast.fieldsRequired')) return } - if (createForm.password.trim().length < 8) { - toast.error(t('settings.passwordTooShort')) + const weak = passwordErrorKey(createForm.password.trim()) + if (weak) { + toast.error(t(weak)) return } try { @@ -474,8 +476,9 @@ export function useAdmin() { role: editForm.role, } if (editForm.password.trim()) { - if (editForm.password.trim().length < 8) { - toast.error(t('settings.passwordTooShort')) + const weak = passwordErrorKey(editForm.password.trim()) + if (weak) { + toast.error(t(weak)) return } payload.password = editForm.password.trim() diff --git a/client/src/pages/login/useLogin.test.tsx b/client/src/pages/login/useLogin.test.tsx index 34d02137a..016901661 100644 --- a/client/src/pages/login/useLogin.test.tsx +++ b/client/src/pages/login/useLogin.test.tsx @@ -630,14 +630,14 @@ describe('useLogin — register submit', () => { act(() => { result.current.setUsername('newuser'); result.current.setEmail('new@example.com'); - result.current.setPassword('password123'); + result.current.setPassword('Passw0rd!23'); }); await act(async () => { await result.current.handleSubmit(formEvent()); }); - expect(auth.register).toHaveBeenCalledWith('newuser', 'new@example.com', 'password123', 'inv-42'); + expect(auth.register).toHaveBeenCalledWith('newuser', 'new@example.com', 'Passw0rd!23', 'inv-42'); expect(result.current.showTakeoff).toBe(true); }); @@ -649,14 +649,14 @@ describe('useLogin — register submit', () => { act(() => { result.current.setUsername('newuser'); result.current.setEmail('new@example.com'); - result.current.setPassword('password123'); + result.current.setPassword('Passw0rd!23'); }); await act(async () => { await result.current.handleSubmit(formEvent()); }); - expect(auth.register).toHaveBeenCalledWith('newuser', 'new@example.com', 'password123', undefined); + expect(auth.register).toHaveBeenCalledWith('newuser', 'new@example.com', 'Passw0rd!23', undefined); }); }); @@ -878,8 +878,8 @@ describe('useLogin — forced password change', () => { const result = await reachPasswordChange(); act(() => { - result.current.setNewPassword('newpassword123'); - result.current.setConfirmPassword('newpassword124'); + result.current.setNewPassword('NewPassw0rd!23'); + result.current.setConfirmPassword('NewPassw0rd!24'); }); await act(async () => { await result.current.handleSubmit(formEvent()); @@ -900,8 +900,8 @@ describe('useLogin — forced password change', () => { const result = await reachPasswordChange(); act(() => { - result.current.setNewPassword('newpassword123'); - result.current.setConfirmPassword('newpassword123'); + result.current.setNewPassword('NewPassw0rd!23'); + result.current.setConfirmPassword('NewPassw0rd!23'); }); vi.useFakeTimers({ shouldAdvanceTime: true }); @@ -909,7 +909,7 @@ describe('useLogin — forced password change', () => { await result.current.handleSubmit(formEvent()); }); - expect(body).toEqual({ current_password: 'old-password', new_password: 'newpassword123' }); + expect(body).toEqual({ current_password: 'old-password', new_password: 'NewPassw0rd!23' }); expect(auth.loadUser).toHaveBeenCalledWith({ silent: true }); expect(result.current.showTakeoff).toBe(true); @@ -929,8 +929,8 @@ describe('useLogin — forced password change', () => { const result = await reachPasswordChange(); act(() => { - result.current.setNewPassword('newpassword123'); - result.current.setConfirmPassword('newpassword123'); + result.current.setNewPassword('NewPassw0rd!23'); + result.current.setConfirmPassword('NewPassw0rd!23'); }); await act(async () => { await result.current.handleSubmit(formEvent()); diff --git a/client/src/pages/login/useLogin.ts b/client/src/pages/login/useLogin.ts index 4a9bb6a0a..bcfe7bb48 100644 --- a/client/src/pages/login/useLogin.ts +++ b/client/src/pages/login/useLogin.ts @@ -8,6 +8,7 @@ import { wasSignedOut } from '../../utils/signedOut' import { authApi, configApi } from '../../api/client' import { getApiErrorMessage } from '../../types' import { START_DESTINATION_ROUTE } from '../../utils/startDestination' +import { passwordErrorKey } from '../../utils/passwordError' interface AppConfig { has_users: boolean @@ -313,7 +314,8 @@ export function useLogin() { try { if (passwordChangeStep) { if (!newPassword) { setError(t('settings.passwordRequired')); setIsLoading(false); return } - if (newPassword.length < 8) { setError(t('settings.passwordTooShort')); setIsLoading(false); return } + const weak = passwordErrorKey(newPassword) + if (weak) { setError(t(weak)); setIsLoading(false); return } if (newPassword !== confirmPassword) { setError(t('settings.passwordMismatch')); setIsLoading(false); return } await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword }) await loadUser({ silent: true }) @@ -338,7 +340,8 @@ export function useLogin() { } if (mode === 'register') { if (!username.trim()) { setError(t('login.usernameRequired')); setIsLoading(false); return } - if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return } + const weakPassword = passwordErrorKey(password) + if (weakPassword) { setError(t(weakPassword)); setIsLoading(false); return } await register(username, email, password, inviteToken || undefined) } else { const result = await login(email, password, rememberMe) diff --git a/client/src/pages/resetPassword/useResetPassword.test.ts b/client/src/pages/resetPassword/useResetPassword.test.ts index 39482ad9a..d1f466379 100644 --- a/client/src/pages/resetPassword/useResetPassword.test.ts +++ b/client/src/pages/resetPassword/useResetPassword.test.ts @@ -56,7 +56,7 @@ describe('useResetPassword', () => { search = new URLSearchParams(); const { result } = renderHook(() => useResetPassword()); - await submit(result, 'longenough1'); + await submit(result, 'Longenough1!'); expect(authApi.resetPassword).not.toHaveBeenCalled(); }); @@ -64,14 +64,14 @@ describe('useResetPassword', () => { const { result } = renderHook(() => useResetPassword()); await submit(result, 'short1'); - expect(result.current.error).toBe('login.passwordMinLength'); + expect(result.current.error).toBe('settings.passwordTooShort'); expect(authApi.resetPassword).not.toHaveBeenCalled(); }); it('FE-PAGE-RESET-005: rejects a mismatched confirmation', async () => { const { result } = renderHook(() => useResetPassword()); - await submit(result, 'longenough1', 'longenough2'); + await submit(result, 'Longenough1!', 'Longenough2!'); expect(result.current.error).toBe('login.passwordsDontMatch'); expect(authApi.resetPassword).not.toHaveBeenCalled(); }); @@ -79,9 +79,9 @@ describe('useResetPassword', () => { it('FE-PAGE-RESET-006: sends token and password and reports success', async () => { const { result } = renderHook(() => useResetPassword()); - await submit(result, 'longenough1'); + await submit(result, 'Longenough1!'); - expect(authApi.resetPassword).toHaveBeenCalledWith({ token: 'abc123', new_password: 'longenough1' }); + expect(authApi.resetPassword).toHaveBeenCalledWith({ token: 'abc123', new_password: 'Longenough1!' }); expect(result.current.success).toBe(true); expect(result.current.isLoading).toBe(false); }); @@ -90,7 +90,7 @@ describe('useResetPassword', () => { vi.mocked(authApi.resetPassword).mockResolvedValueOnce({ mfa_required: true } as never); const { result } = renderHook(() => useResetPassword()); - await submit(result, 'longenough1'); + await submit(result, 'Longenough1!'); expect(result.current.mfaRequired).toBe(true); expect(result.current.success).toBe(false); @@ -100,13 +100,13 @@ describe('useResetPassword', () => { it('FE-PAGE-RESET-008: sends the trimmed MFA code on the second attempt', async () => { vi.mocked(authApi.resetPassword).mockResolvedValueOnce({ mfa_required: true } as never); const { result } = renderHook(() => useResetPassword()); - await submit(result, 'longenough1'); + await submit(result, 'Longenough1!'); act(() => result.current.setMfaCode(' 123456 ')); await act(() => result.current.handleSubmit(submitEvent())); expect(authApi.resetPassword).toHaveBeenLastCalledWith({ - token: 'abc123', new_password: 'longenough1', mfa_code: '123456', + token: 'abc123', new_password: 'Longenough1!', mfa_code: '123456', }); expect(result.current.success).toBe(true); }); @@ -114,10 +114,10 @@ describe('useResetPassword', () => { it('FE-PAGE-RESET-009: omits the MFA field while the code is still empty', async () => { vi.mocked(authApi.resetPassword).mockResolvedValueOnce({ mfa_required: true } as never); const { result } = renderHook(() => useResetPassword()); - await submit(result, 'longenough1'); + await submit(result, 'Longenough1!'); await act(() => result.current.handleSubmit(submitEvent())); - expect(authApi.resetPassword).toHaveBeenLastCalledWith({ token: 'abc123', new_password: 'longenough1' }); + expect(authApi.resetPassword).toHaveBeenLastCalledWith({ token: 'abc123', new_password: 'Longenough1!' }); }); it('FE-PAGE-RESET-010: surfaces the server error message', async () => { @@ -126,7 +126,7 @@ describe('useResetPassword', () => { }); const { result } = renderHook(() => useResetPassword()); - await submit(result, 'longenough1'); + await submit(result, 'Longenough1!'); expect(result.current.error).toBe('This link has expired'); expect(result.current.success).toBe(false); @@ -136,7 +136,7 @@ describe('useResetPassword', () => { vi.mocked(authApi.resetPassword).mockRejectedValueOnce(new Error('Network Error')); const { result } = renderHook(() => useResetPassword()); - await submit(result, 'longenough1'); + await submit(result, 'Longenough1!'); expect(result.current.error).toBe('Network Error'); }); @@ -144,7 +144,7 @@ describe('useResetPassword', () => { vi.mocked(authApi.resetPassword).mockRejectedValueOnce('nope'); const { result } = renderHook(() => useResetPassword()); - await submit(result, 'longenough1'); + await submit(result, 'Longenough1!'); expect(result.current.error).toBe('login.resetPasswordFailed'); }); @@ -152,7 +152,7 @@ describe('useResetPassword', () => { vi.mocked(authApi.resetPassword).mockResolvedValueOnce({ success: false } as never); const { result } = renderHook(() => useResetPassword()); - await submit(result, 'longenough1'); + await submit(result, 'Longenough1!'); expect(result.current.success).toBe(false); expect(result.current.isLoading).toBe(false); }); @@ -164,7 +164,7 @@ describe('useResetPassword', () => { ); const { result } = renderHook(() => useResetPassword()); - act(() => { result.current.setPw('longenough1'); result.current.setPw2('longenough1'); }); + act(() => { result.current.setPw('Longenough1!'); result.current.setPw2('Longenough1!'); }); let first: Promise; act(() => { first = result.current.handleSubmit(submitEvent()) as Promise; }); diff --git a/client/src/pages/resetPassword/useResetPassword.ts b/client/src/pages/resetPassword/useResetPassword.ts index 22a9b0826..e93d347f8 100644 --- a/client/src/pages/resetPassword/useResetPassword.ts +++ b/client/src/pages/resetPassword/useResetPassword.ts @@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router' import { authApi } from '../../api/client' import { getApiErrorMessage } from '../../types' import { useTranslation } from '../../i18n' +import { passwordErrorKey } from '../../utils/passwordError' /** * Reset-password data hook — owns the token lookup, the form state, the @@ -34,7 +35,8 @@ export function useResetPassword() { if (isLoading) return setError('') if (!token) return - if (pw.length < 8) { setError(t('login.passwordMinLength')); return } + const weak = passwordErrorKey(pw) + if (weak) { setError(t(weak)); return } if (pw !== pw2) { setError(t('login.passwordsDontMatch')); return } setIsLoading(true) try { diff --git a/client/src/utils/passwordError.ts b/client/src/utils/passwordError.ts new file mode 100644 index 000000000..6727795dd --- /dev/null +++ b/client/src/utils/passwordError.ts @@ -0,0 +1,18 @@ +import { passwordProblem, type PasswordProblem } from '@trek/shared' + +const PROBLEM_KEYS: Record = { + tooShort: 'settings.passwordTooShort', + repetitive: 'settings.passwordRepetitive', + common: 'settings.passwordCommon', + weak: 'settings.passwordWeak', +} + +/** + * The translation key for why a new password would be refused, or null when the + * server will take it (#1200). It runs the server's own policy from @trek/shared, + * so a form says so before the request instead of after it. + */ +export function passwordErrorKey(password: string): string | null { + const problem = passwordProblem(password) + return problem ? PROBLEM_KEYS[problem] : null +} diff --git a/server/src/nest/common/passwordPolicy.ts b/server/src/nest/common/passwordPolicy.ts index 9b20143e3..f84284ec9 100644 --- a/server/src/nest/common/passwordPolicy.ts +++ b/server/src/nest/common/passwordPolicy.ts @@ -1,28 +1,3 @@ -const COMMON_PASSWORDS = new Set([ - 'password', '12345678', '123456789', '1234567890', 'password1', - 'qwerty123', 'iloveyou', 'admin123', 'letmein12', 'welcome1', - 'monkey123', 'dragon12', 'master12', 'qwerty12', 'abc12345', - 'trustno1', 'baseball', 'football', 'shadow12', 'michael1', - 'jennifer', 'superman', 'abcdefgh', 'abcd1234', 'password123', - 'admin1234', 'changeme', 'welcome123', 'passw0rd', 'p@ssword', -]); - -export function validatePassword(password: string): { ok: boolean; reason?: string } { - if (password.length < 8) return { ok: false, reason: 'Password must be at least 8 characters' }; - - if (/^(.)\1+$/.test(password)) { - return { ok: false, reason: 'Password is too repetitive' }; - } - - if (COMMON_PASSWORDS.has(password.toLowerCase())) { - return { ok: false, reason: 'Password is too common. Please choose a unique password.' }; - } - - const requirementsMessage = 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character'; - if (!/[A-Z]/.test(password)) return { ok: false, reason: requirementsMessage }; - if (!/[a-z]/.test(password)) return { ok: false, reason: requirementsMessage }; - if (!/[0-9]/.test(password)) return { ok: false, reason: requirementsMessage }; - if (!/[^A-Za-z0-9]/.test(password)) return { ok: false, reason: requirementsMessage }; - - return { ok: true }; -} +// The policy lives in @trek/shared so the forms can check a password the same way +// while it is typed (#1200); the server keeps enforcing it from here. +export { validatePassword } from '@trek/shared'; diff --git a/shared/src/auth/password-policy.spec.ts b/shared/src/auth/password-policy.spec.ts new file mode 100644 index 000000000..943ba0acb --- /dev/null +++ b/shared/src/auth/password-policy.spec.ts @@ -0,0 +1,47 @@ +import { passwordProblem, passwordRuleStates, validatePassword, PASSWORD_RULES } from './password-policy'; + +import { describe, expect, it } from 'vitest'; + +describe('password policy (#1200)', () => { + it('names each rule a password meets', () => { + expect(passwordRuleStates('')).toEqual({ length: false, upper: false, lower: false, digit: false, special: false }); + expect(passwordRuleStates('Abcdefg1!')).toEqual({ + length: true, + upper: true, + lower: true, + digit: true, + special: true, + }); + expect(passwordRuleStates('abcdefgh')).toMatchObject({ + length: true, + upper: false, + lower: true, + digit: false, + special: false, + }); + expect(PASSWORD_RULES).toEqual(['length', 'upper', 'lower', 'digit', 'special']); + }); + + it('reports the first problem in the order the server checks', () => { + expect(passwordProblem('Ab1!')).toBe('tooShort'); + expect(passwordProblem('aaaaaaaaaa')).toBe('repetitive'); + expect(passwordProblem('Passw0rd')).toBe('common'); + expect(passwordProblem('abcdefgh1')).toBe('weak'); + expect(passwordProblem('Abcdefg1!')).toBeNull(); + }); + + it('keeps the reasons the API has always answered with', () => { + expect(validatePassword('Ab1!')).toEqual({ ok: false, reason: 'Password must be at least 8 characters' }); + expect(validatePassword('aaaaaaaaaa')).toEqual({ ok: false, reason: 'Password is too repetitive' }); + expect(validatePassword('changeme')).toEqual({ + ok: false, + reason: 'Password is too common. Please choose a unique password.', + }); + expect(validatePassword('abcdefgh1')).toEqual({ + ok: false, + reason: + 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character', + }); + expect(validatePassword('Abcdefg1!')).toEqual({ ok: true }); + }); +}); diff --git a/shared/src/auth/password-policy.ts b/shared/src/auth/password-policy.ts new file mode 100644 index 000000000..57d828bae --- /dev/null +++ b/shared/src/auth/password-policy.ts @@ -0,0 +1,79 @@ +/** + * The password policy, one copy for the server that enforces it and the forms + * that check it while the user types (#1200). A rule changed here changes in + * both places at once; a hand-kept copy in the client would drift. + */ + +export const PASSWORD_MIN_LENGTH = 8; + +const COMMON_PASSWORDS = new Set([ + 'password', + '12345678', + '123456789', + '1234567890', + 'password1', + 'qwerty123', + 'iloveyou', + 'admin123', + 'letmein12', + 'welcome1', + 'monkey123', + 'dragon12', + 'master12', + 'qwerty12', + 'abc12345', + 'trustno1', + 'baseball', + 'football', + 'shadow12', + 'michael1', + 'jennifer', + 'superman', + 'abcdefgh', + 'abcd1234', + 'password123', + 'admin1234', + 'changeme', + 'welcome123', + 'passw0rd', + 'p@ssword', +]); + +/** The rules a form lists under a new password, each one met or not. */ +export const PASSWORD_RULES = ['length', 'upper', 'lower', 'digit', 'special'] as const; +export type PasswordRule = (typeof PASSWORD_RULES)[number]; + +export function passwordRuleStates(password: string): Record { + return { + length: password.length >= PASSWORD_MIN_LENGTH, + upper: /[A-Z]/.test(password), + lower: /[a-z]/.test(password), + digit: /[0-9]/.test(password), + special: /[^A-Za-z0-9]/.test(password), + }; +} + +/** Why a password is refused, as a code a form can translate; null when it passes. */ +export type PasswordProblem = 'tooShort' | 'repetitive' | 'common' | 'weak'; + +export function passwordProblem(password: string): PasswordProblem | null { + if (password.length < PASSWORD_MIN_LENGTH) return 'tooShort'; + if (/^(.)\1+$/.test(password)) return 'repetitive'; + if (COMMON_PASSWORDS.has(password.toLowerCase())) return 'common'; + const rules = passwordRuleStates(password); + if (!rules.upper || !rules.lower || !rules.digit || !rules.special) return 'weak'; + return null; +} + +const SERVER_REASONS: Record = { + tooShort: 'Password must be at least 8 characters', + repetitive: 'Password is too repetitive', + common: 'Password is too common. Please choose a unique password.', + weak: 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character', +}; + +/** The server's answer: the same checks, with the English reasons the API has always sent. */ +export function validatePassword(password: string): { ok: boolean; reason?: string } { + const problem = passwordProblem(password); + return problem ? { ok: false, reason: SERVER_REASONS[problem] } : { ok: true }; +} diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index b63a714b1..2aa724eb9 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -242,6 +242,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل', 'settings.passwordMismatch': 'كلمتا المرور غير متطابقتين', 'settings.passwordWeak': 'يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم ورمز خاص', + 'settings.passwordCommon': 'كلمة المرور هذه شائعة جدًا. يُرجى اختيار كلمة مرور فريدة.', + 'settings.passwordRepetitive': 'كلمة المرور مكررة أكثر من اللازم', + 'settings.passwordRules': 'متطلبات كلمة المرور', + 'settings.passwordRule.length': '8 أحرف على الأقل', + 'settings.passwordRule.upper': 'حرف كبير', + 'settings.passwordRule.lower': 'حرف صغير', + 'settings.passwordRule.digit': 'رقم', + 'settings.passwordRule.special': 'رمز خاص', 'settings.passwordChanged': 'تم تغيير كلمة المرور بنجاح', 'settings.mustChangePassword': 'يجب عليك تغيير كلمة المرور قبل المتابعة. يرجى تعيين كلمة مرور جديدة أدناه.', 'settings.deleteAccount': 'حذف الحساب', diff --git a/shared/src/i18n/az/settings.ts b/shared/src/i18n/az/settings.ts index 9aa9881e9..f3cb50516 100644 --- a/shared/src/i18n/az/settings.ts +++ b/shared/src/i18n/az/settings.ts @@ -365,6 +365,14 @@ const settings: TranslationStrings = { 'settings.passwordMismatch': 'Parollar uyğun gəlmir', 'settings.passwordWeak': 'Parolda böyük hərf, kiçik hərf, rəqəm və xüsusi simvol olmalıdır', + 'settings.passwordCommon': 'Bu parol çox yayğındır. Zəhmət olmasa, unikal parol seçin.', + 'settings.passwordRepetitive': 'Parol həddən artıq təkrarlanır', + 'settings.passwordRules': 'Parol tələbləri', + 'settings.passwordRule.length': 'Ən azı 8 simvol', + 'settings.passwordRule.upper': 'Böyük hərf', + 'settings.passwordRule.lower': 'Kiçik hərf', + 'settings.passwordRule.digit': 'Rəqəm', + 'settings.passwordRule.special': 'Xüsusi simvol', 'settings.passwordChanged': 'Parol uğurla dəyişdirildi', 'settings.mustChangePassword': 'Davam etməzdən əvvəl parolunuzu dəyişməlisiniz. Aşağıda yeni parol təyin edin.', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 67f5d82a1..d1dc6eee9 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -148,6 +148,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'A senha deve ter pelo menos 8 caracteres', 'settings.passwordMismatch': 'As senhas não coincidem', 'settings.passwordWeak': 'A senha deve ter maiúscula, minúscula, número e um caractere especial', + 'settings.passwordCommon': 'Esta senha é muito comum. Escolha uma senha única.', + 'settings.passwordRepetitive': 'A senha é repetitiva demais', + 'settings.passwordRules': 'Requisitos da senha', + 'settings.passwordRule.length': 'Pelo menos 8 caracteres', + 'settings.passwordRule.upper': 'Uma letra maiúscula', + 'settings.passwordRule.lower': 'Uma letra minúscula', + 'settings.passwordRule.digit': 'Um número', + 'settings.passwordRule.special': 'Um caractere especial', 'settings.passwordChanged': 'Senha alterada com sucesso', 'settings.deleteAccount': 'Excluir conta', 'settings.deleteAccountTitle': 'Excluir sua conta?', diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index d77563e58..0be7ebb75 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -267,6 +267,14 @@ const settings: TranslationStrings = { 'settings.bookingLabelsHint': "Mostra noms d'estacions / aeroports al mapa. Desactivat, només es mostra la icona.", 'settings.currentPasswordRequired': 'La contrasenya actual és obligatòria', 'settings.passwordWeak': 'La contrasenya ha de contenir majúscules, minúscules, números i un caràcter especial', + 'settings.passwordCommon': "Aquesta contrasenya és massa comuna. Tria'n una d'única.", + 'settings.passwordRepetitive': 'La contrasenya és massa repetitiva', + 'settings.passwordRules': 'Requisits de la contrasenya', + 'settings.passwordRule.length': 'Almenys 8 caràcters', + 'settings.passwordRule.upper': 'Una majúscula', + 'settings.passwordRule.lower': 'Una minúscula', + 'settings.passwordRule.digit': 'Un número', + 'settings.passwordRule.special': 'Un caràcter especial', 'settings.notifyVersionAvailable': 'Versió nova disponible', 'settings.notifyReplicaFailure': "Error de rèplica d'emmagatzematge", 'settings.notificationPreferences.noChannels': diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index 86da01867..679823bef 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -230,6 +230,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'Heslo musí mít alespoň 8 znaků', 'settings.passwordMismatch': 'Hesla se neshodují', 'settings.passwordWeak': 'Heslo musí obsahovat velké a malé písmeno, číslici a speciální znak', + 'settings.passwordCommon': 'Toto heslo je příliš běžné. Zvolte prosím jedinečné heslo.', + 'settings.passwordRepetitive': 'Heslo se příliš opakuje', + 'settings.passwordRules': 'Požadavky na heslo', + 'settings.passwordRule.length': 'Alespoň 8 znaků', + 'settings.passwordRule.upper': 'Velké písmeno', + 'settings.passwordRule.lower': 'Malé písmeno', + 'settings.passwordRule.digit': 'Číslice', + 'settings.passwordRule.special': 'Speciální znak', 'settings.passwordChanged': 'Heslo bylo úspěšně změněno', 'settings.deleteAccount': 'Smazat účet', 'settings.deleteAccountTitle': 'Smazat váš účet?', diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index 49bf50e72..4307b9ecb 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -236,6 +236,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein', 'settings.passwordMismatch': 'Passwörter stimmen nicht überein', 'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten', + 'settings.passwordCommon': 'Dieses Passwort ist zu verbreitet. Bitte wähle ein eigenes.', + 'settings.passwordRepetitive': 'Das Passwort wiederholt sich zu sehr', + 'settings.passwordRules': 'Anforderungen an das Passwort', + 'settings.passwordRule.length': 'Mindestens 8 Zeichen', + 'settings.passwordRule.upper': 'Ein Großbuchstabe', + 'settings.passwordRule.lower': 'Ein Kleinbuchstabe', + 'settings.passwordRule.digit': 'Eine Zahl', + 'settings.passwordRule.special': 'Ein Sonderzeichen', 'settings.passwordChanged': 'Passwort erfolgreich geändert', 'settings.deleteAccount': 'Löschen', 'settings.deleteAccountTitle': 'Account wirklich löschen?', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index 537879ad9..336c50199 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -292,6 +292,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'Password must be at least 8 characters', 'settings.passwordMismatch': 'Passwords do not match', 'settings.passwordWeak': 'Password must contain uppercase, lowercase, a number, and a special character', + 'settings.passwordCommon': 'This password is too common. Please choose a unique one.', + 'settings.passwordRepetitive': 'Password is too repetitive', + 'settings.passwordRules': 'Password requirements', + 'settings.passwordRule.length': 'At least 8 characters', + 'settings.passwordRule.upper': 'An uppercase letter', + 'settings.passwordRule.lower': 'A lowercase letter', + 'settings.passwordRule.digit': 'A number', + 'settings.passwordRule.special': 'A special character', 'settings.passwordChanged': 'Password changed successfully', 'settings.mustChangePassword': 'You must change your password before you can continue. Please set a new password below.', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index e28a844db..0324079a9 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -285,6 +285,14 @@ const settings: TranslationStrings = { 'Muestra nombres de estaciones / aeropuertos en el mapa. Desactivado, solo se muestra el icono.', 'settings.currentPasswordRequired': 'La contraseña actual es obligatoria', 'settings.passwordWeak': 'La contraseña debe contener mayúsculas, minúsculas, números y un carácter especial', + 'settings.passwordCommon': 'Esta contraseña es demasiado común. Elige una única.', + 'settings.passwordRepetitive': 'La contraseña es demasiado repetitiva', + 'settings.passwordRules': 'Requisitos de la contraseña', + 'settings.passwordRule.length': 'Al menos 8 caracteres', + 'settings.passwordRule.upper': 'Una mayúscula', + 'settings.passwordRule.lower': 'Una minúscula', + 'settings.passwordRule.digit': 'Un número', + 'settings.passwordRule.special': 'Un carácter especial', 'settings.notifyVersionAvailable': 'Nueva versión disponible', 'settings.notifyReplicaFailure': 'Fallo de réplica de almacenamiento', 'settings.notificationPreferences.noChannels': diff --git a/shared/src/i18n/et/settings.ts b/shared/src/i18n/et/settings.ts index 6c7e3db59..0c8368345 100644 --- a/shared/src/i18n/et/settings.ts +++ b/shared/src/i18n/et/settings.ts @@ -299,6 +299,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'Parool peab olema vähemalt 8 märki pikk', 'settings.passwordMismatch': 'Paroolid ei ühti', 'settings.passwordWeak': 'Parool peab sisaldama suur- ja väiketähte, numbrit ning erimärki', + 'settings.passwordCommon': 'See parool on liiga levinud. Palun vali kordumatu parool.', + 'settings.passwordRepetitive': 'Parool on liiga korduv', + 'settings.passwordRules': 'Parooli nõuded', + 'settings.passwordRule.length': 'Vähemalt 8 märki', + 'settings.passwordRule.upper': 'Suurtäht', + 'settings.passwordRule.lower': 'Väiketäht', + 'settings.passwordRule.digit': 'Number', + 'settings.passwordRule.special': 'Erimärk', 'settings.passwordChanged': 'Parool muudetud', 'settings.mustChangePassword': 'Enne jätkamist pead parooli muutma. Määra allpool uus parool.', 'settings.deleteAccount': 'Kustuta konto', diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index 9d1ca0399..ee13943ca 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -238,6 +238,14 @@ const settings: TranslationStrings = { 'settings.passwordMismatch': 'Les mots de passe ne correspondent pas', 'settings.passwordWeak': 'Le mot de passe doit contenir des majuscules, des minuscules, un chiffre et un caractère spécial', + 'settings.passwordCommon': 'Ce mot de passe est trop courant. Choisissez-en un unique.', + 'settings.passwordRepetitive': 'Le mot de passe est trop répétitif', + 'settings.passwordRules': 'Exigences du mot de passe', + 'settings.passwordRule.length': 'Au moins 8 caractères', + 'settings.passwordRule.upper': 'Une majuscule', + 'settings.passwordRule.lower': 'Une minuscule', + 'settings.passwordRule.digit': 'Un chiffre', + 'settings.passwordRule.special': 'Un caractère spécial', 'settings.passwordChanged': 'Mot de passe modifié avec succès', 'settings.deleteAccount': 'Supprimer le compte', 'settings.deleteAccountTitle': 'Supprimer votre compte ?', diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index d5aca3d36..d1c7f6ec5 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -293,6 +293,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'Ο κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες', 'settings.passwordMismatch': 'Οι κωδικοί δεν ταιριάζουν', 'settings.passwordWeak': 'Ο κωδικός πρέπει να περιέχει κεφαλαία, πεζά, έναν αριθμό και έναν ειδικό χαρακτήρα', + 'settings.passwordCommon': 'Αυτός ο κωδικός είναι πολύ συνηθισμένος. Επιλέξτε έναν μοναδικό.', + 'settings.passwordRepetitive': 'Ο κωδικός είναι υπερβολικά επαναλαμβανόμενος', + 'settings.passwordRules': 'Απαιτήσεις κωδικού', + 'settings.passwordRule.length': 'Τουλάχιστον 8 χαρακτήρες', + 'settings.passwordRule.upper': 'Ένα κεφαλαίο γράμμα', + 'settings.passwordRule.lower': 'Ένα πεζό γράμμα', + 'settings.passwordRule.digit': 'Ένας αριθμός', + 'settings.passwordRule.special': 'Ένας ειδικός χαρακτήρας', 'settings.passwordChanged': 'Ο κωδικός άλλαξε επιτυχώς', 'settings.mustChangePassword': 'Πρέπει να αλλάξετε τον κωδικό σας πριν συνεχίσετε. Παρακαλώ ορίστε έναν νέο κωδικό παρακάτω.', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index a57a1276b..b50b2c6c0 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -232,6 +232,14 @@ const settings: TranslationStrings = { 'settings.currentPasswordRequired': 'A jelenlegi jelszó megadása kötelező', 'settings.passwordTooShort': 'A jelszónak legalább 8 karakter hosszúnak kell lennie', 'settings.passwordWeak': 'A jelszónak tartalmaznia kell nagybetűt, kisbetűt, számot és speciális karaktert', + 'settings.passwordCommon': 'Ez a jelszó túl gyakori. Válassz egyedi jelszót.', + 'settings.passwordRepetitive': 'A jelszó túl ismétlődő', + 'settings.passwordRules': 'Jelszókövetelmények', + 'settings.passwordRule.length': 'Legalább 8 karakter', + 'settings.passwordRule.upper': 'Nagybetű', + 'settings.passwordRule.lower': 'Kisbetű', + 'settings.passwordRule.digit': 'Szám', + 'settings.passwordRule.special': 'Különleges karakter', 'settings.passwordMismatch': 'A jelszavak nem egyeznek', 'settings.passwordChanged': 'Jelszó sikeresen módosítva', 'settings.deleteAccount': 'Törlés', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 704e4bb1a..bf00a06f2 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -281,6 +281,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'Kata sandi minimal 8 karakter', 'settings.passwordMismatch': 'Kata sandi tidak cocok', 'settings.passwordWeak': 'Kata sandi harus mengandung huruf besar, huruf kecil, angka, dan karakter khusus', + 'settings.passwordCommon': 'Kata sandi ini terlalu umum. Pilih kata sandi yang unik.', + 'settings.passwordRepetitive': 'Kata sandi terlalu berulang', + 'settings.passwordRules': 'Persyaratan kata sandi', + 'settings.passwordRule.length': 'Minimal 8 karakter', + 'settings.passwordRule.upper': 'Huruf kapital', + 'settings.passwordRule.lower': 'Huruf kecil', + 'settings.passwordRule.digit': 'Angka', + 'settings.passwordRule.special': 'Karakter khusus', 'settings.passwordChanged': 'Kata sandi berhasil diubah', 'settings.mustChangePassword': 'Kamu harus mengubah kata sandi sebelum melanjutkan. Atur kata sandi baru di bawah ini.', diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index 930fb6068..604a011f9 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -234,6 +234,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'La password deve contenere almeno 8 caratteri', 'settings.passwordMismatch': 'Le password non corrispondono', 'settings.passwordWeak': 'La password deve contenere lettere maiuscole, minuscole, un numero e un carattere speciale', + 'settings.passwordCommon': 'Questa password è troppo comune. Scegline una unica.', + 'settings.passwordRepetitive': 'La password è troppo ripetitiva', + 'settings.passwordRules': 'Requisiti della password', + 'settings.passwordRule.length': 'Almeno 8 caratteri', + 'settings.passwordRule.upper': 'Una lettera maiuscola', + 'settings.passwordRule.lower': 'Una lettera minuscola', + 'settings.passwordRule.digit': 'Un numero', + 'settings.passwordRule.special': 'Un carattere speciale', 'settings.passwordChanged': 'Password cambiata con successo', 'settings.deleteAccount': 'Elimina account', 'settings.deleteAccountTitle': 'Eliminare il tuo account?', diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index 837b6e381..778de715a 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -260,6 +260,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': '8文字以上必要です', 'settings.passwordMismatch': 'パスワードが一致しません', 'settings.passwordWeak': '大文字・小文字・数字・記号を含めてください', + 'settings.passwordCommon': 'このパスワードは一般的すぎます。独自のパスワードを選んでください。', + 'settings.passwordRepetitive': 'パスワードの繰り返しが多すぎます', + 'settings.passwordRules': 'パスワードの要件', + 'settings.passwordRule.length': '8文字以上', + 'settings.passwordRule.upper': '大文字', + 'settings.passwordRule.lower': '小文字', + 'settings.passwordRule.digit': '数字', + 'settings.passwordRule.special': '記号', 'settings.passwordChanged': 'パスワードを変更しました', 'settings.mustChangePassword': '続行するにはパスワード変更が必要です。', 'settings.deleteAccount': 'アカウント削除', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index df8f6b221..73c9ec4ec 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -275,6 +275,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': '비밀번호는 최소 8자 이상이어야 합니다', 'settings.passwordMismatch': '비밀번호가 일치하지 않습니다', 'settings.passwordWeak': '비밀번호는 대문자, 소문자, 숫자, 특수문자를 포함해야 합니다', + 'settings.passwordCommon': '너무 흔한 비밀번호입니다. 고유한 비밀번호를 선택하세요.', + 'settings.passwordRepetitive': '비밀번호가 너무 반복적입니다', + 'settings.passwordRules': '비밀번호 요구 사항', + 'settings.passwordRule.length': '8자 이상', + 'settings.passwordRule.upper': '대문자', + 'settings.passwordRule.lower': '소문자', + 'settings.passwordRule.digit': '숫자', + 'settings.passwordRule.special': '특수 문자', 'settings.passwordChanged': '비밀번호가 성공적으로 변경되었습니다', 'settings.mustChangePassword': '계속하기 전에 비밀번호를 변경해야 합니다. 아래에서 새 비밀번호를 설정하세요.', 'settings.deleteAccount': '계정 삭제', diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index d8070a026..27d3da8bc 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -234,6 +234,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'Wachtwoord moet minimaal 8 tekens bevatten', 'settings.passwordMismatch': 'Wachtwoorden komen niet overeen', 'settings.passwordWeak': 'Wachtwoord moet hoofdletters, kleine letters, een cijfer en een speciaal teken bevatten', + 'settings.passwordCommon': 'Dit wachtwoord komt te vaak voor. Kies een uniek wachtwoord.', + 'settings.passwordRepetitive': 'Het wachtwoord is te herhalend', + 'settings.passwordRules': 'Wachtwoordvereisten', + 'settings.passwordRule.length': 'Minstens 8 tekens', + 'settings.passwordRule.upper': 'Een hoofdletter', + 'settings.passwordRule.lower': 'Een kleine letter', + 'settings.passwordRule.digit': 'Een cijfer', + 'settings.passwordRule.special': 'Een speciaal teken', 'settings.passwordChanged': 'Wachtwoord succesvol gewijzigd', 'settings.deleteAccount': 'Account verwijderen', 'settings.deleteAccountTitle': 'Account verwijderen?', diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 19a7c861e..80151bad6 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -229,6 +229,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'Hasło musi mieć co najmniej 8 znaków', 'settings.passwordMismatch': 'Hasła nie są identyczne', 'settings.passwordWeak': 'Hasło musi zawierać wielką literę, małą literę i cyfrę', + 'settings.passwordCommon': 'To hasło jest zbyt popularne. Wybierz unikalne hasło.', + 'settings.passwordRepetitive': 'Hasło jest zbyt powtarzalne', + 'settings.passwordRules': 'Wymagania dotyczące hasła', + 'settings.passwordRule.length': 'Co najmniej 8 znaków', + 'settings.passwordRule.upper': 'Wielka litera', + 'settings.passwordRule.lower': 'Mała litera', + 'settings.passwordRule.digit': 'Cyfra', + 'settings.passwordRule.special': 'Znak specjalny', 'settings.passwordChanged': 'Hasło zostało zmienione pomyślnie', 'settings.deleteAccount': 'Usuń konto', 'settings.deleteAccountTitle': 'Usunąć twoje konto?', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 356cccb4f..4d089e5a7 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -233,6 +233,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'Пароль должен содержать не менее 8 символов', 'settings.passwordMismatch': 'Пароли не совпадают', 'settings.passwordWeak': 'Пароль должен содержать заглавные, строчные буквы, цифру и специальный символ', + 'settings.passwordCommon': 'Этот пароль слишком распространён. Выберите уникальный.', + 'settings.passwordRepetitive': 'Пароль слишком однообразный', + 'settings.passwordRules': 'Требования к паролю', + 'settings.passwordRule.length': 'Не менее 8 символов', + 'settings.passwordRule.upper': 'Заглавная буква', + 'settings.passwordRule.lower': 'Строчная буква', + 'settings.passwordRule.digit': 'Цифра', + 'settings.passwordRule.special': 'Спецсимвол', 'settings.passwordChanged': 'Пароль успешно изменён', 'settings.deleteAccount': 'Удалить аккаунт', 'settings.deleteAccountTitle': 'Удалить ваш аккаунт?', diff --git a/shared/src/i18n/sk/settings.ts b/shared/src/i18n/sk/settings.ts index a1d9bc873..3adf7f14b 100644 --- a/shared/src/i18n/sk/settings.ts +++ b/shared/src/i18n/sk/settings.ts @@ -296,6 +296,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'Heslo musí mať aspoň 8 znakov', 'settings.passwordMismatch': 'Heslá sa nezhodujú', 'settings.passwordWeak': 'Heslo musí obsahovať veľké písmeno, malé písmeno, číslicu a špeciálny znak', + 'settings.passwordCommon': 'Toto heslo je príliš bežné. Zvoľte si jedinečné heslo.', + 'settings.passwordRepetitive': 'Heslo sa príliš opakuje', + 'settings.passwordRules': 'Požiadavky na heslo', + 'settings.passwordRule.length': 'Aspoň 8 znakov', + 'settings.passwordRule.upper': 'Veľké písmeno', + 'settings.passwordRule.lower': 'Malé písmeno', + 'settings.passwordRule.digit': 'Číslica', + 'settings.passwordRule.special': 'Špeciálny znak', 'settings.passwordChanged': 'Heslo bolo úspešne zmenené', 'settings.mustChangePassword': 'Pred pokračovaním musíte zmeniť heslo. Nastavte si prosím nové heslo nižšie.', 'settings.deleteAccount': 'Zmazať účet', diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index 6f4c6aea4..64dd4474a 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -288,6 +288,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'Lösenordet måste bestå av minst 8 tecken', 'settings.passwordMismatch': 'Lösenorden stämmer inte överens', 'settings.passwordWeak': 'Lösenordet måste innehålla versaler, gemener, en siffra och ett specialtecken', + 'settings.passwordCommon': 'Lösenordet är för vanligt. Välj ett unikt lösenord.', + 'settings.passwordRepetitive': 'Lösenordet är för upprepande', + 'settings.passwordRules': 'Lösenordskrav', + 'settings.passwordRule.length': 'Minst 8 tecken', + 'settings.passwordRule.upper': 'En versal', + 'settings.passwordRule.lower': 'En gemen', + 'settings.passwordRule.digit': 'En siffra', + 'settings.passwordRule.special': 'Ett specialtecken', 'settings.passwordChanged': 'Lösenordet har ändrats', 'settings.mustChangePassword': 'Du måste byta lösenord innan du kan fortsätta. Välj ett nytt lösenord nedan.', 'settings.deleteAccount': 'Ta bort konto', diff --git a/shared/src/i18n/th/settings.ts b/shared/src/i18n/th/settings.ts index cbcf6b32c..ddd71c9d3 100644 --- a/shared/src/i18n/th/settings.ts +++ b/shared/src/i18n/th/settings.ts @@ -283,6 +283,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': "รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร", 'settings.passwordMismatch': "รหัสผ่านไม่ตรงกัน", 'settings.passwordWeak': "รหัสผ่านจะต้องมีตัวพิมพ์ใหญ่ ตัวพิมพ์เล็ก ตัวเลข และอักขระพิเศษ", + 'settings.passwordCommon': 'รหัสผ่านนี้พบบ่อยเกินไป โปรดเลือกรหัสผ่านที่ไม่ซ้ำใคร', + 'settings.passwordRepetitive': 'รหัสผ่านซ้ำกันมากเกินไป', + 'settings.passwordRules': 'ข้อกำหนดรหัสผ่าน', + 'settings.passwordRule.length': 'อย่างน้อย 8 ตัวอักษร', + 'settings.passwordRule.upper': 'ตัวพิมพ์ใหญ่', + 'settings.passwordRule.lower': 'ตัวพิมพ์เล็ก', + 'settings.passwordRule.digit': 'ตัวเลข', + 'settings.passwordRule.special': 'อักขระพิเศษ', 'settings.passwordChanged': "เปลี่ยนรหัสผ่านเรียบร้อยแล้ว", 'settings.mustChangePassword': "คุณต้องเปลี่ยนรหัสผ่านก่อนจึงจะสามารถดำเนินการต่อได้ กรุณาตั้งรหัสผ่านใหม่ด้านล่าง", diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 7fc18498b..d90c5c952 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -276,6 +276,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'Şifre en az 8 karakter olmalıdır', 'settings.passwordMismatch': 'Şifreler eşleşmiyor', 'settings.passwordWeak': 'Şifre büyük harf, küçük harf, sayı ve özel karakter içermelidir', + 'settings.passwordCommon': 'Bu parola çok yaygın. Lütfen benzersiz bir parola seçin.', + 'settings.passwordRepetitive': 'Parola çok tekrarlı', + 'settings.passwordRules': 'Parola gereksinimleri', + 'settings.passwordRule.length': 'En az 8 karakter', + 'settings.passwordRule.upper': 'Büyük harf', + 'settings.passwordRule.lower': 'Küçük harf', + 'settings.passwordRule.digit': 'Rakam', + 'settings.passwordRule.special': 'Özel karakter', 'settings.passwordChanged': 'Şifre başarıyla değiştirildi', 'settings.mustChangePassword': 'Devam etmeden önce şifrenizi değiştirmelisiniz. Lütfen aşağıdan yeni bir şifre belirleyin.', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 68a021be2..991bb7c3e 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -229,6 +229,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'Пароль повинен містити не менше 8 символів', 'settings.passwordMismatch': 'Паролі не збігаються', 'settings.passwordWeak': 'Пароль повинен містити великі й малі літери, цифру та спеціальний символ', + 'settings.passwordCommon': 'Цей пароль надто поширений. Виберіть унікальний.', + 'settings.passwordRepetitive': 'Пароль надто одноманітний', + 'settings.passwordRules': 'Вимоги до пароля', + 'settings.passwordRule.length': 'Щонайменше 8 символів', + 'settings.passwordRule.upper': 'Велика літера', + 'settings.passwordRule.lower': 'Мала літера', + 'settings.passwordRule.digit': 'Цифра', + 'settings.passwordRule.special': 'Спецсимвол', 'settings.passwordChanged': 'Пароль успішно змінено', 'settings.deleteAccount': 'Видалити акаунт', 'settings.deleteAccountTitle': 'Видалити ваш акаунт?', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index 21f02274a..8722d0555 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -289,6 +289,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': 'Mật khẩu phải có ít nhất 8 ký tự', 'settings.passwordMismatch': 'Mật khẩu không khớp', 'settings.passwordWeak': 'Mật khẩu phải có chữ hoa, chữ thường, số và ký tự đặc biệt', + 'settings.passwordCommon': 'Mật khẩu này quá phổ biến. Vui lòng chọn mật khẩu riêng.', + 'settings.passwordRepetitive': 'Mật khẩu lặp lại quá nhiều', + 'settings.passwordRules': 'Yêu cầu mật khẩu', + 'settings.passwordRule.length': 'Ít nhất 8 ký tự', + 'settings.passwordRule.upper': 'Một chữ in hoa', + 'settings.passwordRule.lower': 'Một chữ thường', + 'settings.passwordRule.digit': 'Một chữ số', + 'settings.passwordRule.special': 'Một ký tự đặc biệt', 'settings.passwordChanged': 'Đã thay đổi mật khẩu thành công', 'settings.mustChangePassword': 'Bạn phải thay đổi mật khẩu trước khi có thể tiếp tục. Vui lòng đặt mật khẩu mới bên dưới.', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 49d19843b..557d22721 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -269,6 +269,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': '密碼至少需要 8 個字元', 'settings.passwordMismatch': '兩次輸入的密碼不一致', 'settings.passwordWeak': '密碼必須包含大寫字母、小寫字母、數字和特殊字元', + 'settings.passwordCommon': '此密碼太常見,請選擇獨特的密碼。', + 'settings.passwordRepetitive': '密碼重複性太高', + 'settings.passwordRules': '密碼要求', + 'settings.passwordRule.length': '至少 8 個字元', + 'settings.passwordRule.upper': '大寫字母', + 'settings.passwordRule.lower': '小寫字母', + 'settings.passwordRule.digit': '數字', + 'settings.passwordRule.special': '特殊字元', 'settings.passwordChanged': '密碼修改成功', 'settings.deleteAccount': '刪除賬戶', 'settings.deleteAccountTitle': '確定刪除賬戶?', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index ced850587..855a91fe3 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -222,6 +222,14 @@ const settings: TranslationStrings = { 'settings.passwordTooShort': '密码至少需要 8 个字符', 'settings.passwordMismatch': '两次输入的密码不一致', 'settings.passwordWeak': '密码必须包含大写字母、小写字母、数字和特殊字符', + 'settings.passwordCommon': '此密码太常见,请选择独特的密码。', + 'settings.passwordRepetitive': '密码重复性太高', + 'settings.passwordRules': '密码要求', + 'settings.passwordRule.length': '至少 8 个字符', + 'settings.passwordRule.upper': '大写字母', + 'settings.passwordRule.lower': '小写字母', + 'settings.passwordRule.digit': '数字', + 'settings.passwordRule.special': '特殊字符', 'settings.passwordChanged': '密码修改成功', 'settings.deleteAccount': '删除账户', 'settings.deleteAccountTitle': '确定删除账户?', diff --git a/shared/src/index.ts b/shared/src/index.ts index b169f20be..23f9509a0 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -64,6 +64,7 @@ export * from './settings/settings.schema'; export * from './appearance/appearance.schema'; export * from './backup/backup.schema'; export * from './auth/auth.schema'; +export * from './auth/password-policy'; export * from './oidc/oidc.schema'; export * from './oauth/oauth.schema'; export * from './admin/admin.schema';