feat: change usermanagement from git submodule to componenet based

This commit is contained in:
mengstabketemaw
2026-06-23 11:10:35 +03:00
parent 9c035e7333
commit f876d57219
34 changed files with 2601 additions and 3622 deletions

View File

@@ -3,8 +3,6 @@ import type {
Department,
Profession,
ListResponse,
CreateDepartmentPayload,
UpdateDepartmentPayload,
CreateProfessionPayload,
UpdateProfessionPayload,
} from '../types/configuration';
@@ -15,22 +13,6 @@ const configurationApi = baseApi.injectEndpoints({
query: () => '/departments',
providesTags: ['Api'],
}),
createDepartment: builder.mutation<Department, CreateDepartmentPayload>({
query: (body) => ({ url: '/departments', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
updateDepartment: builder.mutation<Department, UpdateDepartmentPayload>({
query: ({ id, ...body }) => ({
url: `/departments/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
deleteDepartment: builder.mutation<void, string>({
query: (id) => ({ url: `/departments/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
getProfessions: builder.query<ListResponse<Profession>, void>({
query: () => '/professions',
@@ -58,9 +40,6 @@ const configurationApi = baseApi.injectEndpoints({
export const {
useGetDepartmentsQuery,
useCreateDepartmentMutation,
useUpdateDepartmentMutation,
useDeleteDepartmentMutation,
useGetProfessionsQuery,
useCreateProfessionMutation,
useUpdateProfessionMutation,

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
Stack,
Title,
@@ -12,308 +12,208 @@ import {
Modal,
Text,
Select,
Badge,
Paper,
Loader,
Center,
Alert,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { IconEdit, IconTrash, IconPlus, IconBuilding, IconBriefcase, IconMap } from '@tabler/icons-react';
import { IconEdit, IconTrash, IconPlus, IconBriefcase, IconMap, IconInfoCircle } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { notify } from '@ema-platform/ui';
import { LocationPage } from '../../location/pages/LocationPage';
import type { Department, Profession } from '../types/configuration';
import {
useGetDepartmentsQuery,
useGetProfessionsQuery,
useCreateProfessionMutation,
useUpdateProfessionMutation,
useDeleteProfessionMutation,
} from '../api/configuration-api';
import type { Profession } from '../types/configuration';
let nextId = 1;
const uid = () => String(nextId++);
const MOCK_DEPARTMENTS: Department[] = [
{ id: uid(), code: 'IT', names: { en: 'Information Technology', am: 'ኢንፎርሜሽን ቴክኖሎጂ' }, description: 'Handles all IT infrastructure and systems.', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
{ id: uid(), code: 'HR', names: { en: 'Human Resources', am: 'የሰው ኃይል ሀብት' }, description: 'Manages personnel and recruitment.', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
{ id: uid(), code: 'FIN', names: { en: 'Finance', am: 'ፋይናንስ' }, description: 'Oversees budgeting and accounting.', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
];
const MOCK_PROFESSIONS: Profession[] = [
{ id: uid(), code: 'SWE', names: { en: 'Software Engineer', am: 'የሶፍትዌር መሐንዲስ' }, description: 'Develops and maintains software applications.', departmentId: '1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
{ id: uid(), code: 'SRE', names: { en: 'Site Reliability Engineer', am: 'የጣቢያ አስተማማኝነት መሐንዲስ' }, description: 'Ensures system reliability and uptime.', departmentId: '1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
{ id: uid(), code: 'HRM', names: { en: 'HR Manager', am: 'የሰው ኃይል አስተዳዳሪ' }, description: 'Leads the HR team and strategy.', departmentId: '2', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
];
function DepartmentTab() {
const { t } = useTranslation();
const [departments, setDepartments] = useState<Department[]>(MOCK_DEPARTMENTS);
const [editingDept, setEditingDept] = useState<Department | null>(null);
const [showDeptForm, setShowDeptForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Department | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const deptForm = useForm({
initialValues: { code: '', nameEn: '', nameAm: '', description: '' },
validate: {
code: (v) => (!v ? t('configuration.validation.codeRequired') : null),
nameEn: (v) => (!v ? t('configuration.validation.nameEnRequired') : null),
nameAm: (v) => (!v ? t('configuration.validation.nameAmRequired') : null),
},
});
const resetDeptForm = () => {
deptForm.reset();
setEditingDept(null);
setShowDeptForm(false);
};
const handleEditDept = (dept: Department) => {
setEditingDept(dept);
deptForm.setValues({
code: dept.code,
nameEn: dept.names.en,
nameAm: dept.names.am,
description: dept.description,
});
setShowDeptForm(true);
};
const handleDeleteDept = (dept: Department) => {
setDeleteTarget(dept);
openDelete();
};
const confirmDeleteDept = () => {
if (!deleteTarget) return;
setDepartments((prev) => prev.filter((d) => d.id !== deleteTarget.id));
notify.success(t('configuration.deleted'));
closeDelete();
setDeleteTarget(null);
};
const handleDeptSubmit = deptForm.onSubmit((values) => {
const now = new Date().toISOString();
if (editingDept) {
setDepartments((prev) =>
prev.map((d) =>
d.id === editingDept.id
? { ...d, code: values.code, names: { en: values.nameEn, am: values.nameAm }, description: values.description, updatedAt: now }
: d
)
);
notify.success(t('configuration.updated'));
} else {
const newDept: Department = {
id: uid(),
code: values.code,
names: { en: values.nameEn, am: values.nameAm },
description: values.description,
createdAt: now,
updatedAt: now,
};
setDepartments((prev) => [...prev, newDept]);
notify.success(t('configuration.created'));
}
resetDeptForm();
});
return (
<>
<Group justify="space-between" mb="md">
<Text fw={600} size="sm">{t('configuration.departmentsList')}</Text>
{!showDeptForm && (
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => { resetDeptForm(); setShowDeptForm(true); }}
size="sm"
>
{t('configuration.addDepartment')}
</Button>
)}
</Group>
{showDeptForm && (
<Paper p="md" withBorder mb="md" radius="md">
<form onSubmit={handleDeptSubmit}>
<Stack gap="sm">
<TextInput
label={t('configuration.code')}
placeholder="e.g., IT, HR"
{...deptForm.getInputProps('code')}
size="sm"
/>
<TextInput
label={t('configuration.nameEn')}
placeholder="English name"
{...deptForm.getInputProps('nameEn')}
size="sm"
/>
<TextInput
label={t('configuration.nameAm')}
placeholder="የአማርኛ ስም"
{...deptForm.getInputProps('nameAm')}
size="sm"
/>
<Textarea
label={t('configuration.description')}
placeholder="Optional description"
{...deptForm.getInputProps('description')}
size="sm"
autosize
minRows={2}
/>
<Group justify="flex-end">
<Button variant="default" onClick={resetDeptForm} size="sm">
{t('configuration.cancel')}
</Button>
<Button type="submit" size="sm">
{editingDept ? t('configuration.update') : t('configuration.create')}
</Button>
</Group>
</Stack>
</form>
</Paper>
)}
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('configuration.code')}</Table.Th>
<Table.Th>{t('configuration.nameEn')}</Table.Th>
<Table.Th>{t('configuration.nameAm')}</Table.Th>
<Table.Th>{t('configuration.description')}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{departments.map((dept) => (
<Table.Tr key={dept.id}>
<Table.Td>
<Badge size="sm" variant="light" color="blue">{dept.code}</Badge>
</Table.Td>
<Table.Td>{dept.names.en}</Table.Td>
<Table.Td>{dept.names.am}</Table.Td>
<Table.Td>
<Text size="sm" lineClamp={2} maw={200}>{dept.description}</Text>
</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handleEditDept(dept)}>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handleDeleteDept(dept)}>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
{departments.length === 0 && (
<Table.Tr>
<Table.Td colSpan={5}>
<Text c="dimmed" ta="center" py="xl">
{t('configuration.noDepartments')}
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
<Modal opened={deleteOpened} onClose={closeDelete} title={t('configuration.confirmDelete')} size="sm">
<Text mb="md">
{t('configuration.deleteConfirmText', { name: deleteTarget?.names.en ?? '' })}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeDelete} size="sm">{t('configuration.cancel')}</Button>
<Button color="red" onClick={confirmDeleteDept} size="sm">{t('configuration.delete')}</Button>
</Group>
</Modal>
</>
);
interface ProfFormValues {
nameEn: string;
nameAm: string;
descEn: string;
descAm: string;
departmentId: string;
}
function ProfessionTab() {
const { t } = useTranslation();
const [departments] = useState<Department[]>(MOCK_DEPARTMENTS);
const [professions, setProfessions] = useState<Profession[]>(MOCK_PROFESSIONS);
const [editingProf, setEditingProf] = useState<Profession | null>(null);
const [showProfForm, setShowProfForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Profession | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
interface ProfFormProps {
editingProf: Profession | null;
deptOptions: { value: string; label: string }[];
isSubmitting: boolean;
onSubmit: (values: ProfFormValues, isEdit: boolean) => void;
onCancel: () => void;
}
const profForm = useForm({
initialValues: { code: '', nameEn: '', nameAm: '', description: '', departmentId: '' },
function ProfessionForm({ editingProf, deptOptions, isSubmitting, onSubmit, onCancel }: ProfFormProps) {
const { t } = useTranslation();
const form = useForm<ProfFormValues>({
initialValues: { nameEn: '', nameAm: '', descEn: '', descAm: '', departmentId: '' },
validate: {
code: (v) => (!v ? t('configuration.validation.codeRequired') : null),
nameEn: (v) => (!v ? t('configuration.validation.nameEnRequired') : null),
nameAm: (v) => (!v ? t('configuration.validation.nameAmRequired') : null),
departmentId: (v) => (!v ? t('configuration.validation.departmentRequired') : null),
},
});
const resetProfForm = () => {
profForm.reset();
setEditingProf(null);
setShowProfForm(false);
};
const handleEditProf = (prof: Profession) => {
setEditingProf(prof);
profForm.setValues({
code: prof.code,
nameEn: prof.names.en,
nameAm: prof.names.am,
description: prof.description,
departmentId: prof.departmentId,
});
setShowProfForm(true);
};
const handleDeleteProf = (prof: Profession) => {
setDeleteTarget(prof);
openDelete();
};
const confirmDeleteProf = () => {
if (!deleteTarget) return;
setProfessions((prev) => prev.filter((p) => p.id !== deleteTarget.id));
notify.success(t('configuration.deleted'));
closeDelete();
setDeleteTarget(null);
};
const handleProfSubmit = profForm.onSubmit((values) => {
const now = new Date().toISOString();
useEffect(() => {
if (editingProf) {
setProfessions((prev) =>
prev.map((p) =>
p.id === editingProf.id
? { ...p, code: values.code, names: { en: values.nameEn, am: values.nameAm }, description: values.description, departmentId: values.departmentId, updatedAt: now }
: p
)
);
notify.success(t('configuration.updated'));
} else {
const newProf: Profession = {
id: uid(),
code: values.code,
names: { en: values.nameEn, am: values.nameAm },
description: values.description,
departmentId: values.departmentId,
createdAt: now,
updatedAt: now,
};
setProfessions((prev) => [...prev, newProf]);
notify.success(t('configuration.created'));
form.setValues({
nameEn: editingProf.name.en,
nameAm: editingProf.name.am,
descEn: editingProf.description.en ?? '',
descAm: editingProf.description.am ?? '',
departmentId: editingProf.departmentId,
});
}
resetProfForm();
});
}, [editingProf]);
const deptOptions = departments.map((d) => ({
const handleSubmit = form.onSubmit((values) => onSubmit(values, !!editingProf));
return (
<Paper p="md" withBorder mb="md" radius="md">
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<TextInput
label={t('configuration.nameEn')}
placeholder="English name"
{...form.getInputProps('nameEn')}
size="sm"
/>
<TextInput
label={t('configuration.nameAm')}
placeholder="የአማርኛ ስም"
{...form.getInputProps('nameAm')}
size="sm"
/>
<Textarea
label={t('configuration.descEn')}
placeholder="English description"
{...form.getInputProps('descEn')}
size="sm"
autosize
minRows={2}
/>
<Textarea
label={t('configuration.descAm')}
placeholder="የአማርኛ መግለጫ"
{...form.getInputProps('descAm')}
size="sm"
autosize
minRows={2}
/>
<Select
label={t('configuration.department')}
placeholder={t('configuration.selectDepartment')}
data={deptOptions}
{...form.getInputProps('departmentId')}
size="sm"
searchable
/>
<Group justify="flex-end">
<Button variant="default" onClick={onCancel} size="sm">
{t('configuration.cancel')}
</Button>
<Button type="submit" size="sm" loading={isSubmitting}>
{editingProf ? t('configuration.update') : t('configuration.create')}
</Button>
</Group>
</Stack>
</form>
</Paper>
);
}
function ProfessionTab() {
const { t } = useTranslation();
const { data: deptRes } = useGetDepartmentsQuery();
const { data: profRes, isLoading, isError } = useGetProfessionsQuery();
const [createProfession, { isLoading: isCreating }] = useCreateProfessionMutation();
const [updateProfession, { isLoading: isUpdating }] = useUpdateProfessionMutation();
const [deleteProfession] = useDeleteProfessionMutation();
const departments = deptRes?.items ?? [];
const professions = profRes?.items ?? [];
const [editingProf, setEditingProf] = useState<Profession | null>(null);
const [showProfForm, setShowProfForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Profession | null>(null);
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
const deptOptions = departments.filter((d) => d.isActive).map((d) => ({
value: d.id,
label: `${d.code}${d.names.en}`,
label: d.name.en,
}));
const getDeptName = (deptId: string) => {
const resetProfForm = useCallback(() => {
setEditingProf(null);
setShowProfForm(false);
}, []);
const handleEditProf = useCallback((prof: Profession) => {
setEditingProf(prof);
setShowProfForm(true);
}, []);
const handleDeleteProf = useCallback((prof: Profession) => {
setDeleteTarget(prof);
openDelete();
}, [openDelete]);
const confirmDeleteProf = useCallback(async () => {
if (!deleteTarget) return;
try {
await deleteProfession(deleteTarget.id).unwrap();
notify.success(t('configuration.deleted'));
closeDelete();
setDeleteTarget(null);
} catch {
notify.error(t('configuration.error'));
}
}, [deleteTarget, deleteProfession, closeDelete, t]);
const handleProfSubmit = useCallback(async (values: ProfFormValues) => {
const name = { en: values.nameEn, am: values.nameAm };
const description = { en: values.descEn, am: values.descAm };
try {
if (editingProf) {
await updateProfession({
id: editingProf.id,
name,
description,
departmentId: values.departmentId,
}).unwrap();
notify.success(t('configuration.updated'));
} else {
await createProfession({
departmentId: values.departmentId,
name,
description,
}).unwrap();
notify.success(t('configuration.created'));
}
resetProfForm();
} catch {
notify.error(t('configuration.error'));
}
}, [editingProf, createProfession, updateProfession, resetProfForm, t]);
const getDeptName = useCallback((deptId: string) => {
const dept = departments.find((d) => d.id === deptId);
return dept ? `${dept.code}${dept.names.en}` : '-';
};
return dept ? dept.name.en : '-';
}, [departments]);
if (isLoading) {
return <Center py="xl"><Loader /></Center>;
}
if (isError) {
return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('configuration.error')} />;
}
return (
<>
@@ -323,7 +223,7 @@ function ProfessionTab() {
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => { resetProfForm(); setShowProfForm(true); }}
onClick={() => setShowProfForm(true)}
size="sm"
>
{t('configuration.addProfession')}
@@ -332,60 +232,18 @@ function ProfessionTab() {
</Group>
{showProfForm && (
<Paper p="md" withBorder mb="md" radius="md">
<form onSubmit={handleProfSubmit}>
<Stack gap="sm">
<TextInput
label={t('configuration.code')}
placeholder="e.g., SWE, HRM"
{...profForm.getInputProps('code')}
size="sm"
/>
<TextInput
label={t('configuration.nameEn')}
placeholder="English name"
{...profForm.getInputProps('nameEn')}
size="sm"
/>
<TextInput
label={t('configuration.nameAm')}
placeholder="የአማርኛ ስም"
{...profForm.getInputProps('nameAm')}
size="sm"
/>
<Textarea
label={t('configuration.description')}
placeholder="Optional description"
{...profForm.getInputProps('description')}
size="sm"
autosize
minRows={2}
/>
<Select
label={t('configuration.department')}
placeholder={t('configuration.selectDepartment')}
data={deptOptions}
{...profForm.getInputProps('departmentId')}
size="sm"
searchable
/>
<Group justify="flex-end">
<Button variant="default" onClick={resetProfForm} size="sm">
{t('configuration.cancel')}
</Button>
<Button type="submit" size="sm">
{editingProf ? t('configuration.update') : t('configuration.create')}
</Button>
</Group>
</Stack>
</form>
</Paper>
<ProfessionForm
editingProf={editingProf}
deptOptions={deptOptions}
isSubmitting={isCreating || isUpdating}
onSubmit={handleProfSubmit}
onCancel={resetProfForm}
/>
)}
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('configuration.code')}</Table.Th>
<Table.Th>{t('configuration.nameEn')}</Table.Th>
<Table.Th>{t('configuration.nameAm')}</Table.Th>
<Table.Th>{t('configuration.description')}</Table.Th>
@@ -394,15 +252,12 @@ function ProfessionTab() {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{professions.map((prof) => (
{professions.filter((p) => p.isActive).map((prof) => (
<Table.Tr key={prof.id}>
<Table.Td>{prof.name.en}</Table.Td>
<Table.Td>{prof.name.am}</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="teal">{prof.code}</Badge>
</Table.Td>
<Table.Td>{prof.names.en}</Table.Td>
<Table.Td>{prof.names.am}</Table.Td>
<Table.Td>
<Text size="sm" lineClamp={2} maw={200}>{prof.description}</Text>
<Text size="sm" lineClamp={2} maw={200}>{prof.description.en ?? prof.description.am}</Text>
</Table.Td>
<Table.Td>{getDeptName(prof.departmentId)}</Table.Td>
<Table.Td>
@@ -419,7 +274,7 @@ function ProfessionTab() {
))}
{professions.length === 0 && (
<Table.Tr>
<Table.Td colSpan={6}>
<Table.Td colSpan={5}>
<Text c="dimmed" ta="center" py="xl">
{t('configuration.noProfessions')}
</Text>
@@ -431,7 +286,7 @@ function ProfessionTab() {
<Modal opened={deleteOpened} onClose={closeDelete} title={t('configuration.confirmDelete')} size="sm">
<Text mb="md">
{t('configuration.deleteConfirmText', { name: deleteTarget?.names.en ?? '' })}
{t('configuration.deleteConfirmText', { name: deleteTarget?.name?.en ?? '' })}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeDelete} size="sm">{t('configuration.cancel')}</Button>
@@ -449,11 +304,8 @@ export function ConfigurationPage() {
<Stack gap="lg">
<Title order={2}>{t('configuration.title')}</Title>
<Tabs defaultValue="departments">
<Tabs defaultValue="professions">
<Tabs.List>
<Tabs.Tab value="departments" leftSection={<IconBuilding size={16} />}>
{t('configuration.departments')}
</Tabs.Tab>
<Tabs.Tab value="professions" leftSection={<IconBriefcase size={16} />}>
{t('configuration.professions')}
</Tabs.Tab>
@@ -462,10 +314,6 @@ export function ConfigurationPage() {
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="departments" pt="md">
<DepartmentTab />
</Tabs.Panel>
<Tabs.Panel value="professions" pt="md">
<ProfessionTab />
</Tabs.Panel>

View File

@@ -5,20 +5,20 @@ export interface NamePair {
export interface Department {
id: string;
code: string;
names: NamePair;
description: string;
name: NamePair;
description: NamePair;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface Profession {
id: string;
code: string;
names: NamePair;
description: string;
departmentId: string;
department?: Department;
name: NamePair;
description: NamePair;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
@@ -28,23 +28,16 @@ export interface ListResponse<T> {
items: T[];
}
export interface CreateDepartmentPayload {
code: string;
names: NamePair;
description: string;
}
export interface UpdateDepartmentPayload extends CreateDepartmentPayload {
id: string;
}
export interface CreateProfessionPayload {
code: string;
names: NamePair;
description: string;
departmentId: string;
name: NamePair;
description: NamePair;
}
export interface UpdateProfessionPayload extends CreateProfessionPayload {
export interface UpdateProfessionPayload {
id: string;
departmentId?: string;
name?: NamePair;
description?: NamePair;
isActive?: boolean;
}

View File

@@ -1,96 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { authStorage } from '@ema-platform/auth';
/**
* Same-origin host for the user-management module.
*
* The host app (React 19 / Mantine 8 / Tailwind 3) embeds the module (React 18 /
* Mantine 7 / Tailwind 4) via an iframe so the two never share a React tree,
* router, or CSS — the version mismatch is fully isolated by the document
* boundary. The module is built into apps/backoffice/public/_um and served by
* THIS same server at <origin>/_um/, so there is no second server and no second
* port. Override the mount path with VITE_USER_MANAGEMENT_BASE (default /_um).
*
* SSO: the module and host authenticate against the SAME backend, so the host's
* token is valid in the module. The module posts `UM_REQUEST_AUTH`; we reply with
* our stored token. Route-sync mirrors the module's internal route into the host
* URL (/um/<path>) so a refresh deep-links back to the selected menu.
*/
function readToken(): string | null {
const escaped = 'auth-token'.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
const match = document.cookie.match(new RegExp('(?:^|; )' + escaped + '=([^;]*)'));
return authStorage.getToken() ?? (match ? decodeURIComponent(match[1]) : null);
}
function readRefreshToken(): string | null {
return authStorage.getRefreshToken() ?? null;
}
export default function UserManagementHostPage() {
const navigate = useNavigate();
const location = useLocation();
const iframeRef = useRef<HTMLIFrameElement>(null);
// Same-origin sub-path the module is served from (matches the module's Vite
// `base` + the apps/backoffice/public/_um build). Same origin ⇒ no second port.
const mountBase = (
(import.meta.env.VITE_USER_MANAGEMENT_BASE as string | undefined) ?? '/_um'
).replace(/\/$/, '');
const moduleOrigin = window.location.origin;
// Deep-link: the host route is /um/*, so whatever follows /um is the module's
// own route. Compute src ONCE (frozen) so later parent-URL updates don't reload.
const [iframeSrc] = useState(() => {
const sub = location.pathname.replace(/^\/um(?=\/|$)/, '');
return mountBase + sub + location.search;
});
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.origin !== moduleOrigin) return;
const data = event.data as { type?: string; path?: string } | undefined;
if (!data) return;
if (data.type === 'UM_REQUEST_AUTH') {
const token = readToken();
const refreshToken = readRefreshToken();
const target = iframeRef.current?.contentWindow;
if (token && target) {
target.postMessage({ type: 'UM_AUTH_TOKEN', token, refreshToken }, moduleOrigin);
}
return;
}
if (data.type === 'UM_ROUTE_CHANGED' && typeof data.path === 'string') {
// Allow the module to navigate the host away by using /return/<path>.
// Add a nav item with href: "/return/dashboard" in project.theme.ts
// navItems to send the user back to the host app.
const returnMatch = data.path.match(/^\/return\/(.+)/);
if (returnMatch) {
navigate('/' + returnMatch[1], { replace: true });
return;
}
const target = '/um' + data.path;
if (window.location.pathname + window.location.search !== target) {
navigate(target, { replace: true });
}
}
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [moduleOrigin, navigate]);
return (
<div style={{ position: 'fixed', inset: 0 }}>
<iframe
ref={iframeRef}
title="User Management"
src={iframeSrc}
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
/>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useNavigate } from 'react-router-dom';
import { UserManagementApp } from '@tria-plc/iamui';
import type { DesignConfig, UserManagementSessionOptions } from '@tria-plc/iamui';
import '@tria-plc/iamui/style.css';
import './um-overrides.css';
const UM_CONFIG: DesignConfig = {
brand: {
appName: 'Ethiopian Maritime Licence',
logoUrl: '/assets/emaLogo.jpg',
},
colors: {
primary: '#2563eb',
sidebar: '#ffffff',
background: '#f8fafc',
foreground: '#1e293b',
border: '#e2e8f0',
mutedForeground: '#94a3b8',
card: '#ffffff',
},
typography: {
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
},
layout: {
userManagementView: 'classic',
sidebarBrandLabel: 'Ethiopian Maritime Authority',
sidebarBrandSublabel: 'User Management',
sidebarBackground: '#ffffff',
sidebarColor: '#1e293b',
sidebarMutedColor: '#94a3b8',
sidebarActiveBackground: '#eff6ff',
sidebarActiveColor: '#2563eb',
sidebarHoverBackground: '#f8fafc',
sidebarBorder: '#e2e8f0',
sidebarWidth: '280px',
sidebarCollapsedWidth: '80px',
modalAccentColor: '#2563eb',
modalHeaderBackground: '#f8fafc',
modalHeaderEditBackground: '#eff6ff',
modalIconBackground: '#eff6ff',
modalIconColor: '#2563eb',
modalTitleColor: '#1e293b',
modalFocusColor: '#2563eb',
modalSurface: '#ffffff',
},
};
const UM_RUNTIME = {
basename: '/um',
apiUrl: import.meta.env.VITE_BASE_API_URL,
};
const buttonStyle: React.CSSProperties = {
position: 'fixed',
top: 12,
left: 12,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 16px',
border: '1px solid #e2e8f0',
borderRadius: 8,
background: '#ffffff',
color: '#2563eb',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
transition: 'all 150ms ease',
};
export default function UserManagementPage() {
const containerRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<Root | null>(null);
const navigate = useNavigate();
const handleReturn = useCallback(() => {
navigate('/dashboard');
}, [navigate]);
useEffect(() => {
if (!containerRef.current) return;
const token = localStorage.getItem('ema-backoffice-auth-token') ?? '';
const refreshToken = localStorage.getItem('ema-backoffice-refresh-token') ?? undefined;
const session: UserManagementSessionOptions = {
initialSession: token
? { token, refreshToken, rememberMe: true }
: null,
enableEmbeddedAuthBridge: false,
};
rootRef.current = createRoot(containerRef.current);
rootRef.current.render(
<UserManagementApp config={UM_CONFIG} runtime={UM_RUNTIME} session={session} />,
);
return () => {
if (rootRef.current) {
rootRef.current.unmount();
rootRef.current = null;
}
};
}, []);
return (
<>
<button
onClick={handleReturn}
style={buttonStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f8fafc';
e.currentTarget.style.boxShadow = '0 1px 6px rgba(0,0,0,0.12)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ffffff';
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.08)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
Return to EMA
</button>
<div ref={containerRef} style={{ position: 'fixed', inset: 0 }} />
</>
);
}

View File

@@ -0,0 +1,22 @@
.um-theme-light {
--background: #ffffff;
--foreground: #1f2937;
--card: #ffffff;
--card-foreground: #1f2937;
--popover: #ffffff;
--popover-foreground: #1f2937;
--secondary: #f1f5f9;
--secondary-foreground: #1e293b;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: var(--primary);
--accent-foreground: var(--primary-foreground);
--border: #e2e8f0;
--input: #e2e8f0;
--sidebar: #ffffff;
--sidebar-foreground: #1e293b;
--sidebar-accent: #f1f5f9;
--sidebar-accent-foreground: #1e293b;
--sidebar-border: #e2e8f0;
--sidebar-ring: var(--primary);
}

View File

@@ -231,10 +231,10 @@ export const am: Translations = {
professionsList: 'ሙያዎች',
addDepartment: 'ክፍል ያክሉ',
addProfession: 'ሙያ ያክሉ',
code: 'ኮድ',
nameEn: 'ስም (እንግሊዝኛ)',
nameAm: 'ስም (አማርኛ)',
description: 'መግለጫ',
descEn: 'መግለጫ (እንግሊዝኛ)',
descAm: 'መግለጫ (አማርኛ)',
department: 'ክፍል',
selectDepartment: 'ክፍል ይምረጡ',
cancel: 'ሰርዝ',
@@ -251,7 +251,6 @@ export const am: Translations = {
noDepartments: 'ገና ምንም ክፍሎች አልተገለጹም',
noProfessions: 'ገና ምንም ሙያዎች አልተገለጹም',
validation: {
codeRequired: 'ኮድ ያስፈልጋል',
nameEnRequired: 'የእንግሊዝኛ ስም ያስፈልጋል',
nameAmRequired: 'የአማርኛ ስም ያስፈልጋል',
departmentRequired: 'ክፍል ያስፈልጋል',

View File

@@ -230,10 +230,10 @@ export const en = {
professionsList: 'Professions',
addDepartment: 'Add Department',
addProfession: 'Add Profession',
code: 'Code',
nameEn: 'Name (English)',
nameAm: 'Name (Amharic)',
description: 'Description',
descEn: 'Description (English)',
descAm: 'Description (Amharic)',
department: 'Department',
selectDepartment: 'Select department',
cancel: 'Cancel',
@@ -250,7 +250,6 @@ export const en = {
noDepartments: 'No departments defined yet',
noProfessions: 'No professions defined yet',
validation: {
codeRequired: 'Code is required',
nameEnRequired: 'English name is required',
nameAmRequired: 'Amharic name is required',
departmentRequired: 'Department is required',

View File

@@ -19,6 +19,7 @@ import {
IconSettings,
IconUser,
IconUsers,
IconUserShield,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
@@ -26,7 +27,7 @@ import { useAppDispatch, useAppSelector } from '../store/hooks';
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
{ to: '/um/user-management/dashboard', label: 'User Management', icon: IconUsers },
{ to: '/um/user-management/dashboard', label: 'User Management', icon: IconUserShield },
{ to: '/seaman-book-queue', label: 'Seaman Book Queue', icon: IconBook2 },
{ to: '/coc-queue', label: 'CoC / CoP Queue', icon: IconShieldCheck },
{ to: '/endorsement-queue', label: 'Endorsement Queue', icon: IconRubberStamp },

View File

@@ -12,7 +12,7 @@ import { AuthLayout } from '../layouts/AuthLayout';
import { BackofficeLayout } from '../layouts/BackofficeLayout';
import { ProtectedRoute } from './ProtectedRoute';
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
import UserManagementPage from '../features/user-management/UserManagementPage';
import { ProfilePage } from '../features/profile/pages/ProfilePage';
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
import { LocationPage } from '../features/location/pages/LocationPage';
@@ -36,7 +36,9 @@ const router = createBrowserRouter([
{ path: '/otp-verify', element: <OTPVerificationPage /> },
],
},
{ path: '/um/*', element: <UserManagementHostPage /> },
{ path: '/um/*', element: <UserManagementPage /> },
{ path: '/', element: <Navigate to="/dashboard" replace /> },
{ path: '/profile-setup', element: <Navigate to="/dashboard" replace /> },
{
element: <ProtectedRoute />,
children: [

View File

@@ -2,20 +2,6 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
function userManagementSpaFallback() {
const rewrite = (req) => {
const url = req.url || '';
if (!url.startsWith('/_um/') && url !== '/_um') return;
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return; // real assets pass through
req.url = '/_um/index.html';
};
return {
name: 'user-management-spa-fallback',
configureServer(s) { s.middlewares.use((req, _res, next) => { rewrite(req); next(); }); },
configurePreviewServer(s) { s.middlewares.use((req, _res, next) => { rewrite(req); next(); }); },
};
}
export default defineConfig({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/backoffice',
@@ -24,7 +10,7 @@ export default defineConfig({
host: 'localhost',
},
preview: { port: 4201, host: 'localhost' },
plugins: [react(), nxViteTsPaths(), userManagementSpaFallback()],
plugins: [react(), nxViteTsPaths()],
resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
},

View File

@@ -1,10 +1,7 @@
import { RouterProvider } from 'react-router-dom';
import { configureIam } from '@tria-plc/iamui-common';
import { AppProviders } from './providers/AppProviders';
import { router } from './router';
// IAM module configuration (used by the isolated /users admin route).
configureIam({ apiUrl: 'http://localhost:3001/api' });
export function App() {
return (

View File

@@ -30,7 +30,6 @@ import {
IconShieldCheck,
} from '@tabler/icons-react';
import { authStorage } from '@ema-platform/auth';
import { useAppSelector } from '../../../store/hooks';
// ---------------------------------------------------------------------------
// Mock data
@@ -77,7 +76,7 @@ async function generateCertificate(profileId: string): Promise<Blob> {
const token = authStorage.getToken();
if (!token) throw new Error('No auth token found');
const res = await fetch(
`${API_BASE}/profiles/generate-seafarer-certificate/e04c4a7c-0feb-4af6-ab00-5ef6600ee2b4`,
`${API_BASE}/profiles/generate-seafarer-certificate/${profileId}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) throw new Error(`Failed to generate certificate (${res.status})`);
@@ -95,8 +94,7 @@ function downloadBlob(blob: Blob, filename: string) {
export function CertificatesPage() {
const navigate = useNavigate();
const user = useAppSelector((state) => state.auth.user);
const profileId = user?.id ?? '';
const profileId = authStorage.getProfileId() ?? '';
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewTitle, setPreviewTitle] = useState('');
const [loading, setLoading] = useState(false);

View File

@@ -0,0 +1,564 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Box,
Button,
Center,
Group,
Loader,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
rem,
} from '@mantine/core';
import {
IconArrowLeft,
IconArrowRight,
IconCheck,
IconCircleCheck,
IconMapPin,
IconUser,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
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 { useAppDispatch, useAppSelector } from '../../../store/hooks';
const GENDERS = ['MALE', 'FEMALE'];
const MARITAL_STATUSES = ['SINGLE', 'MARRIED', 'DIVORCED', 'WIDOWED'];
const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'];
const STEPS = [
{ label: 'Profile', icon: IconUser },
{ label: 'Address', icon: IconMapPin },
];
const profileSchema = z.object({
professionId: z.string().min(1, 'Select your profession'),
firstName: z.string().min(3, 'First name must be at least 3 characters'),
middleName: z.string().min(3, 'Middle name must be at least 3 characters'),
lastName: z.string().min(3, 'Last name must be at least 3 characters'),
gender: z.string().min(1, 'Select your gender'),
dob: z.string().min(1, 'Select your date of birth'),
pob: z.string().optional(),
maritalStatus: z.string().min(1, 'Select your marital status'),
});
type ProfileValues = z.infer<typeof profileSchema>;
const addressSchema = z.object({
idType: z.string().min(1, 'Select ID type'),
idNumber: z.string().min(1, 'Enter ID number'),
nationality: z.string().min(1, 'Enter nationality'),
primaryPhoneNumber: z.string().min(1, 'Enter primary phone number'),
secondaryPhoneNumber: z.string().optional(),
email: z.string().email('Invalid email').optional().or(z.literal('')),
regionId: z.string().optional(),
cityId: z.string().optional(),
subcityId: z.string().optional(),
woredaId: z.string().optional(),
kebeleId: z.string().optional(),
streetAddress: z.string().optional(),
postalAddress: z.string().optional(),
emergencyContactName: z.string().optional(),
emergencyContactPhone: z.string().optional(),
emergencyContactRelation: z.string().optional(),
});
type AddressValues = z.infer<typeof addressSchema>;
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap">
{STEPS.map((step, i) => {
const isDone = completed.includes(i);
const isCurrent = active === i;
return (
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
<Box
style={{
width: rem(40),
height: rem(40),
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: isCurrent
? 'var(--mantine-color-blue-7)'
: 'var(--mantine-color-gray-1)',
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
{isDone ? (
<IconCheck size={18} color="white" stroke={2.5} />
) : (
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
{i + 1}
</Text>
)}
</Box>
<Text
fz="xs"
fw={isCurrent ? 700 : 400}
c={isCurrent ? 'blue.7' : 'dimmed'}
style={{ whiteSpace: 'nowrap' }}
>
{step.label}
</Text>
</Stack>
{i < STEPS.length - 1 && (
<Box
style={{
flex: 1,
height: rem(2),
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: 'var(--mantine-color-gray-2)',
marginBottom: rem(22),
}}
/>
)}
</Group>
);
})}
</Group>
</Box>
);
}
function inferUserType(professionName: string): string {
const name = professionName.toLowerCase();
if (name.includes('seafarer')) return 'SEAFARER';
return 'EMPLOYEE';
}
export function ProfileSetupPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.auth.user);
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string } }>>([]);
const [professionsLoading, setProfessionsLoading] = useState(true);
const [profileTrigger] = useApiMutation<{ id: string }>();
const [addressTrigger] = useApiMutation<unknown>();
const [meTrigger] = useApiMutation<{ id: string }>();
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchProfessions({ url: '/professions?take=100', method: 'GET' })
.unwrap()
.then((data) => setProfessions(data.items ?? []))
.catch(() => setProfessions([]))
.finally(() => setProfessionsLoading(false));
}, [fetchProfessions]);
const professionOptions = useMemo(
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
[professions],
);
const professionNameMap = useMemo(() => {
const map: Record<string, string> = {};
professions.forEach((p) => {
map[p.id] = p.name.en;
});
return map;
}, [professions]);
const {
register: profileRegister,
handleSubmit: profileHandleSubmit,
formState: { errors: profileErrors },
setValue: profileSetValue,
watch: profileWatch,
trigger: profileTriggerValidation,
} = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
professionId: '',
firstName: '',
middleName: '',
lastName: '',
gender: '',
dob: '',
pob: '',
maritalStatus: '',
},
});
const {
register: addressRegister,
handleSubmit: addressHandleSubmit,
formState: { errors: addressErrors },
setValue: addressSetValue,
watch: addressWatch,
trigger: addressTriggerValidation,
} = useForm<AddressValues>({
resolver: zodResolver(addressSchema),
defaultValues: {
idType: '',
idNumber: '',
nationality: '',
primaryPhoneNumber: '',
secondaryPhoneNumber: '',
email: '',
regionId: '',
cityId: '',
subcityId: '',
woredaId: '',
kebeleId: '',
streetAddress: '',
postalAddress: '',
emergencyContactName: '',
emergencyContactPhone: '',
emergencyContactRelation: '',
},
});
const onNext = async () => {
const valid = await profileTriggerValidation();
if (!valid) return;
setCompleted((prev) => (prev.includes(active) ? prev : [...prev, active]));
setActive((c) => c + 1);
};
const onSubmitAddress = async () => {
const valid = await addressTriggerValidation();
if (!valid) return;
setSubmitting(true);
try {
const pv = profileWatch();
const av = addressWatch();
const selectedProfessionName = professionNameMap[pv.professionId] ?? '';
const profileResult = await profileTrigger({
url: '/profiles',
method: 'POST',
body: {
userId: user?.id,
type: inferUserType(selectedProfessionName),
professionId: pv.professionId,
firstName: pv.firstName,
middleName: pv.middleName,
lastName: pv.lastName,
gender: pv.gender,
dob: pv.dob,
pob: pv.pob || undefined,
maritalStatus: pv.maritalStatus,
},
}).unwrap();
authStorage.setProfileId(profileResult.id);
await addressTrigger({
url: `/addresses/profile/${profileResult.id}`,
method: 'POST',
body: {
idType: av.idType,
idNumber: av.idNumber,
nationality: av.nationality,
primaryPhoneNumber: av.primaryPhoneNumber,
secondaryPhoneNumber: av.secondaryPhoneNumber || undefined,
email: av.email || undefined,
regionId: av.regionId || undefined,
cityId: av.cityId || undefined,
subcityId: av.subcityId || undefined,
woredaId: av.woredaId || undefined,
kebeleId: av.kebeleId || undefined,
streetAddress: av.streetAddress || undefined,
postalAddess: av.postalAddress || undefined,
emergencyContactName: av.emergencyContactName || undefined,
emergencyContactPhone: av.emergencyContactPhone || undefined,
emergencyContactRelation: av.emergencyContactRelation || undefined,
},
}).unwrap();
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
notify.success('Profile setup complete!');
navigate('/dashboard');
} catch {
notify.error('Failed to save profile. Please try again.');
} finally {
setSubmitting(false);
}
};
if (!user) {
return (
<Center mih="100vh">
<Text c="dimmed">Please log in first.</Text>
</Center>
);
}
return (
<Center mih="100vh" bg="gray.0">
<Paper withBorder radius="lg" p="xl" maw={900} w="100%" mx="md">
<Stack gap="md">
<div>
<Title order={3}>Complete Your Profile</Title>
<Text fz="sm" c="dimmed">
Set up your profile and address to get started
</Text>
</div>
<StepIndicator active={active} completed={completed} />
{active === 0 && (
<>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
Personal Information
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select
label="Profession"
placeholder={professionsLoading ? 'Loading...' : 'Select'}
required
data={professionOptions}
error={profileErrors.professionId?.message}
value={profileWatch('professionId')}
onChange={(val) => profileSetValue('professionId', val || '', { shouldValidate: true })}
onBlur={() => profileTriggerValidation('professionId')}
name="professionId"
searchable
disabled={professionsLoading}
rightSection={professionsLoading ? <Loader size="xs" /> : undefined}
/>
<TextInput
label="First Name"
placeholder="Enter first name"
required
{...profileRegister('firstName')}
error={profileErrors.firstName?.message}
/>
<TextInput
label="Middle Name"
placeholder="Enter middle name"
required
{...profileRegister('middleName')}
error={profileErrors.middleName?.message}
/>
<TextInput
label="Last Name"
placeholder="Enter last name"
required
{...profileRegister('lastName')}
error={profileErrors.lastName?.message}
/>
<Select
label="Gender"
placeholder="Select"
required
data={GENDERS}
error={profileErrors.gender?.message}
value={profileWatch('gender')}
onChange={(val) => profileSetValue('gender', val || '', { shouldValidate: true })}
onBlur={() => profileTriggerValidation('gender')}
name="gender"
/>
<TextInput
label="Date of Birth"
type="date"
required
{...profileRegister('dob')}
error={profileErrors.dob?.message}
/>
<TextInput
label="Place of Birth"
placeholder="City, Region"
{...profileRegister('pob')}
error={profileErrors.pob?.message}
/>
<Select
label="Marital Status"
placeholder="Select"
required
data={MARITAL_STATUSES}
error={profileErrors.maritalStatus?.message}
value={profileWatch('maritalStatus')}
onChange={(val) => profileSetValue('maritalStatus', val || '', { shouldValidate: true })}
onBlur={() => profileTriggerValidation('maritalStatus')}
name="maritalStatus"
/>
</SimpleGrid>
</>
)}
{active === 1 && (
<>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
Identity & Contact
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<Select
label="ID Type"
placeholder="Select"
required
data={ID_TYPES}
error={addressErrors.idType?.message}
value={addressWatch('idType')}
onChange={(val) => addressSetValue('idType', val || '', { shouldValidate: true })}
onBlur={() => addressTriggerValidation('idType')}
name="idType"
/>
<TextInput
label="ID Number"
placeholder="Enter ID number"
required
{...addressRegister('idNumber')}
error={addressErrors.idNumber?.message}
/>
<TextInput
label="Nationality"
placeholder="e.g. Ethiopian"
required
{...addressRegister('nationality')}
error={addressErrors.nationality?.message}
/>
<TextInput
label="Primary Phone"
placeholder="+251 9XX XXX XXX"
required
{...addressRegister('primaryPhoneNumber')}
error={addressErrors.primaryPhoneNumber?.message}
/>
<TextInput
label="Secondary Phone"
placeholder="+251 9XX XXX XXX"
{...addressRegister('secondaryPhoneNumber')}
error={addressErrors.secondaryPhoneNumber?.message}
/>
<TextInput
label="Email"
type="email"
placeholder="email@example.com"
{...addressRegister('email')}
error={addressErrors.email?.message}
/>
</SimpleGrid>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Address
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Region ID"
placeholder="Region UUID (optional)"
{...addressRegister('regionId')}
error={addressErrors.regionId?.message}
/>
<TextInput
label="City ID"
placeholder="City UUID (optional)"
{...addressRegister('cityId')}
error={addressErrors.cityId?.message}
/>
<TextInput
label="Subcity ID"
placeholder="Subcity UUID (optional)"
{...addressRegister('subcityId')}
error={addressErrors.subcityId?.message}
/>
<TextInput
label="Woreda ID"
placeholder="Woreda UUID (optional)"
{...addressRegister('woredaId')}
error={addressErrors.woredaId?.message}
/>
<TextInput
label="Kebele ID"
placeholder="Kebele UUID (optional)"
{...addressRegister('kebeleId')}
error={addressErrors.kebeleId?.message}
/>
<TextInput
label="Street Address"
placeholder="Street name, house number"
{...addressRegister('streetAddress')}
error={addressErrors.streetAddress?.message}
/>
</SimpleGrid>
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
Emergency Contact
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput
label="Contact Name"
placeholder="Full name"
{...addressRegister('emergencyContactName')}
error={addressErrors.emergencyContactName?.message}
/>
<TextInput
label="Contact Phone"
placeholder="+251 9XX XXX XXX"
{...addressRegister('emergencyContactPhone')}
error={addressErrors.emergencyContactPhone?.message}
/>
<TextInput
label="Relationship"
placeholder="Spouse, Parent, etc."
{...addressRegister('emergencyContactRelation')}
error={addressErrors.emergencyContactRelation?.message}
/>
</SimpleGrid>
</>
)}
<Group justify="space-between" mt="xl">
<Button variant="default" onClick={() => navigate('/dashboard')}>
Cancel
</Button>
<Group gap="sm">
{active > 0 && (
<Button
variant="default"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActive((c) => c - 1)}
>
Previous
</Button>
)}
{active < STEPS.length - 1 ? (
<Button rightSection={<IconArrowRight size={16} />} onClick={onNext}>
Next Step
</Button>
) : (
<Button
color="blue"
leftSection={<IconCircleCheck size={16} />}
onClick={onSubmitAddress}
loading={submitting}
>
Complete Setup
</Button>
)}
</Group>
</Group>
</Stack>
</Paper>
</Center>
);
}

View File

@@ -1,6 +1,5 @@
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@tria-plc/iamui-common';
import { AuthConfigProvider } from '@ema-platform/auth';
import type { ReactNode } from 'react';
import { store } from '../store';
@@ -16,19 +15,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
<ErrorBoundary>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<AuthConfigProvider
value={{
appName: 'Portal',
storagePrefix: 'ema-portal',
loginRedirectPath: '/dashboard',
enableSignup: true,
enableForgotPassword: true,
}}
>
<MantineThemeProvider>{children}</MantineThemeProvider>
</AuthConfigProvider>
</AuthProvider>
<AuthConfigProvider
value={{
appName: 'Portal',
storagePrefix: 'ema-portal',
loginRedirectPath: '/dashboard',
enableSignup: true,
enableForgotPassword: true,
}}
>
<MantineThemeProvider>{children}</MantineThemeProvider>
</AuthConfigProvider>
</QueryClientProvider>
</Provider>
</ErrorBoundary>

View File

@@ -1,5 +1,4 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import type { ReactNode } from 'react';
import { I18nextProvider } from 'react-i18next';
import { i18n } from './i18n/config';
import { PortalLayout } from './layouts/PortalLayout';
@@ -8,6 +7,9 @@ import { ProtectedRoute } from './components/ProtectedRoute';
// Auth (standalone pages, no portal chrome)
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
// Profile setup
import { ProfileSetupPage } from './features/profile-setup/pages/ProfileSetupPage';
// Portal feature pages
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
import { ProfilePage } from './features/profile/pages/ProfilePage';
@@ -29,18 +31,6 @@ import { CoCApplicationPage } from './features/certificates/pages/CoCApplication
// Phase 3 — Endorsement
import { EndorsementPage } from './features/endorsement/pages/EndorsementPage';
// IAM (admin user management) — kept reachable but isolated under its own
// provider so it does not depend on the portal's provider tree.
import {
AppProviders as IamProviders,
UserManagementLayout,
UserManagementPage,
} from '@tria-plc/iamui-common';
function IsolatedIam({ children }: { children: ReactNode }) {
return <IamProviders>{children}</IamProviders>;
}
export const router = createBrowserRouter([
// Public auth pages
{ path: '/login', element: <LoginPage /> },
@@ -55,6 +45,10 @@ export const router = createBrowserRouter([
element: <ProtectedRoute><ForgotPasswordPage /></ProtectedRoute>,
path: '/forgot-password',
},
{
element: <ProtectedRoute><ProfileSetupPage /></ProtectedRoute>,
path: '/profile-setup',
},
// Portal — protected
{
@@ -93,17 +87,5 @@ export const router = createBrowserRouter([
],
},
// IAM admin user management (isolated providers) — protected
{
element: (
<ProtectedRoute>
<IsolatedIam>
<UserManagementLayout />
</IsolatedIam>
</ProtectedRoute>
),
children: [{ path: '/users', element: <UserManagementPage /> }],
},
{ path: '*', element: <Navigate to="/" replace /> },
]);

View File

@@ -2,7 +2,6 @@ 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 './app/theme/portal.css';
import './app/i18n/config';