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 { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthConfigProvider } from '@ema-platform/auth';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { store } from '../store'; import { store } from '../store';
import { MantineThemeProvider } from './MantineThemeProvider'; import { MantineThemeProvider } from './MantineThemeProvider';
@@ -14,7 +15,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
return ( return (
<Provider store={store}> <Provider store={store}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthConfigProvider
value={{
appName: 'Backoffice',
storagePrefix: 'ema-backoffice',
loginRedirectPath: '/dashboard',
enableSignup: false,
enableForgotPassword: true,
}}
>
<MantineThemeProvider>{children}</MantineThemeProvider> <MantineThemeProvider>{children}</MantineThemeProvider>
</AuthConfigProvider>
</QueryClientProvider> </QueryClientProvider>
</Provider> </Provider>
); );

View File

@@ -1,23 +1,20 @@
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom'; import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
import { import {
Login, LoginPage,
SetPasswordPage, ForgotPasswordPage,
} from '@tria-plc/iamui-common'; OTPVerificationPage,
} from '@ema-platform/auth';
import { AuthLayout } from '../layouts/AuthLayout'; import { AuthLayout } from '../layouts/AuthLayout';
import { AuthProviders } from '../iam/IamProviders'; import { AuthProviders } from '../iam/IamProviders';
import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage'; import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
element: ( element: <AuthLayout />,
<AuthProviders>
<AuthLayout />
</AuthProviders>
),
children: [ children: [
{ path: '/login', element: <Login /> }, { path: '/login', element: <LoginPage /> },
{ path: '/otp-verify', element: <SetPasswordPage /> }, { path: '/forgot-password', element: <ForgotPasswordPage /> },
{ path: '/otp-verify', element: <OTPVerificationPage /> },
], ],
}, },
{ path: '/um/*', element: <UserManagementHostPage /> }, { path: '/um/*', element: <UserManagementHostPage /> },

View File

@@ -1,10 +1,26 @@
import { configureStore } from '@reduxjs/toolkit'; import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from '@ema-platform/api'; 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({ export const store = configureStore({
reducer: { reducer: {
auth: authReducer,
signup: signupReducer,
[baseApi.reducerPath]: baseApi.reducer, [baseApi.reducerPath]: baseApi.reducer,
}, },
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(baseApi.middleware), getDefaultMiddleware().concat(baseApi.middleware),
}); });

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 { PageHeader } from '../../../components/PageHeader';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config'; import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks'; 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 type { AuthUser } from '../../auth/types/auth.types';
import classes from './ProfilePage.module.css'; import classes from './ProfilePage.module.css';

View File

@@ -1,6 +1,7 @@
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@tria-plc/iamui-common'; import { AuthProvider } from '@tria-plc/iamui-common';
import { AuthConfigProvider } from '@ema-platform/auth';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { store } from '../store'; import { store } from '../store';
import { MantineThemeProvider } from './MantineThemeProvider'; import { MantineThemeProvider } from './MantineThemeProvider';
@@ -16,7 +17,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
<Provider store={store}> <Provider store={store}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider> <AuthProvider>
<AuthConfigProvider
value={{
appName: 'Portal',
storagePrefix: 'ema-portal',
loginRedirectPath: '/dashboard',
enableSignup: true,
enableForgotPassword: true,
}}
>
<MantineThemeProvider>{children}</MantineThemeProvider> <MantineThemeProvider>{children}</MantineThemeProvider>
</AuthConfigProvider>
</AuthProvider> </AuthProvider>
</QueryClientProvider> </QueryClientProvider>
</Provider> </Provider>

View File

@@ -5,10 +5,7 @@ import { i18n } from './i18n/config';
import { PortalLayout } from './layouts/PortalLayout'; import { PortalLayout } from './layouts/PortalLayout';
// Auth (standalone pages, no portal chrome) // Auth (standalone pages, no portal chrome)
import { LoginPage } from './features/auth/pages/LoginPage'; import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
import { SignupPage } from './features/auth/pages/SignupPage';
import { OTPVerificationPage } from './features/auth/pages/OTPVerificationPage';
import { ForgotPasswordPage } from './features/auth/pages/ForgotPasswordPage';
// Portal feature pages // Portal feature pages
import { DashboardPage } from './features/dashboard/pages/DashboardPage'; import { DashboardPage } from './features/dashboard/pages/DashboardPage';

View File

@@ -1,13 +1,14 @@
import { configureStore } from '@reduxjs/toolkit'; import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from '@ema-platform/api'; import { baseApi } from '@ema-platform/api';
import { authReducer } from '../features/auth/store/auth.slice'; import { authReducer, signupReducer, configureAuthStorage, authStorage } from '@ema-platform/auth';
import { signupReducer } from '../features/auth/store/signup.slice';
import { licensesReducer } from '../features/licenses/store/licenses.slice'; 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 preloadedAuth = (() => {
const token = authStorage.getToken(); const token = authStorage.getToken();
const user = authStorage.getUser(); const user = authStorage.getUser<AuthUser>();
if (token && user) { if (token && user) {
return { token, user, isAuthenticated: true }; 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 { notify } from '@ema-platform/ui';
import { LanguageSwitcher } from '../components/LanguageSwitcher'; import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { ColorSchemeToggle } from '../components/ColorSchemeToggle'; import { ColorSchemeToggle } from '../components/ColorSchemeToggle';
import { BrandMark } from '../features/auth/components/AuthShell'; import { BrandMark } from '@ema-platform/auth';
import { BrandAvatar } from './components/ui'; import { BrandAvatar } from './components/ui';
interface NavItem { interface NavItem {

7
libs/auth/project.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "@ema-platform/auth",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/auth/src",
"projectType": "library",
"tags": []
}

11
libs/auth/src/index.ts Normal file
View File

@@ -0,0 +1,11 @@
export { AuthConfigProvider, useAuthConfig } from './lib/AuthConfig';
export type { AuthConfigValue } from './lib/AuthConfig';
export { AuthShell, BrandMark } from './lib/components/AuthShell';
export { LoginPage } from './lib/pages/LoginPage';
export { SignupPage } from './lib/pages/SignupPage';
export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';
export { OTPVerificationPage } from './lib/pages/OTPVerificationPage';
export { authReducer, loginSuccess, setUser, logout, hydrateAuth } from './lib/store/auth.slice';
export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice';
export { configureAuthStorage, authStorage } from './lib/utils/auth-storage';
export type { AuthUser, AuthState, LoginPayload } from './lib/types/auth.types';

View File

@@ -0,0 +1,40 @@
import { createContext, useContext, type ReactNode } from 'react';
export interface AuthConfigValue {
appName: string;
storagePrefix: string;
loginRedirectPath: string;
enableSignup: boolean;
enableForgotPassword: boolean;
logoUrl: string;
}
const defaultConfig: AuthConfigValue = {
appName: 'Portal',
storagePrefix: 'ema-auth',
loginRedirectPath: '/dashboard',
enableSignup: true,
enableForgotPassword: true,
logoUrl: '/brand/ema-white.png',
};
const AuthConfigContext = createContext<AuthConfigValue>(defaultConfig);
export function AuthConfigProvider({
children,
value,
}: {
children: ReactNode;
value: Partial<AuthConfigValue>;
}) {
const merged = { ...defaultConfig, ...value };
return (
<AuthConfigContext.Provider value={merged}>
{children}
</AuthConfigContext.Provider>
);
}
export function useAuthConfig() {
return useContext(AuthConfigContext);
}

View File

@@ -9,9 +9,25 @@ import {
Title, Title,
rem, rem,
useMantineTheme, useMantineTheme,
type BoxProps,
} from '@mantine/core'; } from '@mantine/core';
import { IconCheck } from '@tabler/icons-react'; import { IconCheck } from '@tabler/icons-react';
import { Logo } from '../../../components/Logo'; import { useAuthConfig } from '../AuthConfig';
export function BrandMark({ size = 44, ...boxProps }: BoxProps & { size?: number }) {
const { logoUrl } = useAuthConfig();
return (
<Box
component="img"
src={logoUrl}
alt="EMA"
w={size}
h={size}
style={{ display: 'block', objectFit: 'contain', flexShrink: 0 }}
{...boxProps}
/>
);
}
const FEATURES = [ const FEATURES = [
'Submit applications online, 24/7', 'Submit applications online, 24/7',
@@ -19,36 +35,19 @@ const FEATURES = [
'Available in English & አማርኛ', '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 { interface AuthShellProps {
children: ReactNode; children: ReactNode;
brandTitle?: string; brandTitle?: string;
brandSubtitle?: 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({ export function AuthShell({
children, children,
brandTitle = 'Maritime licensing, made simple.', brandTitle = 'Maritime licensing, made simple.',
brandSubtitle = 'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.', brandSubtitle = 'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
}: AuthShellProps) { }: AuthShellProps) {
const theme = useMantineTheme(); const theme = useMantineTheme();
const { logoUrl } = useAuthConfig();
const heroGradient = theme.other.heroGradient as string; const heroGradient = theme.other.heroGradient as string;
return ( return (
@@ -72,17 +71,15 @@ export function AuthShell({
}} }}
> >
<Flex direction={{ base: 'column', lg: 'row' }}> <Flex direction={{ base: 'column', lg: 'row' }}>
{/* ---- Form panel (left) ---------------------------------------- */}
<Box <Box
w={{ base: '100%', lg: '50%' }} w={{ base: '100%', lg: '50%' }}
px="xl" px={48}
py={32} py={48}
style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }} style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}
> >
{children} {children}
</Box> </Box>
{/* ---- Brand panel (right) -------------------------------------- */}
<Box <Box
visibleFrom="lg" visibleFrom="lg"
w="50%" w="50%"
@@ -90,7 +87,6 @@ export function AuthShell({
pos="relative" pos="relative"
style={{ background: heroGradient, overflow: 'hidden' }} style={{ background: heroGradient, overflow: 'hidden' }}
> >
{/* Decorative blurred orbs */}
<Box <Box
pos="absolute" pos="absolute"
top={-80} top={-80}
@@ -118,7 +114,7 @@ export function AuthShell({
<Center> <Center>
<Box <Box
component="img" component="img"
src="/brand/ema-white.png" src={logoUrl}
alt="EMA Portal" alt="EMA Portal"
style={{ display: 'block', maxWidth: '60%', height: 'auto' }} style={{ display: 'block', maxWidth: '60%', height: 'auto' }}
/> />

View File

@@ -23,6 +23,7 @@ import { Link } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api'; import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell'; import { AuthShell } from '../components/AuthShell';
import { useAuthConfig } from '../AuthConfig';
const schema = z.object({ const schema = z.object({
email: z.string().email({ message: 'Enter a valid email' }), email: z.string().email({ message: 'Enter a valid email' }),
@@ -31,6 +32,7 @@ const schema = z.object({
type FormValues = z.infer<typeof schema>; type FormValues = z.infer<typeof schema>;
export function ForgotPasswordPage() { export function ForgotPasswordPage() {
const { appName } = useAuthConfig();
const [forgotTrigger, { isLoading }] = useApiMutation(); const [forgotTrigger, { isLoading }] = useApiMutation();
const [sentTo, setSentTo] = useState<string | null>(null); const [sentTo, setSentTo] = useState<string | null>(null);
@@ -90,7 +92,7 @@ export function ForgotPasswordPage() {
return ( return (
<AuthShell <AuthShell
brandTitle="Reset your password securely." 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." brandSubtitle={`We'll email you a secure link to set a new password and get you back into ${appName}.`}
> >
<Stack gap="lg"> <Stack gap="lg">
<ThemeIcon size={56} radius="md" variant="light" color="emaPrimary"> <ThemeIcon size={56} radius="md" variant="light" color="emaPrimary">
@@ -145,7 +147,7 @@ export function ForgotPasswordPage() {
return ( return (
<AuthShell <AuthShell
brandTitle="Reset your password securely." 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." brandSubtitle={`We'll email you a secure link to set a new password and get you back into ${appName}.`}
> >
<Stack gap="lg"> <Stack gap="lg">
<div> <div>

View File

@@ -21,12 +21,13 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { useAppDispatch } from '../../../store/hooks'; import { useDispatch } from 'react-redux';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { LoginPayload, AuthUser } from '../types/auth.types';
import { useApiMutation } from '@ema-platform/api'; import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell'; import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { LoginPayload, AuthUser } from '../types/auth.types';
import { useAuthConfig } from '../AuthConfig';
const schema = z.object({ const schema = z.object({
email: z.string().email({ message: 'Enter a valid email' }), email: z.string().email({ message: 'Enter a valid email' }),
@@ -37,7 +38,9 @@ type FormValues = z.infer<typeof schema>;
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useDispatch();
const { appName, loginRedirectPath, enableSignup, enableForgotPassword } =
useAuthConfig();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(true); const [rememberMe, setRememberMe] = useState(true);
const [loginTrigger] = useApiMutation<LoginPayload>(); const [loginTrigger] = useApiMutation<LoginPayload>();
@@ -68,7 +71,7 @@ export function LoginPage() {
dispatch(setUser(me)); dispatch(setUser(me));
if (me.isPhoneNumberVerified) { if (me.isPhoneNumberVerified) {
navigate('/dashboard'); navigate(loginRedirectPath);
} else { } else {
navigate('/otp-verify', { navigate('/otp-verify', {
state: { email: me.email, phoneNumber: me.phoneNumber }, state: { email: me.email, phoneNumber: me.phoneNumber },
@@ -86,10 +89,10 @@ export function LoginPage() {
<Stack gap="lg"> <Stack gap="lg">
<div> <div>
<Title order={2} fz={30}> <Title order={2} fz={30}>
Welcome back Welcome to {appName}
</Title> </Title>
<Text c="dimmed" mt={6}> <Text c="dimmed" mt={6}>
Sign in to access your maritime licensing dashboard. Sign in to access your account.
</Text> </Text>
</div> </div>
@@ -119,6 +122,7 @@ export function LoginPage() {
checked={rememberMe} checked={rememberMe}
onChange={(e) => setRememberMe(e.currentTarget.checked)} onChange={(e) => setRememberMe(e.currentTarget.checked)}
/> />
{enableForgotPassword && (
<Anchor <Anchor
component={Link} component={Link}
to="/forgot-password" to="/forgot-password"
@@ -127,6 +131,7 @@ export function LoginPage() {
> >
Forgot password? Forgot password?
</Anchor> </Anchor>
)}
</Group> </Group>
<Button <Button
@@ -153,12 +158,14 @@ export function LoginPage() {
Sign in with phone number Sign in with phone number
</Button> </Button>
{enableSignup && (
<Text ta="center" size="sm" c="dimmed"> <Text ta="center" size="sm" c="dimmed">
Don&apos;t have an account?{' '} Don&apos;t have an account?{' '}
<Anchor component={Link} to="/signup" fw={700}> <Anchor component={Link} to="/signup" fw={700}>
Create one Create one
</Anchor> </Anchor>
</Text> </Text>
)}
</Stack> </Stack>
</AuthShell> </AuthShell>
); );

View File

@@ -19,6 +19,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api'; import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell'; import { AuthShell } from '../components/AuthShell';
import { useAuthConfig } from '../AuthConfig';
const CODE_LENGTH = 6; const CODE_LENGTH = 6;
const RESEND_SECONDS = 30; const RESEND_SECONDS = 30;
@@ -34,6 +35,7 @@ type FormValues = z.infer<typeof schema>;
export function OTPVerificationPage() { export function OTPVerificationPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { loginRedirectPath } = useAuthConfig();
const state = location.state as const state = location.state as
| { email?: string; phoneNumber?: string } | { email?: string; phoneNumber?: string }
| null; | null;
@@ -68,7 +70,7 @@ export function OTPVerificationPage() {
}).unwrap(); }).unwrap();
notify.success('Phone number verified successfully'); notify.success('Phone number verified successfully');
navigate('/dashboard'); navigate(loginRedirectPath);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong'; const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg); notify.error(msg);
@@ -95,7 +97,7 @@ export function OTPVerificationPage() {
return ( return (
<AuthShell <AuthShell
brandTitle="One last step to secure your account." 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." brandSubtitle="We use a one-time code to confirm it's really you before granting access."
> >
<Stack gap="lg"> <Stack gap="lg">
<ThemeIcon size={56} radius="md" variant="light" color="emaPrimary"> <ThemeIcon size={56} radius="md" variant="light" color="emaPrimary">
@@ -164,7 +166,7 @@ export function OTPVerificationPage() {
variant="light" variant="light"
fullWidth fullWidth
size="md" size="md"
onClick={() => navigate('/dashboard')} onClick={() => navigate(loginRedirectPath)}
> >
Skip verification for now Skip verification for now
</Button> </Button>

View File

@@ -23,11 +23,12 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { useAppDispatch } from '../../../store/hooks'; import { useDispatch } from 'react-redux';
import { loginSuccess } from '../store/auth.slice';
import { useApiMutation } from '@ema-platform/api'; import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell'; import { AuthShell } from '../components/AuthShell';
import { loginSuccess } from '../store/auth.slice';
import { useAuthConfig } from '../AuthConfig';
const schema = z const schema = z
.object({ .object({
@@ -62,7 +63,8 @@ interface SignupPayload {
export function SignupPage() { export function SignupPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useAppDispatch(); const dispatch = useDispatch();
const { appName, loginRedirectPath } = useAuthConfig();
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(false);
const [signupTrigger, { isLoading: loading }] = useApiMutation<{ const [signupTrigger, { isLoading: loading }] = useApiMutation<{
token: string; token: string;
@@ -106,7 +108,7 @@ export function SignupPage() {
); );
if (data.isPhoneNumberVerified) { if (data.isPhoneNumberVerified) {
navigate('/dashboard'); navigate(loginRedirectPath);
} else { } else {
navigate('/otp-verify', { navigate('/otp-verify', {
state: { email: values.email, phoneNumber: values.phoneNumber }, state: { email: values.email, phoneNumber: values.phoneNumber },
@@ -120,8 +122,8 @@ export function SignupPage() {
return ( return (
<AuthShell <AuthShell
brandTitle="Join Ethiopia's maritime community." brandTitle={`Join ${appName}'s community.`}
brandSubtitle="Create your account to apply for licenses, manage documents, and track approvals from anywhere." brandSubtitle={`Create your account to access ${appName} features.`}
> >
<Stack gap="lg"> <Stack gap="lg">
<div> <div>

View File

@@ -30,7 +30,7 @@ const authSlice = createSlice({
}, },
hydrateAuth(state) { hydrateAuth(state) {
const token = authStorage.getToken(); const token = authStorage.getToken();
const user = authStorage.getUser(); const user = authStorage.getUser<AuthUser>();
if (token && user) { if (token && user) {
state.token = token; state.token = token;
state.user = user; state.user = user;

View File

@@ -0,0 +1,29 @@
let _prefix = 'ema-auth';
export function configureAuthStorage(prefix: string) {
_prefix = prefix;
}
function key(k: string) {
return `${_prefix}-${k}`;
}
export const authStorage = {
getToken: () => localStorage.getItem(key('auth-token')) ?? undefined,
setToken: (token: string) => localStorage.setItem(key('auth-token'), token),
getRefreshToken: () => localStorage.getItem(key('refresh-token')) ?? undefined,
setRefreshToken: (t: string) => localStorage.setItem(key('refresh-token'), t),
getUser: <T = unknown>(): T | null => {
try {
return JSON.parse(localStorage.getItem(key('auth-user')) ?? 'null') as T | null;
} catch {
return null;
}
},
setUser: <T>(u: T) => localStorage.setItem(key('auth-user'), JSON.stringify(u)),
clear: () => {
[key('auth-token'), key('refresh-token'), key('auth-user')].forEach((k) =>
localStorage.removeItem(k),
);
},
};

5
libs/auth/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "../../dist/out-tsc" },
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

View File

@@ -31,4 +31,7 @@ export const emaTheme = createTheme({
md: '0 4px 20px rgba(15,23,42,0.08)', md: '0 4px 20px rgba(15,23,42,0.08)',
lg: '0 8px 30px rgba(15,23,42,0.12)', lg: '0 8px 30px rgba(15,23,42,0.12)',
}, },
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
},
}); });

View File

@@ -19,7 +19,8 @@
"paths": { "paths": {
"@ema-platform/shared": ["libs/shared/src/index.ts"], "@ema-platform/shared": ["libs/shared/src/index.ts"],
"@ema-platform/ui": ["libs/ui/src/index.ts"], "@ema-platform/ui": ["libs/ui/src/index.ts"],
"@ema-platform/api": ["libs/api/src/index.ts"] "@ema-platform/api": ["libs/api/src/index.ts"],
"@ema-platform/auth": ["libs/auth/src/index.ts"]
} }
}, },
"exclude": ["node_modules", "tmp"] "exclude": ["node_modules", "tmp"]

View File

@@ -39,7 +39,7 @@ export const FHC_COLORS = {
"#96BDEB", "#96BDEB",
"#6FA4E0", "#6FA4E0",
"#4A90E2", "#4A90E2",
"#357ABD", "#4b7fe5",
"#2C669D", "#2C669D",
"#224F7A", "#224F7A",
"#173654", "#173654",
@@ -120,7 +120,7 @@ export const FHC_LAYOUT = {
brick: "#1A3A5C", brick: "#1A3A5C",
brickDark: "#0F2440", brickDark: "#0F2440",
blue: "#4A90E2", blue: "#4A90E2",
blueDark: "#357ABD", blueDark: "#4b7fe5",
gold: "#4A90E2", gold: "#4A90E2",
text: "#1F2937", text: "#1F2937",
}, },
@@ -216,7 +216,7 @@ export const fhcMantineTheme: MantineThemeOverride = {
* color while still getting the fhc* palettes + layout tokens via `mantineTheme`. * color while still getting the fhc* palettes + layout tokens via `mantineTheme`.
*/ */
export const fhcDesignPreset = { export const fhcDesignPreset = {
colors: { primary: "#357ABD" }, colors: { primary: "#4b7fe5" },
typography: { fontFamily: "Plus Jakarta Sans, sans-serif" }, typography: { fontFamily: "Plus Jakarta Sans, sans-serif" },
shape: { radius: "10px" }, shape: { radius: "10px" },
mantineTheme: fhcMantineTheme, mantineTheme: fhcMantineTheme,

View File

@@ -40,7 +40,7 @@ export const projectTheme: DesignConfig = {
// still have different colors. // still have different colors.
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
colors: { colors: {
primary: "#357ABD", // FHC blue (fhcBlue-6) — buttons, links, active states primary: "#4b7fe5", // FHC blue (fhcBlue-6) — buttons, links, active states
// // "#5D2E1F" brick (FHC chrome) is used by the sidebar/modal skin below // // "#5D2E1F" brick (FHC chrome) is used by the sidebar/modal skin below
// // "#2563eb" blue | "#7c3aed" purple // // "#2563eb" blue | "#7c3aed" purple
// // "#16a34a" green | "#dc2626" red // // "#16a34a" green | "#dc2626" red
@@ -174,18 +174,18 @@ export const projectTheme: DesignConfig = {
// ── Top tab bar skin (legacy view) ───────────────────────────────────── // ── Top tab bar skin (legacy view) ─────────────────────────────────────
menuBackground: "#ffffff", // TODO: tab bar background menuBackground: "#ffffff", // TODO: tab bar background
menuColor: "#334155", // inactive tab text menuColor: "#334155", // inactive tab text
menuActiveColor: "#357ABD", // active tab text (FHC blue) menuActiveColor: "#4b7fe5", // active tab text (FHC blue)
menuActiveBorderColor: "#357ABD", // active tab underline menuActiveBorderColor: "#4b7fe5", // active tab underline
menuHoverColor: "#357ABD", // tab hover text menuHoverColor: "#4b7fe5", // tab hover text
// ── Create / edit modal skin (shared BackofficeModal) ────────────────── // ── Create / edit modal skin (shared BackofficeModal) ──────────────────
modalAccentColor: "#357ABD", // blue top strip modalAccentColor: "#4b7fe5", // blue top strip
modalHeaderBackground: "#EEF4FC", // header bg (view) modalHeaderBackground: "#EEF4FC", // header bg (view)
modalHeaderEditBackground: "#D9E8FA", // header bg (edit) modalHeaderEditBackground: "#D9E8FA", // header bg (edit)
modalIconBackground: "#D9E8FA", // header icon chip bg modalIconBackground: "#D9E8FA", // header icon chip bg
modalIconColor: "#357ABD", // header icon chip color modalIconColor: "#4b7fe5", // header icon chip color
modalTitleColor: "#1F2937", // modal title text modalTitleColor: "#1F2937", // modal title text
modalFocusColor: "#357ABD", // input focus ring inside modals modalFocusColor: "#4b7fe5", // input focus ring inside modals
modalSurface: "#ffffff", // modal body surface modalSurface: "#ffffff", // modal body surface
}, },