mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-27 15:30:57 +00:00
move login to common ui
This commit is contained in:
BIN
apps/backoffice/public/brand/ema-black.png
Normal file
BIN
apps/backoffice/public/brand/ema-black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
BIN
apps/backoffice/public/brand/ema-white.png
Normal file
BIN
apps/backoffice/public/brand/ema-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
BIN
apps/backoffice/public/ema-logo.png
Normal file
BIN
apps/backoffice/public/ema-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 390 KiB |
@@ -1,5 +1,6 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthConfigProvider } from '@ema-platform/auth';
|
||||
import type { ReactNode } from 'react';
|
||||
import { store } from '../store';
|
||||
import { MantineThemeProvider } from './MantineThemeProvider';
|
||||
@@ -14,7 +15,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||
<AuthConfigProvider
|
||||
value={{
|
||||
appName: 'Backoffice',
|
||||
storagePrefix: 'ema-backoffice',
|
||||
loginRedirectPath: '/dashboard',
|
||||
enableSignup: false,
|
||||
enableForgotPassword: true,
|
||||
}}
|
||||
>
|
||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||
</AuthConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
|
||||
import {
|
||||
Login,
|
||||
SetPasswordPage,
|
||||
} from '@tria-plc/iamui-common';
|
||||
LoginPage,
|
||||
ForgotPasswordPage,
|
||||
OTPVerificationPage,
|
||||
} from '@ema-platform/auth';
|
||||
import { AuthLayout } from '../layouts/AuthLayout';
|
||||
import { AuthProviders } from '../iam/IamProviders';
|
||||
import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
|
||||
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: (
|
||||
<AuthProviders>
|
||||
<AuthLayout />
|
||||
</AuthProviders>
|
||||
),
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{ path: '/login', element: <Login /> },
|
||||
{ path: '/otp-verify', element: <SetPasswordPage /> },
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
|
||||
{ path: '/otp-verify', element: <OTPVerificationPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '/um/*', element: <UserManagementHostPage /> },
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import { authReducer, signupReducer, configureAuthStorage, authStorage } from '@ema-platform/auth';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
|
||||
configureAuthStorage('ema-backoffice');
|
||||
|
||||
const preloadedAuth = (() => {
|
||||
const token = authStorage.getToken();
|
||||
const user = authStorage.getUser<AuthUser>();
|
||||
if (token && user) {
|
||||
return { token, user, isAuthenticated: true };
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
signup: signupReducer,
|
||||
[baseApi.reducerPath]: baseApi.reducer,
|
||||
},
|
||||
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(baseApi.middleware),
|
||||
});
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
Flex,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
rem,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import { Logo } from '../../../components/Logo';
|
||||
|
||||
const FEATURES = [
|
||||
'Submit applications online, 24/7',
|
||||
'Real-time status tracking & alerts',
|
||||
'Available in English & አማርኛ',
|
||||
];
|
||||
|
||||
/**
|
||||
* EMA brand mark — the app logo, kept in sync across the portal via `<Logo>`.
|
||||
* Use `variant="white"` on dark backgrounds (e.g. the auth brand panel).
|
||||
*/
|
||||
export function BrandMark({
|
||||
size = 44,
|
||||
variant = 'gradient',
|
||||
}: {
|
||||
size?: number;
|
||||
variant?: 'gradient' | 'white';
|
||||
}) {
|
||||
return <Logo variant={variant === 'white' ? 'white' : 'color'} size={size} />;
|
||||
}
|
||||
|
||||
interface AuthShellProps {
|
||||
children: ReactNode;
|
||||
brandTitle?: string;
|
||||
brandSubtitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-pane auth card: a centered card with a form panel (left) and a branded
|
||||
* gradient panel (right, hidden below `lg`). Inspired by the UM login layout.
|
||||
*/
|
||||
export function AuthShell({
|
||||
children,
|
||||
brandTitle = 'Maritime licensing, made simple.',
|
||||
brandSubtitle = 'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
|
||||
}: AuthShellProps) {
|
||||
const theme = useMantineTheme();
|
||||
const heroGradient = theme.other.heroGradient as string;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
mih="100vh"
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ background: '#f7f8fa' }}
|
||||
p="md"
|
||||
>
|
||||
<Box
|
||||
mx="auto"
|
||||
maw={1280}
|
||||
w="100%"
|
||||
bg="white"
|
||||
p={12}
|
||||
style={{
|
||||
borderRadius: rem(16),
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.15)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Flex direction={{ base: 'column', lg: 'row' }}>
|
||||
{/* ---- Form panel (left) ---------------------------------------- */}
|
||||
<Box
|
||||
w={{ base: '100%', lg: '50%' }}
|
||||
px="xl"
|
||||
py={32}
|
||||
style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{/* ---- Brand panel (right) -------------------------------------- */}
|
||||
<Box
|
||||
visibleFrom="lg"
|
||||
w="50%"
|
||||
p="lg"
|
||||
pos="relative"
|
||||
style={{ background: heroGradient, overflow: 'hidden' }}
|
||||
>
|
||||
{/* Decorative blurred orbs */}
|
||||
<Box
|
||||
pos="absolute"
|
||||
top={-80}
|
||||
right={-40}
|
||||
w={240}
|
||||
h={240}
|
||||
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.10)' }}
|
||||
/>
|
||||
<Box
|
||||
pos="absolute"
|
||||
bottom={-80}
|
||||
left={-60}
|
||||
w={220}
|
||||
h={220}
|
||||
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.08)' }}
|
||||
/>
|
||||
|
||||
<Stack
|
||||
gap="lg"
|
||||
pos="relative"
|
||||
style={{ zIndex: 1 }}
|
||||
h="100%"
|
||||
justify="center"
|
||||
>
|
||||
<Center>
|
||||
<Box
|
||||
component="img"
|
||||
src="/brand/ema-white.png"
|
||||
alt="EMA Portal"
|
||||
style={{ display: 'block', maxWidth: '60%', height: 'auto' }}
|
||||
/>
|
||||
</Center>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Title order={2} c="white" fz={rem(28)} lh={1.2} fw={700}>
|
||||
{brandTitle}
|
||||
</Title>
|
||||
<Text fz="sm" lh={1.6} style={{ color: 'rgba(255,255,255,0.85)' }}>
|
||||
{brandSubtitle}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
{FEATURES.map((feature) => (
|
||||
<Group key={feature} gap="sm" wrap="nowrap">
|
||||
<Center
|
||||
w={22}
|
||||
h={22}
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IconCheck size={12} color="white" stroke={2.4} />
|
||||
</Center>
|
||||
<Text c="white" fz="sm" fw={500}>
|
||||
{feature}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Text fz="xs" style={{ color: 'rgba(255,255,255,0.7)' }}>
|
||||
© 2026 Ethiopian Maritime Authority
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconMail,
|
||||
IconMailForward,
|
||||
IconSend,
|
||||
} from '@tabler/icons-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email({ message: 'Enter a valid email' }),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function ForgotPasswordPage() {
|
||||
const [forgotTrigger, { isLoading }] = useApiMutation();
|
||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const sendResetLink = async (email: string) => {
|
||||
await forgotTrigger({
|
||||
url: '/auth/forgot-password',
|
||||
method: 'POST',
|
||||
body: { email },
|
||||
}).unwrap();
|
||||
setSentTo(email);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
await sendResetLink(values.email);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Something went wrong';
|
||||
notify.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!sentTo || isLoading) return;
|
||||
try {
|
||||
await sendResetLink(sentTo);
|
||||
notify.success('Reset link sent again');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Something went wrong';
|
||||
notify.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const backToSignIn = (
|
||||
<Center>
|
||||
<Anchor
|
||||
component={Link}
|
||||
to="/login"
|
||||
size="sm"
|
||||
fw={600}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<IconArrowLeft size={16} />
|
||||
Back to sign in
|
||||
</Anchor>
|
||||
</Center>
|
||||
);
|
||||
|
||||
if (sentTo) {
|
||||
return (
|
||||
<AuthShell
|
||||
brandTitle="Reset your password securely."
|
||||
brandSubtitle="We'll email you a secure link to set a new password and get you back into your licensing portal."
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<ThemeIcon size={56} radius="md" variant="light" color="emaPrimary">
|
||||
<IconMailForward size={30} />
|
||||
</ThemeIcon>
|
||||
|
||||
<div>
|
||||
<Title order={2} fz={30}>
|
||||
Check your email
|
||||
</Title>
|
||||
<Text c="dimmed" mt={6}>
|
||||
We've sent a password reset link to{' '}
|
||||
<Text span fw={600} c="dark">
|
||||
{sentTo}
|
||||
</Text>
|
||||
. Follow the link in that email to choose a new password.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
component="a"
|
||||
href="https://mail.google.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
fullWidth
|
||||
size="md"
|
||||
leftSection={<IconMail size={18} />}
|
||||
>
|
||||
Open email app
|
||||
</Button>
|
||||
|
||||
<Group justify="center" gap={6}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Didn't get the email?
|
||||
</Text>
|
||||
<Anchor
|
||||
size="sm"
|
||||
fw={600}
|
||||
onClick={handleResend}
|
||||
style={isLoading ? { pointerEvents: 'none', opacity: 0.6 } : undefined}
|
||||
>
|
||||
Resend link
|
||||
</Anchor>
|
||||
</Group>
|
||||
|
||||
{backToSignIn}
|
||||
</Stack>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
brandTitle="Reset your password securely."
|
||||
brandSubtitle="We'll email you a secure link to set a new password and get you back into your licensing portal."
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2} fz={30}>
|
||||
Forgot your password?
|
||||
</Title>
|
||||
<Text c="dimmed" mt={6}>
|
||||
Enter the email linked to your account and we'll send you a link
|
||||
to reset your password.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email address"
|
||||
placeholder="you@example.com"
|
||||
size="md"
|
||||
leftSection={<IconMail size={18} />}
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
fullWidth
|
||||
size="md"
|
||||
rightSection={<IconSend size={18} />}
|
||||
>
|
||||
Send reset link
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
{backToSignIn}
|
||||
</Stack>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconDeviceMobile,
|
||||
IconLock,
|
||||
IconMail,
|
||||
} from '@tabler/icons-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAppDispatch } from '../../../store/hooks';
|
||||
import { loginSuccess, setUser } from '../store/auth.slice';
|
||||
import type { LoginPayload, AuthUser } from '../types/auth.types';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email({ message: 'Enter a valid email' }),
|
||||
password: z.string().min(5, { message: 'Password must be at least 6 characters' }),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [loginTrigger] = useApiMutation<LoginPayload>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await loginTrigger({
|
||||
url: '/auth/login',
|
||||
method: 'POST',
|
||||
body: values,
|
||||
}).unwrap();
|
||||
dispatch(loginSuccess(data));
|
||||
|
||||
const me = await meTrigger({
|
||||
url: '/auth/me',
|
||||
method: 'GET',
|
||||
}).unwrap();
|
||||
dispatch(setUser(me));
|
||||
|
||||
if (me.isPhoneNumberVerified) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
navigate('/otp-verify', {
|
||||
state: { email: me.email, phoneNumber: me.phoneNumber },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
notify.error('Invalid email or password');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2} fz={30}>
|
||||
Welcome back
|
||||
</Title>
|
||||
<Text c="dimmed" mt={6}>
|
||||
Sign in to access your maritime licensing dashboard.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email or phone"
|
||||
placeholder="you@example.com"
|
||||
size="md"
|
||||
leftSection={<IconMail size={18} />}
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
size="md"
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Checkbox
|
||||
label="Remember me"
|
||||
size="sm"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.currentTarget.checked)}
|
||||
/>
|
||||
<Anchor
|
||||
component={Link}
|
||||
to="/forgot-password"
|
||||
size="sm"
|
||||
fw={600}
|
||||
>
|
||||
Forgot password?
|
||||
</Anchor>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
fullWidth
|
||||
size="md"
|
||||
rightSection={<IconArrowRight size={18} />}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Divider label="or" labelPosition="center" />
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
fullWidth
|
||||
size="md"
|
||||
leftSection={<IconDeviceMobile size={18} />}
|
||||
onClick={() => notify.info('Phone sign-in is coming soon.')}
|
||||
>
|
||||
Sign in with phone number
|
||||
</Button>
|
||||
|
||||
<Text ta="center" size="sm" c="dimmed">
|
||||
Don't have an account?{' '}
|
||||
<Anchor component={Link} to="/signup" fw={700}>
|
||||
Create one
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Stack>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
PinInput,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconArrowRight, IconShieldLock } from '@tabler/icons-react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
|
||||
const CODE_LENGTH = 6;
|
||||
const RESEND_SECONDS = 30;
|
||||
|
||||
const schema = z.object({
|
||||
verificationCode: z
|
||||
.string()
|
||||
.length(CODE_LENGTH, { message: `Enter the ${CODE_LENGTH}-digit code` }),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function OTPVerificationPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const state = location.state as
|
||||
| { email?: string; phoneNumber?: string }
|
||||
| null;
|
||||
const email = state?.email ?? '';
|
||||
const phoneNumber = state?.phoneNumber ?? '';
|
||||
|
||||
const [verifyTrigger, { isLoading: loading }] = useApiMutation();
|
||||
const [resendTrigger, { isLoading: resending }] = useApiMutation();
|
||||
const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { verificationCode: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (secondsLeft <= 0) return;
|
||||
const id = setInterval(() => setSecondsLeft((s) => s - 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [secondsLeft]);
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
await verifyTrigger({
|
||||
url: '/auth/verify-phone-number',
|
||||
method: 'PATCH',
|
||||
body: { email, phoneNumber, verificationCode: values.verificationCode },
|
||||
}).unwrap();
|
||||
|
||||
notify.success('Phone number verified successfully');
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Something went wrong';
|
||||
notify.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendOtp = async () => {
|
||||
if (secondsLeft > 0 || resending) return;
|
||||
try {
|
||||
await resendTrigger({
|
||||
url: '/auth/resend-otp',
|
||||
method: 'POST',
|
||||
body: { email },
|
||||
}).unwrap();
|
||||
|
||||
notify.success('Verification code resent to your email');
|
||||
setSecondsLeft(RESEND_SECONDS);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Something went wrong';
|
||||
notify.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
brandTitle="One last step to secure your account."
|
||||
brandSubtitle="We use a one-time code to confirm it's really you before granting access to the licensing portal."
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<ThemeIcon size={56} radius="md" variant="light" color="emaPrimary">
|
||||
<IconShieldLock size={30} />
|
||||
</ThemeIcon>
|
||||
|
||||
<div>
|
||||
<Title order={2} fz={30}>
|
||||
Verify your account
|
||||
</Title>
|
||||
<Text c="dimmed" mt={6}>
|
||||
A verification code has been sent to{' '}
|
||||
<Text span fw={600} c="dark">
|
||||
{email || 'your email'}
|
||||
</Text>
|
||||
. Enter it below to continue.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Controller
|
||||
control={control}
|
||||
name="verificationCode"
|
||||
render={({ field }) => (
|
||||
<Stack gap={6} align="center">
|
||||
<PinInput
|
||||
length={CODE_LENGTH}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
oneTimeCode
|
||||
size="md"
|
||||
gap="sm"
|
||||
error={!!errors.verificationCode}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onComplete={() => handleSubmit(onSubmit)()}
|
||||
/>
|
||||
{errors.verificationCode?.message && (
|
||||
<Text c="red" size="sm">
|
||||
{errors.verificationCode.message}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
fullWidth
|
||||
size="md"
|
||||
rightSection={<IconArrowRight size={18} />}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Divider
|
||||
label="Having trouble?"
|
||||
labelPosition="center"
|
||||
variant="dashed"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
fullWidth
|
||||
size="md"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
>
|
||||
Skip verification for now
|
||||
</Button>
|
||||
|
||||
<Center>
|
||||
<Group justify="center" gap={6}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Didn't receive a code?
|
||||
</Text>
|
||||
{secondsLeft > 0 ? (
|
||||
<Text size="sm" c="dimmed" fw={600}>
|
||||
Resend in {secondsLeft}s
|
||||
</Text>
|
||||
) : (
|
||||
<Anchor
|
||||
size="sm"
|
||||
fw={600}
|
||||
onClick={handleResendOtp}
|
||||
style={
|
||||
resending ? { pointerEvents: 'none', opacity: 0.6 } : undefined
|
||||
}
|
||||
>
|
||||
Resend code
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
</Center>
|
||||
</Stack>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Anchor,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
PasswordInput,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconAt,
|
||||
IconDeviceMobile,
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAppDispatch } from '../../../store/hooks';
|
||||
import { loginSuccess } from '../store/auth.slice';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
email: z.string().email(),
|
||||
username: z.string().min(3, { message: 'Username must be at least 3 characters' }),
|
||||
phoneNumber: z.string().min(1, { message: 'Phone number is required' }),
|
||||
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' }),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
interface SignupPayload {
|
||||
email: string;
|
||||
username: string;
|
||||
phoneNumber: string;
|
||||
userType: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [signupTrigger, { isLoading: loading }] = useApiMutation<{
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
isPhoneNumberVerified: boolean;
|
||||
}>();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { userType: 'individual' },
|
||||
});
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const payload: SignupPayload = {
|
||||
email: values.email,
|
||||
username: values.username,
|
||||
phoneNumber: values.phoneNumber,
|
||||
userType: values.userType,
|
||||
name: { en: values.nameEn, am: values.nameAm ?? '' },
|
||||
password: values.password,
|
||||
confirmPassword: values.confirmPassword,
|
||||
};
|
||||
|
||||
const data = await signupTrigger({
|
||||
url: '/auth/signup-with-pwd',
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
}).unwrap();
|
||||
|
||||
dispatch(
|
||||
loginSuccess({
|
||||
token: data.token,
|
||||
refreshToken: data.refreshToken,
|
||||
isPhoneNumberVerified: data.isPhoneNumberVerified,
|
||||
}),
|
||||
);
|
||||
|
||||
if (data.isPhoneNumberVerified) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
navigate('/otp-verify', {
|
||||
state: { email: values.email, phoneNumber: values.phoneNumber },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Something went wrong';
|
||||
notify.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
brandTitle="Join Ethiopia's maritime community."
|
||||
brandSubtitle="Create your account to apply for licenses, manage documents, and track approvals from anywhere."
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2} fz={30}>
|
||||
Create account
|
||||
</Title>
|
||||
<Text c="dimmed" mt={6}>
|
||||
It only takes a minute to get started.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="Name (English)"
|
||||
placeholder="Abebe Bekele"
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameEn?.message}
|
||||
{...register('nameEn')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name (Amharic)"
|
||||
placeholder="ስም"
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameAm?.message}
|
||||
{...register('nameAm')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="Email address"
|
||||
placeholder="you@example.com"
|
||||
leftSection={<IconMail size={18} />}
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="Choose a username"
|
||||
leftSection={<IconAt size={18} />}
|
||||
error={errors.username?.message}
|
||||
{...register('username')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Phone number"
|
||||
placeholder="+251 911 234 567"
|
||||
leftSection={<IconDeviceMobile size={18} />}
|
||||
error={errors.phoneNumber?.message}
|
||||
{...register('phoneNumber')}
|
||||
/>
|
||||
|
||||
<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')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Confirm password"
|
||||
placeholder="Re-enter password"
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.currentTarget.checked)}
|
||||
label={
|
||||
<Text size="sm">
|
||||
I agree to the{' '}
|
||||
<Anchor
|
||||
size="sm"
|
||||
fw={600}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
Terms & Privacy Policy
|
||||
</Anchor>
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={!agreed}
|
||||
fullWidth
|
||||
size="md"
|
||||
rightSection={<IconArrowRight size={18} />}
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Text ta="center" size="sm" c="dimmed">
|
||||
Already have an account?{' '}
|
||||
<Anchor component={Link} to="/login" fw={700}>
|
||||
Sign in
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Stack>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { AuthState, AuthUser, LoginPayload } from '../types/auth.types';
|
||||
import { authStorage } from '../utils/auth-storage';
|
||||
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
};
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
loginSuccess(state, action: PayloadAction<LoginPayload>) {
|
||||
state.token = action.payload.token;
|
||||
state.isAuthenticated = true;
|
||||
authStorage.setToken(action.payload.token);
|
||||
authStorage.setRefreshToken(action.payload.refreshToken);
|
||||
},
|
||||
setUser(state, action: PayloadAction<AuthUser>) {
|
||||
state.user = action.payload;
|
||||
authStorage.setUser(action.payload);
|
||||
},
|
||||
logout(state) {
|
||||
state.user = null;
|
||||
state.token = null;
|
||||
state.isAuthenticated = false;
|
||||
authStorage.clear();
|
||||
},
|
||||
hydrateAuth(state) {
|
||||
const token = authStorage.getToken();
|
||||
const user = authStorage.getUser();
|
||||
if (token && user) {
|
||||
state.token = token;
|
||||
state.user = user;
|
||||
state.isAuthenticated = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { loginSuccess, setUser, logout, hydrateAuth } = authSlice.actions;
|
||||
export const authReducer = authSlice.reducer;
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
interface SignupState {
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
step: 'form' | 'otp' | 'complete';
|
||||
}
|
||||
|
||||
const initialState: SignupState = {
|
||||
email: '',
|
||||
phoneNumber: '',
|
||||
step: 'form',
|
||||
};
|
||||
|
||||
const signupSlice = createSlice({
|
||||
name: 'signup',
|
||||
initialState,
|
||||
reducers: {
|
||||
setSignupData(state, action: PayloadAction<{ email: string; phoneNumber: string }>) {
|
||||
state.email = action.payload.email;
|
||||
state.phoneNumber = action.payload.phoneNumber;
|
||||
},
|
||||
setSignupStep(state, action: PayloadAction<SignupState['step']>) {
|
||||
state.step = action.payload;
|
||||
},
|
||||
resetSignup() {
|
||||
return initialState;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setSignupData, setSignupStep, resetSignup } = signupSlice.actions;
|
||||
export const signupReducer = signupSlice.reducer;
|
||||
@@ -1,28 +0,0 @@
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
phoneNumber: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
status: string;
|
||||
sharepointId: string | null;
|
||||
hasSetPassword: boolean;
|
||||
hasFinishedRegistration: boolean;
|
||||
hasFinishedDMSOnboarding: boolean;
|
||||
isPhoneNumberVerified: boolean;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
isPhoneNumberVerified: boolean;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { AuthUser } from '../types/auth.types';
|
||||
|
||||
const KEYS = {
|
||||
token: 'ema-portal-auth-token',
|
||||
refreshToken: 'ema-portal-refresh-token',
|
||||
user: 'ema-portal-auth-user',
|
||||
} as const;
|
||||
|
||||
export const authStorage = {
|
||||
getToken: () => localStorage.getItem(KEYS.token) ?? undefined,
|
||||
setToken: (token: string) => localStorage.setItem(KEYS.token, token),
|
||||
getRefreshToken: () => localStorage.getItem(KEYS.refreshToken) ?? undefined,
|
||||
setRefreshToken: (t: string) => localStorage.setItem(KEYS.refreshToken, t),
|
||||
getUser: (): AuthUser | null => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEYS.user) ?? 'null') as AuthUser | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setUser: (u: AuthUser) => localStorage.setItem(KEYS.user, JSON.stringify(u)),
|
||||
clear: () => Object.values(KEYS).forEach((k) => localStorage.removeItem(k)),
|
||||
};
|
||||
@@ -45,7 +45,7 @@ import { useApiMutation } from '@ema-platform/api';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import { setUser } from '../../auth/store/auth.slice';
|
||||
import { setUser } from '@ema-platform/auth';
|
||||
import type { AuthUser } from '../../auth/types/auth.types';
|
||||
import classes from './ProfilePage.module.css';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from '@tria-plc/iamui-common';
|
||||
import { AuthConfigProvider } from '@ema-platform/auth';
|
||||
import type { ReactNode } from 'react';
|
||||
import { store } from '../store';
|
||||
import { MantineThemeProvider } from './MantineThemeProvider';
|
||||
@@ -16,7 +17,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||
<AuthConfigProvider
|
||||
value={{
|
||||
appName: 'Portal',
|
||||
storagePrefix: 'ema-portal',
|
||||
loginRedirectPath: '/dashboard',
|
||||
enableSignup: true,
|
||||
enableForgotPassword: true,
|
||||
}}
|
||||
>
|
||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||
</AuthConfigProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
|
||||
@@ -5,10 +5,7 @@ import { i18n } from './i18n/config';
|
||||
import { PortalLayout } from './layouts/PortalLayout';
|
||||
|
||||
// Auth (standalone pages, no portal chrome)
|
||||
import { LoginPage } from './features/auth/pages/LoginPage';
|
||||
import { SignupPage } from './features/auth/pages/SignupPage';
|
||||
import { OTPVerificationPage } from './features/auth/pages/OTPVerificationPage';
|
||||
import { ForgotPasswordPage } from './features/auth/pages/ForgotPasswordPage';
|
||||
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
|
||||
|
||||
// Portal feature pages
|
||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import { authReducer } from '../features/auth/store/auth.slice';
|
||||
import { signupReducer } from '../features/auth/store/signup.slice';
|
||||
import { authReducer, signupReducer, configureAuthStorage, authStorage } from '@ema-platform/auth';
|
||||
import { licensesReducer } from '../features/licenses/store/licenses.slice';
|
||||
import { authStorage } from '../features/auth/utils/auth-storage';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
|
||||
configureAuthStorage('ema-portal');
|
||||
|
||||
const preloadedAuth = (() => {
|
||||
const token = authStorage.getToken();
|
||||
const user = authStorage.getUser();
|
||||
const user = authStorage.getUser<AuthUser>();
|
||||
if (token && user) {
|
||||
return { token, user, isAuthenticated: true };
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { LanguageSwitcher } from '../components/LanguageSwitcher';
|
||||
import { ColorSchemeToggle } from '../components/ColorSchemeToggle';
|
||||
import { BrandMark } from '../features/auth/components/AuthShell';
|
||||
import { BrandMark } from '@ema-platform/auth';
|
||||
import { BrandAvatar } from './components/ui';
|
||||
|
||||
interface NavItem {
|
||||
|
||||
Reference in New Issue
Block a user