Merge branch 'feature/authentication' into feature/scalfolding

This commit is contained in:
mengstabketemaw
2026-06-10 10:30:19 +03:00
40 changed files with 1559 additions and 1187 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -1,28 +0,0 @@
import { baseApi } from '@ema-platform/api';
import type { LoginPayload } from '../types/auth.types';
interface LoginArgs {
email: string;
password: string;
}
interface RefreshArgs {
refreshToken: string;
}
const authApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
login: builder.mutation<LoginPayload, LoginArgs>({
query: (body) => ({ url: '/auth/login', method: 'POST', body }),
}),
refresh: builder.mutation<{ token: string }, RefreshArgs>({
query: (body) => ({ url: '/auth/refresh', method: 'POST', body }),
}),
logout: builder.mutation<{ message: string }, void>({
query: () => ({ url: '/auth/logout', method: 'POST' }),
}),
}),
overrideExisting: false,
});
export const { useLoginMutation, useRefreshMutation, useLogoutMutation } = authApi;

View File

@@ -1,86 +0,0 @@
import {
TextInput,
PasswordInput,
Button,
Stack,
Title,
Paper,
Text,
Anchor,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useLoginMutation } from '../api/auth-api';
import { useAuth } from '../hooks/useAuth';
import { notify } from '@ema-platform/ui';
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
type FormValues = z.infer<typeof schema>;
export function LoginForm() {
const navigate = useNavigate();
const { login } = useAuth();
const [loginMutate, { isLoading }] = useLoginMutation();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = async (values: FormValues) => {
try {
const result = await loginMutate(values).unwrap();
login(result);
navigate('/dashboard');
} catch {
notify.error('Invalid email or password');
}
};
return (
<Paper p="xl" shadow="md" radius="md">
<Stack gap="lg">
<Stack gap={4}>
<Title order={2}>Sign in</Title>
<Text c="dimmed" size="sm">
Enter your credentials to access the backoffice
</Text>
</Stack>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Email"
placeholder="you@example.com"
error={errors.email?.message}
{...register('email')}
/>
<PasswordInput
label="Password"
placeholder="Your password"
error={errors.password?.message}
{...register('password')}
/>
<Button type="submit" loading={isLoading} fullWidth mt="sm">
Sign in
</Button>
</Stack>
</form>
<Text ta="center" size="sm" c="dimmed">
Don't have an account?{' '}
<Anchor component={Link} to="/signup">
Sign up
</Anchor>
</Text>
</Stack>
</Paper>
);
}

View File

@@ -1,17 +0,0 @@
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { loginSuccess, logout as logoutAction, hydrateAuth } from '../store/auth.slice';
import type { LoginPayload } from '../types/auth.types';
export function useAuth() {
const dispatch = useAppDispatch();
const { user, token, isAuthenticated } = useAppSelector((s) => s.auth);
return {
user,
token,
isAuthenticated,
login: (payload: LoginPayload) => dispatch(loginSuccess(payload)),
logout: () => dispatch(logoutAction()),
hydrate: () => dispatch(hydrateAuth()),
};
}

View File

@@ -1,5 +0,0 @@
import { LoginForm } from '../components/LoginForm';
export function LoginPage() {
return <LoginForm />;
}

View File

@@ -1,137 +0,0 @@
import {
Paper,
TextInput,
PasswordInput,
Button,
Stack,
Title,
Text,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import {
setPasswordStart,
setPasswordSuccess,
setPasswordFailure,
} from '../store/set-password.slice';
import { notify } from '@ema-platform/ui';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
const schema = z
.object({
userId: z.string().min(1, 'User ID is required'),
email: z.string().email('Enter a valid email'),
verificationCode: z.string().min(1, 'Verification code is required'),
newPassword: z.string().min(6, 'Password must be at least 6 characters'),
confirmPassword: z.string().min(6, 'Confirm your password'),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type FormValues = z.infer<typeof schema>;
export function SetPasswordPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const location = useLocation();
const { loading } = useAppSelector((s) => s.setPassword);
const signupEmail = (location.state as { email?: string } | null)?.email ?? '';
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { email: signupEmail },
});
const onSubmit = async (values: FormValues) => {
dispatch(setPasswordStart());
try {
const res = await fetch(`${BASE_API_URL}/auth/set-password`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: values.userId,
email: values.email,
verificationCode: values.verificationCode,
newPassword: values.newPassword,
confirmPassword: values.confirmPassword,
}),
});
if (!res.ok) {
const errorData = await res.json().catch(() => null);
throw new Error(errorData?.message ?? 'Failed to set password');
}
dispatch(setPasswordSuccess());
notify.success('Password set successfully');
navigate('/dashboard');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
dispatch(setPasswordFailure(msg));
notify.error(msg);
}
};
return (
<Paper p="xl" shadow="md" radius="md">
<Stack gap="lg">
<Stack gap={4}>
<Title order={2}>Set your password</Title>
<Text c="dimmed" size="sm">
A verification code has been sent to your email. Enter it below along
with your new password to complete registration.
</Text>
</Stack>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="User ID"
placeholder="Enter your user ID"
error={errors.userId?.message}
{...register('userId')}
/>
<TextInput
label="Email"
placeholder="you@example.com"
error={errors.email?.message}
{...register('email')}
/>
<TextInput
label="Verification Code"
placeholder="Enter the code from your email"
error={errors.verificationCode?.message}
{...register('verificationCode')}
/>
<PasswordInput
label="New Password"
placeholder="Enter a new password"
error={errors.newPassword?.message}
{...register('newPassword')}
/>
<PasswordInput
label="Confirm Password"
placeholder="Confirm your new password"
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
<Button type="submit" loading={loading} fullWidth mt="sm">
Set Password
</Button>
</Stack>
</form>
</Stack>
</Paper>
);
}

View File

@@ -1,153 +0,0 @@
import {
Paper,
TextInput,
Button,
Stack,
Title,
Text,
Anchor,
} from '@mantine/core';
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, useAppSelector } from '../../../store/hooks';
import { signupStart, signupSuccess, signupFailure } from '../store/signup.slice';
import { hydrateAuth } from '../store/auth.slice';
import { authStorage } from '../utils/auth-storage';
import { notify } from '@ema-platform/ui';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
const schema = z.object({
email: z.string().email('Enter a valid email'),
username: z.string().min(3, 'Username must be at least 3 characters'),
phoneNumber: z.string().min(1, 'Phone number is required'),
userType: z.string().min(1, 'User type is required'),
name: z.string().min(1, 'Name is required'),
});
type FormValues = z.infer<typeof schema>;
interface SignupPayload {
email: string;
username: string;
phoneNumber: string;
userType: string;
name: {
am: string;
en: string;
};
}
export function SignupPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { loading } = useAppSelector((s) => s.signup);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = async (values: FormValues) => {
dispatch(signupStart());
try {
const payload: SignupPayload = {
email: values.email,
username: values.username,
phoneNumber: values.phoneNumber,
userType: values.userType,
name: {
am: '',
en: values.name,
},
};
const res = await fetch(`${BASE_API_URL}/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const errorData = await res.json().catch(() => null);
throw new Error(errorData?.message ?? 'Signup failed');
}
const data = (await res.json()) as { token: string; refreshToken: string };
authStorage.setToken(data.token);
authStorage.setRefreshToken(data.refreshToken);
dispatch(hydrateAuth());
dispatch(signupSuccess());
navigate('/set-password', { state: { email: values.email } });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
dispatch(signupFailure(msg));
notify.error(msg);
}
};
return (
<Paper p="xl" shadow="md" radius="md">
<Stack gap="lg">
<Stack gap={4}>
<Title order={2}>Create an account</Title>
<Text c="dimmed" size="sm">
Fill in your details to get started
</Text>
</Stack>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Full Name"
placeholder="Your full name"
error={errors.name?.message}
{...register('name')}
/>
<TextInput
label="Email"
placeholder="you@example.com"
error={errors.email?.message}
{...register('email')}
/>
<TextInput
label="Username"
placeholder="Choose a username"
error={errors.username?.message}
{...register('username')}
/>
<TextInput
label="Phone Number"
placeholder="+251 911 234 567"
error={errors.phoneNumber?.message}
{...register('phoneNumber')}
/>
<TextInput
label="User Type"
placeholder="e.g. admin, manager"
error={errors.userType?.message}
{...register('userType')}
/>
<Button type="submit" loading={loading} fullWidth mt="sm">
Sign up
</Button>
</Stack>
</form>
<Text ta="center" size="sm" c="dimmed">
Already have an account?{' '}
<Anchor component={Link} to="/login">
Sign in
</Anchor>
</Text>
</Stack>
</Paper>
);
}

View File

@@ -1,42 +0,0 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthState, 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.user = action.payload.user;
state.token = action.payload.token;
state.isAuthenticated = true;
authStorage.setToken(action.payload.token);
authStorage.setRefreshToken(action.payload.refreshToken);
authStorage.setUser(action.payload.user);
},
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, logout, hydrateAuth } = authSlice.actions;
export const authReducer = authSlice.reducer;

View File

@@ -1,42 +0,0 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface SetPasswordState {
loading: boolean;
success: boolean;
error: string | null;
}
const initialState: SetPasswordState = {
loading: false,
success: false,
error: null,
};
const setPasswordSlice = createSlice({
name: 'setPassword',
initialState,
reducers: {
setPasswordStart(state) {
state.loading = true;
state.error = null;
state.success = false;
},
setPasswordSuccess(state) {
state.loading = false;
state.success = true;
},
setPasswordFailure(state, action: PayloadAction<string>) {
state.loading = false;
state.error = action.payload;
},
resetSetPassword(state) {
state.loading = false;
state.success = false;
state.error = null;
},
},
});
export const { setPasswordStart, setPasswordSuccess, setPasswordFailure, resetSetPassword } =
setPasswordSlice.actions;
export const setPasswordReducer = setPasswordSlice.reducer;

View File

@@ -1,41 +0,0 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface SignupState {
loading: boolean;
success: boolean;
error: string | null;
}
const initialState: SignupState = {
loading: false,
success: false,
error: null,
};
const signupSlice = createSlice({
name: 'signup',
initialState,
reducers: {
signupStart(state) {
state.loading = true;
state.error = null;
state.success = false;
},
signupSuccess(state) {
state.loading = false;
state.success = true;
},
signupFailure(state, action: PayloadAction<string>) {
state.loading = false;
state.error = action.payload;
},
resetSignup(state) {
state.loading = false;
state.success = false;
state.error = null;
},
},
});
export const { signupStart, signupSuccess, signupFailure, resetSignup } = signupSlice.actions;
export const signupReducer = signupSlice.reducer;

View File

@@ -1,19 +0,0 @@
export interface AuthUser {
id: string;
email: string;
username: string;
roles: string[];
permissions: string[];
}
export interface AuthState {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
}
export interface LoginPayload {
user: AuthUser;
token: string;
refreshToken: string;
}

View File

@@ -1,51 +0,0 @@
import type { AuthUser } from '../types/auth.types';
const KEYS = {
token: 'ema-backoffice-auth-token',
refreshToken: 'ema-backoffice-refresh-token',
user: 'ema-backoffice-auth-user',
} as const;
const COOKIE_ATTRS = 'path=/;max-age=86400';
function setCookie(name: string, value: string) {
document.cookie = `${name}=${encodeURIComponent(value)};${COOKIE_ATTRS}`;
}
function removeCookie(name: string) {
document.cookie = `${name}=;path=/;max-age=0`;
}
export const authStorage = {
getToken: () => localStorage.getItem(KEYS.token) ?? undefined,
setToken: (token: string) => {
localStorage.setItem(KEYS.token, token);
setCookie('auth-token', token);
},
removeToken: () => {
localStorage.removeItem(KEYS.token);
removeCookie('auth-token');
},
getRefreshToken: () => localStorage.getItem(KEYS.refreshToken) ?? undefined,
setRefreshToken: (token: string) => {
localStorage.setItem(KEYS.refreshToken, token);
setCookie('refresh-token', token);
},
getUser: (): AuthUser | null => {
const raw = localStorage.getItem(KEYS.user);
try {
return raw ? (JSON.parse(raw) as AuthUser) : null;
} catch {
return null;
}
},
setUser: (user: AuthUser) => localStorage.setItem(KEYS.user, JSON.stringify(user)),
clear: () => {
Object.values(KEYS).forEach((k) => localStorage.removeItem(k));
removeCookie('auth-token');
removeCookie('refresh-token');
},
};

View File

@@ -1,10 +1,11 @@
import type { ElementType } from 'react';
import { Grid, Paper, Text, Title, Stack, Group } from '@mantine/core';
import { IconBox, IconUsers, IconActivity } from '@tabler/icons-react';
interface StatCardProps {
label: string;
value: string;
icon: React.ElementType;
icon: ElementType;
color: string;
}

View File

@@ -29,3 +29,13 @@ export function IamProviders({ children }: IamProvidersProps) {
</AuthProvider>
);
}
export function AuthProviders({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<BrandingProvider>
{children}
</BrandingProvider>
</AuthProvider>
);
}

View File

@@ -1,12 +1,5 @@
import { Center, Box } from '@mantine/core';
import { Outlet } from 'react-router-dom';
export function AuthLayout() {
return (
<Center h="100vh" bg="gray.0">
<Box w={420}>
<Outlet />
</Box>
</Center>
);
return <Outlet />;
}

View File

@@ -1,14 +1,14 @@
import { Group, Text, ActionIcon, Burger } from '@mantine/core';
import { IconLogout } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../features/auth/hooks/useAuth';
import { useAuthUser } from '@tria-plc/iamui-common';
interface AppHeaderProps {
onToggle: () => void;
}
export function AppHeader({ onToggle }: AppHeaderProps) {
const { logout } = useAuth();
const { logout } = useAuthUser();
const navigate = useNavigate();
const handleLogout = () => {

View File

@@ -1,5 +1,7 @@
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
import {
Login,
SetPasswordPage,
UserManagementLayout,
UserManagementPage,
BulkUploadPage,
@@ -11,17 +13,18 @@ import {
import { ProtectedRoute } from './ProtectedRoute';
import { BackofficeLayout } from '../layouts/BackofficeLayout';
import { AuthLayout } from '../layouts/AuthLayout';
import { LoginPage } from '../features/auth/pages/LoginPage';
import { SignupPage } from '../features/auth/pages/SignupPage';
import { SetPasswordPage } from '../features/auth/pages/SetPasswordPage';
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
import { ItemPage } from '../features/item/pages/ItemPage';
import { AuditLogPageWrapper } from '../iam/pages/AuditLogPage';
import { IamProviders } from '../iam/IamProviders';
import { IamProviders, AuthProviders } from '../iam/IamProviders';
const router = createBrowserRouter([
{
element: <ProtectedRoute />,
element: (
<AuthProviders>
<ProtectedRoute />
</AuthProviders>
),
children: [
{
element: <BackofficeLayout />,
@@ -51,11 +54,14 @@ const router = createBrowserRouter([
],
},
{
element: <AuthLayout />,
element: (
<AuthProviders>
<AuthLayout />
</AuthProviders>
),
children: [
{ path: '/login', element: <LoginPage /> },
{ path: '/signup', element: <SignupPage /> },
{ path: '/set-password', element: <SetPasswordPage /> },
{ path: '/login', element: <Login /> },
{ path: '/otp-verify', element: <SetPasswordPage /> },
],
},
{ path: '/404', element: <div>Page not found</div> },

View File

@@ -1,14 +1,8 @@
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 { setPasswordReducer } from '../features/auth/store/set-password.slice';
export const store = configureStore({
reducer: {
auth: authReducer,
signup: signupReducer,
setPassword: setPasswordReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
middleware: (getDefaultMiddleware) =>

View File

@@ -7,6 +7,42 @@ import '@tria-plc/iamui-common/styles.css';
import './styles.css';
import { App } from './app/app';
window.__USER_MANAGEMENT_BRANDING__ = {
appName: 'Ethiopian Maritime Licence',
organizationName: 'Ethiopian Maritime Authority',
logoSrc: '/assets/emaLogo.jpg',
logoAlt: 'EMA Logo',
homePath: '/',
moduleBasePath: '/user-management',
backToAppPath: '/dashboard',
backToAppLabel: 'Back to dashboard',
cssVariables: {
'--primary': '#2563eb',
'--primary-foreground': 'oklch(0.99 0 0)',
'--secondary': '#1d4ed8',
'--secondary-foreground': 'oklch(0.99 0 0)',
'--accent': '#60a5fa',
'--accent-foreground': 'oklch(0.99 0 0)',
'--ring': '#2563eb',
'--sidebar': 'oklch(0.21 0.04 265)',
'--sidebar-foreground': 'oklch(0.96 0.01 255)',
'--sidebar-primary': '#3b82f6',
'--sidebar-primary-foreground': 'oklch(0.99 0 0)',
'--sidebar-accent': '#60a5fa',
'--sidebar-accent-foreground': 'oklch(0.96 0.01 255)',
'--sidebar-border': 'oklch(0.3 0.04 265)',
'--sidebar-ring': '#3b82f6',
'--brand-shell-bg': '#eff6ff',
'--brand-sidebar-bg': '#0f172a',
'--brand-sidebar-border': '#1e293b',
'--brand-sidebar-muted': '#94a3b8',
'--brand-primary-solid': '#2563eb',
'--brand-primary-hover': '#1d4ed8',
'--brand-hero-from': '#2563eb',
'--brand-hero-to': '#1e40af',
},
};
const root = document.getElementById('root');
if (!root) throw new Error('Root element not found');

View File

@@ -0,0 +1,50 @@
import { Component } from 'react';
import type { ReactNode, ErrorInfo } from 'react';
import { Center, Paper, Title, Text, Button } from '@mantine/core';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught:', error, info);
}
render() {
if (this.state.hasError) {
return (
<Center h="100vh">
<Paper p="xl" shadow="md" radius="md" w={400}>
<Title order={3} mb="sm">Something went wrong</Title>
<Text c="dimmed" size="sm" mb="lg">
{this.state.error?.message || 'An unexpected error occurred.'}
</Text>
<Button
fullWidth
onClick={() => {
this.setState({ hasError: false, error: null });
window.location.href = '/';
}}
>
Reload page
</Button>
</Paper>
</Center>
);
}
return this.props.children;
}
}

View File

@@ -1,17 +0,0 @@
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { loginSuccess, logout as logoutAction, hydrateAuth } from '../store/auth.slice';
import type { LoginPayload } from '../types/auth.types';
export function useAuth() {
const dispatch = useAppDispatch();
const { user, token, isAuthenticated } = useAppSelector((s) => s.auth);
return {
user,
token,
isAuthenticated,
login: (p: LoginPayload) => dispatch(loginSuccess(p)),
logout: () => dispatch(logoutAction()),
hydrate: () => dispatch(hydrateAuth()),
};
}

View File

@@ -14,24 +14,25 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
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';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(0, 'Password must be at least 6 characters'),
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 { login } = useAuth();
const dispatch = useAppDispatch();
const [isLoading, setIsLoading] = useState(false);
const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>();
const {
register,
@@ -44,15 +45,26 @@ export function LoginPage() {
const onSubmit = async (values: FormValues) => {
setIsLoading(true);
try {
const res = await fetch(`${BASE_API_URL}/auth/login`, {
const data = await loginTrigger({
url: '/auth/login',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (!res.ok) throw new Error('Login failed');
const data = (await res.json()) as Parameters<typeof login>[0];
login(data);
navigate('/dashboard');
body: values,
}).unwrap();
dispatch(loginSuccess(data));
const me = await meTrigger({
url: '/auth/me',
method: 'GET',
}).unwrap();
dispatch(setUser(me));
if (me.status === 'accepted') {
navigate('/dashboard');
} else {
navigate('/otp-verify', {
state: { email: me.email, phoneNumber: me.phoneNumber },
});
}
} catch {
notify.error('Invalid email or password');
} finally {

View File

@@ -0,0 +1,109 @@
import {
Paper,
TextInput,
Button,
Stack,
Title,
Center,
Text,
} from '@mantine/core';
import { 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';
const schema = z.object({
verificationCode: z.string().min(1, { message: 'Verification code is required' }),
});
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 {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
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 () => {
try {
await resendTrigger({
url: '/auth/resend-otp',
method: 'POST',
body: { email },
}).unwrap();
notify.success('Verification code resent to your email');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
}
};
return (
<Center h="100vh">
<Paper p="xl" shadow="md" radius="md" w={400}>
<Stack gap="md">
<Title order={3}>Verify your email</Title>
<Text c="dimmed" size="sm">
A verification code has been sent to {email || 'your email'}. Enter it
below to complete your registration.
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="sm">
<TextInput
label="Verification Code"
placeholder="Enter the code from your email"
error={errors.verificationCode?.message}
{...register('verificationCode')}
/>
<Button type="submit" loading={loading} fullWidth mt="sm">
Verify
</Button>
</Stack>
</form>
<Button
variant="subtle"
size="sm"
loading={resending}
onClick={handleResendOtp}
fullWidth
>
Send OTP again
</Button>
</Stack>
</Paper>
</Center>
);
}

View File

@@ -1,138 +0,0 @@
import {
Paper,
TextInput,
PasswordInput,
Button,
Stack,
Title,
Center,
Text,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import {
setPasswordStart,
setPasswordSuccess,
setPasswordFailure,
} from '../store/set-password.slice';
import { notify } from '@ema-platform/ui';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
const schema = z
.object({
userId: z.string().min(1, 'User ID is required'),
email: z.string().email('Enter a valid email'),
verificationCode: z.string().min(1, 'Verification code is required'),
newPassword: z.string().min(6, 'Password must be at least 6 characters'),
confirmPassword: z.string().min(6, 'Confirm your password'),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type FormValues = z.infer<typeof schema>;
export function SetPasswordPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const location = useLocation();
const { loading } = useAppSelector((s) => s.setPassword);
const signupEmail = (location.state as { email?: string } | null)?.email ?? '';
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { email: signupEmail },
});
const onSubmit = async (values: FormValues) => {
dispatch(setPasswordStart());
try {
const res = await fetch(`${BASE_API_URL}/auth/set-password`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: values.userId,
email: values.email,
verificationCode: values.verificationCode,
newPassword: values.newPassword,
confirmPassword: values.confirmPassword,
}),
});
if (!res.ok) {
const errorData = await res.json().catch(() => null);
throw new Error(errorData?.message ?? 'Failed to set password');
}
dispatch(setPasswordSuccess());
notify.success('Password set successfully');
navigate('/dashboard');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
dispatch(setPasswordFailure(msg));
notify.error(msg);
}
};
return (
<Center h="100vh">
<Paper p="xl" shadow="md" radius="md" w={400}>
<Stack gap="md">
<Title order={3}>Set your password</Title>
<Text c="dimmed" size="sm">
A verification code has been sent to your email. Enter it below along
with your new password to complete registration.
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="sm">
<TextInput
label="User ID"
placeholder="Enter your user ID"
error={errors.userId?.message}
{...register('userId')}
/>
<TextInput
label="Email"
placeholder="you@example.com"
error={errors.email?.message}
{...register('email')}
/>
<TextInput
label="Verification Code"
placeholder="Enter the code from your email"
error={errors.verificationCode?.message}
{...register('verificationCode')}
/>
<PasswordInput
label="New Password"
placeholder="Enter a new password"
error={errors.newPassword?.message}
{...register('newPassword')}
/>
<PasswordInput
label="Confirm Password"
placeholder="Confirm your new password"
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
<Button type="submit" loading={loading} fullWidth mt="sm">
Set Password
</Button>
</Stack>
</form>
</Stack>
</Paper>
</Center>
);
}

View File

@@ -1,6 +1,7 @@
import {
Paper,
TextInput,
PasswordInput,
Button,
Stack,
Title,
@@ -12,23 +13,26 @@ 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, useAppSelector } from '../../../store/hooks';
import { signupStart, signupSuccess, signupFailure } from '../store/signup.slice';
import { hydrateAuth } from '../store/auth.slice';
import { authStorage } from '../utils/auth-storage';
import { useAppDispatch } from '../../../store/hooks';
import { loginSuccess } from '../store/auth.slice';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
const schema = z.object({
email: z.string().email('Enter a valid email'),
username: z.string().min(3, 'Username must be at least 3 characters'),
phoneNumber: z.string().min(1, 'Phone number is required'),
userType: z.string().min(1, 'User type is required'),
name: z.string().min(1, 'Name is required'),
});
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>;
@@ -41,12 +45,18 @@ interface SignupPayload {
am: string;
en: string;
};
password: string;
confirmPassword: string;
}
export function SignupPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { loading } = useAppSelector((s) => s.signup);
const [signupTrigger, { isLoading: loading }] = useApiMutation<{
token: string;
refreshToken: string;
isPhoneNumberVerified: boolean;
}>();
const {
register,
@@ -54,44 +64,40 @@ export function SignupPage() {
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { userType: 'individual' },
});
const onSubmit = async (values: FormValues) => {
dispatch(signupStart());
try {
const payload: SignupPayload = {
email: values.email,
username: values.username,
phoneNumber: values.phoneNumber,
userType: values.userType,
name: {
am: '',
en: values.name,
},
name: { en: values.nameEn, am: values.nameAm ?? '' },
password: values.password,
confirmPassword: values.confirmPassword,
};
const res = await fetch(`${BASE_API_URL}/auth/signup`, {
const data = await signupTrigger({
url: '/auth/signup-with-pwd',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: payload,
}).unwrap();
dispatch(
loginSuccess({
token: data.token,
refreshToken: data.refreshToken,
isPhoneNumberVerified: data.isPhoneNumberVerified,
}),
);
navigate('/otp-verify', {
state: { email: values.email, phoneNumber: values.phoneNumber },
});
if (!res.ok) {
const errorData = await res.json().catch(() => null);
throw new Error(errorData?.message ?? 'Signup failed');
}
const data = (await res.json()) as { token: string; refreshToken: string };
authStorage.setToken(data.token);
authStorage.setRefreshToken(data.refreshToken);
dispatch(hydrateAuth());
dispatch(signupSuccess());
navigate('/set-password', { state: { email: values.email } });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
dispatch(signupFailure(msg));
notify.error(msg);
}
};
@@ -104,10 +110,16 @@ export function SignupPage() {
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="sm">
<TextInput
label="Full Name"
placeholder="Your full name"
error={errors.name?.message}
{...register('name')}
label="Name (English)"
placeholder="Your name in English"
error={errors.nameEn?.message}
{...register('nameEn')}
/>
<TextInput
label="Name (Amharic)"
placeholder="Your name in Amharic"
error={errors.nameAm?.message}
{...register('nameAm')}
/>
<TextInput
label="Email"
@@ -127,11 +139,17 @@ export function SignupPage() {
error={errors.phoneNumber?.message}
{...register('phoneNumber')}
/>
<TextInput
label="User Type"
placeholder="e.g. customer, admin"
error={errors.userType?.message}
{...register('userType')}
<PasswordInput
label="Password"
placeholder="Enter a password"
error={errors.password?.message}
{...register('password')}
/>
<PasswordInput
label="Confirm Password"
placeholder="Confirm your password"
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
<Button type="submit" loading={loading} fullWidth mt="sm">
Sign up

View File

@@ -1,5 +1,5 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthState, LoginPayload } from '../types/auth.types';
import type { AuthState, AuthUser, LoginPayload } from '../types/auth.types';
import { authStorage } from '../utils/auth-storage';
const initialState: AuthState = {
@@ -13,12 +13,14 @@ const authSlice = createSlice({
initialState,
reducers: {
loginSuccess(state, action: PayloadAction<LoginPayload>) {
state.user = action.payload.user;
state.token = action.payload.token;
state.isAuthenticated = true;
authStorage.setToken(action.payload.token);
authStorage.setRefreshToken(action.payload.refreshToken);
authStorage.setUser(action.payload.user);
},
setUser(state, action: PayloadAction<AuthUser>) {
state.user = action.payload;
authStorage.setUser(action.payload);
},
logout(state) {
state.user = null;
@@ -38,5 +40,5 @@ const authSlice = createSlice({
},
});
export const { loginSuccess, logout, hydrateAuth } = authSlice.actions;
export const { loginSuccess, setUser, logout, hydrateAuth } = authSlice.actions;
export const authReducer = authSlice.reducer;

View File

@@ -1,42 +0,0 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface SetPasswordState {
loading: boolean;
success: boolean;
error: string | null;
}
const initialState: SetPasswordState = {
loading: false,
success: false,
error: null,
};
const setPasswordSlice = createSlice({
name: 'setPassword',
initialState,
reducers: {
setPasswordStart(state) {
state.loading = true;
state.error = null;
state.success = false;
},
setPasswordSuccess(state) {
state.loading = false;
state.success = true;
},
setPasswordFailure(state, action: PayloadAction<string>) {
state.loading = false;
state.error = action.payload;
},
resetSetPassword(state) {
state.loading = false;
state.success = false;
state.error = null;
},
},
});
export const { setPasswordStart, setPasswordSuccess, setPasswordFailure, resetSetPassword } =
setPasswordSlice.actions;
export const setPasswordReducer = setPasswordSlice.reducer;

View File

@@ -1,41 +0,0 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface SignupState {
loading: boolean;
success: boolean;
error: string | null;
}
const initialState: SignupState = {
loading: false,
success: false,
error: null,
};
const signupSlice = createSlice({
name: 'signup',
initialState,
reducers: {
signupStart(state) {
state.loading = true;
state.error = null;
state.success = false;
},
signupSuccess(state) {
state.loading = false;
state.success = true;
},
signupFailure(state, action: PayloadAction<string>) {
state.loading = false;
state.error = action.payload;
},
resetSignup(state) {
state.loading = false;
state.success = false;
state.error = null;
},
},
});
export const { signupStart, signupSuccess, signupFailure, resetSignup } = signupSlice.actions;
export const signupReducer = signupSlice.reducer;

View File

@@ -2,8 +2,16 @@ export interface AuthUser {
id: string;
email: string;
username: string;
roles: string[];
permissions: string[];
phoneNumber: string;
name: {
am: string;
en: string;
};
status: string;
sharepointId: string | null;
hasSetPassword: boolean;
hasFinishedRegistration: boolean;
hasFinishedDMSOnboarding: boolean;
}
export interface AuthState {
@@ -13,7 +21,7 @@ export interface AuthState {
}
export interface LoginPayload {
user: AuthUser;
token: string;
refreshToken: string;
isPhoneNumberVerified: boolean;
}

View File

@@ -1,8 +1,10 @@
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@tria-plc/iamui-common';
import type { ReactNode } from 'react';
import { store } from '../store';
import { MantineThemeProvider } from './MantineThemeProvider';
import { ErrorBoundary } from '../components/ErrorBoundary';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 1000 * 60 * 5 } },
@@ -10,10 +12,14 @@ const queryClient = new QueryClient({
export function AppProviders({ children }: { children: ReactNode }) {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MantineThemeProvider>{children}</MantineThemeProvider>
</QueryClientProvider>
</Provider>
<ErrorBoundary>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<MantineThemeProvider>{children}</MantineThemeProvider>
</AuthProvider>
</QueryClientProvider>
</Provider>
</ErrorBoundary>
);
}

View File

@@ -4,6 +4,16 @@ import { authReducer } from '../features/auth/store/auth.slice';
import { signupReducer } from '../features/auth/store/signup.slice';
import { setPasswordReducer } from '../features/auth/store/set-password.slice';
import { licensesReducer } from '../features/licenses/store/licenses.slice';
import { authStorage } from '../features/auth/utils/auth-storage';
const preloadedAuth = (() => {
const token = authStorage.getToken();
const user = authStorage.getUser();
if (token && user) {
return { token, user, isAuthenticated: true };
}
return undefined;
})();
export const store = configureStore({
reducer: {
@@ -13,6 +23,7 @@ export const store = configureStore({
licenses: licensesReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(baseApi.middleware),
});