feat: implement password requirements validation and UI component

This commit is contained in:
estifanos
2026-08-01 06:27:59 +00:00
parent 18f18b71dc
commit af4ef8b3a7
6 changed files with 110 additions and 41 deletions

View File

@@ -26,7 +26,7 @@ import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { useApiMutation } from '@ema-platform/api';
import { useErrorHandler } from '@ema-platform/ui';
import { useErrorHandler, passwordSchema, PasswordRequirements } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { AuthUser } from '../types/auth.types';
@@ -40,8 +40,8 @@ const schema = z
userType: z.literal('individual'),
nameEn: z.string().min(1, { message: 'Name (English) is required' }),
nameAm: z.string().optional(),
password: z.string().min(8, { message: 'Password must be at least 8 characters' }),
confirmPassword: z.string().min(8, { message: 'Confirm your password' }),
password: passwordSchema(8),
confirmPassword: z.string().min(1, { message: 'Confirm your password' }),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
@@ -80,6 +80,7 @@ export function SignupPage() {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -197,13 +198,16 @@ export function SignupPage() {
/>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<PasswordInput
label="Password"
placeholder="At least 8 characters"
leftSection={<IconLock size={18} />}
error={errors.password?.message}
{...register('password')}
/>
<div>
<PasswordInput
label="Password"
placeholder="At least 8 characters"
leftSection={<IconLock size={18} />}
error={errors.password?.message}
{...register('password')}
/>
<PasswordRequirements password={watch('password') ?? ''} minLength={8} />
</div>
<PasswordInput
label="Confirm password"
placeholder="Re-enter password"

View File

@@ -1,5 +1,6 @@
export * from "./lib/input/BilingualInput";
export * from "./lib/input/CountrySelect";
export * from "./lib/input/PasswordRequirements";
export * from "./lib/feedback/ConfirmModal";
export * from "./lib/feedback/ApiErrorAlert";
export * from "./lib/feedback/notify";
@@ -13,3 +14,4 @@ export * from "./lib/layout/PageHeader";
export * from "./lib/data/AdvancedTable";
export * from "./lib/data/useServerTable";
export * from "./lib/components/MaritimeLoader"

View File

@@ -0,0 +1,56 @@
import { Stack, Text, Group } from '@mantine/core';
import { IconCheck, IconX } from '@tabler/icons-react';
import { z } from 'zod';
export function passwordRules(minLength: number) {
return [
{ label: `At least ${minLength} characters`, test: (p: string) => 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 (
<Stack gap={6} mt={6}>
{rules.map((rule, i) => {
const met = rule.test(password);
return (
<Group key={rule.label} gap={6} wrap="nowrap">
{met ? (
<IconCheck size={14} color="var(--mantine-color-teal-6)" />
) : (
<IconX size={14} color="var(--mantine-color-gray-5)" />
)}
<Text fz="xs" c={met ? 'teal' : 'dimmed'}>
{labels?.[i] ?? rule.label}
</Text>
</Group>
);
})}
</Stack>
);
}