Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1

This commit is contained in:
Estifo77
2026-08-08 09:03:50 +03:00
66 changed files with 6665 additions and 3846 deletions

View File

@@ -1,30 +1,17 @@
export { AuthConfigProvider, useAuthConfig } from "./lib/AuthConfig";
export type { AuthConfigValue } from "./lib/AuthConfig";
export { AuthShell, BrandMark } from "./lib/components/AuthShell";
export { ProtectedRoute } from "./lib/components/ProtectedRoute";
export { AuthBootstrap } from "./lib/components/AuthBootstrap";
export { LoginPage } from "./lib/pages/LoginPage";
export { SignupPage } from "./lib/pages/SignupPage";
export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage";
export { OTPVerificationPage } from "./lib/pages/OTPVerificationPage";
export {
authReducer,
loginSuccess,
setUser,
setCurrentProfile,
clearCurrentProfile,
logout,
hydrateAuth,
setToken,
} from "./lib/store/auth.slice";
export {
signupReducer,
setSignupData,
setSignupStep,
resetSignup,
} from "./lib/store/signup.slice";
export { usePermissions } from "./lib/hooks/usePermissions";
export type { PermissionSet } from "./lib/hooks/usePermissions";
export { AuthConfigProvider, useAuthConfig } from './lib/AuthConfig';
export type { AuthConfigValue } from './lib/AuthConfig';
export { AuthShell, BrandMark } from './lib/components/AuthShell';
export { ProtectedRoute } from './lib/components/ProtectedRoute';
export { AuthBootstrap } from './lib/components/AuthBootstrap';
export { LoginPage } from './lib/pages/LoginPage';
export { SignupPage } from './lib/pages/SignupPage';
export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';
export { SetPasswordPage } from './lib/pages/SetPasswordPage';
export { OTPVerificationPage } from './lib/pages/OTPVerificationPage';
export { authReducer, loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } from './lib/store/auth.slice';
export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice';
export { usePermissions } from './lib/hooks/usePermissions';
export type { PermissionSet } from './lib/hooks/usePermissions';
export {
useCurrentProfile,
useGetMyProfileQuery,

View File

@@ -94,7 +94,49 @@ export function LoginPage() {
method: "POST",
body: values,
}).unwrap();
dispatch(loginSuccess(data));
// MFA-enabled accounts get no tokens yet — an OTP has been sent, and
// the session only exists once /auth/mfa-verify accepts it (US-IAM-006).
if (data.mfaRequired) {
setMfaEmail(values.email);
notify.success('Enter the verification code we just sent you');
return;
}
await completeSession(data);
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong');
setServerError(msg);
notify.error(msg);
} finally {
setIsLoading(false);
}
};
const verifyMfa = async () => {
if (!mfaEmail || !mfaOtp.trim()) return;
setIsLoading(true);
try {
const data = await loginTrigger({
url: '/auth/mfa-verify',
method: 'POST',
body: { email: mfaEmail, otp: mfaOtp.trim() },
}).unwrap();
// The second factor proves possession of the verified phone.
await completeSession({ ...data, isPhoneNumberVerified: true });
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
'Verification failed';
setServerError(msg === 'unable_to_log_in' ? 'Invalid or expired code' : msg);
} finally {
setIsLoading(false);
}
};
const completeSession = async (data: LoginPayload) => {
dispatch(loginSuccess(data));
const me = await meTrigger({
url: "/auth/me",
@@ -162,6 +204,42 @@ export function LoginPage() {
</Alert>
)}
{mfaEmail ? (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconDeviceMobile size={18} />}>
This account requires a second factor. Enter the code we sent to
your registered phone.
</Alert>
<TextInput
label="Verification code"
placeholder="6-digit code"
size="md"
value={mfaOtp}
onChange={(e) => setMfaOtp(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && verifyMfa()}
/>
<Button
size="md"
loading={isLoading}
disabled={!mfaOtp.trim()}
onClick={verifyMfa}
rightSection={<IconArrowRight size={18} />}
>
Verify and sign in
</Button>
<Anchor
size="sm"
ta="center"
onClick={() => {
setMfaEmail(null);
setMfaOtp('');
setServerError(null);
}}
>
Back to sign in
</Anchor>
</Stack>
) : (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput

View File

@@ -0,0 +1,152 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Alert,
Button,
Card,
Center,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { IconInfoCircle, IconLockCheck } from '@tabler/icons-react';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
/** Mirrors the API's IsStrongPassword rule so failures are explained locally. */
function passwordProblem(password: string): string | null {
if (password.length < 8) return 'At least 8 characters';
if (!/[a-z]/.test(password)) return 'At least one lowercase letter';
if (!/[0-9]/.test(password)) return 'At least one number';
if (!/[^A-Za-z0-9]/.test(password)) return 'At least one symbol';
return null;
}
/**
* Completes the forgot-password flow (US-IAM-007).
*
* The reset message carries a link to this page with `userId` and `email` —
* the set-password endpoint requires both, and the account holder only knows
* one of them. Arriving without the link means the code alone cannot finish
* the reset, and the page says so instead of failing cryptically.
*/
export function SetPasswordPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const userId = params.get('userId') ?? '';
const email = params.get('email') ?? '';
// The IAM package's link carries the code as `verificationCode`.
const [code, setCode] = useState(
params.get('verificationCode') ?? params.get('code') ?? '',
);
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState<string | null>(null);
const [setPasswordTrigger, { isLoading }] = useApiMutation();
const linkMissing = !userId || !email;
const problem = password ? passwordProblem(password) : null;
const submit = async () => {
setError(null);
if (problem) return setError(`Password needs: ${problem.toLowerCase()}`);
if (password !== confirm) return setError("Passwords don't match");
try {
await setPasswordTrigger({
url: '/auth/set-password',
method: 'PATCH',
body: {
userId,
email,
verificationCode: code,
newPassword: password,
confirmPassword: confirm,
},
}).unwrap();
notify.success('Password updated — sign in with your new password');
navigate('/login');
} catch (err) {
const message = (err as { data?: { message?: string } })?.data?.message;
setError(
typeof message === 'string' && message === 'verification_code_expired'
? 'The code has expired. Request a new reset from the sign-in page.'
: 'Could not set the password. Check the code and try again.',
);
}
};
return (
<Center mih="100vh" p="md">
<Card withBorder radius="md" p="xl" w={420}>
<Stack>
<Stack gap={4} align="center">
<IconLockCheck size={32} color="var(--mantine-color-blue-6)" />
<Title order={3}>Set a new password</Title>
{email && (
<Text size="sm" c="dimmed">
for {email}
</Text>
)}
</Stack>
{linkMissing ? (
<Alert color="orange" icon={<IconInfoCircle size={16} />}>
Open this page from the reset link we sent you the link carries
the details needed to finish the reset. You can request one from
the{' '}
<Text
component="span"
c="blue"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/forgot-password')}
>
forgot-password page
</Text>
.
</Alert>
) : (
<>
<TextInput
label="Verification code"
placeholder="The code from the reset message"
required
value={code}
onChange={(e) => setCode(e.currentTarget.value)}
/>
<PasswordInput
label="New password"
required
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
error={password && problem ? problem : undefined}
/>
<PasswordInput
label="Confirm password"
required
value={confirm}
onChange={(e) => setConfirm(e.currentTarget.value)}
error={
confirm && confirm !== password
? "Passwords don't match"
: undefined
}
/>
{error && <Alert color="red">{error}</Alert>}
<Button
fullWidth
loading={isLoading}
disabled={!code.trim() || !password || !confirm}
onClick={submit}
>
Set password
</Button>
</>
)}
</Stack>
</Card>
</Center>
);
}

View File

@@ -27,6 +27,8 @@ export interface LoginPayload {
token: string;
refreshToken: string;
isPhoneNumberVerified: boolean;
/** Set (with no tokens) when the account requires an OTP second factor. */
mfaRequired?: boolean;
}
export interface CurrentProfileAddress {
@@ -74,6 +76,14 @@ export interface CurrentProfile {
pob: string;
maritalStatus: string;
isComplete: boolean;
/**
* Registered-seafarer identity — written by the platform when a seafarer
* registration is approved, null before that.
*/
seafarerNumber?: string | null;
seafarerStatus?: 'ACTIVE' | 'INACTIVE' | 'PENDING' | 'SUSPENDED' | null;
seafarerDepartment?: 'DECK' | 'ENGINE' | 'CATERING' | null;
seafarerStatusReason?: string | null;
user: AuthUser;
address: CurrentProfileAddress;
profession: CurrentProfileProfession;