Initial End to End functionality

This commit is contained in:
Mulu Mehari
2026-08-02 22:44:08 +03:00
parent c9d885356c
commit c62ec59655
53 changed files with 6791 additions and 1208 deletions

View File

@@ -9,6 +9,17 @@ export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';
export { OTPVerificationPage } from './lib/pages/OTPVerificationPage';
export { authReducer, loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } from './lib/store/auth.slice';
export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice';
export { usePermissions } from './lib/hooks/usePermissions';
export type { PermissionSet } from './lib/hooks/usePermissions';
export {
useCurrentProfile,
useGetMyProfileQuery,
useUpdateMyProfileMutation,
useUpdateMyAddressMutation,
PROFILE_FIELDS,
PROFILE_FIELD_SECTION,
} from './lib/hooks/useCurrentProfile';
export type { ProfileField, ProfileRequirement, ProfileMeResponse } from './lib/hooks/useCurrentProfile';
export { configureAuthStorage, authStorage } from './lib/utils/auth-storage';
export { refreshAccessToken } from './lib/utils/refresh-token';
export type { AuthUser, AuthState, LoginPayload, CurrentProfile, CurrentProfileAddress, CurrentProfileProfession } from './lib/types/auth.types';

View File

@@ -0,0 +1,164 @@
import { useEffect, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { baseApi } from '@ema-platform/api';
import { authStorage } from '../utils/auth-storage';
import { setCurrentProfile } from '../store/auth.slice';
import type { CurrentProfile } from '../types/auth.types';
/**
* Profile fields the portal knows how to collect.
*
* Mirrors `PROFILE_FIELDS` in the API (`module/profile/profile-completeness.ts`).
* Screens name the fields they need from this list rather than checking
* properties ad hoc, so "what is missing" has one definition.
*/
export const PROFILE_FIELDS = [
'firstName',
'middleName',
'lastName',
'gender',
'dob',
'pob',
'maritalStatus',
'professionId',
'idType',
'idNumber',
'nationality',
'primaryPhoneNumber',
'email',
'regionId',
'cityId',
'subCityId',
'woredaId',
'streetAddress',
'emergencyContactName',
'emergencyContactPhone',
'emergencyContactRelation',
] as const;
export type ProfileField = (typeof PROFILE_FIELDS)[number];
/** Which `/profile` tab collects a field — used to build deep links. */
export const PROFILE_FIELD_SECTION: Record<ProfileField, 'personal' | 'address' | 'emergency'> = {
firstName: 'personal',
middleName: 'personal',
lastName: 'personal',
gender: 'personal',
dob: 'personal',
pob: 'personal',
maritalStatus: 'personal',
professionId: 'personal',
idType: 'address',
idNumber: 'address',
nationality: 'address',
primaryPhoneNumber: 'address',
email: 'address',
regionId: 'address',
cityId: 'address',
subCityId: 'address',
woredaId: 'address',
streetAddress: 'address',
emergencyContactName: 'emergency',
emergencyContactPhone: 'emergency',
emergencyContactRelation: 'emergency',
};
/** What a screen needs before it can do its job. */
export interface ProfileRequirement {
fields: ProfileField[];
/** Shown to the applicant — why this is being asked for, in plain language. */
reason: string;
}
export interface ProfileMeResponse {
profile: CurrentProfile;
completeness: number;
missing: ProfileField[];
}
const profileApi = baseApi
.enhanceEndpoints({ addTagTypes: ['CurrentProfile'] as const })
.injectEndpoints({
endpoints: (builder) => ({
getMyProfile: builder.query<ProfileMeResponse, void>({
query: () => ({ url: '/profiles/me' }),
providesTags: ['CurrentProfile'],
}),
/**
* Saves one tab of `/profile`. Invalidates the profile so the
* completeness meter and every requirement gate recompute at once.
*/
updateMyProfile: builder.mutation<
unknown,
{ id: string; body: Record<string, unknown> }
>({
query: ({ id, body }) => ({ url: `/profiles/${id}`, method: 'PATCH', body }),
invalidatesTags: ['CurrentProfile'],
}),
updateMyAddress: builder.mutation<
unknown,
{ id: string; body: Record<string, unknown> }
>({
query: ({ id, body }) => ({ url: `/addresses/${id}`, method: 'PATCH', body }),
invalidatesTags: ['CurrentProfile'],
}),
}),
overrideExisting: false,
});
export const {
useGetMyProfileQuery,
useUpdateMyProfileMutation,
useUpdateMyAddressMutation,
} = profileApi;
export const currentProfileApi = profileApi;
/**
* The one way to get at the signed-in user's profile.
*
* Replaces `authStorage.getProfileId()`, which only ever held a value if the
* user had been through the (now deleted) setup wizard. Pages that read it
* directly did `if (!profileId) return;` and rendered blank forever for anyone
* who signed up afterwards.
*
* Resolution order: RTK Query cache → `authStorage` (so the id is available
* synchronously on the very first render) → `GET /profiles/me`, which
* provisions a profile if the user has none. The resolved id is written back
* to storage. Never blocks render: `profileId` may be undefined for a tick,
* and callers should show a loading or empty state rather than bail out.
*/
export function useCurrentProfile() {
const dispatch = useDispatch();
const { data, isLoading, isFetching, error, refetch } = useGetMyProfileQuery();
const profileId = data?.profile?.id ?? authStorage.getProfileId();
useEffect(() => {
if (!data?.profile) return;
authStorage.setProfileId(data.profile.id);
dispatch(setCurrentProfile(data.profile));
}, [data?.profile, dispatch]);
const missing = useMemo(() => data?.missing ?? [], [data?.missing]);
return {
profileId,
profile: data?.profile,
isLoading,
isFetching,
error,
refetch,
completeness: data?.completeness ?? 0,
missing,
/**
* True when nothing the requirement asks for is still blank. Unknown
* profile (still loading) reads as not-ready, so a caller never submits
* against data it has not seen.
*/
isReadyFor: (requirement: ProfileRequirement) =>
Boolean(data) && requirement.fields.every((field) => !missing.includes(field)),
/** The subset of a requirement that is still outstanding. */
gapsFor: (requirement: ProfileRequirement) =>
requirement.fields.filter((field) => missing.includes(field)),
};
}

View File

@@ -0,0 +1,80 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { authStorage } from '../utils/auth-storage';
interface TokenClaims {
permissions?: string[];
roles?: string[];
}
/**
* Reads the claims out of the access token without verifying it.
*
* Verification is the API's job — this is only used to decide what to *show*.
* Every guarded route is enforced server-side by `PermissionGuard`, so the
* worst a wrong answer here can do is offer a menu item that then 403s.
*/
function decodeClaims(token: string | undefined): TokenClaims | null {
if (!token) return null;
const payload = token.split('.')[1];
if (!payload) return null;
try {
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
// The claim set is UTF-8; atob yields latin-1, so non-ASCII names would
// otherwise come back mangled.
const decoded = decodeURIComponent(
json
.split('')
.map((c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
.join(''),
);
return JSON.parse(decoded) as TokenClaims;
} catch {
return null;
}
}
export interface PermissionSet {
permissions: string[];
/** True if the user holds any one of `required`. Empty `required` = allowed. */
can: (required?: string[]) => boolean;
/**
* Whether permissions could be read at all. When false, callers should show
* everything rather than hide the whole application from someone whose token
* simply does not carry the claim.
*/
known: boolean;
}
/**
* What the signed-in user is allowed to do.
*
* Deliberately fails open: if the token carries no `permissions` claim we
* report `known: false` and `can()` returns true. Hiding navigation on a
* claim-shape mismatch would leave a legitimate officer staring at an empty
* sidebar with no way to tell why, whereas failing open costs at most a 403
* on a link they should not have seen.
*/
export function usePermissions(): PermissionSet {
// Re-read whenever the session changes rather than only on mount.
const token = useSelector(
(state: { auth?: { token?: string | null } }) => state.auth?.token,
);
return useMemo(() => {
const claims = decodeClaims(token ?? authStorage.getToken());
const permissions = claims?.permissions ?? [];
const known = Array.isArray(claims?.permissions);
const granted = new Set(permissions);
return {
permissions,
known,
can: (required?: string[]) => {
if (!required?.length) return true;
if (!known) return true;
return required.some((permission) => granted.has(permission));
},
};
}, [token]);
}

View File

@@ -48,7 +48,7 @@ export function LoginPage() {
const [serverError, setServerError] = useState<string | null>(null);
const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>();
const [profileCheckTrigger] = useApiMutation<{ total: number; items: CurrentProfile[] }>();
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
const {
register,
@@ -74,22 +74,22 @@ export function LoginPage() {
}).unwrap();
dispatch(setUser(me));
// Load the seafarer profile if one exists, so those screens have it —
// but never gate sign-in on it.
// Warm the profile so screens that need an id have one on first paint.
// `/profiles/me` provisions an empty profile when the user has none, so
// unlike the old filtered lookup this cannot come back empty-handed.
// Sign-in is still never gated on it — a failure here is ignored and
// `useCurrentProfile` resolves it again on demand.
try {
const q = `w=user_id:=:${me.id}&i=user,address,profession`;
const result = await profileCheckTrigger({
url: `/profiles?q=${encodeURIComponent(q)}`,
const { profile } = await profileTrigger({
url: '/profiles/me',
method: 'GET',
}).unwrap();
if (result.total > 0 && result.items.length > 0) {
const profile = result.items[0];
if (profile) {
authStorage.setProfileId(profile.id);
dispatch(setCurrentProfile(profile));
}
} catch {
// No profile yet. That is fine — a profile is only needed by the
// seafarer features, not to apply for a licence.
// Offline or a 5xx — the portal still works; the resolver retries.
}
if (!me.isPhoneNumberVerified) {