mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
move login to common ui
This commit is contained in:
BIN
apps/backoffice/public/brand/ema-black.png
Normal file
BIN
apps/backoffice/public/brand/ema-black.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
BIN
apps/backoffice/public/brand/ema-white.png
Normal file
BIN
apps/backoffice/public/brand/ema-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
BIN
apps/backoffice/public/ema-logo.png
Normal file
BIN
apps/backoffice/public/ema-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 390 KiB |
@@ -1,5 +1,6 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthConfigProvider } from '@ema-platform/auth';
|
||||
import type { ReactNode } from 'react';
|
||||
import { store } from '../store';
|
||||
import { MantineThemeProvider } from './MantineThemeProvider';
|
||||
@@ -14,7 +15,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||
<AuthConfigProvider
|
||||
value={{
|
||||
appName: 'Backoffice',
|
||||
storagePrefix: 'ema-backoffice',
|
||||
loginRedirectPath: '/dashboard',
|
||||
enableSignup: false,
|
||||
enableForgotPassword: true,
|
||||
}}
|
||||
>
|
||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||
</AuthConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
|
||||
import {
|
||||
Login,
|
||||
SetPasswordPage,
|
||||
} from '@tria-plc/iamui-common';
|
||||
LoginPage,
|
||||
ForgotPasswordPage,
|
||||
OTPVerificationPage,
|
||||
} from '@ema-platform/auth';
|
||||
import { AuthLayout } from '../layouts/AuthLayout';
|
||||
import { AuthProviders } from '../iam/IamProviders';
|
||||
import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
|
||||
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: (
|
||||
<AuthProviders>
|
||||
<AuthLayout />
|
||||
</AuthProviders>
|
||||
),
|
||||
element: <AuthLayout />,
|
||||
children: [
|
||||
{ path: '/login', element: <Login /> },
|
||||
{ path: '/otp-verify', element: <SetPasswordPage /> },
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
|
||||
{ path: '/otp-verify', element: <OTPVerificationPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '/um/*', element: <UserManagementHostPage /> },
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import { authReducer, signupReducer, configureAuthStorage, authStorage } from '@ema-platform/auth';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
|
||||
configureAuthStorage('ema-backoffice');
|
||||
|
||||
const preloadedAuth = (() => {
|
||||
const token = authStorage.getToken();
|
||||
const user = authStorage.getUser<AuthUser>();
|
||||
if (token && user) {
|
||||
return { token, user, isAuthenticated: true };
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
signup: signupReducer,
|
||||
[baseApi.reducerPath]: baseApi.reducer,
|
||||
},
|
||||
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().concat(baseApi.middleware),
|
||||
});
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { AuthUser } from '../types/auth.types';
|
||||
|
||||
const KEYS = {
|
||||
token: 'ema-portal-auth-token',
|
||||
refreshToken: 'ema-portal-refresh-token',
|
||||
user: 'ema-portal-auth-user',
|
||||
} as const;
|
||||
|
||||
export const authStorage = {
|
||||
getToken: () => localStorage.getItem(KEYS.token) ?? undefined,
|
||||
setToken: (token: string) => localStorage.setItem(KEYS.token, token),
|
||||
getRefreshToken: () => localStorage.getItem(KEYS.refreshToken) ?? undefined,
|
||||
setRefreshToken: (t: string) => localStorage.setItem(KEYS.refreshToken, t),
|
||||
getUser: (): AuthUser | null => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEYS.user) ?? 'null') as AuthUser | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setUser: (u: AuthUser) => localStorage.setItem(KEYS.user, JSON.stringify(u)),
|
||||
clear: () => Object.values(KEYS).forEach((k) => localStorage.removeItem(k)),
|
||||
};
|
||||
@@ -45,7 +45,7 @@ import { useApiMutation } from '@ema-platform/api';
|
||||
import { PageHeader } from '../../../components/PageHeader';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import { setUser } from '../../auth/store/auth.slice';
|
||||
import { setUser } from '@ema-platform/auth';
|
||||
import type { AuthUser } from '../../auth/types/auth.types';
|
||||
import classes from './ProfilePage.module.css';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider } from '@tria-plc/iamui-common';
|
||||
import { AuthConfigProvider } from '@ema-platform/auth';
|
||||
import type { ReactNode } from 'react';
|
||||
import { store } from '../store';
|
||||
import { MantineThemeProvider } from './MantineThemeProvider';
|
||||
@@ -16,7 +17,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||
<AuthConfigProvider
|
||||
value={{
|
||||
appName: 'Portal',
|
||||
storagePrefix: 'ema-portal',
|
||||
loginRedirectPath: '/dashboard',
|
||||
enableSignup: true,
|
||||
enableForgotPassword: true,
|
||||
}}
|
||||
>
|
||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||
</AuthConfigProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
|
||||
@@ -5,10 +5,7 @@ import { i18n } from './i18n/config';
|
||||
import { PortalLayout } from './layouts/PortalLayout';
|
||||
|
||||
// Auth (standalone pages, no portal chrome)
|
||||
import { LoginPage } from './features/auth/pages/LoginPage';
|
||||
import { SignupPage } from './features/auth/pages/SignupPage';
|
||||
import { OTPVerificationPage } from './features/auth/pages/OTPVerificationPage';
|
||||
import { ForgotPasswordPage } from './features/auth/pages/ForgotPasswordPage';
|
||||
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
|
||||
|
||||
// Portal feature pages
|
||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import { authReducer } from '../features/auth/store/auth.slice';
|
||||
import { signupReducer } from '../features/auth/store/signup.slice';
|
||||
import { authReducer, signupReducer, configureAuthStorage, authStorage } from '@ema-platform/auth';
|
||||
import { licensesReducer } from '../features/licenses/store/licenses.slice';
|
||||
import { authStorage } from '../features/auth/utils/auth-storage';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
|
||||
configureAuthStorage('ema-portal');
|
||||
|
||||
const preloadedAuth = (() => {
|
||||
const token = authStorage.getToken();
|
||||
const user = authStorage.getUser();
|
||||
const user = authStorage.getUser<AuthUser>();
|
||||
if (token && user) {
|
||||
return { token, user, isAuthenticated: true };
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { LanguageSwitcher } from '../components/LanguageSwitcher';
|
||||
import { ColorSchemeToggle } from '../components/ColorSchemeToggle';
|
||||
import { BrandMark } from '../features/auth/components/AuthShell';
|
||||
import { BrandMark } from '@ema-platform/auth';
|
||||
import { BrandAvatar } from './components/ui';
|
||||
|
||||
interface NavItem {
|
||||
|
||||
7
libs/auth/project.json
Normal file
7
libs/auth/project.json
Normal 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
11
libs/auth/src/index.ts
Normal 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';
|
||||
40
libs/auth/src/lib/AuthConfig.tsx
Normal file
40
libs/auth/src/lib/AuthConfig.tsx
Normal 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);
|
||||
}
|
||||
@@ -9,9 +9,25 @@ import {
|
||||
Title,
|
||||
rem,
|
||||
useMantineTheme,
|
||||
type BoxProps,
|
||||
} from '@mantine/core';
|
||||
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 = [
|
||||
'Submit applications online, 24/7',
|
||||
@@ -19,36 +35,19 @@ const FEATURES = [
|
||||
'Available in English & አማርኛ',
|
||||
];
|
||||
|
||||
/**
|
||||
* EMA brand mark — the app logo, kept in sync across the portal via `<Logo>`.
|
||||
* Use `variant="white"` on dark backgrounds (e.g. the auth brand panel).
|
||||
*/
|
||||
export function BrandMark({
|
||||
size = 44,
|
||||
variant = 'gradient',
|
||||
}: {
|
||||
size?: number;
|
||||
variant?: 'gradient' | 'white';
|
||||
}) {
|
||||
return <Logo variant={variant === 'white' ? 'white' : 'color'} size={size} />;
|
||||
}
|
||||
|
||||
interface AuthShellProps {
|
||||
children: ReactNode;
|
||||
brandTitle?: string;
|
||||
brandSubtitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-pane auth card: a centered card with a form panel (left) and a branded
|
||||
* gradient panel (right, hidden below `lg`). Inspired by the UM login layout.
|
||||
*/
|
||||
export function AuthShell({
|
||||
children,
|
||||
brandTitle = 'Maritime licensing, made simple.',
|
||||
brandSubtitle = 'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
|
||||
}: AuthShellProps) {
|
||||
const theme = useMantineTheme();
|
||||
const { logoUrl } = useAuthConfig();
|
||||
const heroGradient = theme.other.heroGradient as string;
|
||||
|
||||
return (
|
||||
@@ -72,17 +71,15 @@ export function AuthShell({
|
||||
}}
|
||||
>
|
||||
<Flex direction={{ base: 'column', lg: 'row' }}>
|
||||
{/* ---- Form panel (left) ---------------------------------------- */}
|
||||
<Box
|
||||
w={{ base: '100%', lg: '50%' }}
|
||||
px="xl"
|
||||
py={32}
|
||||
px={48}
|
||||
py={48}
|
||||
style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{/* ---- Brand panel (right) -------------------------------------- */}
|
||||
<Box
|
||||
visibleFrom="lg"
|
||||
w="50%"
|
||||
@@ -90,7 +87,6 @@ export function AuthShell({
|
||||
pos="relative"
|
||||
style={{ background: heroGradient, overflow: 'hidden' }}
|
||||
>
|
||||
{/* Decorative blurred orbs */}
|
||||
<Box
|
||||
pos="absolute"
|
||||
top={-80}
|
||||
@@ -118,7 +114,7 @@ export function AuthShell({
|
||||
<Center>
|
||||
<Box
|
||||
component="img"
|
||||
src="/brand/ema-white.png"
|
||||
src={logoUrl}
|
||||
alt="EMA Portal"
|
||||
style={{ display: 'block', maxWidth: '60%', height: 'auto' }}
|
||||
/>
|
||||
@@ -23,6 +23,7 @@ import { Link } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
import { useAuthConfig } from '../AuthConfig';
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email({ message: 'Enter a valid email' }),
|
||||
@@ -31,6 +32,7 @@ const schema = z.object({
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function ForgotPasswordPage() {
|
||||
const { appName } = useAuthConfig();
|
||||
const [forgotTrigger, { isLoading }] = useApiMutation();
|
||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||
|
||||
@@ -90,7 +92,7 @@ export function ForgotPasswordPage() {
|
||||
return (
|
||||
<AuthShell
|
||||
brandTitle="Reset your password securely."
|
||||
brandSubtitle="We'll email you a secure link to set a new password and get you back into your licensing portal."
|
||||
brandSubtitle={`We'll email you a secure link to set a new password and get you back into ${appName}.`}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<ThemeIcon size={56} radius="md" variant="light" color="emaPrimary">
|
||||
@@ -145,7 +147,7 @@ export function ForgotPasswordPage() {
|
||||
return (
|
||||
<AuthShell
|
||||
brandTitle="Reset your password securely."
|
||||
brandSubtitle="We'll email you a secure link to set a new password and get you back into your licensing portal."
|
||||
brandSubtitle={`We'll email you a secure link to set a new password and get you back into ${appName}.`}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
@@ -21,12 +21,13 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAppDispatch } from '../../../store/hooks';
|
||||
import { loginSuccess, setUser } from '../store/auth.slice';
|
||||
import type { LoginPayload, AuthUser } from '../types/auth.types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
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({
|
||||
email: z.string().email({ message: 'Enter a valid email' }),
|
||||
@@ -37,7 +38,9 @@ type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const { appName, loginRedirectPath, enableSignup, enableForgotPassword } =
|
||||
useAuthConfig();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [loginTrigger] = useApiMutation<LoginPayload>();
|
||||
@@ -68,7 +71,7 @@ export function LoginPage() {
|
||||
dispatch(setUser(me));
|
||||
|
||||
if (me.isPhoneNumberVerified) {
|
||||
navigate('/dashboard');
|
||||
navigate(loginRedirectPath);
|
||||
} else {
|
||||
navigate('/otp-verify', {
|
||||
state: { email: me.email, phoneNumber: me.phoneNumber },
|
||||
@@ -86,10 +89,10 @@ export function LoginPage() {
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2} fz={30}>
|
||||
Welcome back
|
||||
Welcome to {appName}
|
||||
</Title>
|
||||
<Text c="dimmed" mt={6}>
|
||||
Sign in to access your maritime licensing dashboard.
|
||||
Sign in to access your account.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -119,14 +122,16 @@ export function LoginPage() {
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.currentTarget.checked)}
|
||||
/>
|
||||
<Anchor
|
||||
component={Link}
|
||||
to="/forgot-password"
|
||||
size="sm"
|
||||
fw={600}
|
||||
>
|
||||
Forgot password?
|
||||
</Anchor>
|
||||
{enableForgotPassword && (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to="/forgot-password"
|
||||
size="sm"
|
||||
fw={600}
|
||||
>
|
||||
Forgot password?
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
@@ -153,12 +158,14 @@ export function LoginPage() {
|
||||
Sign in with phone number
|
||||
</Button>
|
||||
|
||||
<Text ta="center" size="sm" c="dimmed">
|
||||
Don't have an account?{' '}
|
||||
<Anchor component={Link} to="/signup" fw={700}>
|
||||
Create one
|
||||
</Anchor>
|
||||
</Text>
|
||||
{enableSignup && (
|
||||
<Text ta="center" size="sm" c="dimmed">
|
||||
Don't have an account?{' '}
|
||||
<Anchor component={Link} to="/signup" fw={700}>
|
||||
Create one
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</AuthShell>
|
||||
);
|
||||
@@ -19,6 +19,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
import { useAuthConfig } from '../AuthConfig';
|
||||
|
||||
const CODE_LENGTH = 6;
|
||||
const RESEND_SECONDS = 30;
|
||||
@@ -34,6 +35,7 @@ type FormValues = z.infer<typeof schema>;
|
||||
export function OTPVerificationPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { loginRedirectPath } = useAuthConfig();
|
||||
const state = location.state as
|
||||
| { email?: string; phoneNumber?: string }
|
||||
| null;
|
||||
@@ -68,7 +70,7 @@ export function OTPVerificationPage() {
|
||||
}).unwrap();
|
||||
|
||||
notify.success('Phone number verified successfully');
|
||||
navigate('/dashboard');
|
||||
navigate(loginRedirectPath);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Something went wrong';
|
||||
notify.error(msg);
|
||||
@@ -95,7 +97,7 @@ export function OTPVerificationPage() {
|
||||
return (
|
||||
<AuthShell
|
||||
brandTitle="One last step to secure your account."
|
||||
brandSubtitle="We use a one-time code to confirm it's really you before granting access to the licensing portal."
|
||||
brandSubtitle="We use a one-time code to confirm it's really you before granting access."
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<ThemeIcon size={56} radius="md" variant="light" color="emaPrimary">
|
||||
@@ -164,7 +166,7 @@ export function OTPVerificationPage() {
|
||||
variant="light"
|
||||
fullWidth
|
||||
size="md"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
onClick={() => navigate(loginRedirectPath)}
|
||||
>
|
||||
Skip verification for now
|
||||
</Button>
|
||||
@@ -23,11 +23,12 @@ 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 { useDispatch } from 'react-redux';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
import { loginSuccess } from '../store/auth.slice';
|
||||
import { useAuthConfig } from '../AuthConfig';
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
@@ -62,7 +63,8 @@ interface SignupPayload {
|
||||
|
||||
export function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const dispatch = useDispatch();
|
||||
const { appName, loginRedirectPath } = useAuthConfig();
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [signupTrigger, { isLoading: loading }] = useApiMutation<{
|
||||
token: string;
|
||||
@@ -106,7 +108,7 @@ export function SignupPage() {
|
||||
);
|
||||
|
||||
if (data.isPhoneNumberVerified) {
|
||||
navigate('/dashboard');
|
||||
navigate(loginRedirectPath);
|
||||
} else {
|
||||
navigate('/otp-verify', {
|
||||
state: { email: values.email, phoneNumber: values.phoneNumber },
|
||||
@@ -120,8 +122,8 @@ export function SignupPage() {
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
brandTitle="Join Ethiopia's maritime community."
|
||||
brandSubtitle="Create your account to apply for licenses, manage documents, and track approvals from anywhere."
|
||||
brandTitle={`Join ${appName}'s community.`}
|
||||
brandSubtitle={`Create your account to access ${appName} features.`}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
@@ -30,7 +30,7 @@ const authSlice = createSlice({
|
||||
},
|
||||
hydrateAuth(state) {
|
||||
const token = authStorage.getToken();
|
||||
const user = authStorage.getUser();
|
||||
const user = authStorage.getUser<AuthUser>();
|
||||
if (token && user) {
|
||||
state.token = token;
|
||||
state.user = user;
|
||||
29
libs/auth/src/lib/utils/auth-storage.ts
Normal file
29
libs/auth/src/lib/utils/auth-storage.ts
Normal 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
5
libs/auth/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "../../dist/out-tsc" },
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -31,4 +31,7 @@ export const emaTheme = createTheme({
|
||||
md: '0 4px 20px rgba(15,23,42,0.08)',
|
||||
lg: '0 8px 30px rgba(15,23,42,0.12)',
|
||||
},
|
||||
other: {
|
||||
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"paths": {
|
||||
"@ema-platform/shared": ["libs/shared/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"]
|
||||
|
||||
@@ -39,7 +39,7 @@ export const FHC_COLORS = {
|
||||
"#96BDEB",
|
||||
"#6FA4E0",
|
||||
"#4A90E2",
|
||||
"#357ABD",
|
||||
"#4b7fe5",
|
||||
"#2C669D",
|
||||
"#224F7A",
|
||||
"#173654",
|
||||
@@ -120,7 +120,7 @@ export const FHC_LAYOUT = {
|
||||
brick: "#1A3A5C",
|
||||
brickDark: "#0F2440",
|
||||
blue: "#4A90E2",
|
||||
blueDark: "#357ABD",
|
||||
blueDark: "#4b7fe5",
|
||||
gold: "#4A90E2",
|
||||
text: "#1F2937",
|
||||
},
|
||||
@@ -216,7 +216,7 @@ export const fhcMantineTheme: MantineThemeOverride = {
|
||||
* color while still getting the fhc* palettes + layout tokens via `mantineTheme`.
|
||||
*/
|
||||
export const fhcDesignPreset = {
|
||||
colors: { primary: "#357ABD" },
|
||||
colors: { primary: "#4b7fe5" },
|
||||
typography: { fontFamily: "Plus Jakarta Sans, sans-serif" },
|
||||
shape: { radius: "10px" },
|
||||
mantineTheme: fhcMantineTheme,
|
||||
|
||||
@@ -40,7 +40,7 @@ export const projectTheme: DesignConfig = {
|
||||
// still have different 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
|
||||
// // "#2563eb" blue | "#7c3aed" purple
|
||||
// // "#16a34a" green | "#dc2626" red
|
||||
@@ -174,18 +174,18 @@ export const projectTheme: DesignConfig = {
|
||||
// ── Top tab bar skin (legacy view) ─────────────────────────────────────
|
||||
menuBackground: "#ffffff", // TODO: tab bar background
|
||||
menuColor: "#334155", // inactive tab text
|
||||
menuActiveColor: "#357ABD", // active tab text (FHC blue)
|
||||
menuActiveBorderColor: "#357ABD", // active tab underline
|
||||
menuHoverColor: "#357ABD", // tab hover text
|
||||
menuActiveColor: "#4b7fe5", // active tab text (FHC blue)
|
||||
menuActiveBorderColor: "#4b7fe5", // active tab underline
|
||||
menuHoverColor: "#4b7fe5", // tab hover text
|
||||
|
||||
// ── Create / edit modal skin (shared BackofficeModal) ──────────────────
|
||||
modalAccentColor: "#357ABD", // blue top strip
|
||||
modalAccentColor: "#4b7fe5", // blue top strip
|
||||
modalHeaderBackground: "#EEF4FC", // header bg (view)
|
||||
modalHeaderEditBackground: "#D9E8FA", // header bg (edit)
|
||||
modalIconBackground: "#D9E8FA", // header icon chip bg
|
||||
modalIconColor: "#357ABD", // header icon chip color
|
||||
modalIconColor: "#4b7fe5", // header icon chip color
|
||||
modalTitleColor: "#1F2937", // modal title text
|
||||
modalFocusColor: "#357ABD", // input focus ring inside modals
|
||||
modalFocusColor: "#4b7fe5", // input focus ring inside modals
|
||||
modalSurface: "#ffffff", // modal body surface
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user