mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 02:58:12 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
@@ -1,34 +1,25 @@
|
||||
export { AuthConfigProvider, useAuthConfig } from "./lib/AuthConfig";
|
||||
export type { AuthConfigValue } from "./lib/AuthConfig";
|
||||
export { AuthShell, BrandMark } from "./lib/components/AuthShell";
|
||||
export { ProtectedRoute } from "./lib/components/ProtectedRoute";
|
||||
export { LoginPage } from "./lib/pages/LoginPage";
|
||||
export { SignupPage } from "./lib/pages/SignupPage";
|
||||
export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage";
|
||||
export { OTPVerificationPage } from "./lib/pages/OTPVerificationPage";
|
||||
export { AuthConfigProvider, useAuthConfig } from './lib/AuthConfig';
|
||||
export type { AuthConfigValue } from './lib/AuthConfig';
|
||||
export { AuthShell, BrandMark } from './lib/components/AuthShell';
|
||||
export { ProtectedRoute } from './lib/components/ProtectedRoute';
|
||||
export { AuthBootstrap } from './lib/components/AuthBootstrap';
|
||||
export { LoginPage } from './lib/pages/LoginPage';
|
||||
export { SignupPage } from './lib/pages/SignupPage';
|
||||
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 {
|
||||
authReducer,
|
||||
loginSuccess,
|
||||
setUser,
|
||||
setCurrentProfile,
|
||||
clearCurrentProfile,
|
||||
logout,
|
||||
hydrateAuth,
|
||||
setToken,
|
||||
} from "./lib/store/auth.slice";
|
||||
export {
|
||||
signupReducer,
|
||||
setSignupData,
|
||||
setSignupStep,
|
||||
resetSignup,
|
||||
} from "./lib/store/signup.slice";
|
||||
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";
|
||||
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';
|
||||
|
||||
70
libs/auth/src/lib/components/AuthBootstrap.tsx
Normal file
70
libs/auth/src/lib/components/AuthBootstrap.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { authStorage } from '../utils/auth-storage';
|
||||
import { hydrateAuth, logout, setUser } from '../store/auth.slice';
|
||||
import type { AuthUser } from '../types/auth.types';
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3001/api';
|
||||
|
||||
/**
|
||||
* Restores the signed-in session before the router renders.
|
||||
*
|
||||
* Redux starts empty on every page load while the token lives in
|
||||
* localStorage, so without this a refresh leaves the app half–signed-in: the
|
||||
* route guards see a token and let you through, but pages that read
|
||||
* `auth.user` think you are a stranger.
|
||||
*
|
||||
* A token the server no longer accepts is cleared here rather than left to
|
||||
* strand the user on a page they cannot act on or sign out of.
|
||||
*/
|
||||
export function AuthBootstrap({ children }: { children: ReactNode }) {
|
||||
const dispatch = useDispatch();
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function restore() {
|
||||
dispatch(hydrateAuth());
|
||||
|
||||
const token = authStorage.getToken();
|
||||
if (!token) {
|
||||
if (!cancelled) setReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BASE_API_URL}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const user = (await response.json()) as AuthUser;
|
||||
if (!cancelled) dispatch(setUser(user));
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
// Expired or revoked — drop it so the user gets a login screen
|
||||
// instead of a dead end.
|
||||
if (!cancelled) dispatch(logout());
|
||||
}
|
||||
} catch {
|
||||
// Offline or the API is down: keep the stored session and let the
|
||||
// individual screens surface their own errors.
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
restore();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// Rendering the router before the session resolves would let the guards
|
||||
// redirect based on a state that is about to change.
|
||||
if (!ready) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
164
libs/auth/src/lib/hooks/useCurrentProfile.ts
Normal file
164
libs/auth/src/lib/hooks/useCurrentProfile.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
80
libs/auth/src/lib/hooks/usePermissions.ts
Normal file
80
libs/auth/src/lib/hooks/usePermissions.ts
Normal 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]);
|
||||
}
|
||||
@@ -55,10 +55,7 @@ export function LoginPage() {
|
||||
const { handleError } = useErrorHandler();
|
||||
const [loginTrigger] = useApiMutation<LoginPayload>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
const [profileCheckTrigger] = useApiMutation<{
|
||||
total: number;
|
||||
items: CurrentProfile[];
|
||||
}>();
|
||||
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -84,21 +81,22 @@ export function LoginPage() {
|
||||
}).unwrap();
|
||||
dispatch(setUser(me));
|
||||
|
||||
let hasProfile = false;
|
||||
// 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)}`,
|
||||
method: "GET",
|
||||
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));
|
||||
hasProfile = true;
|
||||
}
|
||||
} catch {
|
||||
// profile not found — redirect to setup
|
||||
// Offline or a 5xx — the portal still works; the resolver retries.
|
||||
}
|
||||
|
||||
if (!me.isPhoneNumberVerified) {
|
||||
@@ -106,17 +104,11 @@ export function LoginPage() {
|
||||
state: {
|
||||
email: me.email,
|
||||
phoneNumber: me.phoneNumber,
|
||||
needsProfile: !hasProfile,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasProfile) {
|
||||
navigate("/profile-setup");
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(loginRedirectPath);
|
||||
} catch (err: unknown) {
|
||||
setServerError(handleError(err));
|
||||
|
||||
@@ -38,11 +38,10 @@ export function OTPVerificationPage() {
|
||||
const location = useLocation();
|
||||
const { loginRedirectPath } = useAuthConfig();
|
||||
const state = location.state as
|
||||
| { email?: string; phoneNumber?: string; needsProfile?: boolean }
|
||||
| { email?: string; phoneNumber?: string }
|
||||
| null;
|
||||
const email = state?.email ?? '';
|
||||
const phoneNumber = state?.phoneNumber ?? '';
|
||||
const needsProfile = state?.needsProfile ?? false;
|
||||
|
||||
const [verifyTrigger, { isLoading: loading }] = useApiMutation();
|
||||
const [resendTrigger, { isLoading: resending }] = useApiMutation();
|
||||
@@ -74,7 +73,7 @@ export function OTPVerificationPage() {
|
||||
}).unwrap();
|
||||
|
||||
notify.success('Phone number verified successfully');
|
||||
navigate(needsProfile ? '/profile-setup' : loginRedirectPath);
|
||||
navigate(loginRedirectPath);
|
||||
} catch (err: unknown) {
|
||||
setServerError(handleError(err));
|
||||
}
|
||||
@@ -135,7 +134,7 @@ export function OTPVerificationPage() {
|
||||
<Stack gap={6} align="center">
|
||||
<PinInput
|
||||
length={CODE_LENGTH}
|
||||
type="text"
|
||||
type="alphanumeric"
|
||||
inputMode="text"
|
||||
oneTimeCode
|
||||
size="md"
|
||||
|
||||
@@ -117,13 +117,12 @@ export function SignupPage() {
|
||||
dispatch(setUser(me));
|
||||
|
||||
if (data.isPhoneNumberVerified) {
|
||||
navigate('/profile-setup');
|
||||
navigate(loginRedirectPath);
|
||||
} else {
|
||||
navigate('/otp-verify', {
|
||||
state: {
|
||||
email: values.email,
|
||||
phoneNumber: values.phoneNumber,
|
||||
needsProfile: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface CurrentProfileAddress {
|
||||
postalAddress: string | null;
|
||||
emergencyContactName: string | null;
|
||||
emergencyContactPhone: string | null;
|
||||
emergencycontactRelation: string | null;
|
||||
emergencyContactRelation: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,11 @@ export async function refreshAccessToken(): Promise<string> {
|
||||
const refreshToken = authStorage.getRefreshToken();
|
||||
if (!refreshToken) throw new Error("No refresh token available");
|
||||
|
||||
// The IAM service exposes this as `refresh-token`; posting to `/auth/refresh`
|
||||
// 404s, which the catch below turns into a silent logout.
|
||||
const response = await fetch(`${BASE_API_URL}/auth/refresh-token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user