diff --git a/src/features/identity-setup/components/ConfirmChange.tsx b/src/features/identity-setup/components/ConfirmChange.tsx index 605d0eb..ed77e79 100644 --- a/src/features/identity-setup/components/ConfirmChange.tsx +++ b/src/features/identity-setup/components/ConfirmChange.tsx @@ -24,8 +24,14 @@ export const ConfirmChange = ({ const { translate } = useTranslationContext(); const newEmail = userNewEmail.address + '@' + userNewEmail.domain; + const handleSubmit = (e: React.SubmitEvent) => { + e.preventDefault(); + if (isDisabled) return; + onConfirmChanges(); + }; + return ( -
+
{/* Avatar */}
@@ -49,10 +55,10 @@ export const ConfirmChange = ({
-
-
+ ); }; diff --git a/src/features/identity-setup/components/ConfirmPassword.tsx b/src/features/identity-setup/components/ConfirmPassword.tsx index d1afe7f..0ab7d8f 100644 --- a/src/features/identity-setup/components/ConfirmPassword.tsx +++ b/src/features/identity-setup/components/ConfirmPassword.tsx @@ -18,6 +18,11 @@ export const ConfirmPassword = ({ userFullName, onBack, onNext }: ConfirmPasswor onNext(password); }; + const handleSubmit = (e: React.SubmitEvent) => { + e.preventDefault(); + onConfirmPassword(); + }; + return (
@@ -28,7 +33,7 @@ export const ConfirmPassword = ({ userFullName, onBack, onNext }: ConfirmPasswor
-
+
{/* Avatar */}
@@ -54,9 +59,9 @@ export const ConfirmPassword = ({ userFullName, onBack, onNext }: ConfirmPasswor
- +
-
+ ); }; diff --git a/src/features/identity-setup/components/EmailAddressRulesPanel.tsx b/src/features/identity-setup/components/EmailAddressRulesPanel.tsx new file mode 100644 index 0000000..c85d3f9 --- /dev/null +++ b/src/features/identity-setup/components/EmailAddressRulesPanel.tsx @@ -0,0 +1,40 @@ +import { useTranslationContext } from '@/i18n'; +import { CheckCircleIcon, MinusCircleIcon, XCircleIcon } from '@phosphor-icons/react'; +import type { EmailAddressRule, EmailAddressRuleStatus } from '../hooks/emailAddressRules'; + +interface EmailAddressRulesPanelProps { + rules: EmailAddressRule[]; +} + +const STATUS_TEXT_CLASSNAME: Record = { + idle: 'text-gray-50', + valid: 'text-green', + invalid: 'text-red', +}; + +const STATUS_ICON: Record = { + idle: MinusCircleIcon, + valid: CheckCircleIcon, + invalid: XCircleIcon, +}; + +export const EmailAddressRulesPanel = ({ rules }: EmailAddressRulesPanelProps) => { + const { translate } = useTranslationContext(); + + return ( +
    + {rules.map((rule) => { + const StatusIcon = STATUS_ICON[rule.status]; + + return ( +
  • + + {translate(rule.labelKey)} +
  • + ); + })} +
+ ); +}; + +export default EmailAddressRulesPanel; diff --git a/src/features/identity-setup/components/SelectMailInput.tsx b/src/features/identity-setup/components/SelectMailInput.tsx index fc5e999..5fa02e9 100644 --- a/src/features/identity-setup/components/SelectMailInput.tsx +++ b/src/features/identity-setup/components/SelectMailInput.tsx @@ -9,9 +9,17 @@ interface SelectMailInputProps { domains: EmailDomainsResponse; onChangeValue: (value: string) => void; onChangeDomain: (domain: string) => void; + onFocusChange?: (isFocused: boolean) => void; } -const SelectMailInput = ({ value, onChangeValue, selectedDomain, domains, onChangeDomain }: SelectMailInputProps) => { +const SelectMailInput = ({ + value, + onChangeValue, + selectedDomain, + domains, + onChangeDomain, + onFocusChange, +}: SelectMailInputProps) => { const [isFocused, setIsFocused] = useState(false); const menuItems = domains.map(({ domain }) => ( @@ -38,11 +46,19 @@ const SelectMailInput = ({ value, onChangeValue, selectedDomain, domains, onChan className="h-full min-w-0 flex-1 bg-transparent pl-3 text-lg text-gray-100 outline-none placeholder:text-gray-40" value={value} onChange={(e) => onChangeValue(e.target.value)} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} + onFocus={() => { + setIsFocused(true); + onFocusChange?.(true); + }} + onBlur={() => { + setIsFocused(false); + onFocusChange?.(false); + }} /> -
+ {/* The Dropdown's internal buttons don't expose a `type` prop, so inside a +
they'd default to type="submit" and prematurely submit it. */} +
e.preventDefault()}> { const { translate, translateArray } = useTranslationContext(); - const [username, setUsername] = useState(''); - const [domain, setDomain] = useState(activeDomains[0]?.domain ?? ''); - const [usernameError, setUsernameError] = useState(''); + const { + username, + rules, + isValid, + validateAddress, + domain, + setDomain, + setIsUsernameFocused, + isPanelVisible, + handleSubmit, + submit, + } = useUpdateEmail({ activeDomains, onNext }); const descriptions = translateArray('identitySetup.updateEmail.description', { name: userFullName, current_email: currentEmail, }); - const handleConfirm = () => { - if (!username.trim()) { - setUsernameError(translate('errors.identitySetup.usernameRequired')); - return; - } - onNext({ address: username, domain }); + const handleFormKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== 'Enter' || (e.target as HTMLElement).tagName === 'BUTTON') return; + + e.preventDefault(); + submit(); }; return ( -
+ {/* Avatar */}
@@ -52,26 +65,30 @@ export const UpdateEmail = ({ userFullName, activeDomains, currentEmail, onNext {/* Email input */}
+ {translate('identitySetup.updateEmail.mail')} { - setUsername(value); - if (value.trim()) setUsernameError(''); - }} + onChangeValue={validateAddress} selectedDomain={domain} domains={activeDomains} onChangeDomain={setDomain} + onFocusChange={setIsUsernameFocused} /> - {usernameError ? ( -

{usernameError}

- ) : ( -

{translate('identitySetup.updateEmail.mailType')}

- )} +
+
{isPanelVisible && }
+
+ {!isPanelVisible &&

{translate('identitySetup.updateEmail.mailType')}

}
- +
-
+ ); }; diff --git a/src/features/identity-setup/hooks/emailAddressRules.test.ts b/src/features/identity-setup/hooks/emailAddressRules.test.ts new file mode 100644 index 0000000..9a8cbfd --- /dev/null +++ b/src/features/identity-setup/hooks/emailAddressRules.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, test } from 'vitest'; +import { + isEmailAddressValid, + isRuleStatusValid, + validateEmailAddress, + type EmailAddressRuleId, + type EmailAddressRuleStatus, +} from './emailAddressRules'; + +const ruleStatus = (username: string, id: EmailAddressRuleId): EmailAddressRuleStatus => + validateEmailAddress(username).find((rule) => rule.id === id)!.status; + +describe('emailAddressRules', () => { + describe('length rule', () => { + test('When username has fewer than 3 characters, then it is invalid', () => { + const username = 'ab'; + + const status = ruleStatus(username, 'length'); + + expect(status).toBe('invalid'); + }); + + test('When username has exactly 3 characters, then it is valid', () => { + const username = 'abc'; + + const status = ruleStatus(username, 'length'); + + expect(status).toBe('valid'); + }); + + test('When username has exactly 30 characters, then it is valid', () => { + const username = 'a'.repeat(30); + + const status = ruleStatus(username, 'length'); + + expect(status).toBe('valid'); + }); + + test('When username has more than 30 characters, then it is invalid', () => { + const username = 'a'.repeat(31); + + const status = ruleStatus(username, 'length'); + + expect(status).toBe('invalid'); + }); + + test('When username is empty, then it is idle', () => { + const username = ''; + + const status = ruleStatus(username, 'length'); + + expect(status).toBe('idle'); + }); + }); + + describe('charset rule', () => { + test('When username only has lowercase letters, numbers, periods, hyphens and underscores, then it is valid', () => { + const username = 'jane.doe-99_x'; + + const status = ruleStatus(username, 'charset'); + + expect(status).toBe('valid'); + }); + + test('When username contains an uppercase letter, then it is invalid', () => { + const username = 'Jane'; + + const status = ruleStatus(username, 'charset'); + + expect(status).toBe('invalid'); + }); + + test('When username contains a space, then it is invalid', () => { + const username = 'jane doe'; + + const status = ruleStatus(username, 'charset'); + + expect(status).toBe('invalid'); + }); + + test('When username contains an unsupported symbol, then it is invalid', () => { + const username = 'jane@doe'; + + const status = ruleStatus(username, 'charset'); + + expect(status).toBe('invalid'); + }); + + test('When username has two consecutive periods, then it is invalid', () => { + const username = 'jane..doe'; + + const status = ruleStatus(username, 'charset'); + + expect(status).toBe('invalid'); + }); + + test('When username has two consecutive underscores, then it is invalid', () => { + const username = 'jane__doe'; + + const status = ruleStatus(username, 'charset'); + + expect(status).toBe('invalid'); + }); + + test('When username mixes two different consecutive special characters, then it is invalid', () => { + const username = 'jane.-doe'; + + const status = ruleStatus(username, 'charset'); + + expect(status).toBe('invalid'); + }); + + test('When username has single, non-adjacent special characters, then it is valid', () => { + const username = 'jane.doe-99_x'; + + const status = ruleStatus(username, 'charset'); + + expect(status).toBe('valid'); + }); + + test('When username is empty, then it is idle', () => { + const username = ''; + + const status = ruleStatus(username, 'charset'); + + expect(status).toBe('idle'); + }); + }); + + describe('edges rule', () => { + test('When username starts with a period, then it is invalid', () => { + const username = '.jane'; + + const status = ruleStatus(username, 'edges'); + + expect(status).toBe('invalid'); + }); + + test('When username ends with a hyphen, then it is invalid', () => { + const username = 'jane-'; + + const status = ruleStatus(username, 'edges'); + + expect(status).toBe('invalid'); + }); + + test('When username starts with an underscore, then it is invalid', () => { + const username = '_jane'; + + const status = ruleStatus(username, 'edges'); + + expect(status).toBe('invalid'); + }); + + test('When username starts and ends with alphanumeric characters, then it is valid', () => { + const username = 'jane.doe'; + + const status = ruleStatus(username, 'edges'); + + expect(status).toBe('valid'); + }); + + test('When username is empty, then it is idle', () => { + const username = ''; + + const status = ruleStatus(username, 'edges'); + + expect(status).toBe('idle'); + }); + }); + + describe('available rule', () => { + test.each(['admin', 'support', 'postmaster', 'noreply'])( + 'When username is the reserved name "%s", then it is invalid', + (reservedName) => { + const status = ruleStatus(reservedName, 'available'); + + expect(status).toBe('invalid'); + }, + ); + + test('When username is not a reserved name, then it is valid', () => { + const username = 'jane.doe'; + + const status = ruleStatus(username, 'available'); + + expect(status).toBe('valid'); + }); + + test('When username is empty, then it is idle', () => { + const username = ''; + + const status = ruleStatus(username, 'available'); + + expect(status).toBe('idle'); + }); + + test('When a formatting rule fails, then it stays idle instead of being checked', () => { + const username = 'Jane'; + + const status = ruleStatus(username, 'available'); + + expect(status).toBe('idle'); + }); + }); + + describe('isRuleStatusValid', () => { + test('When status is valid, then it returns true', () => { + const status: EmailAddressRuleStatus = 'valid'; + + const result = isRuleStatusValid(status); + + expect(result).toBe(true); + }); + + test('When status is invalid, then it returns false', () => { + const status: EmailAddressRuleStatus = 'invalid'; + + const result = isRuleStatusValid(status); + + expect(result).toBe(false); + }); + + test('When status is idle, then it returns false', () => { + const status: EmailAddressRuleStatus = 'idle'; + + const result = isRuleStatusValid(status); + + expect(result).toBe(false); + }); + }); + + describe('isEmailAddressValid', () => { + test('When username satisfies every rule, then it is valid', () => { + const username = 'jane.doe-99_x'; + + const result = isEmailAddressValid(username); + + expect(result).toBe(true); + }); + + test('When username fails at least one rule, then it is invalid', () => { + const username = 'admin'; + + const result = isEmailAddressValid(username); + + expect(result).toBe(false); + }); + + test('When username is empty, then it is invalid', () => { + const username = ''; + + const result = isEmailAddressValid(username); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/features/identity-setup/hooks/emailAddressRules.ts b/src/features/identity-setup/hooks/emailAddressRules.ts new file mode 100644 index 0000000..460b512 --- /dev/null +++ b/src/features/identity-setup/hooks/emailAddressRules.ts @@ -0,0 +1,88 @@ +import type { TranslationKey } from '@/i18n/types'; + +export const EMAIL_ADDRESS_MIN_LENGTH = 3; +export const EMAIL_ADDRESS_MAX_LENGTH = 30; + +const ALLOWED_CHARS_REGEX = /^[a-z0-9._-]+$/; +const EDGE_SPECIAL_CHARS_REGEX = /^[._-]|[._-]$/; +const CONSECUTIVE_SPECIAL_CHARS_REGEX = /[._-]{2,}/; + +const RESERVED_USERNAMES = new Set([ + 'admin', + 'administrator', + 'root', + 'support', + 'postmaster', + 'noreply', + 'no-reply', + 'webmaster', + 'hostmaster', + 'abuse', + 'security', + 'info', + 'help', + 'contact', + 'billing', + 'sales', + 'mailer-daemon', + 'daemon', + 'ftp', + 'www', + 'system', + 'test', +]); + +export type EmailAddressRuleId = 'length' | 'charset' | 'edges' | 'available'; +export type EmailAddressRuleStatus = 'idle' | 'valid' | 'invalid'; + +export interface EmailAddressRule { + id: EmailAddressRuleId; + labelKey: TranslationKey; + status: EmailAddressRuleStatus; +} + +const isLengthValid = (username: string): boolean => + username.length >= EMAIL_ADDRESS_MIN_LENGTH && username.length <= EMAIL_ADDRESS_MAX_LENGTH; + +const isCharsetValid = (username: string): boolean => + ALLOWED_CHARS_REGEX.test(username) && !CONSECUTIVE_SPECIAL_CHARS_REGEX.test(username); + +const isEdgesValid = (username: string): boolean => !EDGE_SPECIAL_CHARS_REGEX.test(username); + +const isAvailable = (username: string): boolean => !RESERVED_USERNAMES.has(username); + +export const validateEmailAddress = (username: string): EmailAddressRule[] => { + const hasContent = username.length > 0; + const lengthValid = isLengthValid(username); + const charsetValid = isCharsetValid(username); + const edgesValid = hasContent && isEdgesValid(username); + const formatValid = lengthValid && charsetValid && edgesValid; + + return [ + { + id: 'length', + labelKey: 'identitySetup.updateEmail.rules.length', + status: hasContent ? (lengthValid ? 'valid' : 'invalid') : 'idle', + }, + { + id: 'charset', + labelKey: 'identitySetup.updateEmail.rules.charset', + status: hasContent ? (charsetValid ? 'valid' : 'invalid') : 'idle', + }, + { + id: 'edges', + labelKey: 'identitySetup.updateEmail.rules.edges', + status: hasContent ? (edgesValid ? 'valid' : 'invalid') : 'idle', + }, + { + id: 'available', + labelKey: 'identitySetup.updateEmail.rules.available', + status: formatValid ? (isAvailable(username) ? 'valid' : 'invalid') : 'idle', + }, + ]; +}; + +export const isRuleStatusValid = (status: EmailAddressRuleStatus): boolean => status === 'valid'; + +export const isEmailAddressValid = (username: string): boolean => + validateEmailAddress(username).every((rule) => isRuleStatusValid(rule.status)); diff --git a/src/features/identity-setup/hooks/useEmailAddressValidation.test.ts b/src/features/identity-setup/hooks/useEmailAddressValidation.test.ts new file mode 100644 index 0000000..6c21dc9 --- /dev/null +++ b/src/features/identity-setup/hooks/useEmailAddressValidation.test.ts @@ -0,0 +1,83 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { useEmailAddressValidation } from './useEmailAddressValidation'; + +describe('useEmailAddressValidation', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('When the hook is initialized, then username is empty and the user has not interacted yet', () => { + const { result } = renderHook(() => useEmailAddressValidation(300)); + + expect(result.current.username).toBe(''); + expect(result.current.hasInteracted).toBe(false); + expect(result.current.isValid).toBe(false); + }); + + test('When validateAddress is called, then username updates immediately without waiting for the debounce', () => { + const { result } = renderHook(() => useEmailAddressValidation(300)); + + act(() => result.current.validateAddress('jane.doe')); + + expect(result.current.username).toBe('jane.doe'); + }); + + test('When validateAddress is called with uppercase characters, then the value is lowercased', () => { + const { result } = renderHook(() => useEmailAddressValidation(300)); + + act(() => result.current.validateAddress('Jane.Doe')); + + expect(result.current.username).toBe('jane.doe'); + }); + + test('When validateAddress is called, then hasInteracted becomes true immediately', () => { + const { result } = renderHook(() => useEmailAddressValidation(300)); + + act(() => result.current.validateAddress('j')); + + expect(result.current.hasInteracted).toBe(true); + }); + + test('When validateAddress is called, then rules are not recomputed before the debounce delay elapses', () => { + const { result } = renderHook(() => useEmailAddressValidation(300)); + + act(() => result.current.validateAddress('jane.doe')); + act(() => vi.advanceTimersByTime(299)); + + expect(result.current.rules.find((rule) => rule.id === 'length')!.status).toBe('idle'); + }); + + test('When the debounce delay elapses, then rules reflect the latest typed value', () => { + const { result } = renderHook(() => useEmailAddressValidation(300)); + + act(() => result.current.validateAddress('admin')); + act(() => vi.advanceTimersByTime(300)); + + expect(result.current.rules.find((rule) => rule.id === 'available')!.status).toBe('invalid'); + }); + + test('When the typed value satisfies every rule and the debounce elapses, then isValid is true', () => { + const { result } = renderHook(() => useEmailAddressValidation(300)); + + act(() => result.current.validateAddress('jane.doe-99_x')); + act(() => vi.advanceTimersByTime(300)); + + expect(result.current.isValid).toBe(true); + }); + + test('When the value changes rapidly, then only the last value is validated after the delay', () => { + const { result } = renderHook(() => useEmailAddressValidation(300)); + + act(() => result.current.validateAddress('admin')); + act(() => vi.advanceTimersByTime(100)); + act(() => result.current.validateAddress('jane.doe')); + act(() => vi.advanceTimersByTime(300)); + + expect(result.current.isValid).toBe(true); + }); +}); diff --git a/src/features/identity-setup/hooks/useEmailAddressValidation.ts b/src/features/identity-setup/hooks/useEmailAddressValidation.ts new file mode 100644 index 0000000..402bb13 --- /dev/null +++ b/src/features/identity-setup/hooks/useEmailAddressValidation.ts @@ -0,0 +1,29 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useDebounce } from '@/hooks/useDebounce'; +import { isRuleStatusValid, validateEmailAddress, type EmailAddressRule } from './emailAddressRules'; + +const DEFAULT_DEBOUNCE_MS = 300; + +export interface UseEmailAddressValidationResult { + username: string; + rules: EmailAddressRule[]; + isValid: boolean; + hasInteracted: boolean; + validateAddress: (value: string) => void; +} + +export const useEmailAddressValidation = (debounceMs = DEFAULT_DEBOUNCE_MS): UseEmailAddressValidationResult => { + const [username, setUsername] = useState(''); + const [hasInteracted, setHasInteracted] = useState(false); + const debouncedUsername = useDebounce(username, debounceMs); + + const validateAddress = useCallback((value: string) => { + setHasInteracted(true); + setUsername(value.toLowerCase()); + }, []); + + const rules = useMemo(() => validateEmailAddress(debouncedUsername), [debouncedUsername]); + const isValid = useMemo(() => rules.every((rule) => isRuleStatusValid(rule.status)), [rules]); + + return { username, rules, isValid, hasInteracted, validateAddress }; +}; diff --git a/src/features/identity-setup/hooks/useUpdateEmail.ts b/src/features/identity-setup/hooks/useUpdateEmail.ts new file mode 100644 index 0000000..6d7608d --- /dev/null +++ b/src/features/identity-setup/hooks/useUpdateEmail.ts @@ -0,0 +1,41 @@ +import type { EmailDomainsResponse } from '@internxt/sdk/dist/mail/types'; +import { useState } from 'react'; +import { useEmailAddressValidation } from './useEmailAddressValidation'; + +interface UseUpdateEmailParams { + activeDomains: EmailDomainsResponse; + onNext: (params: { address: string; domain: string }) => void; +} + +export const useUpdateEmail = ({ activeDomains, onNext }: UseUpdateEmailParams) => { + const { username, rules, isValid, validateAddress } = useEmailAddressValidation(); + const [domain, setDomain] = useState(activeDomains[0]?.domain ?? ''); + const [isUsernameFocused, setIsUsernameFocused] = useState(false); + + const submit = () => { + if (!isValid) return; + onNext({ address: username, domain }); + }; + + const handleSubmit = (e: React.SubmitEvent) => { + e.preventDefault(); + submit(); + }; + + const hasStartedTyping = rules.some((rule) => rule.status !== 'idle'); + const isPanelVisible = isUsernameFocused || hasStartedTyping; + + return { + username, + rules, + isValid, + validateAddress, + domain, + setDomain, + isUsernameFocused, + setIsUsernameFocused, + isPanelVisible, + handleSubmit, + submit, + }; +}; diff --git a/src/features/identity-setup/index.tsx b/src/features/identity-setup/index.tsx index 5734556..41565da 100644 --- a/src/features/identity-setup/index.tsx +++ b/src/features/identity-setup/index.tsx @@ -1,22 +1,22 @@ -import { Header } from './components/Header'; -import { Footer } from './components/Footer'; -import { use, useState, type ReactNode } from 'react'; -import { UpdateEmail } from './components/UpdateEmail'; -import { ConfirmPassword } from './components/ConfirmPassword'; -import { useAppSelector, useAppDispatch } from '@/store/hooks'; -import { NavigationService } from '@/services/navigation'; +import { DEFAULT_USER_NAME } from '@/constants'; +import { useTranslationContext } from '@/i18n'; import { AppView } from '@/routes/paths'; -import { ConfirmChange } from './components/ConfirmChange'; -import { MailService } from '@/services/sdk/mail'; -import { AuthService } from '@/services/sdk/auth'; +import { CryptoService } from '@/services/crypto'; import { ErrorService } from '@/services/error'; +import { LocalStorageService } from '@/services/local-storage'; +import { NavigationService } from '@/services/navigation'; +import { AuthService } from '@/services/sdk/auth'; +import { MailService } from '@/services/sdk/mail'; +import { mailApi } from '@/store/api/mail'; +import { useAppDispatch, useAppSelector } from '@/store/hooks'; import type { SetupMailAccountPayload } from '@internxt/sdk/dist/mail/types'; -import { CryptoService } from '@/services/crypto'; -import { useTranslationContext } from '@/i18n'; -import { DEFAULT_USER_NAME } from '@/constants'; import { createEncryptionAndRecoveryKeystores } from 'internxt-crypto'; -import { mailApi } from '@/store/api/mail'; -import { LocalStorageService } from '@/services/local-storage'; +import { use, useState, type ReactNode } from 'react'; +import { ConfirmChange } from './components/ConfirmChange'; +import { ConfirmPassword } from './components/ConfirmPassword'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { UpdateEmail } from './components/UpdateEmail'; type Step = 'updateEmail' | 'confirmPassword' | 'confirmChange'; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e43973f..444386f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -53,8 +53,14 @@ "Your previous email {{current_email}} will be saved for account recovery." ], "mail": "Mail", - "mailType": "You can use letters, numbers and periods", - "action": "Save new email" + "mailType": "You can use lowercase letters, numbers, periods, hyphens and underscores", + "action": "Save new email", + "rules": { + "length": "3 to 30 characters", + "charset": "Lowercase letters, numbers, ., - and _", + "edges": "Start and end with a letter or number", + "available": "Username must be available" + } }, "confirmPassword": { "goBack": "Choose email", @@ -152,7 +158,6 @@ "authTimeout": "Authentication timeout. Please try again." }, "identitySetup": { - "usernameRequired": "The username is required", "passwordWrong": "The password is incorrect. Please try again.", "emailNotSelected": "Please choose an email address first", "passwordCheckFailed": "Something went wrong while verifying your password", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0e1b939..97d6fee 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -55,8 +55,14 @@ "Tu correo anterior {{current_email}} se guardará para la recuperación de cuenta." ], "mail": "Correo", - "mailType": "Puedes usar letras, números y puntos", - "action": "Guardar nuevo correo" + "mailType": "Puedes usar minúsculas, números, puntos, guiones y guiones bajos", + "action": "Guardar nuevo correo", + "rules": { + "length": "Entre 3 y 30 caracteres", + "charset": "Minúsculas, números, ., - y _", + "edges": "Debe empezar y terminar con una letra o número", + "available": "El nombre de usuario debe estar disponible" + } }, "confirmPassword": { "goBack": "Elegir correo", @@ -154,7 +160,6 @@ "authTimeout": "Tiempo de autenticación agotado. Por favor, inténtalo de nuevo." }, "identitySetup": { - "usernameRequired": "El nombre de usuario es obligatorio", "passwordWrong": "La contraseña es incorrecta. Por favor, inténtalo de nuevo.", "emailNotSelected": "Por favor, elige una dirección de correo primero", "passwordCheckFailed": "Algo ha ido mal al verificar tu contraseña", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3f84fff..a219d82 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -55,8 +55,14 @@ "Votre e-mail précédent {{current_email}} sera conservé pour la récupération du compte." ], "mail": "E-mail", - "mailType": "Vous pouvez utiliser des lettres, des chiffres et des points", - "action": "Enregistrer le nouvel e-mail" + "mailType": "Vous pouvez utiliser des minuscules, des chiffres, des points, des tirets et des underscores", + "action": "Enregistrer le nouvel e-mail", + "rules": { + "length": "Entre 3 et 30 caractères", + "charset": "Minuscules, chiffres, ., - et _", + "edges": "Doit commencer et se terminer par une lettre ou un chiffre", + "available": "Le nom d'utilisateur doit être disponible" + } }, "confirmPassword": { "goBack": "Choisir un e-mail", @@ -154,7 +160,6 @@ "authTimeout": "Délai d'authentification dépassé. Veuillez réessayer." }, "identitySetup": { - "usernameRequired": "Le nom d'utilisateur est obligatoire", "passwordWrong": "Le mot de passe est incorrect. Veuillez réessayer.", "emailNotSelected": "Veuillez d'abord choisir une adresse e-mail", "passwordCheckFailed": "Une erreur s'est produite lors de la vérification de votre mot de passe", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index e6ffd4a..984dbb5 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -55,8 +55,14 @@ "La tua email precedente {{current_email}} verrà salvata per il recupero dell'account." ], "mail": "Email", - "mailType": "Puoi usare lettere, numeri e punti", - "action": "Salva nuova email" + "mailType": "Puoi usare minuscole, numeri, punti, trattini e underscore", + "action": "Salva nuova email", + "rules": { + "length": "Da 3 a 30 caratteri", + "charset": "Minuscole, numeri, ., - e _", + "edges": "Deve iniziare e finire con una lettera o un numero", + "available": "Il nome utente deve essere disponibile" + } }, "confirmPassword": { "goBack": "Scegli email", @@ -154,7 +160,6 @@ "authTimeout": "Timeout di autenticazione. Riprova." }, "identitySetup": { - "usernameRequired": "Il nome utente è obbligatorio", "passwordWrong": "La password non è corretta. Riprova.", "emailNotSelected": "Per favore, scegli prima un indirizzo email", "passwordCheckFailed": "Si è verificato un errore durante la verifica della password",