From af4ef8b3a72c8eba899b7d26c8df2bf93043762c Mon Sep 17 00:00:00 2001 From: estifanos Date: Sat, 1 Aug 2026 06:27:59 +0000 Subject: [PATCH] feat: implement password requirements validation and UI component --- .../features/profile/pages/ProfilePage.tsx | 23 ++++---- .../features/profile/pages/ProfilePage.tsx | 21 ++++--- .../pages/VesselOwnerRegisterPage.tsx | 25 +++++---- libs/auth/src/lib/pages/SignupPage.tsx | 24 ++++---- libs/ui/src/index.ts | 2 + .../ui/src/lib/input/PasswordRequirements.tsx | 56 +++++++++++++++++++ 6 files changed, 110 insertions(+), 41 deletions(-) create mode 100644 libs/ui/src/lib/input/PasswordRequirements.tsx diff --git a/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx b/apps/backoffice/src/app/features/profile/pages/ProfilePage.tsx index 63435484b..b40a053da 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 } from '@ema-platform/ui'; +import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui'; import { useApiMutation } from '@ema-platform/api'; import { setUser } from '@ema-platform/auth'; import type { AuthUser } from '@ema-platform/auth'; @@ -170,12 +170,10 @@ export function ProfilePage() { oldPassword: z .string() .min(1, { message: t('profile.validation.passwordMin') }), - newPassword: z - .string() - .min(8, { message: t('profile.validation.passwordMin') }), + newPassword: strongPasswordSchema(12), confirmPassword: z .string() - .min(8, { message: t('profile.validation.passwordMin') }), + .min(1, { message: t('profile.validation.passwordMin') }), }) .refine((data) => data.newPassword === data.confirmPassword, { message: t('profile.validation.passwordMismatch'), @@ -414,12 +412,15 @@ export function ProfilePage() { {...registerPassword('oldPassword')} /> - } - error={passwordErrors.newPassword?.message} - {...registerPassword('newPassword')} - /> +
+ } + error={passwordErrors.newPassword?.message} + {...registerPassword('newPassword')} + /> + +
} diff --git a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx index 460d73c69..023e677cd 100644 --- a/apps/portal/src/app/features/profile/pages/ProfilePage.tsx +++ b/apps/portal/src/app/features/profile/pages/ProfilePage.tsx @@ -43,7 +43,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 } from '@ema-platform/ui'; +import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui'; import { useApiMutation } from '@ema-platform/api'; import { authStorage, setUser, setCurrentProfile } from '@ema-platform/auth'; import type { CurrentProfile } from '@ema-platform/auth'; @@ -331,8 +331,8 @@ export function ProfilePage() { const passwordSchema = z .object({ oldPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }), - newPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }), - confirmPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }), + newPassword: strongPasswordSchema(8), + confirmPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }), }) .refine((data) => data.newPassword === data.confirmPassword, { message: t('profile.validation.passwordMismatch'), @@ -654,12 +654,15 @@ export function ProfilePage() { {...registerPassword('oldPassword')} /> - } - error={passwordErrors.newPassword?.message} - {...registerPassword('newPassword')} - /> +
+ } + error={passwordErrors.newPassword?.message} + {...registerPassword('newPassword')} + /> + +
} diff --git a/apps/portal/src/app/features/vessel-owner/pages/VesselOwnerRegisterPage.tsx b/apps/portal/src/app/features/vessel-owner/pages/VesselOwnerRegisterPage.tsx index 75d216232..72e0f2d01 100644 --- a/apps/portal/src/app/features/vessel-owner/pages/VesselOwnerRegisterPage.tsx +++ b/apps/portal/src/app/features/vessel-owner/pages/VesselOwnerRegisterPage.tsx @@ -28,7 +28,7 @@ import { IconUser, } from '@tabler/icons-react'; import { useApiMutation } from '@ema-platform/api'; -import { notify } from '@ema-platform/ui'; +import { notify, passwordMeetsAll, PasswordRequirements } from '@ema-platform/ui'; const OWNER_TYPES = [ 'Individual (Private Owner)', @@ -52,11 +52,11 @@ export function VesselOwnerRegisterPage() { const [success, setSuccess] = useState(false); const [registerTrigger] = useApiMutation<{ id: string }>(); - const canSubmit = !!fullName.trim() && !!email.trim() && !!phone.trim() && !!ownerType && !!nationalIdOrTin.trim() && !!password && password === confirmPassword; + const canSubmit = !!fullName.trim() && !!email.trim() && !!phone.trim() && !!ownerType && !!nationalIdOrTin.trim() && passwordMeetsAll(password, 8) && password === confirmPassword; const handleRegister = async () => { if (!canSubmit) { - setError('Please fill in all required fields. Passwords must match.'); + setError('Please fill in all required fields. Password must meet all requirements and match.'); return; } setError(''); @@ -164,14 +164,17 @@ export function VesselOwnerRegisterPage() { - } - required - value={password} - onChange={(e) => setPassword(e.currentTarget.value)} - /> +
+ } + required + value={password} + onChange={(e) => setPassword(e.currentTarget.value)} + /> + +
data.password === data.confirmPassword, { message: 'Passwords do not match', @@ -80,6 +80,7 @@ export function SignupPage() { const { register, handleSubmit, + watch, formState: { errors }, } = useForm({ resolver: zodResolver(schema), @@ -197,13 +198,16 @@ export function SignupPage() { /> - } - error={errors.password?.message} - {...register('password')} - /> +
+ } + error={errors.password?.message} + {...register('password')} + /> + +
p.length >= minLength }, + { label: 'One lowercase letter', test: (p: string) => /[a-z]/.test(p) }, + { label: 'One uppercase letter', test: (p: string) => /[A-Z]/.test(p) }, + { label: 'One number', test: (p: string) => /\d/.test(p) }, + { label: 'One special character', test: (p: string) => /[^A-Za-z0-9]/.test(p) }, + ]; +} + +/** Zod field schema enforcing every rule; unmet rules surface as separate issues. */ +export const passwordSchema = (minLength: number) => + z.string().superRefine((val, ctx) => { + for (const rule of passwordRules(minLength)) { + if (!rule.test(val)) { + ctx.addIssue({ code: 'custom', message: rule.label }); + } + } + }); + +export const passwordMeetsAll = (password: string, minLength: number) => + passwordRules(minLength).every((rule) => rule.test(password)); + +interface PasswordRequirementsProps { + password: string; + minLength: number; + labels?: string[]; +} + +/** Live checklist of password requirements, ticking off as the user types. */ +export function PasswordRequirements({ password, minLength, labels }: PasswordRequirementsProps) { + const rules = passwordRules(minLength); + return ( + + {rules.map((rule, i) => { + const met = rule.test(password); + return ( + + {met ? ( + + ) : ( + + )} + + {labels?.[i] ?? rule.label} + + + ); + })} + + ); +}