protal signup and login

This commit is contained in:
mengstabketemaw
2026-06-05 12:19:48 +03:00
parent d730be3e85
commit 78c7d08d82
9 changed files with 173 additions and 133 deletions

View File

@@ -1,16 +1,17 @@
// src/main.tsx or App.tsx
import { AppProviders, configureIam } from "@tria-plc/iamui-common";
import { configureIam } from "@tria-plc/iamui-common";
import { BrowserRouter } from 'react-router-dom';
import "@tria-plc/iamui-common/styles.css";
// import { AppProviders } from './providers/AppProviders';
import { AppProviders } from './providers/AppProviders';
import { AppRouter } from './router';
configureIam({ apiUrl: 'http://localhost:3001/api'});
// import './styles.css';
configureIam({ apiUrl: 'http://localhost:3001/api' });
export function App() {
return (
// 2. Wrap with Providers so hooks like useAuthUser() work
<AppProviders>
<AppRouter />
<BrowserRouter>
<AppRouter />
</BrowserRouter>
</AppProviders>
);
}

View File

@@ -14,7 +14,9 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { useAppDispatch } from '../../../store/hooks';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { LoginPayload, AuthUser } from '../types/auth.types';
import { notify } from '@ema-platform/ui';
const BASE_API_URL =
@@ -30,7 +32,7 @@ type FormValues = z.infer<typeof schema>;
export function LoginPage() {
const navigate = useNavigate();
const { login } = useAuth();
const dispatch = useAppDispatch();
const [isLoading, setIsLoading] = useState(false);
const {
@@ -50,9 +52,23 @@ export function LoginPage() {
body: JSON.stringify(values),
});
if (!res.ok) throw new Error('Login failed');
const data = (await res.json()) as Parameters<typeof login>[0];
login(data);
navigate('/dashboard');
const data = (await res.json()) as LoginPayload;
dispatch(loginSuccess(data));
const meRes = await fetch(`${BASE_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${data.token}` },
});
if (!meRes.ok) throw new Error('Failed to fetch user');
const me = (await meRes.json()) as AuthUser;
dispatch(setUser(me));
if (me.status === 'accepted') {
navigate('/dashboard');
} else {
navigate('/set-password', {
state: { email: me.email, phoneNumber: me.phoneNumber },
});
}
} catch {
notify.error('Invalid email or password');
} finally {

View File

@@ -1,7 +1,6 @@
import {
Paper,
TextInput,
PasswordInput,
Button,
Stack,
Title,
@@ -19,23 +18,15 @@ import {
setPasswordFailure,
} from '../store/set-password.slice';
import { notify } from '@ema-platform/ui';
import { useState } from 'react';
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'],
});
const schema = z.object({
verificationCode: z.string().min(1, 'Verification code is required'),
});
type FormValues = z.infer<typeof schema>;
@@ -44,7 +35,12 @@ export function SetPasswordPage() {
const dispatch = useAppDispatch();
const location = useLocation();
const { loading } = useAppSelector((s) => s.setPassword);
const signupEmail = (location.state as { email?: string } | null)?.email ?? '';
const state = location.state as
| { email?: string; phoneNumber?: string }
| null;
const email = state?.email ?? '';
const phoneNumber = state?.phoneNumber ?? '';
const [resending, setResending] = useState(false);
const {
register,
@@ -52,31 +48,28 @@ export function SetPasswordPage() {
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`, {
const res = await fetch(`${BASE_API_URL}/auth/verify-phone-number`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: values.userId,
email: values.email,
email,
phoneNumber,
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');
throw new Error(errorData?.message ?? 'Verification failed');
}
dispatch(setPasswordSuccess());
notify.success('Password set successfully');
notify.success('Phone number verified successfully');
navigate('/dashboard');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
@@ -85,52 +78,60 @@ export function SetPasswordPage() {
}
};
const handleResendOtp = async () => {
setResending(true);
try {
const res = await fetch(`${BASE_API_URL}/auth/resend-otp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const errorData = await res.json().catch(() => null);
throw new Error(errorData?.message ?? 'Failed to resend OTP');
}
notify.success('Verification code resent to your email');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
} finally {
setResending(false);
}
};
return (
<Center h="100vh">
<Paper p="xl" shadow="md" radius="md" w={400}>
<Stack gap="md">
<Title order={3}>Set your password</Title>
<Title order={3}>Verify your email</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.
A verification code has been sent to {email || 'your email'}. Enter it
below to complete your 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
Verify
</Button>
</Stack>
</form>
<Button
variant="subtle"
size="sm"
loading={resending}
onClick={handleResendOtp}
fullWidth
>
Send OTP again
</Button>
</Stack>
</Paper>
</Center>

View File

@@ -1,6 +1,7 @@
import {
Paper,
TextInput,
PasswordInput,
Button,
Stack,
Title,
@@ -22,13 +23,21 @@ 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'),
});
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.literal('individual'),
nameEn: z.string().min(1, 'Name (English) is required'),
nameAm: z.string().optional(),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string().min(8, 'Confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type FormValues = z.infer<typeof schema>;
@@ -41,6 +50,8 @@ interface SignupPayload {
am: string;
en: string;
};
password: string;
confirmPassword: string;
}
export function SignupPage() {
@@ -54,6 +65,7 @@ export function SignupPage() {
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { userType: 'individual' },
});
const onSubmit = async (values: FormValues) => {
@@ -64,13 +76,12 @@ export function SignupPage() {
username: values.username,
phoneNumber: values.phoneNumber,
userType: values.userType,
name: {
am: '',
en: values.name,
},
name: { en: values.nameEn, am: values.nameAm ?? '' },
password: values.password,
confirmPassword: values.confirmPassword,
};
const res = await fetch(`${BASE_API_URL}/auth/signup`, {
const res = await fetch(`${BASE_API_URL}/auth/signup-with-pwd`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -81,14 +92,16 @@ export function SignupPage() {
throw new Error(errorData?.message ?? 'Signup failed');
}
const data = (await res.json()) as { token: string; refreshToken: string };
const data = (await res.json()) as { token: string; refreshToken: string; isPhoneNumberVerified: boolean };
authStorage.setToken(data.token);
authStorage.setRefreshToken(data.refreshToken);
dispatch(hydrateAuth());
dispatch(signupSuccess());
navigate('/set-password', { state: { email: values.email } });
navigate('/set-password', {
state: { email: values.email, phoneNumber: values.phoneNumber },
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
dispatch(signupFailure(msg));
@@ -104,10 +117,16 @@ export function SignupPage() {
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="sm">
<TextInput
label="Full Name"
placeholder="Your full name"
error={errors.name?.message}
{...register('name')}
label="Name (English)"
placeholder="Your name in English"
error={errors.nameEn?.message}
{...register('nameEn')}
/>
<TextInput
label="Name (Amharic)"
placeholder="Your name in Amharic"
error={errors.nameAm?.message}
{...register('nameAm')}
/>
<TextInput
label="Email"
@@ -127,11 +146,17 @@ export function SignupPage() {
error={errors.phoneNumber?.message}
{...register('phoneNumber')}
/>
<TextInput
label="User Type"
placeholder="e.g. customer, admin"
error={errors.userType?.message}
{...register('userType')}
<PasswordInput
label="Password"
placeholder="Enter a password"
error={errors.password?.message}
{...register('password')}
/>
<PasswordInput
label="Confirm Password"
placeholder="Confirm your password"
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
<Button type="submit" loading={loading} fullWidth mt="sm">
Sign up

View File

@@ -1,5 +1,5 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthState, LoginPayload } from '../types/auth.types';
import type { AuthState, AuthUser, LoginPayload } from '../types/auth.types';
import { authStorage } from '../utils/auth-storage';
const initialState: AuthState = {
@@ -13,12 +13,14 @@ const authSlice = createSlice({
initialState,
reducers: {
loginSuccess(state, action: PayloadAction<LoginPayload>) {
state.user = action.payload.user;
state.token = action.payload.token;
state.isAuthenticated = true;
authStorage.setToken(action.payload.token);
authStorage.setRefreshToken(action.payload.refreshToken);
authStorage.setUser(action.payload.user);
},
setUser(state, action: PayloadAction<AuthUser>) {
state.user = action.payload;
authStorage.setUser(action.payload);
},
logout(state) {
state.user = null;
@@ -38,5 +40,5 @@ const authSlice = createSlice({
},
});
export const { loginSuccess, logout, hydrateAuth } = authSlice.actions;
export const { loginSuccess, setUser, logout, hydrateAuth } = authSlice.actions;
export const authReducer = authSlice.reducer;

View File

@@ -2,8 +2,16 @@ export interface AuthUser {
id: string;
email: string;
username: string;
roles: string[];
permissions: string[];
phoneNumber: string;
name: {
am: string;
en: string;
};
status: string;
sharepointId: string | null;
hasSetPassword: boolean;
hasFinishedRegistration: boolean;
hasFinishedDMSOnboarding: boolean;
}
export interface AuthState {
@@ -13,7 +21,7 @@ export interface AuthState {
}
export interface LoginPayload {
user: AuthUser;
token: string;
refreshToken: string;
isPhoneNumberVerified: boolean;
}

View File

@@ -1,5 +1,6 @@
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@tria-plc/iamui-common';
import type { ReactNode } from 'react';
import { store } from '../store';
import { MantineThemeProvider } from './MantineThemeProvider';
@@ -12,7 +13,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MantineThemeProvider>{children}</MantineThemeProvider>
<AuthProvider>
<MantineThemeProvider>{children}</MantineThemeProvider>
</AuthProvider>
</QueryClientProvider>
</Provider>
);

View File

@@ -1,12 +1,11 @@
import { createBrowserRouter, RouterProvider, Navigate, Route, Routes } from 'react-router-dom';
import { Navigate, Route, Routes } 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';
import { UserManagementPage, UserManagementLayout, Login} from "@tria-plc/iamui-common";
import { UserManagementPage, UserManagementLayout } from "@tria-plc/iamui-common";
function getToken(): string | null {
return localStorage.getItem('ema-portal-auth-token');
@@ -17,40 +16,27 @@ function ProtectedRoute({ children }: { children: ReactNode }) {
return <>{children}</>;
}
const router = createBrowserRouter([
{
element: <UserManagementLayout />,
children: [
{path: '/user', element: <UserManagementPage />}
]
},
{ path: '/login', element: <LoginPage /> },
{ path: '/signup', element: <SignupPage /> },
{ path: '/set-password', element: <SetPasswordPage /> },
{
element: <PortalLayout />,
children: [
{ path: '/', element: <Navigate to="/dashboard" replace /> },
{
path: '/dashboard',
element: (
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
),
},
],
},
{ path: '*', element: <Navigate to="/" replace /> },
]);
export function AppRouter() {
return (
<Routes>
<Route path='/login' element={<Login/>}/>
<Route element={<UserManagementLayout />}>
<Route path="/users" element={<UserManagementPage />} />
</Route>
</Routes>
);
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/set-password" element={<SetPasswordPage />} />
<Route element={<UserManagementLayout />}>
<Route path="/users" element={<UserManagementPage />} />
</Route>
<Route element={<PortalLayout />}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -2,9 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import "@tria-plc/iamui-common/styles.css";
import { configureIam } from "@tria-plc/iamui-common";
import { App } from './app/app';
const root = document.getElementById('root');