mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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': 'حذف الحساب',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ?',
|
||||
|
||||
@@ -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':
|
||||
'Πρέπει να αλλάξετε τον κωδικό σας πριν συνεχίσετε. Παρακαλώ ορίστε έναν νέο κωδικό παρακάτω.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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': 'アカウント削除',
|
||||
|
||||
@@ -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': '계정 삭제',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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': 'Удалить ваш аккаунт?',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
"คุณต้องเปลี่ยนรหัสผ่านก่อนจึงจะสามารถดำเนินการต่อได้ กรุณาตั้งรหัสผ่านใหม่ด้านล่าง",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'Видалити ваш акаунт?',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': '確定刪除賬戶?',
|
||||
|
||||
@@ -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': '确定删除账户?',
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user