feat(auth): password rules checked while typing, with the server's own policy

The password policy moves to @trek/shared, where the server keeps enforcing it unchanged and every form that sets a password now runs it before sending: the forced change after the first login, registration, the reset link, the account settings on desktop and phone, and creating or editing a user in admin. Under each new password a checklist ticks off length, upper and lower case, a number and a special character, and a refusal names the actual reason, a too common or too repetitive password included. (#1200)
This commit is contained in:
Maurice
2026-10-01 09:59:20 +02:00
parent 44cc596c39
commit 542a57b74e
46 changed files with 546 additions and 72 deletions
@@ -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"
/>
<PasswordChecklist password={newPassword} />
<input
type="password"
value={confirmPassword}
@@ -210,7 +213,8 @@ export default function AccountTab(): React.ReactElement {
onClick={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 })
@@ -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(<PasswordChecklist password="" />)
expect(container).toBeEmptyDOMElement()
})
it('FE-SHARED-PWCHECK-002: ticks off each rule the password meets', () => {
render(<PasswordChecklist password="abcdefg1" />)
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(<PasswordChecklist password="A" tone="light" />)
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()
})
})
@@ -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 (
<ul aria-label={t('settings.passwordRules')} className={`flex flex-wrap gap-1.5 ${className}`}>
{PASSWORD_RULES.map(rule => {
const met = states[rule]
return (
<li key={rule} data-met={met || undefined}
className={`inline-flex items-center gap-1 rounded-full px-2 py-[3px] text-caption font-medium ${tone === 'light' ? '' : met ? 'bg-success-soft text-success' : 'bg-surface-tertiary text-content-faint'}`}
style={tone === 'light' ? (met ? LIGHT_MET : LIGHT_OPEN) : undefined}>
{met ? <Check size={11} strokeWidth={2.6} aria-hidden /> : <Circle size={9} strokeWidth={2.4} aria-hidden />}
<span>{t(`settings.passwordRule.${rule}`)}</span>
</li>
)
})}
</ul>
)
}
@@ -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')}
/>
<PasswordChecklist password={newPassword} className="mt-2" />
<MSetInput
type="password"
className="mt-2"
+49 -5
View File
@@ -154,6 +154,50 @@ describe('LoginPage', () => {
});
});
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<string, unknown> | null = null;
server.use(
http.post('/api/auth/login', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ user: { id: 1, username: 'test', email: 'test@example.com', role: 'user' } });
}),
);
const user = userEvent.setup();
render(<LoginPage />);
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(<LoginPage />);
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(() => {
+3
View File
@@ -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<HTMLInputElement>) => (e.target.style.borderColor = '#e5e7eb')}
/>
</div>
<PasswordChecklist password={newPassword} className="mt-2" tone="light" />
</div>
<div>
<label
@@ -961,6 +963,7 @@ export default function LoginPage(): React.ReactElement {
/>
</button>
</div>
{mode === 'register' && !passwordChangeStep && <PasswordChecklist password={password} className="mt-2" tone="light" />}
{mode === 'login' && (
<div
style={{
+2
View File
@@ -2,6 +2,7 @@ import React, { ChangeEvent } from 'react'
import { Lock, KeyRound, CheckCircle2, AlertTriangle, Eye, EyeOff } from 'lucide-react'
import { useTranslation } from '../i18n'
import { useResetPassword } from './resetPassword/useResetPassword'
import PasswordChecklist from '../components/shared/PasswordChecklist'
const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 44px 11px 38px', borderRadius: 12,
@@ -114,6 +115,7 @@ function ResetPasswordPage() {
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#9ca3af',
}}>{showPw ? <EyeOff size={16} /> : <Eye size={16} />}</button>
</div>
<PasswordChecklist password={pw} className="mt-2" tone="light" />
</div>
<div>
<label style={{ display: 'block', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', fontWeight: 600, color: '#374151', marginBottom: 6 }}>
@@ -14,6 +14,7 @@ import CustomSelect from '../../components/shared/CustomSelect';
import Modal from '../../components/shared/Modal';
import type { TranslationFn } from '../../types';
import type { useAdmin } from './useAdmin';
import PasswordChecklist from '../../components/shared/PasswordChecklist';
interface AdminUserModalsProps {
admin: ReturnType<typeof useAdmin>;
@@ -114,6 +115,7 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
{showCreatePw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<PasswordChecklist password={createForm.password.trim()} className="mt-2" />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('settings.role')}</label>
@@ -195,6 +197,7 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
{showEditPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<PasswordChecklist password={editForm.password.trim()} className="mt-2" />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('settings.role')}</label>
+4 -4
View File
@@ -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 () => {
+7 -4
View File
@@ -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()
+11 -11
View File
@@ -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());
+5 -2
View File
@@ -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)
@@ -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<void>;
act(() => { first = result.current.handleSubmit(submitEvent()) as Promise<void>; });
@@ -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 {
+18
View File
@@ -0,0 +1,18 @@
import { passwordProblem, type PasswordProblem } from '@trek/shared'
const PROBLEM_KEYS: Record<PasswordProblem, string> = {
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
}
+3 -28
View File
@@ -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';
+47
View File
@@ -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 });
});
});
+79
View File
@@ -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<PasswordRule, boolean> {
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<PasswordProblem, string> = {
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 };
}
+8
View File
@@ -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': 'حذف الحساب',
+8
View File
@@ -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.',
+8
View File
@@ -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?',
+8
View File
@@ -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':
+8
View File
@@ -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?',
+8
View File
@@ -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?',
+8
View File
@@ -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.',
+8
View File
@@ -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':
+8
View File
@@ -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',
+8
View File
@@ -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 ?',
+8
View File
@@ -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':
'Πρέπει να αλλάξετε τον κωδικό σας πριν συνεχίσετε. Παρακαλώ ορίστε έναν νέο κωδικό παρακάτω.',
+8
View File
@@ -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',
+8
View File
@@ -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.',
+8
View File
@@ -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?',
+8
View File
@@ -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': 'アカウント削除',
+8
View File
@@ -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': '계정 삭제',
+8
View File
@@ -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?',
+8
View File
@@ -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?',
+8
View File
@@ -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': 'Удалить ваш аккаунт?',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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':
"คุณต้องเปลี่ยนรหัสผ่านก่อนจึงจะสามารถดำเนินการต่อได้ กรุณาตั้งรหัสผ่านใหม่ด้านล่าง",
+8
View File
@@ -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.',
+8
View File
@@ -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': 'Видалити ваш акаунт?',
+8
View File
@@ -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.',
+8
View File
@@ -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': '確定刪除賬戶?',
+8
View File
@@ -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': '确定删除账户?',
+1
View File
@@ -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';