diff --git a/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx b/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx index 51f3dd333..1cffa5444 100644 --- a/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx @@ -42,7 +42,7 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; -import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui'; +import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber } from '@ema-platform/ui'; import { useApiMutation } from '@ema-platform/api'; import { ActiveSessions, setUser } from '@ema-platform/auth'; import type { AuthUser } from '@ema-platform/auth'; @@ -126,9 +126,9 @@ export function ProfilePage() { .string() .min(1, { message: t('profile.validation.usernameRequired') }), email: z.string().email({ message: t('profile.validation.emailInvalid') }), - phoneNumber: z - .string() - .min(1, { message: t('profile.validation.phoneRequired') }), + // Shared international rule: bare 09xxxxxxxx normalizes to +251, any + // other E.164 number is accepted as typed. + phoneNumber, }); type ProfileValues = z.infer; diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts index c2f0544b9..c34fb1468 100644 --- a/libs/api/src/lib/features/licensing/licensing.helpers.ts +++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts @@ -1,3 +1,4 @@ +import { isValidPhoneNumber } from 'libphonenumber-js'; import { resolveTokenFromStorage } from '../../session'; import type { Bilingual, @@ -526,6 +527,13 @@ export function validateSections( } if (empty) continue; + // PhoneInput emits E.164 while typing, so a half-typed "+2519" is a + // non-empty string that still has to be caught here. + if (field.type === 'PHONE' && !isValidPhoneNumber(String(value))) { + errors[`${section.key}.${field.key}`] = 'Enter a valid phone number'; + continue; + } + const numeric = Number(value); if (!Number.isNaN(numeric)) { if (field.min !== undefined && numeric < field.min) { diff --git a/libs/ui/src/lib/input/PhoneInput.tsx b/libs/ui/src/lib/input/PhoneInput.tsx index 1493a2d0c..a6b4c6e9c 100644 --- a/libs/ui/src/lib/input/PhoneInput.tsx +++ b/libs/ui/src/lib/input/PhoneInput.tsx @@ -8,7 +8,7 @@ import { type CountryCode, } from 'libphonenumber-js'; import { CountryFlag, getCountryName, resolveLang } from './CountrySelect'; -import { formatNational, nextNationalDigits, toE164, toNationalDigits } from './phone'; +import { exceedsMaxLength, formatNational, maxNationalLength, nextNationalDigits, toE164, toNationalDigits } from './phone'; // Static list, computed once at module load — same as CountrySelect's dataset. const COUNTRY_CODES = getCountries(); @@ -45,7 +45,11 @@ export interface PhoneInputProps { /** E.164 (`+14155552671`), or '' when empty. */ value: string; onChange: (value: string) => void; - /** Fired when the number box loses focus — wire to the form's `trigger`. */ + /** + * Fired when focus leaves a field that has digits in it — wire to the + * form's `trigger`. A blank field is left to submit-time validation, like + * the form's other inputs, so tabbing past it doesn't raise an error. + */ onBlur?: () => void; label?: React.ReactNode; placeholder?: string; @@ -124,6 +128,8 @@ export function PhoneInput({ } function applyDigits(digits: string, forCountry: CountryCode) { + // Keystroke past the longest possible number is ignored, not stored. + if (exceedsMaxLength(digits, forCountry)) return; // Once the number is valid, show the true national part: someone who // types a trunk prefix (0911111111) or the country code (251911111111) // shouldn't end up with it doubled beside the "+251" selector. Not @@ -155,7 +161,8 @@ export function PhoneInput({ if (!next) return; const code = next as CountryCode; setCountry(code); - applyDigits(national.replace(/\D/g, ''), code); + // A number carried over from a longer plan is cut to fit the new one. + applyDigits(national.replace(/\D/g, '').slice(0, maxNationalLength(code)), code); } return ( @@ -177,7 +184,7 @@ export function PhoneInput({ // country select mustn't validate a half-typed number. wrapperProps={{ onBlur: (e: React.FocusEvent) => { - if (!e.currentTarget.contains(e.relatedTarget)) onBlur?.(); + if (national && !e.currentTarget.contains(e.relatedTarget)) onBlur?.(); }, }} leftSectionWidth={92} diff --git a/libs/ui/src/lib/input/phone.spec.ts b/libs/ui/src/lib/input/phone.spec.ts index e1c2a0f11..4c1f622e4 100644 --- a/libs/ui/src/lib/input/phone.spec.ts +++ b/libs/ui/src/lib/input/phone.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatNational, nextNationalDigits, optionalPhoneNumber, phoneNumber, toE164, toNationalDigits } from './phone'; +import { exceedsMaxLength, formatNational, maxNationalLength, nextNationalDigits, optionalPhoneNumber, phoneNumber, toE164, toNationalDigits } from './phone'; import { AsYouType, parsePhoneNumberFromString } from 'libphonenumber-js'; describe('phoneNumber', () => { @@ -20,7 +20,11 @@ describe('phoneNumber', () => { }); it('rejects non-numeric input', () => { - expect(() => phoneNumber.parse('abc')).toThrow(); + expect(() => phoneNumber.parse('abc')).toThrow('Enter a valid phone number'); + }); + + it('reports a blank value as missing, not invalid', () => { + expect(() => phoneNumber.parse('')).toThrow('Phone number is required'); }); }); @@ -110,3 +114,18 @@ describe('legacy stored values', () => { expect(toNationalDigits('0911223344', 'ET')).toBe('911223344'); }); }); + +describe('length limit', () => { + it('reports the longest national number per country', () => { + expect(maxNationalLength('ET')).toBe(9); + expect(maxNationalLength('US')).toBe(10); + }); + + it('flags digits past the limit, counting the national part only', () => { + expect(exceedsMaxLength('911223344', 'ET')).toBe(false); + expect(exceedsMaxLength('9112233445', 'ET')).toBe(true); + expect(exceedsMaxLength('0911223344', 'ET')).toBe(false); + expect(exceedsMaxLength('251911223344', 'ET')).toBe(false); + expect(exceedsMaxLength('09112233445', 'ET')).toBe(true); + }); +}); diff --git a/libs/ui/src/lib/input/phone.ts b/libs/ui/src/lib/input/phone.ts index be38b6238..9d804db3e 100644 --- a/libs/ui/src/lib/input/phone.ts +++ b/libs/ui/src/lib/input/phone.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { AsYouType, getCountryCallingCode, isValidPhoneNumber, parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js'; +import { AsYouType, Metadata, getCountryCallingCode, isValidPhoneNumber, parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js'; /** * Accepts any international number in E.164 (`+`) or a @@ -10,8 +10,9 @@ export const phoneNumber = z .string() .trim() .transform((v) => parsePhoneNumberFromString(v, 'ET')?.number ?? v) - .refine((v) => isValidPhoneNumber(v), { - message: 'Enter a valid phone number', + .superRefine((v, ctx) => { + if (!v) ctx.addIssue({ code: 'custom', message: 'Phone number is required' }); + else if (!isValidPhoneNumber(v)) ctx.addIssue({ code: 'custom', message: 'Enter a valid phone number' }); }); /** Same rules, but blank is allowed. */ @@ -67,3 +68,21 @@ export function formatNational(digits: string, country: CountryCode): string { const formatted = new AsYouType().input(prefix + digits); return formatted.startsWith(prefix) ? formatted.slice(prefix.length).trimStart() : digits; } + +const metadata = new Metadata(); + +/** Longest national number the country's numbering plan allows. */ +export function maxNationalLength(country: CountryCode): number { + metadata.selectNumberingPlan(country); + return Math.max(...(metadata.numberingPlan?.possibleLengths() ?? [15])); +} + +/** + * True when the typed digits exceed the country's longest number. Judged on + * the national part once it parses, so a trunk prefix (0911223344) or typed + * country code (251911223344) isn't counted against the limit. + */ +export function exceedsMaxLength(digits: string, country: CountryCode): boolean { + const national = parsePhoneNumberFromString(digits, country)?.nationalNumber ?? digits; + return national.length > maxNationalLength(country); +}