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

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

@@ -0,0 +1,162 @@
import type { ReactNode } from 'react';
import {
Box,
Center,
Flex,
Group,
Stack,
Text,
Title,
rem,
useMantineTheme,
type BoxProps,
} from '@mantine/core';
import { IconCheck } from '@tabler/icons-react';
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',
'Real-time status tracking & alerts',
'Available in English & አማርኛ',
];
interface AuthShellProps {
children: ReactNode;
brandTitle?: string;
brandSubtitle?: string;
}
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 (
<Flex
mih="100vh"
align="center"
justify="center"
style={{ background: '#f7f8fa' }}
p="md"
>
<Box
mx="auto"
maw={1280}
w="100%"
bg="white"
p={12}
style={{
borderRadius: rem(16),
boxShadow: '0 20px 60px rgba(0,0,0,0.15)',
overflow: 'hidden',
}}
>
<Flex direction={{ base: 'column', lg: 'row' }}>
<Box
w={{ base: '100%', lg: '50%' }}
px={48}
py={48}
style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}
>
{children}
</Box>
<Box
visibleFrom="lg"
w="50%"
p="lg"
pos="relative"
style={{ background: heroGradient, overflow: 'hidden' }}
>
<Box
pos="absolute"
top={-80}
right={-40}
w={240}
h={240}
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.10)' }}
/>
<Box
pos="absolute"
bottom={-80}
left={-60}
w={220}
h={220}
style={{ borderRadius: '50%', background: 'rgba(255,255,255,0.08)' }}
/>
<Stack
gap="lg"
pos="relative"
style={{ zIndex: 1 }}
h="100%"
justify="center"
>
<Center>
<Box
component="img"
src={logoUrl}
alt="EMA Portal"
style={{ display: 'block', maxWidth: '60%', height: 'auto' }}
/>
</Center>
<Stack gap={6}>
<Title order={2} c="white" fz={rem(28)} lh={1.2} fw={700}>
{brandTitle}
</Title>
<Text fz="sm" lh={1.6} style={{ color: 'rgba(255,255,255,0.85)' }}>
{brandSubtitle}
</Text>
</Stack>
<Stack gap="sm">
{FEATURES.map((feature) => (
<Group key={feature} gap="sm" wrap="nowrap">
<Center
w={22}
h={22}
style={{
borderRadius: '50%',
background: 'rgba(255,255,255,0.2)',
flexShrink: 0,
}}
>
<IconCheck size={12} color="white" stroke={2.4} />
</Center>
<Text c="white" fz="sm" fw={500}>
{feature}
</Text>
</Group>
))}
</Stack>
<Text fz="xs" style={{ color: 'rgba(255,255,255,0.7)' }}>
© 2026 Ethiopian Maritime Authority
</Text>
</Stack>
</Box>
</Flex>
</Box>
</Flex>
);
}

View File

@@ -0,0 +1,190 @@
import { useState } from 'react';
import {
Anchor,
Button,
Center,
Group,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconArrowLeft,
IconMail,
IconMailForward,
IconSend,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
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' }),
});
type FormValues = z.infer<typeof schema>;
export function ForgotPasswordPage() {
const { appName } = useAuthConfig();
const [forgotTrigger, { isLoading }] = useApiMutation();
const [sentTo, setSentTo] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const sendResetLink = async (email: string) => {
await forgotTrigger({
url: '/auth/forgot-password',
method: 'POST',
body: { email },
}).unwrap();
setSentTo(email);
};
const onSubmit = async (values: FormValues) => {
try {
await sendResetLink(values.email);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
}
};
const handleResend = async () => {
if (!sentTo || isLoading) return;
try {
await sendResetLink(sentTo);
notify.success('Reset link sent again');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
}
};
const backToSignIn = (
<Center>
<Anchor
component={Link}
to="/login"
size="sm"
fw={600}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<IconArrowLeft size={16} />
Back to sign in
</Anchor>
</Center>
);
if (sentTo) {
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 ${appName}.`}
>
<Stack gap="lg">
<ThemeIcon size={56} radius="md" variant="light" color="emaPrimary">
<IconMailForward size={30} />
</ThemeIcon>
<div>
<Title order={2} fz={30}>
Check your email
</Title>
<Text c="dimmed" mt={6}>
We&apos;ve sent a password reset link to{' '}
<Text span fw={600} c="dark">
{sentTo}
</Text>
. Follow the link in that email to choose a new password.
</Text>
</div>
<Button
component="a"
href="https://mail.google.com"
target="_blank"
rel="noopener noreferrer"
fullWidth
size="md"
leftSection={<IconMail size={18} />}
>
Open email app
</Button>
<Group justify="center" gap={6}>
<Text size="sm" c="dimmed">
Didn&apos;t get the email?
</Text>
<Anchor
size="sm"
fw={600}
onClick={handleResend}
style={isLoading ? { pointerEvents: 'none', opacity: 0.6 } : undefined}
>
Resend link
</Anchor>
</Group>
{backToSignIn}
</Stack>
</AuthShell>
);
}
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 ${appName}.`}
>
<Stack gap="lg">
<div>
<Title order={2} fz={30}>
Forgot your password?
</Title>
<Text c="dimmed" mt={6}>
Enter the email linked to your account and we&apos;ll send you a link
to reset your password.
</Text>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Email address"
placeholder="you@example.com"
size="md"
leftSection={<IconMail size={18} />}
error={errors.email?.message}
{...register('email')}
/>
<Button
type="submit"
loading={isLoading}
fullWidth
size="md"
rightSection={<IconSend size={18} />}
>
Send reset link
</Button>
</Stack>
</form>
{backToSignIn}
</Stack>
</AuthShell>
);
}

View File

@@ -0,0 +1,172 @@
import { useState } from 'react';
import {
Anchor,
Button,
Checkbox,
Divider,
Group,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import {
IconArrowRight,
IconDeviceMobile,
IconLock,
IconMail,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
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' }),
password: z.string().min(5, { message: 'Password must be at least 6 characters' }),
});
type FormValues = z.infer<typeof schema>;
export function LoginPage() {
const navigate = useNavigate();
const dispatch = useDispatch();
const { appName, loginRedirectPath, enableSignup, enableForgotPassword } =
useAuthConfig();
const [isLoading, setIsLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(true);
const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = async (values: FormValues) => {
setIsLoading(true);
try {
const data = await loginTrigger({
url: '/auth/login',
method: 'POST',
body: values,
}).unwrap();
dispatch(loginSuccess(data));
const me = await meTrigger({
url: '/auth/me',
method: 'GET',
}).unwrap();
dispatch(setUser(me));
if (me.isPhoneNumberVerified) {
navigate(loginRedirectPath);
} else {
navigate('/otp-verify', {
state: { email: me.email, phoneNumber: me.phoneNumber },
});
}
} catch {
notify.error('Invalid email or password');
} finally {
setIsLoading(false);
}
};
return (
<AuthShell>
<Stack gap="lg">
<div>
<Title order={2} fz={30}>
Welcome to {appName}
</Title>
<Text c="dimmed" mt={6}>
Sign in to access your account.
</Text>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Email or phone"
placeholder="you@example.com"
size="md"
leftSection={<IconMail size={18} />}
error={errors.email?.message}
{...register('email')}
/>
<PasswordInput
label="Password"
placeholder="Your password"
size="md"
leftSection={<IconLock size={18} />}
error={errors.password?.message}
{...register('password')}
/>
<Group justify="space-between">
<Checkbox
label="Remember me"
size="sm"
checked={rememberMe}
onChange={(e) => setRememberMe(e.currentTarget.checked)}
/>
{enableForgotPassword && (
<Anchor
component={Link}
to="/forgot-password"
size="sm"
fw={600}
>
Forgot password?
</Anchor>
)}
</Group>
<Button
type="submit"
loading={isLoading}
fullWidth
size="md"
rightSection={<IconArrowRight size={18} />}
>
Sign in
</Button>
</Stack>
</form>
<Divider label="or" labelPosition="center" />
<Button
variant="default"
fullWidth
size="md"
leftSection={<IconDeviceMobile size={18} />}
onClick={() => notify.info('Phone sign-in is coming soon.')}
>
Sign in with phone number
</Button>
{enableSignup && (
<Text ta="center" size="sm" c="dimmed">
Don&apos;t have an account?{' '}
<Anchor component={Link} to="/signup" fw={700}>
Create one
</Anchor>
</Text>
)}
</Stack>
</AuthShell>
);
}

View File

@@ -0,0 +1,200 @@
import { useEffect, useState } from 'react';
import {
Anchor,
Button,
Center,
Divider,
Group,
PinInput,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import { IconArrowRight, IconShieldLock } from '@tabler/icons-react';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, useLocation } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { useAuthConfig } from '../AuthConfig';
const CODE_LENGTH = 6;
const RESEND_SECONDS = 30;
const schema = z.object({
verificationCode: z
.string()
.length(CODE_LENGTH, { message: `Enter the ${CODE_LENGTH}-digit code` }),
});
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;
const email = state?.email ?? '';
const phoneNumber = state?.phoneNumber ?? '';
const [verifyTrigger, { isLoading: loading }] = useApiMutation();
const [resendTrigger, { isLoading: resending }] = useApiMutation();
const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS);
const {
control,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { verificationCode: '' },
});
useEffect(() => {
if (secondsLeft <= 0) return;
const id = setInterval(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearInterval(id);
}, [secondsLeft]);
const onSubmit = async (values: FormValues) => {
try {
await verifyTrigger({
url: '/auth/verify-phone-number',
method: 'PATCH',
body: { email, phoneNumber, verificationCode: values.verificationCode },
}).unwrap();
notify.success('Phone number verified successfully');
navigate(loginRedirectPath);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
}
};
const handleResendOtp = async () => {
if (secondsLeft > 0 || resending) return;
try {
await resendTrigger({
url: '/auth/resend-otp',
method: 'POST',
body: { email },
}).unwrap();
notify.success('Verification code resent to your email');
setSecondsLeft(RESEND_SECONDS);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
}
};
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."
>
<Stack gap="lg">
<ThemeIcon size={56} radius="md" variant="light" color="emaPrimary">
<IconShieldLock size={30} />
</ThemeIcon>
<div>
<Title order={2} fz={30}>
Verify your account
</Title>
<Text c="dimmed" mt={6}>
A verification code has been sent to{' '}
<Text span fw={600} c="dark">
{email || 'your email'}
</Text>
. Enter it below to continue.
</Text>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Controller
control={control}
name="verificationCode"
render={({ field }) => (
<Stack gap={6} align="center">
<PinInput
length={CODE_LENGTH}
type="number"
inputMode="numeric"
oneTimeCode
size="md"
gap="sm"
error={!!errors.verificationCode}
value={field.value}
onChange={field.onChange}
onComplete={() => handleSubmit(onSubmit)()}
/>
{errors.verificationCode?.message && (
<Text c="red" size="sm">
{errors.verificationCode.message}
</Text>
)}
</Stack>
)}
/>
<Button
type="submit"
loading={loading}
fullWidth
size="md"
rightSection={<IconArrowRight size={18} />}
>
Verify
</Button>
</Stack>
</form>
<Divider
label="Having trouble?"
labelPosition="center"
variant="dashed"
/>
<Button
variant="light"
fullWidth
size="md"
onClick={() => navigate(loginRedirectPath)}
>
Skip verification for now
</Button>
<Center>
<Group justify="center" gap={6}>
<Text size="sm" c="dimmed">
Didn&apos;t receive a code?
</Text>
{secondsLeft > 0 ? (
<Text size="sm" c="dimmed" fw={600}>
Resend in {secondsLeft}s
</Text>
) : (
<Anchor
size="sm"
fw={600}
onClick={handleResendOtp}
style={
resending ? { pointerEvents: 'none', opacity: 0.6 } : undefined
}
>
Resend code
</Anchor>
)}
</Group>
</Center>
</Stack>
</AuthShell>
);
}

View File

@@ -0,0 +1,239 @@
import { useState } from 'react';
import {
Anchor,
Button,
Checkbox,
Group,
PasswordInput,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import {
IconArrowRight,
IconAt,
IconDeviceMobile,
IconLock,
IconMail,
IconUser,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
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({
email: z.string().email(),
username: z.string().min(3, { message: 'Username must be at least 3 characters' }),
phoneNumber: z.string().min(1, { message: 'Phone number is required' }),
userType: z.literal('individual'),
nameEn: z.string().min(1, { message: 'Name (English) is required' }),
nameAm: z.string().optional(),
password: z.string().min(8, { message: 'Password must be at least 8 characters' }),
confirmPassword: z.string().min(8, { message: 'Confirm your password' }),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type FormValues = z.infer<typeof schema>;
interface SignupPayload {
email: string;
username: string;
phoneNumber: string;
userType: string;
name: {
am: string;
en: string;
};
password: string;
confirmPassword: string;
}
export function SignupPage() {
const navigate = useNavigate();
const dispatch = useDispatch();
const { appName, loginRedirectPath } = useAuthConfig();
const [agreed, setAgreed] = useState(false);
const [signupTrigger, { isLoading: loading }] = useApiMutation<{
token: string;
refreshToken: string;
isPhoneNumberVerified: boolean;
}>();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { userType: 'individual' },
});
const onSubmit = async (values: FormValues) => {
try {
const payload: SignupPayload = {
email: values.email,
username: values.username,
phoneNumber: values.phoneNumber,
userType: values.userType,
name: { en: values.nameEn, am: values.nameAm ?? '' },
password: values.password,
confirmPassword: values.confirmPassword,
};
const data = await signupTrigger({
url: '/auth/signup-with-pwd',
method: 'POST',
body: payload,
}).unwrap();
dispatch(
loginSuccess({
token: data.token,
refreshToken: data.refreshToken,
isPhoneNumberVerified: data.isPhoneNumberVerified,
}),
);
if (data.isPhoneNumberVerified) {
navigate(loginRedirectPath);
} else {
navigate('/otp-verify', {
state: { email: values.email, phoneNumber: values.phoneNumber },
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
}
};
return (
<AuthShell
brandTitle={`Join ${appName}'s community.`}
brandSubtitle={`Create your account to access ${appName} features.`}
>
<Stack gap="lg">
<div>
<Title order={2} fz={30}>
Create account
</Title>
<Text c="dimmed" mt={6}>
It only takes a minute to get started.
</Text>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<TextInput
label="Name (English)"
placeholder="Abebe Bekele"
leftSection={<IconUser size={18} />}
error={errors.nameEn?.message}
{...register('nameEn')}
/>
<TextInput
label="Name (Amharic)"
placeholder="ስም"
leftSection={<IconUser size={18} />}
error={errors.nameAm?.message}
{...register('nameAm')}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<TextInput
label="Email address"
placeholder="you@example.com"
leftSection={<IconMail size={18} />}
error={errors.email?.message}
{...register('email')}
/>
<TextInput
label="Username"
placeholder="Choose a username"
leftSection={<IconAt size={18} />}
error={errors.username?.message}
{...register('username')}
/>
</SimpleGrid>
<TextInput
label="Phone number"
placeholder="+251 911 234 567"
leftSection={<IconDeviceMobile size={18} />}
error={errors.phoneNumber?.message}
{...register('phoneNumber')}
/>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<PasswordInput
label="Password"
placeholder="At least 8 characters"
leftSection={<IconLock size={18} />}
error={errors.password?.message}
{...register('password')}
/>
<PasswordInput
label="Confirm password"
placeholder="Re-enter password"
leftSection={<IconLock size={18} />}
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
</SimpleGrid>
<Checkbox
size="sm"
checked={agreed}
onChange={(e) => setAgreed(e.currentTarget.checked)}
label={
<Text size="sm">
I agree to the{' '}
<Anchor
size="sm"
fw={600}
onClick={(e) => e.preventDefault()}
>
Terms &amp; Privacy Policy
</Anchor>
</Text>
}
/>
<Button
type="submit"
loading={loading}
disabled={!agreed}
fullWidth
size="md"
rightSection={<IconArrowRight size={18} />}
>
Create account
</Button>
</Stack>
</form>
<Text ta="center" size="sm" c="dimmed">
Already have an account?{' '}
<Anchor component={Link} to="/login" fw={700}>
Sign in
</Anchor>
</Text>
</Stack>
</AuthShell>
);
}

View File

@@ -0,0 +1,44 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthState, AuthUser, LoginPayload } from '../types/auth.types';
import { authStorage } from '../utils/auth-storage';
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
loginSuccess(state, action: PayloadAction<LoginPayload>) {
state.token = action.payload.token;
state.isAuthenticated = true;
authStorage.setToken(action.payload.token);
authStorage.setRefreshToken(action.payload.refreshToken);
},
setUser(state, action: PayloadAction<AuthUser>) {
state.user = action.payload;
authStorage.setUser(action.payload);
},
logout(state) {
state.user = null;
state.token = null;
state.isAuthenticated = false;
authStorage.clear();
},
hydrateAuth(state) {
const token = authStorage.getToken();
const user = authStorage.getUser<AuthUser>();
if (token && user) {
state.token = token;
state.user = user;
state.isAuthenticated = true;
}
},
},
});
export const { loginSuccess, setUser, logout, hydrateAuth } = authSlice.actions;
export const authReducer = authSlice.reducer;

View File

@@ -0,0 +1,33 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface SignupState {
email: string;
phoneNumber: string;
step: 'form' | 'otp' | 'complete';
}
const initialState: SignupState = {
email: '',
phoneNumber: '',
step: 'form',
};
const signupSlice = createSlice({
name: 'signup',
initialState,
reducers: {
setSignupData(state, action: PayloadAction<{ email: string; phoneNumber: string }>) {
state.email = action.payload.email;
state.phoneNumber = action.payload.phoneNumber;
},
setSignupStep(state, action: PayloadAction<SignupState['step']>) {
state.step = action.payload;
},
resetSignup() {
return initialState;
},
},
});
export const { setSignupData, setSignupStep, resetSignup } = signupSlice.actions;
export const signupReducer = signupSlice.reducer;

View File

@@ -0,0 +1,28 @@
export interface AuthUser {
id: string;
email: string;
username: string;
phoneNumber: string;
name: {
am: string;
en: string;
};
status: string;
sharepointId: string | null;
hasSetPassword: boolean;
hasFinishedRegistration: boolean;
hasFinishedDMSOnboarding: boolean;
isPhoneNumberVerified: boolean;
}
export interface AuthState {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
}
export interface LoginPayload {
token: string;
refreshToken: string;
isPhoneNumberVerified: boolean;
}

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),
);
},
};