mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12: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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { 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';
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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,
|
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' }}
|
||||||
/>
|
/>
|
||||||
@@ -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>
|
||||||
@@ -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't have an account?{' '}
|
Don'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>
|
||||||
);
|
);
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
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)',
|
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%)',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user