move login to common ui

This commit is contained in:
mengstabketemaw
2026-06-13 11:21:32 +03:00
parent cf72572944
commit de6d5a01cb
29 changed files with 230 additions and 115 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

View File

@@ -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>
);

View File

@@ -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 /> },

View File

@@ -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),
});

View File

@@ -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>
);
}

View File

@@ -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&apos;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&apos;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&apos;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>
);
}

View File

@@ -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&apos;t have an account?{' '}
<Anchor component={Link} to="/signup" fw={700}>
Create one
</Anchor>
</Text>
</Stack>
</AuthShell>
);
}

View File

@@ -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&apos;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>
);
}

View File

@@ -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 &amp; 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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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)),
};

View File

@@ -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';

View File

@@ -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>

View File

@@ -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';

View File

@@ -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 };
}

View File

@@ -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 {