login and sign up configuration

This commit is contained in:
mengstabketemaw
2026-06-02 12:11:29 +03:00
parent e735ba4fca
commit df745bbc41
15 changed files with 785 additions and 7 deletions

View File

@@ -6,11 +6,12 @@ import {
Title,
Paper,
Text,
Anchor,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate } from 'react-router-dom';
import { useNavigate, Link } from 'react-router-dom';
import { useLoginMutation } from '../api/auth-api';
import { useAuth } from '../hooks/useAuth';
import { notify } from '@ema-platform/ui';
@@ -72,8 +73,14 @@ export function LoginForm() {
Sign in
</Button>
</Stack>
</form>
</Stack>
</Paper>
</form>
<Text ta="center" size="sm" c="dimmed">
Don't have an account?{' '}
<Anchor component={Link} to="/signup">
Sign up
</Anchor>
</Text>
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,137 @@
import {
Paper,
TextInput,
PasswordInput,
Button,
Stack,
Title,
Text,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import {
setPasswordStart,
setPasswordSuccess,
setPasswordFailure,
} from '../store/set-password.slice';
import { notify } from '@ema-platform/ui';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
const schema = z
.object({
userId: z.string().min(1, 'User ID is required'),
email: z.string().email('Enter a valid email'),
verificationCode: z.string().min(1, 'Verification code is required'),
newPassword: z.string().min(6, 'Password must be at least 6 characters'),
confirmPassword: z.string().min(6, 'Confirm your password'),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type FormValues = z.infer<typeof schema>;
export function SetPasswordPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const location = useLocation();
const { loading } = useAppSelector((s) => s.setPassword);
const signupEmail = (location.state as { email?: string } | null)?.email ?? '';
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { email: signupEmail },
});
const onSubmit = async (values: FormValues) => {
dispatch(setPasswordStart());
try {
const res = await fetch(`${BASE_API_URL}/auth/set-password`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: values.userId,
email: values.email,
verificationCode: values.verificationCode,
newPassword: values.newPassword,
confirmPassword: values.confirmPassword,
}),
});
if (!res.ok) {
const errorData = await res.json().catch(() => null);
throw new Error(errorData?.message ?? 'Failed to set password');
}
dispatch(setPasswordSuccess());
notify.success('Password set successfully');
navigate('/dashboard');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
dispatch(setPasswordFailure(msg));
notify.error(msg);
}
};
return (
<Paper p="xl" shadow="md" radius="md">
<Stack gap="lg">
<Stack gap={4}>
<Title order={2}>Set your password</Title>
<Text c="dimmed" size="sm">
A verification code has been sent to your email. Enter it below along
with your new password to complete registration.
</Text>
</Stack>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="User ID"
placeholder="Enter your user ID"
error={errors.userId?.message}
{...register('userId')}
/>
<TextInput
label="Email"
placeholder="you@example.com"
error={errors.email?.message}
{...register('email')}
/>
<TextInput
label="Verification Code"
placeholder="Enter the code from your email"
error={errors.verificationCode?.message}
{...register('verificationCode')}
/>
<PasswordInput
label="New Password"
placeholder="Enter a new password"
error={errors.newPassword?.message}
{...register('newPassword')}
/>
<PasswordInput
label="Confirm Password"
placeholder="Confirm your new password"
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
<Button type="submit" loading={loading} fullWidth mt="sm">
Set Password
</Button>
</Stack>
</form>
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,153 @@
import {
Paper,
TextInput,
Button,
Stack,
Title,
Text,
Anchor,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { signupStart, signupSuccess, signupFailure } from '../store/signup.slice';
import { hydrateAuth } from '../store/auth.slice';
import { authStorage } from '../utils/auth-storage';
import { notify } from '@ema-platform/ui';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
const schema = z.object({
email: z.string().email('Enter a valid email'),
username: z.string().min(3, 'Username must be at least 3 characters'),
phoneNumber: z.string().min(1, 'Phone number is required'),
userType: z.string().min(1, 'User type is required'),
name: z.string().min(1, 'Name is required'),
});
type FormValues = z.infer<typeof schema>;
interface SignupPayload {
email: string;
username: string;
phoneNumber: string;
userType: string;
name: {
am: string;
en: string;
};
}
export function SignupPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { loading } = useAppSelector((s) => s.signup);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = async (values: FormValues) => {
dispatch(signupStart());
try {
const payload: SignupPayload = {
email: values.email,
username: values.username,
phoneNumber: values.phoneNumber,
userType: values.userType,
name: {
am: '',
en: values.name,
},
};
const res = await fetch(`${BASE_API_URL}/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const errorData = await res.json().catch(() => null);
throw new Error(errorData?.message ?? 'Signup failed');
}
const data = (await res.json()) as { token: string; refreshToken: string };
authStorage.setToken(data.token);
authStorage.setRefreshToken(data.refreshToken);
dispatch(hydrateAuth());
dispatch(signupSuccess());
navigate('/set-password', { state: { email: values.email } });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
dispatch(signupFailure(msg));
notify.error(msg);
}
};
return (
<Paper p="xl" shadow="md" radius="md">
<Stack gap="lg">
<Stack gap={4}>
<Title order={2}>Create an account</Title>
<Text c="dimmed" size="sm">
Fill in your details to get started
</Text>
</Stack>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Full Name"
placeholder="Your full name"
error={errors.name?.message}
{...register('name')}
/>
<TextInput
label="Email"
placeholder="you@example.com"
error={errors.email?.message}
{...register('email')}
/>
<TextInput
label="Username"
placeholder="Choose a username"
error={errors.username?.message}
{...register('username')}
/>
<TextInput
label="Phone Number"
placeholder="+251 911 234 567"
error={errors.phoneNumber?.message}
{...register('phoneNumber')}
/>
<TextInput
label="User Type"
placeholder="e.g. admin, manager"
error={errors.userType?.message}
{...register('userType')}
/>
<Button type="submit" loading={loading} fullWidth mt="sm">
Sign up
</Button>
</Stack>
</form>
<Text ta="center" size="sm" c="dimmed">
Already have an account?{' '}
<Anchor component={Link} to="/login">
Sign in
</Anchor>
</Text>
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,42 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface SetPasswordState {
loading: boolean;
success: boolean;
error: string | null;
}
const initialState: SetPasswordState = {
loading: false,
success: false,
error: null,
};
const setPasswordSlice = createSlice({
name: 'setPassword',
initialState,
reducers: {
setPasswordStart(state) {
state.loading = true;
state.error = null;
state.success = false;
},
setPasswordSuccess(state) {
state.loading = false;
state.success = true;
},
setPasswordFailure(state, action: PayloadAction<string>) {
state.loading = false;
state.error = action.payload;
},
resetSetPassword(state) {
state.loading = false;
state.success = false;
state.error = null;
},
},
});
export const { setPasswordStart, setPasswordSuccess, setPasswordFailure, resetSetPassword } =
setPasswordSlice.actions;
export const setPasswordReducer = setPasswordSlice.reducer;

View File

@@ -0,0 +1,41 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface SignupState {
loading: boolean;
success: boolean;
error: string | null;
}
const initialState: SignupState = {
loading: false,
success: false,
error: null,
};
const signupSlice = createSlice({
name: 'signup',
initialState,
reducers: {
signupStart(state) {
state.loading = true;
state.error = null;
state.success = false;
},
signupSuccess(state) {
state.loading = false;
state.success = true;
},
signupFailure(state, action: PayloadAction<string>) {
state.loading = false;
state.error = action.payload;
},
resetSignup(state) {
state.loading = false;
state.success = false;
state.error = null;
},
},
});
export const { signupStart, signupSuccess, signupFailure, resetSignup } = signupSlice.actions;
export const signupReducer = signupSlice.reducer;

View File

@@ -3,6 +3,8 @@ import { ProtectedRoute } from './ProtectedRoute';
import { BackofficeLayout } from '../layouts/BackofficeLayout';
import { AuthLayout } from '../layouts/AuthLayout';
import { LoginPage } from '../features/auth/pages/LoginPage';
import { SignupPage } from '../features/auth/pages/SignupPage';
import { SetPasswordPage } from '../features/auth/pages/SetPasswordPage';
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
import { ItemPage } from '../features/item/pages/ItemPage';
@@ -22,7 +24,11 @@ const router = createBrowserRouter([
},
{
element: <AuthLayout />,
children: [{ path: '/login', element: <LoginPage /> }],
children: [
{ path: '/login', element: <LoginPage /> },
{ path: '/signup', element: <SignupPage /> },
{ path: '/set-password', element: <SetPasswordPage /> },
],
},
{ path: '/404', element: <div>Page not found</div> },
{ path: '*', element: <Navigate to="/404" replace /> },

View File

@@ -1,10 +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 { setPasswordReducer } from '../features/auth/store/set-password.slice';
export const store = configureStore({
reducer: {
auth: authReducer,
signup: signupReducer,
setPassword: setPasswordReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
middleware: (getDefaultMiddleware) =>

View File

@@ -7,11 +7,13 @@ import {
Stack,
Title,
Center,
Text,
Anchor,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate } from 'react-router-dom';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { notify } from '@ema-platform/ui';
@@ -82,6 +84,12 @@ export function LoginPage() {
</Button>
</Stack>
</form>
<Text ta="center" size="sm" c="dimmed">
Don't have an account?{' '}
<Anchor component={Link} to="/signup">
Sign up
</Anchor>
</Text>
</Stack>
</Paper>
</Center>

View File

@@ -0,0 +1,138 @@
import {
Paper,
TextInput,
PasswordInput,
Button,
Stack,
Title,
Center,
Text,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import {
setPasswordStart,
setPasswordSuccess,
setPasswordFailure,
} from '../store/set-password.slice';
import { notify } from '@ema-platform/ui';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
const schema = z
.object({
userId: z.string().min(1, 'User ID is required'),
email: z.string().email('Enter a valid email'),
verificationCode: z.string().min(1, 'Verification code is required'),
newPassword: z.string().min(6, 'Password must be at least 6 characters'),
confirmPassword: z.string().min(6, 'Confirm your password'),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type FormValues = z.infer<typeof schema>;
export function SetPasswordPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const location = useLocation();
const { loading } = useAppSelector((s) => s.setPassword);
const signupEmail = (location.state as { email?: string } | null)?.email ?? '';
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { email: signupEmail },
});
const onSubmit = async (values: FormValues) => {
dispatch(setPasswordStart());
try {
const res = await fetch(`${BASE_API_URL}/auth/set-password`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: values.userId,
email: values.email,
verificationCode: values.verificationCode,
newPassword: values.newPassword,
confirmPassword: values.confirmPassword,
}),
});
if (!res.ok) {
const errorData = await res.json().catch(() => null);
throw new Error(errorData?.message ?? 'Failed to set password');
}
dispatch(setPasswordSuccess());
notify.success('Password set successfully');
navigate('/dashboard');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
dispatch(setPasswordFailure(msg));
notify.error(msg);
}
};
return (
<Center h="100vh">
<Paper p="xl" shadow="md" radius="md" w={400}>
<Stack gap="md">
<Title order={3}>Set your password</Title>
<Text c="dimmed" size="sm">
A verification code has been sent to your email. Enter it below along
with your new password to complete registration.
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="sm">
<TextInput
label="User ID"
placeholder="Enter your user ID"
error={errors.userId?.message}
{...register('userId')}
/>
<TextInput
label="Email"
placeholder="you@example.com"
error={errors.email?.message}
{...register('email')}
/>
<TextInput
label="Verification Code"
placeholder="Enter the code from your email"
error={errors.verificationCode?.message}
{...register('verificationCode')}
/>
<PasswordInput
label="New Password"
placeholder="Enter a new password"
error={errors.newPassword?.message}
{...register('newPassword')}
/>
<PasswordInput
label="Confirm Password"
placeholder="Confirm your new password"
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
<Button type="submit" loading={loading} fullWidth mt="sm">
Set Password
</Button>
</Stack>
</form>
</Stack>
</Paper>
</Center>
);
}

View File

@@ -0,0 +1,151 @@
import {
Paper,
TextInput,
Button,
Stack,
Title,
Center,
Text,
Anchor,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { signupStart, signupSuccess, signupFailure } from '../store/signup.slice';
import { hydrateAuth } from '../store/auth.slice';
import { authStorage } from '../utils/auth-storage';
import { notify } from '@ema-platform/ui';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
const schema = z.object({
email: z.string().email('Enter a valid email'),
username: z.string().min(3, 'Username must be at least 3 characters'),
phoneNumber: z.string().min(1, 'Phone number is required'),
userType: z.string().min(1, 'User type is required'),
name: z.string().min(1, 'Name is required'),
});
type FormValues = z.infer<typeof schema>;
interface SignupPayload {
email: string;
username: string;
phoneNumber: string;
userType: string;
name: {
am: string;
en: string;
};
}
export function SignupPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const { loading } = useAppSelector((s) => s.signup);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = async (values: FormValues) => {
dispatch(signupStart());
try {
const payload: SignupPayload = {
email: values.email,
username: values.username,
phoneNumber: values.phoneNumber,
userType: values.userType,
name: {
am: '',
en: values.name,
},
};
const res = await fetch(`${BASE_API_URL}/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const errorData = await res.json().catch(() => null);
throw new Error(errorData?.message ?? 'Signup failed');
}
const data = (await res.json()) as { token: string; refreshToken: string };
authStorage.setToken(data.token);
authStorage.setRefreshToken(data.refreshToken);
dispatch(hydrateAuth());
dispatch(signupSuccess());
navigate('/set-password', { state: { email: values.email } });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
dispatch(signupFailure(msg));
notify.error(msg);
}
};
return (
<Center h="100vh">
<Paper p="xl" shadow="md" radius="md" w={400}>
<Stack gap="md">
<Title order={3}>Create an account</Title>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="sm">
<TextInput
label="Full Name"
placeholder="Your full name"
error={errors.name?.message}
{...register('name')}
/>
<TextInput
label="Email"
placeholder="you@example.com"
error={errors.email?.message}
{...register('email')}
/>
<TextInput
label="Username"
placeholder="Choose a username"
error={errors.username?.message}
{...register('username')}
/>
<TextInput
label="Phone Number"
placeholder="+251 911 234 567"
error={errors.phoneNumber?.message}
{...register('phoneNumber')}
/>
<TextInput
label="User Type"
placeholder="e.g. customer, admin"
error={errors.userType?.message}
{...register('userType')}
/>
<Button type="submit" loading={loading} fullWidth mt="sm">
Sign up
</Button>
</Stack>
</form>
<Text ta="center" size="sm" c="dimmed">
Already have an account?{' '}
<Anchor component={Link} to="/login">
Sign in
</Anchor>
</Text>
</Stack>
</Paper>
</Center>
);
}

View File

@@ -0,0 +1,42 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface SetPasswordState {
loading: boolean;
success: boolean;
error: string | null;
}
const initialState: SetPasswordState = {
loading: false,
success: false,
error: null,
};
const setPasswordSlice = createSlice({
name: 'setPassword',
initialState,
reducers: {
setPasswordStart(state) {
state.loading = true;
state.error = null;
state.success = false;
},
setPasswordSuccess(state) {
state.loading = false;
state.success = true;
},
setPasswordFailure(state, action: PayloadAction<string>) {
state.loading = false;
state.error = action.payload;
},
resetSetPassword(state) {
state.loading = false;
state.success = false;
state.error = null;
},
},
});
export const { setPasswordStart, setPasswordSuccess, setPasswordFailure, resetSetPassword } =
setPasswordSlice.actions;
export const setPasswordReducer = setPasswordSlice.reducer;

View File

@@ -0,0 +1,41 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
interface SignupState {
loading: boolean;
success: boolean;
error: string | null;
}
const initialState: SignupState = {
loading: false,
success: false,
error: null,
};
const signupSlice = createSlice({
name: 'signup',
initialState,
reducers: {
signupStart(state) {
state.loading = true;
state.error = null;
state.success = false;
},
signupSuccess(state) {
state.loading = false;
state.success = true;
},
signupFailure(state, action: PayloadAction<string>) {
state.loading = false;
state.error = action.payload;
},
resetSignup(state) {
state.loading = false;
state.success = false;
state.error = null;
},
},
});
export const { signupStart, signupSuccess, signupFailure, resetSignup } = signupSlice.actions;
export const signupReducer = signupSlice.reducer;

View File

@@ -2,6 +2,8 @@ import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom'
import type { ReactNode } from 'react';
import { PortalLayout } from './layouts/PortalLayout';
import { LoginPage } from './features/auth/pages/LoginPage';
import { SignupPage } from './features/auth/pages/SignupPage';
import { SetPasswordPage } from './features/auth/pages/SetPasswordPage';
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
function getToken(): string | null {
@@ -15,6 +17,8 @@ function ProtectedRoute({ children }: { children: ReactNode }) {
const router = createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
{ path: '/signup', element: <SignupPage /> },
{ path: '/set-password', element: <SetPasswordPage /> },
{
element: <PortalLayout />,
children: [

View File

@@ -1,10 +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 { setPasswordReducer } from '../features/auth/store/set-password.slice';
export const store = configureStore({
reducer: {
auth: authReducer,
signup: signupReducer,
setPassword: setPasswordReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
middleware: (getDefaultMiddleware) =>

View File

@@ -3,7 +3,7 @@ import { resolveSessionContext } from '../session';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001';
'http://localhost:3001/api';
export const baseApi = createApi({
reducerPath: 'baseApi',