Merge remote-tracking branch 'origin/WorkflowChange' into feature/exam-attempt-domain

This commit is contained in:
mihretue
2026-08-21 06:58:57 +00:00
53 changed files with 2950 additions and 1258 deletions

View File

@@ -23,11 +23,25 @@ import { z } from 'zod';
import { Link } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify, useErrorHandler } from '@ema-platform/ui';
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
import { AuthShell } from '../components/AuthShell';
import { useAuthConfig } from '../AuthConfig';
// Same email-or-phone rule as LoginPage: a phone-looking value normalizes to
// E.164 (bare Ethiopian national numbers default to +251) so the backend
// always gets a value it can look the account up by, under the `email` key.
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const schema = z.object({
email: z.string().email({ message: 'Enter a valid email' }),
email: z
.string()
.trim()
.transform((value) => {
if (emailRegex.test(value)) return value;
return parsePhoneNumberFromString(value, 'ET')?.number ?? value;
})
.refine((value) => emailRegex.test(value) || isValidPhoneNumber(value), {
message: 'Enter a valid email or phone number',
}),
});
type FormValues = z.infer<typeof schema>;
@@ -156,8 +170,8 @@ export function ForgotPasswordPage() {
Forgot your password?
</Title>
<Text c="dimmed" mt={6}>
Enter the email linked to your account and we&apos;ll send you a link
to reset your password.
Enter the email or phone number linked to your account and
we&apos;ll send you a link to reset your password.
</Text>
</div>
@@ -170,7 +184,7 @@ export function ForgotPasswordPage() {
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Email address"
label="Email or phone"
placeholder="you@example.com"
size="md"
leftSection={<IconMail size={18} />}

View File

@@ -28,6 +28,7 @@ import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useApiMutation } from "@ema-platform/api";
import { notify, useErrorHandler } from "@ema-platform/ui";
import { isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-js";
import { AuthShell } from "../components/AuthShell";
import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice";
import type {
@@ -53,11 +54,7 @@ export function LoginPage() {
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
const handleBack = () => {
if (window.history.length > 1) {
navigate(-1);
} else {
navigate("/");
}
navigate("/");
};
// Built inside the component (not module scope) so validation messages
@@ -67,24 +64,23 @@ export function LoginPage() {
.string()
.trim()
.transform((value) => {
// Convert 09xxxxxxxx -> +2519xxxxxxxx
if (/^09\d{8}$/.test(value)) {
return `+251${value.substring(1)}`;
}
return value;
// A phone-looking value normalizes to E.164 (bare Ethiopian
// national numbers, e.g. 09xxxxxxxx, default to +251) so the
// international check below can validate it; anything else
// (an email) passes through untouched.
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailRegex.test(value)) return value;
return parsePhoneNumberFromString(value, "ET")?.number ?? value;
})
.refine(
(value) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRegex = /^\+2519\d{8}$/;
return emailRegex.test(value) || phoneRegex.test(value);
return emailRegex.test(value) || isValidPhoneNumber(value);
},
{
message: t(
"login.emailOrPhoneInvalid",
"Enter a valid email or phone number (+2519xxxxxxxx)",
"Enter a valid email or phone number",
),
},
),

View File

@@ -17,7 +17,6 @@ import {
IconArrowLeft,
IconArrowRight,
IconAt,
IconDeviceMobile,
IconLock,
IconMail,
IconUser,
@@ -29,7 +28,7 @@ import { useNavigate, Link } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { useApiMutation } from '@ema-platform/api';
import { useErrorHandler, passwordSchema, PasswordRequirements } from '@ema-platform/ui';
import { useErrorHandler, passwordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { AuthUser } from '../types/auth.types';
@@ -88,7 +87,7 @@ export function SignupPage() {
.object({
email: z.string().email(),
username: z.string().min(3, { message: t('signup.usernameMinLength', 'Username must be at least 3 characters') }),
phoneNumber: z.string().min(1, { message: t('signup.phoneRequired', 'Phone number is required') }),
phoneNumber,
userType: z.literal('individual'),
nameEn: z
.string()
@@ -111,6 +110,8 @@ export function SignupPage() {
register,
handleSubmit,
watch,
setValue,
trigger,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -247,12 +248,13 @@ export function SignupPage() {
/>
</SimpleGrid>
<TextInput
<PhoneInput
label={t('signup.phoneLabel', 'Phone number')}
placeholder={t('signup.phonePlaceholder', '+251 911 234 567')}
leftSection={<IconDeviceMobile size={18} />}
placeholder={t('signup.phonePlaceholder', '9XX XXX XXX')}
value={watch('phoneNumber') || ''}
onChange={(val) => setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })}
onBlur={() => trigger('phoneNumber')}
error={errors.phoneNumber?.message}
{...register('phoneNumber')}
/>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">

View File

@@ -87,6 +87,11 @@ export interface CurrentProfile {
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
seafarerStatusReason?: string | null;
/** Identifying particulars for the Seaman Book. Left blank by choice. */
bloodType?: string | null;
hairColor?: string | null;
eyeColor?: string | null;
heightCm?: number | null;
user: AuthUser;
address: CurrentProfileAddress;
profession: CurrentProfileProfession;

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { currentSessionId } from './jwt';
/** Builds a JWT-shaped string whose payload is `claims`, base64url encoded. */
function token(claims: Record<string, unknown>): string {
const payload = btoa(JSON.stringify(claims))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return `header.${payload}.signature`;
}
describe('currentSessionId', () => {
it('reads the sessionId claim', () => {
expect(currentSessionId(token({ sessionId: 'abc' }))).toBe('abc');
});
it('falls back to sid, then jti', () => {
expect(currentSessionId(token({ sid: 'from-sid' }))).toBe('from-sid');
expect(currentSessionId(token({ jti: 'from-jti' }))).toBe('from-jti');
});
it('decodes payloads containing base64url characters', () => {
// '>' and '?' are what force '+' and '/' in standard base64.
const id = 'a>b?c>d?e>f?';
expect(currentSessionId(token({ sessionId: id }))).toBe(id);
});
it('returns undefined for a token with no session claim', () => {
expect(currentSessionId(token({ sub: 'user-1' }))).toBeUndefined();
});
it('returns undefined rather than throwing on junk', () => {
expect(currentSessionId(undefined)).toBeUndefined();
expect(currentSessionId('')).toBeUndefined();
expect(currentSessionId('opaque-token')).toBeUndefined();
expect(currentSessionId('header.not-base64!!.sig')).toBeUndefined();
});
});