mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
152
libs/auth/src/lib/pages/SetPasswordPage.tsx
Normal file
152
libs/auth/src/lib/pages/SetPasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user