mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge pull request #1 from Tria-plc/feature/authentication
Feature/authentication
This commit is contained in:
BIN
apps/backoffice/public/assets/emaLogo.jpg
Normal file
BIN
apps/backoffice/public/assets/emaLogo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@@ -1,7 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { configureIam } from '@tria-plc/iamui-common';
|
||||
import { AppProviders } from './providers/AppProviders';
|
||||
import { AppRouter } from './router';
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3001/api';
|
||||
|
||||
export function App() {
|
||||
useEffect(() => {
|
||||
configureIam({ apiUrl: BASE_API_URL });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppProviders>
|
||||
<AppRouter />
|
||||
|
||||
@@ -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;
|
||||
@@ -1,79 +0,0 @@
|
||||
import {
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
Button,
|
||||
Stack,
|
||||
Title,
|
||||
Paper,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useNavigate } 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>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -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()),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { LoginForm } from '../components/LoginForm';
|
||||
|
||||
export function LoginPage() {
|
||||
return <LoginForm />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,28 +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;
|
||||
|
||||
export const authStorage = {
|
||||
getToken: () => localStorage.getItem(KEYS.token) ?? undefined,
|
||||
setToken: (token: string) => localStorage.setItem(KEYS.token, token),
|
||||
removeToken: () => localStorage.removeItem(KEYS.token),
|
||||
|
||||
getRefreshToken: () => localStorage.getItem(KEYS.refreshToken) ?? undefined,
|
||||
setRefreshToken: (token: string) => localStorage.setItem(KEYS.refreshToken, 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)),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
41
apps/backoffice/src/app/iam/IamProviders.tsx
Normal file
41
apps/backoffice/src/app/iam/IamProviders.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import {
|
||||
AuthProvider,
|
||||
PermissionProvider,
|
||||
BrandingProvider,
|
||||
BreadcrumbProvider,
|
||||
UnitProvider,
|
||||
UserProvider,
|
||||
} from '@tria-plc/iamui-common';
|
||||
|
||||
interface IamProvidersProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function IamProviders({ children }: IamProvidersProps) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrandingProvider>
|
||||
<BreadcrumbProvider>
|
||||
<PermissionProvider>
|
||||
<UnitProvider>
|
||||
<UserProvider>
|
||||
{children}
|
||||
</UserProvider>
|
||||
</UnitProvider>
|
||||
</PermissionProvider>
|
||||
</BreadcrumbProvider>
|
||||
</BrandingProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrandingProvider>
|
||||
{children}
|
||||
</BrandingProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
14
apps/backoffice/src/app/iam/pages/AuditLogPage.tsx
Normal file
14
apps/backoffice/src/app/iam/pages/AuditLogPage.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { AuditLogPage } from '@tria-plc/iamui-common';
|
||||
import { IamProviders } from '../IamProviders';
|
||||
|
||||
export function AuditLogPageWrapper() {
|
||||
return (
|
||||
<IamProviders>
|
||||
<AuditLogPage
|
||||
moduleKey="backoffice"
|
||||
title="Audit Log"
|
||||
subtitle="Track all activities and changes in the backoffice"
|
||||
/>
|
||||
</IamProviders>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { NavLink, Stack } from '@mantine/core';
|
||||
import { IconDashboard, IconBox } from '@tabler/icons-react';
|
||||
import {
|
||||
IconDashboard,
|
||||
IconBox,
|
||||
IconUsers,
|
||||
IconClipboardList,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
|
||||
{ label: 'Items', icon: IconBox, path: '/items' },
|
||||
{ label: 'User Management', icon: IconUsers, path: '/user-management' },
|
||||
{ label: 'Audit Log', icon: IconClipboardList, path: '/audit-log' },
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
|
||||
import {
|
||||
Login,
|
||||
SetPasswordPage,
|
||||
UserManagementLayout,
|
||||
UserManagementPage,
|
||||
BulkUploadPage,
|
||||
ArchivedUsersPage,
|
||||
PositionManagementPage,
|
||||
CreatePositionPage,
|
||||
EditPositionPage,
|
||||
} from '@tria-plc/iamui-common';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||
import { AuthLayout } from '../layouts/AuthLayout';
|
||||
import { LoginPage } from '../features/auth/pages/LoginPage';
|
||||
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
|
||||
import { ItemPage } from '../features/item/pages/ItemPage';
|
||||
import { AuditLogPageWrapper } from '../iam/pages/AuditLogPage';
|
||||
import { IamProviders, AuthProviders } from '../iam/IamProviders';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
element: (
|
||||
<AuthProviders>
|
||||
<ProtectedRoute />
|
||||
</AuthProviders>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
element: <BackofficeLayout />,
|
||||
@@ -16,13 +32,37 @@ const router = createBrowserRouter([
|
||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: '/dashboard', element: <DashboardPage /> },
|
||||
{ path: '/items', element: <ItemPage /> },
|
||||
{
|
||||
path: '/user-management',
|
||||
element: (
|
||||
<IamProviders>
|
||||
<UserManagementLayout />
|
||||
</IamProviders>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <UserManagementPage /> },
|
||||
{ path: 'bulk-upload', element: <BulkUploadPage /> },
|
||||
{ path: 'archived-users', element: <ArchivedUsersPage /> },
|
||||
{ path: 'position-management', element: <PositionManagementPage /> },
|
||||
{ path: 'position-management/new', element: <CreatePositionPage /> },
|
||||
{ path: 'position-management/edit/:id', element: <EditPositionPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '/audit-log', element: <AuditLogPageWrapper /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
element: <AuthLayout />,
|
||||
children: [{ path: '/login', element: <LoginPage /> }],
|
||||
element: (
|
||||
<AuthProviders>
|
||||
<AuthLayout />
|
||||
</AuthProviders>
|
||||
),
|
||||
children: [
|
||||
{ path: '/login', element: <Login /> },
|
||||
{ path: '/otp-verify', element: <SetPasswordPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '/404', element: <div>Page not found</div> },
|
||||
{ path: '*', element: <Navigate to="/404" replace /> },
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import { authReducer } from '../features/auth/store/auth.slice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
[baseApi.reducerPath]: baseApi.reducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
|
||||
@@ -3,9 +3,46 @@ import { createRoot } from 'react-dom/client';
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
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');
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
// import { configureIam } from "@tria-plc/iamui-common";
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
// import "@tria-plc/iamui-common/styles.css";
|
||||
import { AppProviders } from './providers/AppProviders';
|
||||
import { AppRouter } from './router';
|
||||
|
||||
// configureIam({ apiUrl: 'http://localhost:3001/api' });
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AppProviders>
|
||||
<BrowserRouter>
|
||||
<AppRouter />
|
||||
</BrowserRouter>
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
50
apps/portal/src/app/components/ErrorBoundary.tsx
Normal file
50
apps/portal/src/app/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
};
|
||||
}
|
||||
@@ -7,29 +7,32 @@ import {
|
||||
Stack,
|
||||
Title,
|
||||
Center,
|
||||
Text,
|
||||
Anchor,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
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';
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000';
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Enter a valid email'),
|
||||
password: z.string().min(6, '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,
|
||||
@@ -42,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);
|
||||
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 {
|
||||
@@ -82,6 +96,12 @@ export function LoginPage() {
|
||||
</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>
|
||||
</Center>
|
||||
|
||||
109
apps/portal/src/app/features/auth/pages/OTPVerificationPage.tsx
Normal file
109
apps/portal/src/app/features/auth/pages/OTPVerificationPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
apps/portal/src/app/features/auth/pages/SignupPage.tsx
Normal file
169
apps/portal/src/app/features/auth/pages/SignupPage.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
Paper,
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
Button,
|
||||
Stack,
|
||||
Title,
|
||||
Center,
|
||||
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 } from '../../../store/hooks';
|
||||
import { loginSuccess } from '../store/auth.slice';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
|
||||
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 [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,
|
||||
}),
|
||||
);
|
||||
|
||||
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 (
|
||||
<Center h="100vh">
|
||||
<Paper p="xl" shadow="md" radius="md" w={400}>
|
||||
<Stack gap="md">
|
||||
<Title order={3}>Create an account</Title>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
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"
|
||||
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')}
|
||||
/>
|
||||
<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
|
||||
</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>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { AppShell, Group, Text, Button } from '@mantine/core';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { logout } from '../features/auth/store/auth.slice';
|
||||
|
||||
export function PortalLayout() {
|
||||
const navigate = useNavigate();
|
||||
const isLoggedIn = !!localStorage.getItem('ema-portal-auth-token');
|
||||
const dispatch = useAppDispatch();
|
||||
const isAuthenticated = useAppSelector((s) => s.auth.isAuthenticated);
|
||||
|
||||
const handleLogout = () => {
|
||||
dispatch(logout());
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell header={{ height: 56 }} padding="md">
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between">
|
||||
<Text fw={700}>EMA Portal</Text>
|
||||
{isLoggedIn ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
localStorage.removeItem('ema-portal-auth-token');
|
||||
navigate('/login');
|
||||
}}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<Button variant="subtle" size="sm" onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
@@ -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 (
|
||||
<ErrorBoundary>
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,43 @@
|
||||
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { PortalLayout } from './layouts/PortalLayout';
|
||||
import { LoginPage } from './features/auth/pages/LoginPage';
|
||||
import { SignupPage } from './features/auth/pages/SignupPage';
|
||||
import { OTPVerificationPage } from './features/auth/pages/OTPVerificationPage';
|
||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||
|
||||
function getToken(): string | null {
|
||||
return localStorage.getItem('ema-portal-auth-token');
|
||||
}
|
||||
import { useAppSelector } from './store/hooks';
|
||||
|
||||
function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
const isAuthenticated = useAppSelector((s) => s.auth.isAuthenticated);
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{
|
||||
element: <PortalLayout />,
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||
{
|
||||
path: '/dashboard',
|
||||
element: (
|
||||
export function AppRouter() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route
|
||||
path="/otp-verify"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<OTPVerificationPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route element={<PortalLayout />}>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
|
||||
export function AppRouter() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import { authReducer } from '../features/auth/store/auth.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: {
|
||||
auth: authReducer,
|
||||
[baseApi.reducerPath]: baseApi.reducer,
|
||||
},
|
||||
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(baseApi.middleware),
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import './styles.css';
|
||||
|
||||
import { App } from './app/app';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
|
||||
@@ -3,7 +3,7 @@ import { resolveSessionContext } from '../session';
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000';
|
||||
'http://localhost:3001/api';
|
||||
|
||||
export const baseApi = createApi({
|
||||
reducerPath: 'baseApi',
|
||||
|
||||
@@ -37,8 +37,22 @@ export function useApiQuery<TData = unknown>(
|
||||
};
|
||||
}
|
||||
|
||||
export function useApiMutation<TData = unknown>() {
|
||||
return useApiMutationMutation() as ReturnType<typeof useApiMutationMutation> & {
|
||||
type UseApiMutationResult<TData> = {
|
||||
data: TData | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
isSuccess: boolean;
|
||||
status: 'uninitialized' | 'pending' | 'fulfilled' | 'rejected';
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export function useApiMutation<TData = unknown>(): [
|
||||
(args: ApiQueryArgs) => Promise<{ data: TData }> & { unwrap: () => Promise<TData> },
|
||||
UseApiMutationResult<TData>,
|
||||
] {
|
||||
return useApiMutationMutation() as unknown as [
|
||||
(args: ApiQueryArgs) => Promise<{ data: TData }> & { unwrap: () => Promise<TData> },
|
||||
UseApiMutationResult<TData>,
|
||||
];
|
||||
}
|
||||
|
||||
38
nx.json
38
nx.json
@@ -2,8 +2,14 @@
|
||||
"$schema": "./node_modules/nx/schemas/nx-schema.json",
|
||||
"defaultBase": "main",
|
||||
"namedInputs": {
|
||||
"default": ["{projectRoot}/**/*", "sharedGlobals"],
|
||||
"sharedGlobals": ["{workspaceRoot}/nx.json", "{workspaceRoot}/tsconfig.base.json"],
|
||||
"default": [
|
||||
"{projectRoot}/**/*",
|
||||
"sharedGlobals"
|
||||
],
|
||||
"sharedGlobals": [
|
||||
"{workspaceRoot}/nx.json",
|
||||
"{workspaceRoot}/tsconfig.base.json"
|
||||
],
|
||||
"production": [
|
||||
"default",
|
||||
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
|
||||
@@ -11,18 +17,34 @@
|
||||
]
|
||||
},
|
||||
"targetDefaults": {
|
||||
"build": { "dependsOn": ["^build"], "cache": true },
|
||||
"lint": { "cache": true },
|
||||
"test": { "cache": true }
|
||||
"build": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"cache": true
|
||||
},
|
||||
"lint": {
|
||||
"cache": true
|
||||
},
|
||||
"test": {
|
||||
"cache": true
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"plugin": "@nx/vite/plugin",
|
||||
"options": { "buildTargetName": "build", "serveTargetName": "serve", "testTargetName": "test" }
|
||||
"options": {
|
||||
"buildTargetName": "build",
|
||||
"serveTargetName": "serve",
|
||||
"testTargetName": "test"
|
||||
}
|
||||
},
|
||||
{
|
||||
"plugin": "@nx/eslint/plugin",
|
||||
"options": { "targetName": "lint" }
|
||||
"options": {
|
||||
"targetName": "lint"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"analytics": true
|
||||
}
|
||||
7075
package-lock.json
generated
7075
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,11 +23,11 @@
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"@tria-plc/iamui-common": "1.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"i18next": "^25.6.0",
|
||||
"mantine-react-table": "^2.0.0-beta.9",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
|
||||
Reference in New Issue
Block a user