feat: implement profile completion flow and guard commit

This commit is contained in:
mengstabketemaw
2026-06-26 15:27:13 +03:00
parent d7420d2de6
commit 08ade42157
9 changed files with 106 additions and 46 deletions

View 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 />;
}

View File

@@ -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 && (

View File

@@ -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: [

View File

@@ -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
}

View File

@@ -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);

View File

@@ -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) {

8
package-lock.json generated
View File

@@ -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",

View File

@@ -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",