mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
feat: implement profile completion flow and guard commit
This commit is contained in:
12
apps/portal/src/app/components/ProfileGuard.tsx
Normal file
12
apps/portal/src/app/components/ProfileGuard.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { authStorage } from '@ema-platform/auth';
|
||||
|
||||
export function ProfileGuard() {
|
||||
const profileId = authStorage.getProfileId();
|
||||
|
||||
if (!profileId) {
|
||||
return <Navigate to="/profile-setup" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconLogout2,
|
||||
IconMapPin,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
@@ -28,7 +29,7 @@ import { z } from 'zod';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { authStorage, setUser } from '@ema-platform/auth';
|
||||
import { authStorage, setUser, logout } from '@ema-platform/auth';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
|
||||
const GENDERS = ['MALE', 'FEMALE'];
|
||||
@@ -159,9 +160,12 @@ export function ProfileSetupPage() {
|
||||
const [profileTrigger] = useApiMutation<{ id: string }>();
|
||||
const [addressTrigger] = useApiMutation<unknown>();
|
||||
const [meTrigger] = useApiMutation<{ id: string }>();
|
||||
const [profileCheckTrigger] = useApiMutation<{ count: number; items: Array<{ id: string; userId: string }> }>();
|
||||
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
|
||||
const fetched = useRef(false);
|
||||
|
||||
const checkedExistingProfile = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetched.current) return;
|
||||
fetched.current = true;
|
||||
@@ -172,6 +176,21 @@ export function ProfileSetupPage() {
|
||||
.finally(() => setProfessionsLoading(false));
|
||||
}, [fetchProfessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || checkedExistingProfile.current) return;
|
||||
checkedExistingProfile.current = true;
|
||||
profileCheckTrigger({ url: '/profiles?take=1&skip=0', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((data) => {
|
||||
const existing = data.items?.find((p) => p.userId === user.id);
|
||||
if (existing) {
|
||||
authStorage.setProfileId(existing.id);
|
||||
navigate('/dashboard', { replace: true });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [user, navigate, profileCheckTrigger]);
|
||||
|
||||
const professionOptions = useMemo(
|
||||
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
|
||||
[professions],
|
||||
@@ -185,6 +204,37 @@ export function ProfileSetupPage() {
|
||||
return map;
|
||||
}, [professions]);
|
||||
|
||||
const nameParts = useMemo(() => (user?.name?.en || '').trim().split(/\s+/), [user]);
|
||||
const profileDefaults: ProfileValues = useMemo(() => ({
|
||||
professionId: '',
|
||||
firstName: nameParts[0] || '',
|
||||
middleName: nameParts.length > 2 ? nameParts.slice(1, -1).join(' ') : '',
|
||||
lastName: nameParts.length > 1 ? nameParts[nameParts.length - 1] : '',
|
||||
gender: '',
|
||||
dob: '',
|
||||
pob: '',
|
||||
maritalStatus: '',
|
||||
}), [nameParts]);
|
||||
|
||||
const addressDefaults: AddressValues = useMemo(() => ({
|
||||
idType: '',
|
||||
idNumber: '',
|
||||
nationality: '',
|
||||
primaryPhoneNumber: user?.phoneNumber || '',
|
||||
secondaryPhoneNumber: '',
|
||||
email: user?.email || '',
|
||||
regionId: '',
|
||||
cityId: '',
|
||||
subcityId: '',
|
||||
woredaId: '',
|
||||
kebeleId: '',
|
||||
streetAddress: '',
|
||||
postalAddress: '',
|
||||
emergencyContactName: '',
|
||||
emergencyContactPhone: '',
|
||||
emergencyContactRelation: '',
|
||||
}), [user]);
|
||||
|
||||
const {
|
||||
register: profileRegister,
|
||||
handleSubmit: profileHandleSubmit,
|
||||
@@ -194,16 +244,7 @@ export function ProfileSetupPage() {
|
||||
trigger: profileTriggerValidation,
|
||||
} = useForm<ProfileValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: {
|
||||
professionId: '',
|
||||
firstName: '',
|
||||
middleName: '',
|
||||
lastName: '',
|
||||
gender: '',
|
||||
dob: '',
|
||||
pob: '',
|
||||
maritalStatus: '',
|
||||
},
|
||||
defaultValues: profileDefaults,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -215,24 +256,7 @@ export function ProfileSetupPage() {
|
||||
trigger: addressTriggerValidation,
|
||||
} = useForm<AddressValues>({
|
||||
resolver: zodResolver(addressSchema),
|
||||
defaultValues: {
|
||||
idType: '',
|
||||
idNumber: '',
|
||||
nationality: '',
|
||||
primaryPhoneNumber: '',
|
||||
secondaryPhoneNumber: '',
|
||||
email: '',
|
||||
regionId: '',
|
||||
cityId: '',
|
||||
subcityId: '',
|
||||
woredaId: '',
|
||||
kebeleId: '',
|
||||
streetAddress: '',
|
||||
postalAddress: '',
|
||||
emergencyContactName: '',
|
||||
emergencyContactPhone: '',
|
||||
emergencyContactRelation: '',
|
||||
},
|
||||
defaultValues: addressDefaults,
|
||||
});
|
||||
|
||||
const onNext = async () => {
|
||||
@@ -528,8 +552,16 @@ export function ProfileSetupPage() {
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="xl">
|
||||
<Button variant="default" onClick={() => navigate('/dashboard')}>
|
||||
Cancel
|
||||
<Button
|
||||
variant="default"
|
||||
color="gray"
|
||||
leftSection={<IconLogout2 size={16} />}
|
||||
onClick={() => {
|
||||
dispatch(logout());
|
||||
navigate('/login');
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
{active > 0 && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { I18nextProvider } from 'react-i18next';
|
||||
import { i18n } from './i18n/config';
|
||||
import { PortalLayout } from './layouts/PortalLayout';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { ProfileGuard } from './components/ProfileGuard';
|
||||
|
||||
// Auth (standalone pages, no portal chrome)
|
||||
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
|
||||
@@ -54,9 +55,11 @@ export const router = createBrowserRouter([
|
||||
{
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<ProfileGuard>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<PortalLayout />
|
||||
</I18nextProvider>
|
||||
</ProfileGuard>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
|
||||
@@ -28,6 +28,7 @@ import { AuthShell } from '../components/AuthShell';
|
||||
import { loginSuccess, setUser } from '../store/auth.slice';
|
||||
import type { LoginPayload, AuthUser } from '../types/auth.types';
|
||||
import { useAuthConfig } from '../AuthConfig';
|
||||
import { authStorage } from '../utils/auth-storage';
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email({ message: 'Enter a valid email' }),
|
||||
@@ -76,12 +77,14 @@ export function LoginPage() {
|
||||
url: '/profiles?take=10000&skip=0',
|
||||
method: 'GET',
|
||||
}).unwrap();
|
||||
const hasProfile = profiles.items?.some((p) => p.userId === me.id);
|
||||
const userProfile = profiles.items?.find((p) => p.userId === me.id);
|
||||
|
||||
if (!hasProfile) {
|
||||
if (!userProfile) {
|
||||
navigate('/profile-setup');
|
||||
return;
|
||||
}
|
||||
|
||||
authStorage.setProfileId(userProfile.id);
|
||||
} catch {
|
||||
// profile check failed — proceed to dashboard anyway
|
||||
}
|
||||
|
||||
@@ -37,10 +37,11 @@ export function OTPVerificationPage() {
|
||||
const location = useLocation();
|
||||
const { loginRedirectPath } = useAuthConfig();
|
||||
const state = location.state as
|
||||
| { email?: string; phoneNumber?: string }
|
||||
| { email?: string; phoneNumber?: string; needsProfile?: boolean }
|
||||
| null;
|
||||
const email = state?.email ?? '';
|
||||
const phoneNumber = state?.phoneNumber ?? '';
|
||||
const needsProfile = state?.needsProfile ?? false;
|
||||
|
||||
const [verifyTrigger, { isLoading: loading }] = useApiMutation();
|
||||
const [resendTrigger, { isLoading: resending }] = useApiMutation();
|
||||
@@ -70,7 +71,7 @@ export function OTPVerificationPage() {
|
||||
}).unwrap();
|
||||
|
||||
notify.success('Phone number verified successfully');
|
||||
navigate(loginRedirectPath);
|
||||
navigate(needsProfile ? '/profile-setup' : loginRedirectPath);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Something went wrong';
|
||||
notify.error(msg);
|
||||
|
||||
@@ -27,7 +27,8 @@ 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 { loginSuccess, setUser } from '../store/auth.slice';
|
||||
import type { AuthUser } from '../types/auth.types';
|
||||
import { useAuthConfig } from '../AuthConfig';
|
||||
|
||||
const schema = z
|
||||
@@ -71,6 +72,7 @@ export function SignupPage() {
|
||||
refreshToken: string;
|
||||
isPhoneNumberVerified: boolean;
|
||||
}>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -107,11 +109,18 @@ export function SignupPage() {
|
||||
}),
|
||||
);
|
||||
|
||||
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
|
||||
dispatch(setUser(me));
|
||||
|
||||
if (data.isPhoneNumberVerified) {
|
||||
navigate(loginRedirectPath);
|
||||
navigate('/profile-setup');
|
||||
} else {
|
||||
navigate('/otp-verify', {
|
||||
state: { email: values.email, phoneNumber: values.phoneNumber },
|
||||
state: {
|
||||
email: values.email,
|
||||
phoneNumber: values.phoneNumber,
|
||||
needsProfile: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
Binary file not shown.
8
package-lock.json
generated
8
package-lock.json
generated
@@ -20,7 +20,7 @@
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"@tria-plc/iamui": "file:local-packages/tria-plc-iamui-0.0.3.tgz",
|
||||
"@tria-plc/iamui": "file:local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
@@ -8632,9 +8632,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tria-plc/iamui": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "file:local-packages/tria-plc-iamui-0.0.3.tgz",
|
||||
"integrity": "sha512-aZhIeNq2Uui7TUkG88G9QTBqdhaVB5kvqfd47HLgVE7qKkVyfjlm4nwlSWnHh5MO+CjgP9vRi6ILwMJx1ULO8g==",
|
||||
"version": "0.1.1",
|
||||
"resolved": "file:local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||
"integrity": "sha512-FTihoH0lIqKV/0s+FTJZokQw8xX3XztXIqT8eEYEZgDM2xyzVSCHY8pHCUxw0MQtiR8R97zxZJ/o192eWt+E/g==",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"@tria-plc/iamui": "file:local-packages/tria-plc-iamui-0.0.3.tgz",
|
||||
"@tria-plc/iamui": "file:local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
|
||||
Reference in New Issue
Block a user