mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: implement password requirements validation and UI component
This commit is contained in:
@@ -42,7 +42,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useApiMutation } from '@ema-platform/api';
|
||||||
import { setUser } from '@ema-platform/auth';
|
import { setUser } from '@ema-platform/auth';
|
||||||
import type { AuthUser } from '@ema-platform/auth';
|
import type { AuthUser } from '@ema-platform/auth';
|
||||||
@@ -170,12 +170,10 @@ export function ProfilePage() {
|
|||||||
oldPassword: z
|
oldPassword: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, { message: t('profile.validation.passwordMin') }),
|
.min(1, { message: t('profile.validation.passwordMin') }),
|
||||||
newPassword: z
|
newPassword: strongPasswordSchema(12),
|
||||||
.string()
|
|
||||||
.min(8, { message: t('profile.validation.passwordMin') }),
|
|
||||||
confirmPassword: z
|
confirmPassword: z
|
||||||
.string()
|
.string()
|
||||||
.min(8, { message: t('profile.validation.passwordMin') }),
|
.min(1, { message: t('profile.validation.passwordMin') }),
|
||||||
})
|
})
|
||||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
message: t('profile.validation.passwordMismatch'),
|
message: t('profile.validation.passwordMismatch'),
|
||||||
@@ -414,12 +412,15 @@ export function ProfilePage() {
|
|||||||
{...registerPassword('oldPassword')}
|
{...registerPassword('oldPassword')}
|
||||||
/>
|
/>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
<PasswordInput
|
<div>
|
||||||
label={t('profile.fields.newPassword')}
|
<PasswordInput
|
||||||
leftSection={<IconLock size={18} />}
|
label={t('profile.fields.newPassword')}
|
||||||
error={passwordErrors.newPassword?.message}
|
leftSection={<IconLock size={18} />}
|
||||||
{...registerPassword('newPassword')}
|
error={passwordErrors.newPassword?.message}
|
||||||
/>
|
{...registerPassword('newPassword')}
|
||||||
|
/>
|
||||||
|
<PasswordRequirements password={watchPassword('newPassword')} minLength={12} />
|
||||||
|
</div>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t('profile.fields.confirmPassword')}
|
label={t('profile.fields.confirmPassword')}
|
||||||
leftSection={<IconLock size={18} />}
|
leftSection={<IconLock size={18} />}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useApiMutation } from '@ema-platform/api';
|
||||||
import { authStorage, setUser, setCurrentProfile } from '@ema-platform/auth';
|
import { authStorage, setUser, setCurrentProfile } from '@ema-platform/auth';
|
||||||
import type { CurrentProfile } from '@ema-platform/auth';
|
import type { CurrentProfile } from '@ema-platform/auth';
|
||||||
@@ -331,8 +331,8 @@ export function ProfilePage() {
|
|||||||
const passwordSchema = z
|
const passwordSchema = z
|
||||||
.object({
|
.object({
|
||||||
oldPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
|
oldPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
|
||||||
newPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }),
|
newPassword: strongPasswordSchema(8),
|
||||||
confirmPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }),
|
confirmPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
|
||||||
})
|
})
|
||||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
message: t('profile.validation.passwordMismatch'),
|
message: t('profile.validation.passwordMismatch'),
|
||||||
@@ -654,12 +654,15 @@ export function ProfilePage() {
|
|||||||
{...registerPassword('oldPassword')}
|
{...registerPassword('oldPassword')}
|
||||||
/>
|
/>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
<PasswordInput
|
<div>
|
||||||
label={t('profile.fields.newPassword')}
|
<PasswordInput
|
||||||
leftSection={<IconLock size={18} />}
|
label={t('profile.fields.newPassword')}
|
||||||
error={passwordErrors.newPassword?.message}
|
leftSection={<IconLock size={18} />}
|
||||||
{...registerPassword('newPassword')}
|
error={passwordErrors.newPassword?.message}
|
||||||
/>
|
{...registerPassword('newPassword')}
|
||||||
|
/>
|
||||||
|
<PasswordRequirements password={watchPassword('newPassword')} minLength={8} />
|
||||||
|
</div>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t('profile.fields.confirmPassword')}
|
label={t('profile.fields.confirmPassword')}
|
||||||
leftSection={<IconLock size={18} />}
|
leftSection={<IconLock size={18} />}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
IconUser,
|
IconUser,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify, passwordMeetsAll, PasswordRequirements } from '@ema-platform/ui';
|
||||||
|
|
||||||
const OWNER_TYPES = [
|
const OWNER_TYPES = [
|
||||||
'Individual (Private Owner)',
|
'Individual (Private Owner)',
|
||||||
@@ -52,11 +52,11 @@ export function VesselOwnerRegisterPage() {
|
|||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [registerTrigger] = useApiMutation<{ id: string }>();
|
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 () => {
|
const handleRegister = async () => {
|
||||||
if (!canSubmit) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setError('');
|
setError('');
|
||||||
@@ -164,14 +164,17 @@ export function VesselOwnerRegisterPage() {
|
|||||||
<Divider label="Set Password" labelPosition="center" />
|
<Divider label="Set Password" labelPosition="center" />
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
<PasswordInput
|
<div>
|
||||||
label="Password"
|
<PasswordInput
|
||||||
placeholder="Min. 8 characters"
|
label="Password"
|
||||||
leftSection={<IconLock size={16} />}
|
placeholder="Min. 8 characters"
|
||||||
required
|
leftSection={<IconLock size={16} />}
|
||||||
value={password}
|
required
|
||||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
value={password}
|
||||||
/>
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<PasswordRequirements password={password} minLength={8} />
|
||||||
|
</div>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Confirm Password"
|
label="Confirm Password"
|
||||||
placeholder="Repeat password"
|
placeholder="Repeat password"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { z } from 'zod';
|
|||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
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 { AuthShell } from '../components/AuthShell';
|
||||||
import { loginSuccess, setUser } from '../store/auth.slice';
|
import { loginSuccess, setUser } from '../store/auth.slice';
|
||||||
import type { AuthUser } from '../types/auth.types';
|
import type { AuthUser } from '../types/auth.types';
|
||||||
@@ -40,8 +40,8 @@ const schema = z
|
|||||||
userType: z.literal('individual'),
|
userType: z.literal('individual'),
|
||||||
nameEn: z.string().min(1, { message: 'Name (English) is required' }),
|
nameEn: z.string().min(1, { message: 'Name (English) is required' }),
|
||||||
nameAm: z.string().optional(),
|
nameAm: z.string().optional(),
|
||||||
password: z.string().min(8, { message: 'Password must be at least 8 characters' }),
|
password: passwordSchema(8),
|
||||||
confirmPassword: z.string().min(8, { message: 'Confirm your password' }),
|
confirmPassword: z.string().min(1, { message: 'Confirm your password' }),
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
message: 'Passwords do not match',
|
message: 'Passwords do not match',
|
||||||
@@ -80,6 +80,7 @@ export function SignupPage() {
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormValues>({
|
} = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -197,13 +198,16 @@ export function SignupPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||||
<PasswordInput
|
<div>
|
||||||
label="Password"
|
<PasswordInput
|
||||||
placeholder="At least 8 characters"
|
label="Password"
|
||||||
leftSection={<IconLock size={18} />}
|
placeholder="At least 8 characters"
|
||||||
error={errors.password?.message}
|
leftSection={<IconLock size={18} />}
|
||||||
{...register('password')}
|
error={errors.password?.message}
|
||||||
/>
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
<PasswordRequirements password={watch('password') ?? ''} minLength={8} />
|
||||||
|
</div>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Confirm password"
|
label="Confirm password"
|
||||||
placeholder="Re-enter password"
|
placeholder="Re-enter password"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./lib/input/BilingualInput";
|
export * from "./lib/input/BilingualInput";
|
||||||
export * from "./lib/input/CountrySelect";
|
export * from "./lib/input/CountrySelect";
|
||||||
|
export * from "./lib/input/PasswordRequirements";
|
||||||
export * from "./lib/feedback/ConfirmModal";
|
export * from "./lib/feedback/ConfirmModal";
|
||||||
export * from "./lib/feedback/ApiErrorAlert";
|
export * from "./lib/feedback/ApiErrorAlert";
|
||||||
export * from "./lib/feedback/notify";
|
export * from "./lib/feedback/notify";
|
||||||
@@ -13,3 +14,4 @@ export * from "./lib/layout/PageHeader";
|
|||||||
export * from "./lib/data/AdvancedTable";
|
export * from "./lib/data/AdvancedTable";
|
||||||
export * from "./lib/data/useServerTable";
|
export * from "./lib/data/useServerTable";
|
||||||
export * from "./lib/components/MaritimeLoader"
|
export * from "./lib/components/MaritimeLoader"
|
||||||
|
|
||||||
|
|||||||
56
libs/ui/src/lib/input/PasswordRequirements.tsx
Normal file
56
libs/ui/src/lib/input/PasswordRequirements.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user