mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-28 19:30:58 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into estif-branch-1
This commit is contained in:
39
.env.example
Normal file
39
.env.example
Normal file
@@ -0,0 +1,39 @@
|
||||
# emaui environment
|
||||
#
|
||||
# Vite reads this from the workspace root, not from the app directory:
|
||||
# apps/portal/vite.config.mts and apps/backoffice/vite.config.mts both set
|
||||
# `envDir: '../../'`. So copy this file to ./.env here, at the repo root.
|
||||
#
|
||||
# cp .env.example .env
|
||||
#
|
||||
# Only VITE_-prefixed values reach the browser, and everything that does is
|
||||
# public — it ships inside the built bundle. Never put a secret here. The Fayda
|
||||
# client id, private key and endpoints live in the API's environment only; the
|
||||
# frontend never speaks to Fayda directly.
|
||||
|
||||
# Base URL of the emaapi backend, including the /api prefix.
|
||||
# 3001, not 3000: the portal itself takes 3000 in development, because that is
|
||||
# the port in the Fayda redirect URI registered for local testing. Set PORT=3001
|
||||
# in emaapi's .env to match.
|
||||
VITE_BASE_API_URL=http://localhost:3001/api
|
||||
|
||||
# Serve fixture data instead of calling the API. Any value other than "true"
|
||||
# uses the real backend.
|
||||
VITE_USE_MOCKS=false
|
||||
|
||||
# --- Docker Compose only -----------------------------------------------------
|
||||
# Host ports published by docker-compose.yml. It also expects per-app env files
|
||||
# at apps/portal/.env and apps/backoffice/.env, which can each be a copy of this
|
||||
# file. Ignored when running the Vite dev servers, which serve the portal on
|
||||
# 3000 and the backoffice on 4201.
|
||||
# EMA_PORTAL_PORT=8021
|
||||
# EMA_BACKOFFICE_PORT=8022
|
||||
|
||||
# --- Fayda note --------------------------------------------------------------
|
||||
# There is nothing to configure here for Fayda. The portal serves the callback
|
||||
# page at /callback and /signup/fayda/callback, and whichever path is registered
|
||||
# with Fayda must match the API's FAYDA_REDIRECT_URI exactly.
|
||||
#
|
||||
# The value being registered first is http://localhost:3001/callback, so the
|
||||
# portal's dev server now listens on 3000 and emaapi moves to 3001. Nothing
|
||||
# extra to run — `nx serve portal` already binds the right port.
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,6 +13,8 @@ coverage/
|
||||
# env
|
||||
.env
|
||||
.env.*
|
||||
# ...but the checked-in template must survive that rule.
|
||||
!.env.example
|
||||
|
||||
# logs
|
||||
*.log
|
||||
|
||||
48
README.md
48
README.md
@@ -6,20 +6,20 @@ A fully scaffolded Nx monorepo housing two Vite + React 19 SPAs (Backoffice and
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Tool | Version |
|
||||
|------|---------|
|
||||
| React | 19 |
|
||||
| Nx | 22 |
|
||||
| Vite | 7 |
|
||||
| TypeScript | 5.9 |
|
||||
| Redux Toolkit | 2.11 |
|
||||
| Mantine | 8.3 |
|
||||
| React Router | 7 |
|
||||
| TanStack Query | 5 |
|
||||
| React Hook Form | 7 |
|
||||
| Zod | 4 |
|
||||
| Tailwind CSS | 3.4 |
|
||||
| Vitest | 4 |
|
||||
| Tool | Version |
|
||||
| --------------- | ------- |
|
||||
| React | 19 |
|
||||
| Nx | 22 |
|
||||
| Vite | 7 |
|
||||
| TypeScript | 5.9 |
|
||||
| Redux Toolkit | 2.11 |
|
||||
| Mantine | 8.3 |
|
||||
| React Router | 7 |
|
||||
| TanStack Query | 5 |
|
||||
| React Hook Form | 7 |
|
||||
| Zod | 4 |
|
||||
| Tailwind CSS | 3.4 |
|
||||
| Vitest | 4 |
|
||||
|
||||
---
|
||||
|
||||
@@ -37,16 +37,19 @@ emaui/
|
||||
```
|
||||
|
||||
### libs/api
|
||||
|
||||
- `base-api/` — RTK Query `createApi` instance with `prepareHeaders` that injects the Bearer token from Redux state or storage.
|
||||
- `session/` — `resolveTokenFromStorage()` reads the `auth-token` cookie first, falling back to `localStorage` for legacy pre-migration sessions. `resolveSessionContext()` merges Redux state token with storage fallback.
|
||||
- `query-and-mutation/` — Generic `useApiQuery` / `useApiMutation` wrappers for one-off API calls without defining a dedicated endpoint file.
|
||||
|
||||
### libs/ui
|
||||
|
||||
- `ConfirmModal` — Reusable Mantine modal for destructive-action confirmation.
|
||||
- `ApiErrorAlert` — Extracts a human-readable message from RTK Query error shapes or Error objects.
|
||||
- `notify` — Thin wrapper around `@mantine/notifications` with `.success`, `.error`, `.info`, `.warning` helpers.
|
||||
|
||||
### libs/shared
|
||||
|
||||
- `ema-theme` — Mantine v8 `createTheme()` with `emaPrimary` (blue) and `emaSecondary` (warm) color tuples, Inter font, and custom shadow scale.
|
||||
|
||||
---
|
||||
@@ -86,14 +89,14 @@ npm run dev:all
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `VITE_BASE_API_URL` | Yes | `http://localhost:3000` | Base URL for all API requests |
|
||||
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
|
||||
| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it |
|
||||
| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret |
|
||||
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
|
||||
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
|
||||
| Variable | Required | Default | Description |
|
||||
| ----------------------------- | ------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `VITE_BASE_API_URL` | Yes | `http://localhost:3001` | Base URL for all API requests |
|
||||
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
|
||||
| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it |
|
||||
| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret |
|
||||
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
|
||||
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
|
||||
|
||||
---
|
||||
|
||||
@@ -112,6 +115,7 @@ The `Dockerfile` uses multi-stage builds with named targets (`portal` / `backoff
|
||||
## Adding a New Feature
|
||||
|
||||
1. Create the feature folder under the relevant app:
|
||||
|
||||
```
|
||||
apps/backoffice/src/app/features/<feature-name>/
|
||||
├── types/ # TypeScript interfaces
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Bilingual, LicenseCategory, LicenseTemplate, LicenseType } from '@ema-platform/api';
|
||||
|
||||
import { BASE_API_URL } from '@ema-platform/api';
|
||||
|
||||
/** Same resolution — and same fallback — the shared RTK Query baseQuery uses. */
|
||||
export const API_BASE_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
export const API_BASE_URL = BASE_API_URL;
|
||||
|
||||
export const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
DRAFT: 'gray',
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
CreateIncidentPayload,
|
||||
ResolveIncidentPayload,
|
||||
RegradeOutcome,
|
||||
GradingSheet,
|
||||
} from '../types/exam';
|
||||
|
||||
const examApi = baseApi.injectEndpoints({
|
||||
@@ -101,6 +102,15 @@ const examApi = baseApi.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
}),
|
||||
/** The candidate's answers plus auto-computable CHOICE scores, for RecordResultModal. */
|
||||
getGradingSheet: builder.query<
|
||||
GradingSheet,
|
||||
{ examId: string; profileId: string }
|
||||
>({
|
||||
query: ({ examId, profileId }) =>
|
||||
`/exam-attempts/exam/${examId}/candidate/${profileId}/grading-sheet`,
|
||||
providesTags: ['Api'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -119,4 +129,5 @@ export const {
|
||||
useRecordIncidentMutation,
|
||||
useResolveIncidentMutation,
|
||||
useRegradeAttemptMutation,
|
||||
useGetGradingSheetQuery,
|
||||
} = examApi;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Box,
|
||||
Tooltip,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
useUpdateExamMutation,
|
||||
useAssignQuestionsMutation,
|
||||
useSelectRandomQuestionsMutation,
|
||||
useGetExamRegistrationsQuery,
|
||||
} from '../api/exam-api';
|
||||
import { useGetQuestionsQuery } from '../../question/api/question-api';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
@@ -114,6 +116,12 @@ export function ExamDetailPage() {
|
||||
const [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation();
|
||||
|
||||
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
|
||||
// The backend locks the paper the moment the first candidate registers
|
||||
// (ExamService.assertPaperEditable) — every candidate must sit the same
|
||||
// paper. Same query ExamCandidatesPanel already runs, so RTK Query serves
|
||||
// it from cache rather than issuing a second request.
|
||||
const { data: registrations } = useGetExamRegistrationsQuery(id ?? '', { skip: !id });
|
||||
const paperLocked = (registrations?.length ?? 0) > 0;
|
||||
const { data: qRes } = useGetQuestionsQuery();
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const allQuestions = qRes?.items ?? [];
|
||||
@@ -122,10 +130,14 @@ export function ExamDetailPage() {
|
||||
// Only approved bank items may go on a paper (US-EXAM-003), so the picker
|
||||
// must not offer drafts or retired questions either.
|
||||
//
|
||||
// BOTH describes a mixed paper — a question itself is never "BOTH" (see
|
||||
// Filters on exam.form alone, not administrationMethod: the backend no
|
||||
// longer restricts ONLINE to CHOICE (ExamService no longer has an
|
||||
// assertOnlineIsChoiceOnly gate), so exam.form is now the sole source of
|
||||
// truth for what belongs on the paper, ONLINE or OFFLINE alike. BOTH
|
||||
// describes a mixed paper — a question itself is never "BOTH" (see
|
||||
// QuestionForm), so an equality check against it would match nothing and
|
||||
// silently offer zero questions. Same skip-condition as the backend's own
|
||||
// random draw (ExamService.selectRandomQuestions).
|
||||
// silently offer zero questions; skipped the same way the backend's own
|
||||
// random draw does (ExamService.selectRandomQuestions).
|
||||
const eligibleQuestions = useMemo(() => {
|
||||
if (!exam) return [];
|
||||
return allQuestions
|
||||
@@ -179,7 +191,9 @@ export function ExamDetailPage() {
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, t('exam.randomError'));
|
||||
notify.error(
|
||||
key.startsWith('insufficient_approved_questions')
|
||||
key === 'paper_locked_after_registration'
|
||||
? t('exam.paperLockedHint', { count: registrations?.length ?? 0 })
|
||||
: key.startsWith('insufficient_approved_questions')
|
||||
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
|
||||
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||
? t('exam.cannotReachCuttingPoint', {
|
||||
@@ -200,7 +214,9 @@ export function ExamDetailPage() {
|
||||
} catch (error) {
|
||||
const key = extractErrorMessage(error, 'Failed to assign questions');
|
||||
notify.error(
|
||||
key.startsWith('question_not_approved')
|
||||
key === 'paper_locked_after_registration'
|
||||
? t('exam.paperLockedHint', { count: registrations?.length ?? 0 })
|
||||
: key.startsWith('question_not_approved')
|
||||
? t('question.qc.onlyApprovedUsable')
|
||||
: key.startsWith('paper_cannot_reach_cutting_point')
|
||||
? t('exam.cannotReachCuttingPoint', {
|
||||
@@ -452,19 +468,45 @@ export function ExamDetailPage() {
|
||||
{t("exam.detail.questionsSection", { pts: totalPoints })}
|
||||
</Title>
|
||||
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={openAssignModal}
|
||||
>
|
||||
{t("exam.manageQuestions")}
|
||||
</Button>
|
||||
<Group gap="xs">
|
||||
{paperLocked && (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t("exam.paperLocked")}
|
||||
</Badge>
|
||||
)}
|
||||
<Tooltip
|
||||
label={t("exam.paperLockedHint", {
|
||||
count: registrations?.length ?? 0,
|
||||
})}
|
||||
disabled={!paperLocked}
|
||||
multiline
|
||||
w={280}
|
||||
>
|
||||
{/* Wrapped: a disabled Mantine Button fires no pointer events,
|
||||
so the tooltip needs an enabled element to hang off. */}
|
||||
<Box>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={openAssignModal}
|
||||
disabled={paperLocked}
|
||||
>
|
||||
{t("exam.manageQuestions")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</RequirePermission>
|
||||
</Group>
|
||||
{(exam.questions ?? []).length === 0 ? (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
||||
{t("exam.noQuestionsAssigned")}
|
||||
<Alert
|
||||
color={paperLocked ? "red" : "gray"}
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
{paperLocked
|
||||
? t("exam.paperLockedHint", { count: registrations?.length ?? 0 })
|
||||
: t("exam.noQuestionsAssigned")}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
|
||||
@@ -79,21 +79,44 @@ function ExamForm({
|
||||
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
||||
const [activeTab, setActiveTab] = useState<string | null>("basic");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
/**
|
||||
* Split per tab so "Next" can check just the tab in front of the user.
|
||||
* Submitting from Basic Info used to complain about Settings fields the
|
||||
* user had not been shown yet — the error was correct and unactionable at
|
||||
* the same time. Each returns the message key for what is missing, or null.
|
||||
*/
|
||||
const validateBasic = (): string | null => {
|
||||
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
|
||||
setActiveTab("basic");
|
||||
notify.error(t("exam.form.fillRequiredBasic"));
|
||||
return;
|
||||
return "exam.form.fillRequiredBasic";
|
||||
}
|
||||
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
|
||||
setActiveTab("basic");
|
||||
notify.error(t("exam.form.directionBothLanguages"));
|
||||
return "exam.form.directionBothLanguages";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const validateSettings = (): string | null =>
|
||||
!type || !form || !adminMethod || !evalMethod || !cuttingPoint
|
||||
? "exam.form.fillRequiredSettings"
|
||||
: null;
|
||||
|
||||
const goNext = () => {
|
||||
const error = validateBasic();
|
||||
if (error) {
|
||||
notify.error(t(error));
|
||||
return;
|
||||
}
|
||||
if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
|
||||
setActiveTab("settings");
|
||||
notify.error(t("exam.form.fillRequiredSettings"));
|
||||
setActiveTab("settings");
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Still checks both: the tabs are clickable, so a user can reach Settings
|
||||
// without going through Next.
|
||||
const error = validateBasic() ?? validateSettings();
|
||||
if (error) {
|
||||
setActiveTab(error === "exam.form.fillRequiredSettings" ? "settings" : "basic");
|
||||
notify.error(t(error));
|
||||
return;
|
||||
}
|
||||
onSubmit(
|
||||
@@ -255,12 +278,6 @@ function ExamForm({
|
||||
onChange={setForm}
|
||||
size="sm"
|
||||
required
|
||||
disabled={adminMethod === "ONLINE"}
|
||||
description={
|
||||
adminMethod === "ONLINE"
|
||||
? t("exam.form.onlineChoiceOnlyHint")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.administration")}
|
||||
@@ -270,14 +287,7 @@ function ExamForm({
|
||||
{ value: "ONLINE", label: t("exam.form.online") },
|
||||
]}
|
||||
value={adminMethod}
|
||||
onChange={(value) => {
|
||||
setAdminMethod(value);
|
||||
// Online exams are graded automatically, and that only
|
||||
// has an answer model for CHOICE — matches the backend
|
||||
// rule (online_exam_requires_choice_form), not just a
|
||||
// UI nicety.
|
||||
if (value === "ONLINE") setForm("CHOICE");
|
||||
}}
|
||||
onChange={setAdminMethod}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
@@ -350,9 +360,26 @@ function ExamForm({
|
||||
<Button variant="default" onClick={onCancel} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editing ? t("exam.update") : t("exam.create")}
|
||||
</Button>
|
||||
{activeTab === "basic" ? (
|
||||
/* Not type="submit": Basic Info is not the last step, so the
|
||||
primary action advances rather than saves. */
|
||||
<Button size="sm" onClick={goNext}>
|
||||
{t("exam.form.next")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setActiveTab("basic")}
|
||||
>
|
||||
{t("exam.form.back")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editing ? t("exam.update") : t("exam.create")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -133,6 +133,24 @@ export interface ExamRegistration {
|
||||
export type RegradeOutcome =
|
||||
{ graded: true; resultId: string } | { graded: false; reason: string };
|
||||
|
||||
/** One question's row on the staff grading sheet — the candidate's own
|
||||
* answer plus the auto-computable score, where one exists. */
|
||||
export interface GradingSheetQuestion {
|
||||
questionId: string;
|
||||
form: QuestionForm;
|
||||
points: number;
|
||||
answerText: string | null;
|
||||
selectedOptionId: string | null;
|
||||
selectedOptionText: { en?: string; am?: string } | null;
|
||||
/** null means "no auto-score" — examiner enters one by hand. */
|
||||
autoScore: number | null;
|
||||
}
|
||||
|
||||
export interface GradingSheet {
|
||||
attemptStatus: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED';
|
||||
questions: GradingSheetQuestion[];
|
||||
}
|
||||
|
||||
export interface RecordAttendancePayload {
|
||||
registrationId: string;
|
||||
status: AttendanceStatus;
|
||||
|
||||
@@ -22,6 +22,9 @@ interface Props {
|
||||
* of a per-candidate appointment. Scoped to sittings whose certification
|
||||
* matches this application's rank, so a Chief Mate candidate cannot be seated
|
||||
* into an OOW Deck sitting by accident.
|
||||
*
|
||||
* Scheduling makes the sitting available; it does not register the candidate.
|
||||
* That is their own act, from the portal's Register button.
|
||||
*/
|
||||
export function ScheduleExamModal({
|
||||
opened,
|
||||
@@ -61,7 +64,7 @@ export function ScheduleExamModal({
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('review.scheduleExam.intro', {
|
||||
defaultValue:
|
||||
'Assign {{applicant}} to a scheduled sitting. They will be notified with the date and their admission number.',
|
||||
'Make a sitting available to {{applicant}}. They register for it themselves from the portal.',
|
||||
applicant: applicantName,
|
||||
})}
|
||||
</Text>
|
||||
@@ -89,7 +92,7 @@ export function ScheduleExamModal({
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'review.scheduleExam.admissionHint',
|
||||
'An admission number is issued automatically when the candidate is seated.',
|
||||
'Scheduling does not seat the candidate. They must register for the sitting from the portal, and the admission number is issued then.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NumberInput, Text, TextInput } from '@mantine/core';
|
||||
import { Badge, Group, NumberInput, Text, TextInput } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { QuestionBrief } from '../../../exam/types/exam';
|
||||
import type { GradingSheetQuestion, QuestionBrief } from '../../../exam/types/exam';
|
||||
|
||||
export function recordResultColumns(
|
||||
t: TFunction,
|
||||
@@ -11,6 +11,9 @@ export function recordResultColumns(
|
||||
questionRemarks: Record<string, string>;
|
||||
onScoreChange: (questionId: string, value: number) => void;
|
||||
onRemarkChange: (questionId: string, value: string) => void;
|
||||
/** The candidate's own answer + auto-score, when available (empty for
|
||||
* an OFFLINE candidate or one who hasn't sat an online attempt). */
|
||||
answersByQuestion: Map<string, GradingSheetQuestion>;
|
||||
},
|
||||
): AdvancedColumn<QuestionBrief>[] {
|
||||
return [
|
||||
@@ -22,6 +25,20 @@ export function recordResultColumns(
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('result.recordModal.candidateAnswer'),
|
||||
cell: ({ row }) => {
|
||||
const answer = handlers.answersByQuestion.get(row.original.id);
|
||||
if (!answer || (!answer.answerText && !answer.selectedOptionText)) {
|
||||
return <Text fz="xs" c="dimmed">{t('result.recordModal.noAnswer')}</Text>;
|
||||
}
|
||||
return (
|
||||
<Text fz="sm" maw={220} lineClamp={3}>
|
||||
{answer.selectedOptionText?.[locale] ?? answer.answerText}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t('result.recordModal.maxPoints'),
|
||||
cell: ({ row }) => (
|
||||
@@ -32,16 +49,26 @@ export function recordResultColumns(
|
||||
},
|
||||
{
|
||||
header: t('result.recordModal.score'),
|
||||
cell: ({ row }) => (
|
||||
<NumberInput
|
||||
value={handlers.scores[row.original.id] ?? 0}
|
||||
onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))}
|
||||
min={0}
|
||||
max={row.original.points}
|
||||
size="xs"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const autoGraded = handlers.answersByQuestion.get(row.original.id)?.autoScore != null;
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<NumberInput
|
||||
value={handlers.scores[row.original.id] ?? 0}
|
||||
onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))}
|
||||
min={0}
|
||||
max={row.original.points}
|
||||
size="xs"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
{autoGraded && (
|
||||
<Badge size="xs" variant="light" color="teal">
|
||||
{t('result.recordModal.autoGraded')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t('result.recordModal.remark'),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Modal,
|
||||
@@ -20,7 +20,7 @@ import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
|
||||
import { recordResultColumns } from './columns';
|
||||
import { useCreateResultMutation } from '../../api/result-api';
|
||||
import { useGetExamRegistrationsQuery } from '../../../exam/api/exam-api';
|
||||
import { useGetExamRegistrationsQuery, useGetGradingSheetQuery } from '../../../exam/api/exam-api';
|
||||
import type { Exam } from '../../../exam/types/exam';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
@@ -55,12 +55,39 @@ export function RecordResultModal({
|
||||
const { data: registrations } = useGetExamRegistrationsQuery(exam.id, {
|
||||
skip: !opened,
|
||||
});
|
||||
// The candidate's own answers plus whatever score auto-grading could
|
||||
// already compute for the CHOICE portion — degrades to "no data" for an
|
||||
// OFFLINE candidate or one who never sat an online attempt, same as
|
||||
// before this existed.
|
||||
const { data: gradingSheet } = useGetGradingSheetQuery(
|
||||
{ examId: exam.id, profileId: selectedSeafarerId ?? '' },
|
||||
{ skip: !opened || !selectedSeafarerId },
|
||||
);
|
||||
const answersByQuestion = new Map(
|
||||
(gradingSheet?.questions ?? []).map((q) => [q.questionId, q]),
|
||||
);
|
||||
const [createResult, { isLoading: isSaving }] = useCreateResultMutation();
|
||||
const table = useServerTable();
|
||||
|
||||
const questions = exam.questions ?? [];
|
||||
const pagedQuestions = table.paginate(questions);
|
||||
|
||||
// Prefill (never override) the CHOICE questions auto-grading already
|
||||
// scored — the examiner only has to key in the ESSAY marks. A fresh
|
||||
// seafarer selection always starts from an empty scores map, so this
|
||||
// only ever fills in blanks, never stomps a manual edit already made.
|
||||
useEffect(() => {
|
||||
if (!gradingSheet) return;
|
||||
const autoScores: Record<string, number> = {};
|
||||
for (const q of gradingSheet.questions) {
|
||||
if (q.autoScore !== null) autoScores[q.questionId] = q.autoScore;
|
||||
}
|
||||
if (Object.keys(autoScores).length) {
|
||||
setScores((prev) => ({ ...autoScores, ...prev }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gradingSheet]);
|
||||
|
||||
const seafarerOptions = (registrations ?? [])
|
||||
.filter((registration) =>
|
||||
['PRESENT', 'LATE'].includes(registration.attendanceStatus),
|
||||
@@ -175,6 +202,7 @@ export function RecordResultModal({
|
||||
questionRemarks,
|
||||
onScoreChange: handleScoreChange,
|
||||
onRemarkChange: handleQuestionRemarkChange,
|
||||
answersByQuestion,
|
||||
})}
|
||||
data={pagedQuestions.rows}
|
||||
itemCount={pagedQuestions.itemCount}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import Cookies from 'js-cookie';
|
||||
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 Cookies from "js-cookie";
|
||||
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 { BASE_API_URL } from "@ema-platform/api";
|
||||
import type {
|
||||
DesignConfig,
|
||||
UserManagementSessionOptions,
|
||||
} from "@tria-plc/iamui";
|
||||
import "@tria-plc/iamui/style.css";
|
||||
|
||||
const UM_OVERRIDES = `
|
||||
.um-theme-light {
|
||||
@@ -58,73 +62,73 @@ const UM_OVERRIDES = `
|
||||
|
||||
const UM_CONFIG: DesignConfig = {
|
||||
brand: {
|
||||
appName: 'Ethiopian Maritime Licence',
|
||||
logoUrl: '/assets/emaLogo.jpg',
|
||||
appName: "Ethiopian Maritime Licence",
|
||||
logoUrl: "/assets/emaLogo.jpg",
|
||||
},
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
sidebar: '#ffffff',
|
||||
background: '#f8fafc',
|
||||
foreground: '#1e293b',
|
||||
border: '#e2e8f0',
|
||||
mutedForeground: '#94a3b8',
|
||||
card: '#ffffff',
|
||||
primary: "#2563eb",
|
||||
sidebar: "#ffffff",
|
||||
background: "#f8fafc",
|
||||
foreground: "#1e293b",
|
||||
border: "#e2e8f0",
|
||||
mutedForeground: "#94a3b8",
|
||||
card: "#ffffff",
|
||||
},
|
||||
typography: {
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
|
||||
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',
|
||||
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',
|
||||
basename: "/um",
|
||||
// Keep the embedded IAM module on the same API as the backoffice client.
|
||||
// When VITE_BASE_API_URL is absent locally, passing undefined makes iamui
|
||||
// fall back to its remote development server, where the local JWT is
|
||||
// rejected and the module redirects to its login page.
|
||||
apiUrl: import.meta.env.VITE_BASE_API_URL ?? 'http://localhost:3000/api',
|
||||
apiUrl: import.meta.env.VITE_BASE_API_URL ?? "http://localhost:3000/api",
|
||||
};
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
position: "fixed",
|
||||
top: 12,
|
||||
left: 12,
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: '8px 16px',
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: "8px 16px",
|
||||
border: "1px solid #e2e8f0",
|
||||
borderRadius: 8,
|
||||
background: '#ffffff',
|
||||
color: '#2563eb',
|
||||
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',
|
||||
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() {
|
||||
@@ -133,29 +137,31 @@ export default function UserManagementPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleReturn = useCallback(() => {
|
||||
navigate('/dashboard');
|
||||
navigate("/dashboard");
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
const style = document.createElement("style");
|
||||
style.textContent = UM_OVERRIDES;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const token = Cookies.get('ema-backoffice-auth-token') ?? '';
|
||||
const refreshToken = Cookies.get('ema-backoffice-refresh-token');
|
||||
const token = Cookies.get("ema-backoffice-auth-token") ?? "";
|
||||
const refreshToken = Cookies.get("ema-backoffice-refresh-token");
|
||||
|
||||
const session: UserManagementSessionOptions = {
|
||||
initialSession: token
|
||||
? { token, refreshToken, rememberMe: true }
|
||||
: null,
|
||||
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} />,
|
||||
<UserManagementApp
|
||||
config={UM_CONFIG}
|
||||
runtime={UM_RUNTIME}
|
||||
session={session}
|
||||
/>,
|
||||
);
|
||||
|
||||
return () => {
|
||||
@@ -173,21 +179,28 @@ export default function UserManagementPage() {
|
||||
onClick={handleReturn}
|
||||
style={buttonStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f8fafc';
|
||||
e.currentTarget.style.boxShadow = '0 1px 6px rgba(0,0,0,0.12)';
|
||||
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">
|
||||
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 }} />
|
||||
<div ref={containerRef} style={{ position: "fixed", inset: 0 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ export const am: Translations = {
|
||||
btcQueue: "የBTC ወረፋ",
|
||||
cocQueue: "የCoC ወረፋ",
|
||||
copQueue: "የCoP ወረፋ",
|
||||
endorsementCocQueue: "የCoC እውቅና ወረፋ",
|
||||
endorsementGocQueue: "የGOC እውቅና ወረፋ",
|
||||
endorsementQueue: "የማስተያየት ወረፋ",
|
||||
vesselRegistrations: "የመርከብ ምዝገባ",
|
||||
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
|
||||
@@ -261,7 +263,6 @@ export const am: Translations = {
|
||||
both: "ሁለቱም",
|
||||
offline: "ከመስመር ውጪ",
|
||||
online: "በመስመር",
|
||||
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
|
||||
sum: "ድምር",
|
||||
average: "አማካይ",
|
||||
percentage: "መቶኛ",
|
||||
@@ -273,6 +274,8 @@ export const am: Translations = {
|
||||
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
|
||||
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
|
||||
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
|
||||
next: "ቀጣይ",
|
||||
back: "ተመለስ",
|
||||
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
|
||||
status: "ሁኔታ",
|
||||
statusPlaceholder: "የፈተና ሁኔታ",
|
||||
@@ -389,6 +392,9 @@ export const am: Translations = {
|
||||
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
|
||||
cannotReachCuttingPoint:
|
||||
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
|
||||
paperLocked: "ወረቀቱ ተቆልፏል",
|
||||
paperLockedHint:
|
||||
"ለዚህ ፈተና {{count}} ተፈታኝ(ዎች) ተመዝግበዋል። ሁሉም ተፈታኞች አንድ ዓይነት ወረቀት መፈተን ስላለባቸው ጥያቄዎቹ ከዚህ በኋላ አይቀየሩም።",
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -659,6 +665,9 @@ export const am: Translations = {
|
||||
seafarerPlaceholder: "መርከበኛ ይፈልጉ እና ይምረጡ",
|
||||
scorePerQuestion: "በጥያቄ ውጤት",
|
||||
question: "ጥያቄ",
|
||||
candidateAnswer: "የተፈታኙ መልስ",
|
||||
noAnswer: "የተመዘገበ መልስ የለም",
|
||||
autoGraded: "በራስ-ሰር የተመዘነ",
|
||||
maxPoints: "ከፍተኛ ውጤት",
|
||||
score: "ውጤት",
|
||||
remark: "ማስታወሻ",
|
||||
|
||||
@@ -88,6 +88,8 @@ export const en = {
|
||||
postWaiverQueue: 'Post-Waiver Queue',
|
||||
cocQueue: 'CoC Queue',
|
||||
copQueue: 'CoP Queue',
|
||||
endorsementCocQueue: 'CoC Endorsement Queue',
|
||||
endorsementGocQueue: 'GOC Endorsement Queue',
|
||||
endorsementQueue: 'Endorsement Queue',
|
||||
vesselRegistrations: 'Vessel Registration',
|
||||
vesselTransfers: 'Vessel Ownership Transfer',
|
||||
@@ -260,7 +262,6 @@ export const en = {
|
||||
both: 'Both',
|
||||
offline: 'Offline',
|
||||
online: 'Online',
|
||||
onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
|
||||
sum: 'Sum',
|
||||
average: 'Average',
|
||||
percentage: 'Percentage',
|
||||
@@ -273,6 +274,8 @@ export const en = {
|
||||
fillRequiredBasic: 'Please fill all required fields in Basic Info.',
|
||||
fillRequiredSettings: 'Please fill all required fields in Settings — type, form, administration method, evaluation method, and cutting point.',
|
||||
directionBothLanguages: 'Direction needs text in both English and Amharic, or leave both empty.',
|
||||
next: 'Next',
|
||||
back: 'Back',
|
||||
status: 'Status',
|
||||
statusPlaceholder: 'Exam status',
|
||||
pending: 'Pending',
|
||||
@@ -388,6 +391,9 @@ export const en = {
|
||||
'Not enough approved questions in the bank for this subject.',
|
||||
cannotReachCuttingPoint:
|
||||
'This paper cannot reach the passing mark (max {{max}}, pass mark {{cuttingPoint}}). Add more/higher-point questions, or lower the cutting point.',
|
||||
paperLocked: 'Paper locked',
|
||||
paperLockedHint:
|
||||
'{{count}} candidate(s) have registered for this session. Every candidate must sit the same paper, so questions can no longer be changed.',
|
||||
},
|
||||
|
||||
country: {
|
||||
@@ -659,6 +665,9 @@ export const en = {
|
||||
seafarerPlaceholder: 'Search and select a seafarer',
|
||||
scorePerQuestion: 'Score per Question',
|
||||
question: 'Question',
|
||||
candidateAnswer: "Candidate's Answer",
|
||||
noAnswer: 'No answer on file',
|
||||
autoGraded: 'Auto-graded',
|
||||
maxPoints: 'Max Points',
|
||||
score: 'Score',
|
||||
remark: 'Remark',
|
||||
|
||||
@@ -42,6 +42,12 @@ const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
||||
*/
|
||||
const SEAFARER_QUEUE = [P.VIEW_SEAFARER_REGISTRY, ...APPLICATION_QUEUE];
|
||||
|
||||
const BIOMETRIC_ENROLLMENT = [
|
||||
P.ENROLL_BIOMETRICS,
|
||||
P.VIEW_BIOMETRICS,
|
||||
P.VIEW_SEAFARER_REGISTRY,
|
||||
];
|
||||
|
||||
/**
|
||||
* The backoffice information architecture.
|
||||
*
|
||||
@@ -149,7 +155,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
to: "/biometric-enrollment",
|
||||
label: "nav.biometricEnrollment",
|
||||
icon: IconFingerprint,
|
||||
permissions: [P.ENROLL_BIOMETRICS, P.VIEW_BIOMETRICS],
|
||||
permissions: BIOMETRIC_ENROLLMENT,
|
||||
},
|
||||
{
|
||||
to: "/seafarer-registrations",
|
||||
|
||||
@@ -53,6 +53,12 @@ import { BiometricEnrollmentPage } from '../features/biometric-enrollment/pages/
|
||||
/** Any-of gate shared by every licence-type queue and its review workspace. */
|
||||
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
|
||||
|
||||
const BIOMETRIC_ENROLLMENT = [
|
||||
P.ENROLL_BIOMETRICS,
|
||||
P.VIEW_BIOMETRICS,
|
||||
P.VIEW_SEAFARER_REGISTRY,
|
||||
];
|
||||
|
||||
/** Route gate: same keys as the route's nav item in nav-config.ts. */
|
||||
const guard = (anyOf: string[], element: ReactNode) => (
|
||||
<RequirePermission anyOf={anyOf}>{element}</RequirePermission>
|
||||
@@ -101,7 +107,7 @@ const router = createBrowserRouter([
|
||||
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
|
||||
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
|
||||
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
|
||||
{ path: 'biometric-enrollment', element: guard([P.ENROLL_BIOMETRICS, P.VIEW_BIOMETRICS], <BiometricEnrollmentPage />) },
|
||||
{ path: 'biometric-enrollment', element: guard(BIOMETRIC_ENROLLMENT, <BiometricEnrollmentPage />) },
|
||||
// Seafarer registration is not a licence: own queue, own review.
|
||||
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
|
||||
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },
|
||||
|
||||
@@ -17,7 +17,7 @@ export default defineConfig({
|
||||
// port: 4201,
|
||||
// proxy: {
|
||||
// '/api': {
|
||||
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
|
||||
// target: 'http://localhost:3001', // change from 'https://ema-api-dev.triaplc.com'
|
||||
// changeOrigin: true,
|
||||
// },
|
||||
// },
|
||||
|
||||
@@ -22,7 +22,7 @@ own ports, against its own database:
|
||||
| Backoffice | 4303 | same |
|
||||
| Database | — | `ema_e2e` |
|
||||
|
||||
This is deliberate. A developer's stack is usually already up on 3000/4200/4201,
|
||||
This is deliberate. A developer's stack is usually already up on 3000/3001/4201,
|
||||
and `dev/start.sh` **rewrites** `emaapi/apps/server/emaapi/.env` and the apps'
|
||||
`.env.local` on every run — a suite that read those files would point at
|
||||
whichever stack was started last. The API is launched with `DATABASE_NAME`,
|
||||
|
||||
@@ -120,9 +120,7 @@ function formatDate(value: string | null | undefined): string {
|
||||
});
|
||||
}
|
||||
|
||||
const API_BASE =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
import { BASE_API_URL as API_BASE } from '@ema-platform/api';
|
||||
|
||||
async function generateCertificate(profileId: string): Promise<Blob> {
|
||||
const token = authStorage.getToken();
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
SaveState,
|
||||
} from '../types/exam-attempt';
|
||||
|
||||
/** Mirrors the server's allow-list (ExamAttemptService.MAY_SIT). */
|
||||
const MAY_SIT = ['PRESENT', 'LATE'];
|
||||
|
||||
const ESSAY_DEBOUNCE_MS = 1500;
|
||||
|
||||
type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error';
|
||||
@@ -83,6 +86,19 @@ export function useExamAttempt(examId: string | undefined) {
|
||||
setErrorMessage('You are not registered for this examination.');
|
||||
return;
|
||||
}
|
||||
// Attendance gates the sitting, and the exam list already hides "Take
|
||||
// exam" for these — this is the direct-link path. The server refuses
|
||||
// either way (ExamAttemptService.MAY_SIT); this only makes the refusal
|
||||
// legible instead of a raw error key on a Start button that never works.
|
||||
if (!MAY_SIT.includes(registration.attendanceStatus)) {
|
||||
setViewState('error');
|
||||
setErrorMessage(
|
||||
registration.attendanceStatus === 'REGISTERED'
|
||||
? 'An invigilator must confirm you are present before this exam opens.'
|
||||
: 'Your attendance record does not permit sitting this examination.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (mineData) {
|
||||
seedFrom(mineData);
|
||||
return;
|
||||
@@ -200,7 +216,14 @@ export function useExamAttempt(examId: string | undefined) {
|
||||
}).unwrap();
|
||||
seedFrom(result);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not start the exam.'));
|
||||
const key = extractErrorMessage(error, 'Could not start the exam.');
|
||||
notify.error(
|
||||
key === 'attendance_not_confirmed'
|
||||
? 'An invigilator must confirm you are present before this exam opens.'
|
||||
: key === 'candidate_not_present'
|
||||
? 'Your attendance record does not permit sitting this examination.'
|
||||
: key,
|
||||
);
|
||||
}
|
||||
}, [examId, startTrigger, seedFrom]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
@@ -20,7 +20,14 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
DISQUALIFIED: 'red',
|
||||
};
|
||||
|
||||
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
|
||||
/**
|
||||
* Attendance rulings that permit sitting the paper — an allow-list mirroring
|
||||
* the server's (ExamAttemptService.MAY_SIT). The deny-list this replaced named
|
||||
* only ABSENT/WITHDRAWN/DISQUALIFIED, so the default REGISTERED fell through
|
||||
* and "Take exam" appeared before any invigilator had confirmed the candidate
|
||||
* was there. LATE counts: a late arrival is present, just not on time.
|
||||
*/
|
||||
const MAY_SIT: AttendanceStatus[] = ['PRESENT', 'LATE'];
|
||||
|
||||
export function registrationColumns(
|
||||
t: TFunction,
|
||||
@@ -115,9 +122,26 @@ export function registrationColumns(
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const eligible =
|
||||
exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus);
|
||||
if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
|
||||
if (!deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
|
||||
// Say why the exam is shut rather than rendering an empty cell: the
|
||||
// candidate is waiting on an invigilator, and silence reads as a bug.
|
||||
if (row.original.attendanceStatus === 'REGISTERED') {
|
||||
return (
|
||||
<Tooltip label={t('exams.columns.awaitingAttendanceHint')} multiline w={240}>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t('exams.columns.awaitingAttendance')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (!MAY_SIT.includes(row.original.attendanceStatus)) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="orange">
|
||||
{t('exams.columns.notSitting')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (exam?.status !== 'ACTIVE') return null;
|
||||
return (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -59,6 +59,7 @@ export const am: Translations = {
|
||||
seaRecords: 'የባህር መዝገቦቼ',
|
||||
seaService: 'የባህር አገልግሎት',
|
||||
medical: 'የሕክምና የምስክር ወረቀት',
|
||||
biometrics: 'ባዮሜትሪክ',
|
||||
myApplication: 'ማመልከቻዬ',
|
||||
certificates: 'የምስክር ወረቀቶች',
|
||||
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
|
||||
@@ -603,6 +604,28 @@ export const am: Translations = {
|
||||
createOne: "አንድ ይፍጠሩ",
|
||||
},
|
||||
|
||||
fayda: {
|
||||
continueWith: "በፋይዳ ይቀጥሉ",
|
||||
orFillManually: "ወይም መረጃዎን ራስዎ ይሙሉ",
|
||||
verifiedTitle: "በፋይዳ ተረጋግጧል",
|
||||
verifiedBody: "ፋይዳ ያረጋገጣቸውን መረጃዎች ሞልተናል። እባክዎ የቀሩትን መስኮች ያሟሉ።",
|
||||
discard: "እነዚህን መረጃዎች አጥፍቼ ቅጹን ራሴ እሞላለሁ",
|
||||
fieldVerified: "ከፋይዳ",
|
||||
fieldConflict: "በሌላ መለያ ተይዟል",
|
||||
conflictBody:
|
||||
"አንዳንድ የተረጋገጡ መረጃዎች አስቀድሞ የሌላ መለያ ናቸው። የተመለከቱትን መስኮች ይቀይሩ ወይም ይግቡ።",
|
||||
brandTitle: "በፋይዳ በማረጋገጥ ላይ",
|
||||
brandSubtitle: "ማንነትዎን እስክናረጋግጥ ድረስ አንድ አፍታ።",
|
||||
verifying: "የፋይዳ ማንነትዎን በማረጋገጥ ላይ…",
|
||||
failedTitle: "ማረጋገጡ አልተጠናቀቀም",
|
||||
backToSignup: "ወደ ምዝገባ ተመለስ",
|
||||
cancelled: "የፋይዳ ማረጋገጫው ተሰርዟል። አሁንም በእጅ መመዝገብ ይችላሉ።",
|
||||
rejected: "ፋይዳ ማንነትዎን ማረጋገጥ አልቻለም። እባክዎ እንደገና ይሞክሩ።",
|
||||
invalidCallback: "ይህ የማረጋገጫ ሊንክ አልተሟላም። እባክዎ እንደገና ይጀምሩ።",
|
||||
sessionLost: "የማረጋገጫ ክፍለ ጊዜዎ አልፏል። እባክዎ እንደገና ይጀምሩ።",
|
||||
stateMismatch: "ይህ ማረጋገጫ ሊታመን አልቻለም። እባክዎ እንደገና ይጀምሩ።",
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
|
||||
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
|
||||
@@ -989,6 +1012,10 @@ export const am: Translations = {
|
||||
timeExpired: 'ጊዜው አልቋል',
|
||||
resumeExam: 'ፈተና ይቀጥሉ',
|
||||
takeExam: 'ፈተና ይውሰዱ',
|
||||
awaitingAttendance: 'መገኘት በመጠባበቅ ላይ',
|
||||
awaitingAttendanceHint:
|
||||
'ፈተናው ከመከፈቱ በፊት ተቆጣጣሪ መገኘትዎን ማረጋገጥ አለበት።',
|
||||
notSitting: 'አይፈተኑም',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'አልተጠራም',
|
||||
PRESENT: 'ተገኝቷል',
|
||||
|
||||
@@ -49,6 +49,7 @@ export const en = {
|
||||
seaRecords: 'My Sea Records',
|
||||
seaService: 'Sea Service',
|
||||
medical: 'Medical Certificate',
|
||||
biometrics: 'Biometrics',
|
||||
myApplication: 'My Application',
|
||||
vesselRegistration: 'Vessel Registration',
|
||||
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
|
||||
@@ -603,6 +604,28 @@ export const en = {
|
||||
createOne: 'Create one',
|
||||
},
|
||||
|
||||
fayda: {
|
||||
continueWith: 'Continue with Fayda',
|
||||
orFillManually: 'or fill in your details',
|
||||
verifiedTitle: 'Verified with Fayda',
|
||||
verifiedBody: 'We filled in the details Fayda confirmed. Please complete the remaining fields.',
|
||||
discard: 'Clear these details and fill the form myself',
|
||||
fieldVerified: 'From Fayda',
|
||||
fieldConflict: 'Already used by another account',
|
||||
conflictBody:
|
||||
'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.',
|
||||
brandTitle: 'Verifying with Fayda',
|
||||
brandSubtitle: 'One moment while we confirm your identity.',
|
||||
verifying: 'Verifying your Fayda identity\u2026',
|
||||
failedTitle: 'Verification incomplete',
|
||||
backToSignup: 'Back to sign up',
|
||||
cancelled: 'Fayda verification was cancelled. You can still sign up manually.',
|
||||
rejected: 'Fayda could not verify your identity. Please try again.',
|
||||
invalidCallback: 'This verification link is incomplete. Please start again.',
|
||||
sessionLost: 'Your verification session has expired. Please start again.',
|
||||
stateMismatch: 'This verification could not be trusted. Please start again.',
|
||||
},
|
||||
|
||||
signup: {
|
||||
usernameMinLength: 'Username must be at least 3 characters',
|
||||
nameEnRequired: 'Name (English) is required',
|
||||
@@ -991,6 +1014,10 @@ export const en = {
|
||||
timeExpired: 'Time expired',
|
||||
resumeExam: 'Resume exam',
|
||||
takeExam: 'Take exam',
|
||||
awaitingAttendance: 'Awaiting attendance',
|
||||
awaitingAttendanceHint:
|
||||
'An invigilator must confirm you are present before the exam opens.',
|
||||
notSitting: 'Not sitting',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'Not called',
|
||||
PRESENT: 'Present',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { LandingRoute } from "./components/LandingRoute";
|
||||
import {
|
||||
LoginPage,
|
||||
SignupPage,
|
||||
FaydaCallbackPage,
|
||||
OTPVerificationPage,
|
||||
ForgotPasswordPage,
|
||||
SetPasswordPage,
|
||||
@@ -68,6 +69,16 @@ export const router = createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/signup", element: <SignupPage /> },
|
||||
|
||||
// Where Fayda returns the applicant. Public by necessity — they have no
|
||||
// account yet. It redeems the code and hands control back to /signup.
|
||||
//
|
||||
// Two paths for one page: whichever is registered with Fayda has to match the
|
||||
// API's FAYDA_REDIRECT_URI exactly, and the value being registered first is a
|
||||
// bare /callback. The descriptive path is kept so the route still reads as
|
||||
// part of signup once that can be changed.
|
||||
{ path: "/signup/fayda/callback", element: <FaydaCallbackPage /> },
|
||||
{ path: "/callback", element: <FaydaCallbackPage /> },
|
||||
|
||||
// Completes the forgot-password flow; the reset message links here. The
|
||||
// IAM package generates `/reset-password` links, `/set-password` is the
|
||||
// first-time-credential variant — one page serves both.
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
// Env lives at the workspace root, shared with the backoffice — without this
|
||||
// Vite looks in apps/portal and VITE_BASE_API_URL silently falls back to its
|
||||
// built-in default.
|
||||
envDir: '../../',
|
||||
cacheDir: '../../node_modules/.vite/apps/portal',
|
||||
server: { port: 4200, host: 'localhost' },
|
||||
// server: {
|
||||
// port: 4200,
|
||||
// proxy: {
|
||||
// '/api': {
|
||||
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
|
||||
// changeOrigin: true,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
preview: { port: 4200, host: 'localhost' },
|
||||
envDir: "../../",
|
||||
cacheDir: "../../node_modules/.vite/apps/portal",
|
||||
// 3000, not the usual 4200: the Fayda redirect URI registered for local
|
||||
// testing is http://localhost:3001/callback, and the provider matches it
|
||||
// exactly. The API moves to 3001 to make room.
|
||||
server: { port: 3000, host: "localhost" },
|
||||
// server: {
|
||||
// port: 4200,
|
||||
// proxy: {
|
||||
// '/api': {
|
||||
// target: 'http://localhost:3001', // change from 'https://ema-api-dev.triaplc.com'
|
||||
// changeOrigin: true,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
preview: { port: 3000, host: "localhost" },
|
||||
plugins: [react(), nxViteTsPaths()],
|
||||
resolve: {
|
||||
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
|
||||
dedupe: ["react", "react-dom", "react-router-dom", "@tanstack/react-query"],
|
||||
},
|
||||
build: {
|
||||
outDir: '../../dist/apps/portal',
|
||||
outDir: "../../dist/apps/portal",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
},
|
||||
|
||||
@@ -9,5 +9,5 @@ export * from './lib/features/seafarer-document';
|
||||
export * from './lib/features/personal-document';
|
||||
export * from './lib/features/biometric-enrollment';
|
||||
export * from './lib/features/vessel';
|
||||
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||
export { BASE_API_URL, baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
|
||||
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';
|
||||
|
||||
@@ -2,10 +2,15 @@ import { fetchBaseQuery, type BaseQueryFn } from "@reduxjs/toolkit/query/react";
|
||||
import type { FetchArgs, FetchBaseQueryError } from "@reduxjs/toolkit/query";
|
||||
import { resolveSessionContext } from "../session";
|
||||
|
||||
/**
|
||||
* The one place the backend URL is resolved: VITE_BASE_API_URL from the env,
|
||||
* falling back to the local dev API (3001 — the portal itself owns 3000 for
|
||||
* the Fayda redirect). Import this; do not re-derive it.
|
||||
*/
|
||||
export const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.[
|
||||
"VITE_BASE_API_URL"
|
||||
] ?? "http://localhost:3000/api";
|
||||
]?.trim() || "http://localhost:3001/api";
|
||||
|
||||
let _onTokenExpired: (() => Promise<string>) | null = null;
|
||||
let _onAuthFailure: (() => void) | null = null;
|
||||
|
||||
@@ -11,9 +11,7 @@ import type {
|
||||
ValidationIssue,
|
||||
} from './licensing.types';
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
import { BASE_API_URL } from '../../base-api/base-query-with-reauth';
|
||||
|
||||
/**
|
||||
* Uploads a document straight to the API.
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
export const SESSION_HEADER_KEYS = {
|
||||
tenantId: 'x-tenant-id',
|
||||
organizationUnitId: 'x-organization-unit-id',
|
||||
currentPositionId: 'x-current-position-id',
|
||||
currentProjectId: 'x-current-project-id',
|
||||
tenantId: "x-tenant-id",
|
||||
organizationUnitId: "x-organization-unit-id",
|
||||
currentPositionId: "x-current-position-id",
|
||||
currentProjectId: "x-current-project-id",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Which app this bundle is, so it reads its own session and no one else's.
|
||||
*
|
||||
* Set by each app's store via `configureSessionScope`. Cookies ignore the
|
||||
* port, so `localhost:4200` and `localhost:4201` share one jar: without a
|
||||
* port, so `localhost:3001` and `localhost:4201` share one jar: without a
|
||||
* scope the backoffice would happily authenticate as whoever last signed into
|
||||
* the portal, and render a staff console with an applicant's permissions.
|
||||
*/
|
||||
@@ -22,7 +22,7 @@ export function configureSessionScope(prefix: string): void {
|
||||
}
|
||||
|
||||
/** Legacy, pre-prefix sessions. Read only when the scoped key is empty. */
|
||||
const LEGACY_TOKEN_KEY = 'auth-token';
|
||||
const LEGACY_TOKEN_KEY = "auth-token";
|
||||
|
||||
export function resolveTokenFromStorage(): string | undefined {
|
||||
// Only this app's key, then the legacy unprefixed one. Never another app's:
|
||||
|
||||
@@ -6,6 +6,7 @@ export { AuthBootstrap } from "./lib/components/AuthBootstrap";
|
||||
export { useIdleTimer } from "./lib/hooks/useIdleTimer";
|
||||
export { LoginPage } from "./lib/pages/LoginPage";
|
||||
export { SignupPage } from "./lib/pages/SignupPage";
|
||||
export { FaydaCallbackPage } from "./lib/pages/FaydaCallbackPage";
|
||||
export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage";
|
||||
export { SetPasswordPage } from "./lib/pages/SetPasswordPage";
|
||||
export { OTPVerificationPage } from "./lib/pages/OTPVerificationPage";
|
||||
|
||||
@@ -6,9 +6,7 @@ import { hydrateAuth, logout, setToken, setUser } from '../store/auth.slice';
|
||||
import { refreshAccessToken } from '../utils/refresh-token';
|
||||
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:3000/api';
|
||||
import { BASE_API_URL } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Restores the signed-in session before the router renders.
|
||||
|
||||
121
libs/auth/src/lib/pages/FaydaCallbackPage.tsx
Normal file
121
libs/auth/src/lib/pages/FaydaCallbackPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Button, Group, Loader, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconAlertTriangle, IconArrowLeft } from '@tabler/icons-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { useErrorHandler } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
import { faydaSession, type FaydaResult } from '../utils/fayda-session';
|
||||
|
||||
/**
|
||||
* Where Fayda returns the applicant.
|
||||
*
|
||||
* It creates no account and holds no credentials — it hands the authorization
|
||||
* code to the API, stashes the normalised result, and sends the applicant back
|
||||
* to the signup form they started on.
|
||||
*/
|
||||
export function FaydaCallbackPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [params] = useSearchParams();
|
||||
const { handleError } = useErrorHandler();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [callbackTrigger] = useApiMutation<FaydaResult>();
|
||||
|
||||
// React 18 mounts effects twice in development, and the authorization code is
|
||||
// single-use — the second redemption would fail and show a spurious error.
|
||||
const redeemed = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (redeemed.current) return;
|
||||
redeemed.current = true;
|
||||
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
const providerError = params.get('error');
|
||||
const request = faydaSession.takeRequest();
|
||||
|
||||
if (providerError) {
|
||||
setError(
|
||||
providerError === 'access_denied'
|
||||
? t('fayda.cancelled', 'Fayda verification was cancelled. You can still sign up manually.')
|
||||
: t('fayda.rejected', 'Fayda could not verify your identity. Please try again.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
setError(t('fayda.invalidCallback', 'This verification link is incomplete. Please start again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
setError(
|
||||
t('fayda.sessionLost', 'Your verification session has expired. Please start again.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.state !== state) {
|
||||
setError(t('fayda.stateMismatch', 'This verification could not be trusted. Please start again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
callbackTrigger({
|
||||
url: '/auth/register-with-fayda',
|
||||
method: 'POST',
|
||||
// `verify` returns the identity without creating an account — the
|
||||
// existing signup endpoint still does that.
|
||||
body: { action: 'verify', code, state, transactionToken: request.transactionToken },
|
||||
})
|
||||
.unwrap()
|
||||
.then((result) => {
|
||||
faydaSession.saveResult(result);
|
||||
// replace: the callback URL carries a spent code, so it must not come
|
||||
// back on Back.
|
||||
navigate('/signup', { replace: true });
|
||||
})
|
||||
.catch((err: unknown) => setError(handleError(err)));
|
||||
// Runs once on mount; the guard above makes that explicit.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
brandTitle={t('fayda.brandTitle', 'Verifying with Fayda')}
|
||||
brandSubtitle={t('fayda.brandSubtitle', 'One moment while we confirm your identity.')}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
{error ? (
|
||||
<>
|
||||
<Title order={2} fz={26}>
|
||||
{t('fayda.failedTitle', 'Verification incomplete')}
|
||||
</Title>
|
||||
<Alert
|
||||
variant="light"
|
||||
color="orange"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconArrowLeft size={18} />}
|
||||
onClick={() => navigate('/signup', { replace: true })}
|
||||
>
|
||||
{t('fayda.backToSignup', 'Back to sign up')}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<Group gap="sm">
|
||||
<Loader size="sm" />
|
||||
<Text c="dimmed">{t('fayda.verifying', 'Verifying your Fayda identity…')}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Divider,
|
||||
PasswordInput,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -14,11 +15,14 @@ import {
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
IconAt,
|
||||
IconId,
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconRosetteDiscountCheck,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -33,6 +37,7 @@ import { AuthShell } from '../components/AuthShell';
|
||||
import { loginSuccess, setUser } from '../store/auth.slice';
|
||||
import type { AuthUser } from '../types/auth.types';
|
||||
import { useAuthConfig } from '../AuthConfig';
|
||||
import { faydaSession, type FaydaResult } from '../utils/fayda-session';
|
||||
|
||||
interface SignupPayload {
|
||||
email: string;
|
||||
@@ -62,6 +67,56 @@ export function SignupPage() {
|
||||
}>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
|
||||
// Fayda is optional: the form below works exactly as before without it.
|
||||
const [fayda, setFayda] = useState<FaydaResult | null>(() => faydaSession.peekResult());
|
||||
const [faydaStarting, setFaydaStarting] = useState(false);
|
||||
const [startTrigger] = useApiMutation<{
|
||||
authorizationUrl: string;
|
||||
state: string;
|
||||
transactionToken: string;
|
||||
expiresIn: number;
|
||||
}>();
|
||||
const [linkTrigger] = useApiMutation<{ phoneNumberVerified?: boolean }>();
|
||||
|
||||
const verified = (field: string) => fayda?.verifiedFields.includes(field) ?? false;
|
||||
const conflicted = (field: string) => fayda?.conflicts.includes(field) ?? false;
|
||||
|
||||
/**
|
||||
* Per-field provenance, so it is obvious which values came from Fayda and
|
||||
* which are still the applicant's to supply. Verified fields stay editable —
|
||||
* a conflicting email has to be changeable for the form to be completable at
|
||||
* all.
|
||||
*/
|
||||
const faydaMark = (field: string): { description?: React.ReactNode } => {
|
||||
if (conflicted(field)) {
|
||||
return {
|
||||
// component="span" on these badges: the description slot renders
|
||||
// inside a <p>, where Badge's default <div> is invalid HTML.
|
||||
description: (
|
||||
<Badge component="span" size="xs" variant="light" color="orange">
|
||||
{t('fayda.fieldConflict', 'Already used by another account')}
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (verified(field)) {
|
||||
return {
|
||||
description: (
|
||||
<Badge
|
||||
component="span"
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<IconRosetteDiscountCheck size={11} />}
|
||||
>
|
||||
{t('fayda.fieldVerified', 'From Fayda')}
|
||||
</Badge>
|
||||
),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
@@ -118,6 +173,42 @@ export function SignupPage() {
|
||||
defaultValues: { userType: 'individual' },
|
||||
});
|
||||
|
||||
// Fills what Fayda vouched for and leaves the rest — username and password
|
||||
// are always the applicant's to choose, and Fayda supplies neither.
|
||||
useEffect(() => {
|
||||
if (!fayda) return;
|
||||
const { email, phoneNumber: phone, nameEn, nameAm } = fayda.identity;
|
||||
if (email) setValue('email', email);
|
||||
if (phone) setValue('phoneNumber', phone);
|
||||
if (nameEn) setValue('nameEn', nameEn);
|
||||
if (nameAm) setValue('nameAm', nameAm);
|
||||
}, [fayda, setValue]);
|
||||
|
||||
const startFayda = async () => {
|
||||
setServerError(null);
|
||||
setFaydaStarting(true);
|
||||
try {
|
||||
// Same endpoint the registration itself uses; `start` only opens the
|
||||
// attempt and hands back where to send the user.
|
||||
const { authorizationUrl, transactionToken, state } = await startTrigger({
|
||||
url: '/auth/register-with-fayda',
|
||||
method: 'POST',
|
||||
body: { action: 'start' },
|
||||
}).unwrap();
|
||||
|
||||
faydaSession.saveRequest({ transactionToken, state });
|
||||
window.location.assign(authorizationUrl);
|
||||
} catch (err: unknown) {
|
||||
setFaydaStarting(false);
|
||||
setServerError(handleError(err));
|
||||
}
|
||||
};
|
||||
|
||||
const clearFayda = () => {
|
||||
faydaSession.clearResult();
|
||||
setFayda(null);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const payload: SignupPayload = {
|
||||
@@ -147,7 +238,28 @@ export function SignupPage() {
|
||||
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
|
||||
dispatch(setUser(me));
|
||||
|
||||
if (data.isPhoneNumberVerified) {
|
||||
// Records the Fayda-verified identity on the new account: marks the
|
||||
// phone verified when it is the one Fayda vouched for, and fills the
|
||||
// still-empty profile fields. Best-effort — the account already works,
|
||||
// and the token can be presented again on a retry.
|
||||
let faydaPhoneVerified = false;
|
||||
if (fayda?.verificationToken) {
|
||||
try {
|
||||
const applied = await linkTrigger({
|
||||
url: '/profiles/me/fayda',
|
||||
method: 'POST',
|
||||
body: { verificationToken: fayda.verificationToken },
|
||||
}).unwrap();
|
||||
faydaPhoneVerified = Boolean(applied?.phoneNumberVerified);
|
||||
} catch {
|
||||
/* deliberately ignored — signup already succeeded */
|
||||
}
|
||||
}
|
||||
faydaSession.clearResult();
|
||||
|
||||
// Fayda already verified this exact number via its own OTP; asking for
|
||||
// a second OTP on the same number is theatre.
|
||||
if (data.isPhoneNumberVerified || faydaPhoneVerified) {
|
||||
navigate(loginRedirectPath);
|
||||
} else {
|
||||
navigate('/otp-verify', {
|
||||
@@ -212,6 +324,53 @@ export function SignupPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{fayda ? (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="teal"
|
||||
icon={<IconRosetteDiscountCheck size={18} />}
|
||||
title={t('fayda.verifiedTitle', 'Verified with Fayda')}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'fayda.verifiedBody',
|
||||
'We filled in the details Fayda confirmed. Please complete the remaining fields.',
|
||||
)}
|
||||
</Text>
|
||||
<Anchor size="sm" component="button" type="button" onClick={clearFayda}>
|
||||
{t('fayda.discard', 'Clear these details and fill the form myself')}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="md"
|
||||
fullWidth
|
||||
loading={faydaStarting}
|
||||
leftSection={<IconId size={18} />}
|
||||
onClick={startFayda}
|
||||
>
|
||||
{t('fayda.continueWith', 'Continue with Fayda')}
|
||||
</Button>
|
||||
<Divider
|
||||
label={t('fayda.orFillManually', 'or fill in your details')}
|
||||
labelPosition="center"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{fayda && fayda.conflicts.length > 0 && (
|
||||
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={18} />}>
|
||||
{t(
|
||||
'fayda.conflictBody',
|
||||
'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.',
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
@@ -220,6 +379,7 @@ export function SignupPage() {
|
||||
placeholder={t('signup.nameEnPlaceholder', 'Abebe Bekele')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameEn?.message}
|
||||
{...faydaMark('nameEn')}
|
||||
{...register('nameEn')}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -227,6 +387,7 @@ export function SignupPage() {
|
||||
placeholder={t('signup.nameAmPlaceholder', 'ስም')}
|
||||
leftSection={<IconUser size={18} />}
|
||||
error={errors.nameAm?.message}
|
||||
{...faydaMark('nameAm')}
|
||||
{...register('nameAm')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
@@ -237,6 +398,7 @@ export function SignupPage() {
|
||||
placeholder={t('signup.emailPlaceholder', 'you@example.com')}
|
||||
leftSection={<IconMail size={18} />}
|
||||
error={errors.email?.message}
|
||||
{...faydaMark('email')}
|
||||
{...register('email')}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -255,6 +417,7 @@ export function SignupPage() {
|
||||
onChange={(val) => setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })}
|
||||
onBlur={() => trigger('phoneNumber')}
|
||||
error={errors.phoneNumber?.message}
|
||||
{...faydaMark('phoneNumber')}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
|
||||
|
||||
87
libs/auth/src/lib/utils/fayda-session.ts
Normal file
87
libs/auth/src/lib/utils/fayda-session.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* The Fayda round trip leaves the app entirely, so the little state that has to
|
||||
* survive it lives in sessionStorage: same tab, same origin, gone when the tab
|
||||
* closes.
|
||||
*
|
||||
* Nothing secret is kept here. The `transactionToken` is signed by the API and
|
||||
* useless without it — the PKCE verifier, the nonce and the client key never
|
||||
* leave the backend.
|
||||
*/
|
||||
|
||||
const REQUEST_KEY = 'fayda:request';
|
||||
const RESULT_KEY = 'fayda:result';
|
||||
|
||||
export interface FaydaRequest {
|
||||
transactionToken: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface FaydaPrefill {
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
nameEn?: string;
|
||||
nameAm?: string;
|
||||
/** Shown for context only — the signup form has no field for these. */
|
||||
gender?: string;
|
||||
address?: string;
|
||||
birthdate?: string;
|
||||
nationality?: string;
|
||||
faydaNumber?: string;
|
||||
}
|
||||
|
||||
/** Shape of `POST /auth/register-with-fayda` with `action: "verify"`. */
|
||||
export interface FaydaResult {
|
||||
identity: FaydaPrefill;
|
||||
faydaVerified: boolean;
|
||||
/** Signup fields Fayda vouched for. */
|
||||
verifiedFields: string[];
|
||||
/** Prefilled fields already taken by another account. */
|
||||
conflicts: string[];
|
||||
/**
|
||||
* Encrypted proof of the verification, presented to POST /profiles/me/fayda
|
||||
* after signup so the account and profile record what Fayda vouched for.
|
||||
*/
|
||||
verificationToken: string;
|
||||
}
|
||||
|
||||
// Private browsing and locked-down browsers can throw on access, and a failure
|
||||
// here should degrade to "no Fayda prefill", never break the signup page.
|
||||
function read<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: unknown): void {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* nothing to do — the flow reports a generic failure instead */
|
||||
}
|
||||
}
|
||||
|
||||
function clear(key: string): void {
|
||||
try {
|
||||
sessionStorage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export const faydaSession = {
|
||||
saveRequest: (request: FaydaRequest) => write(REQUEST_KEY, request),
|
||||
takeRequest: (): FaydaRequest | null => {
|
||||
const request = read<FaydaRequest>(REQUEST_KEY);
|
||||
// Single use: a stale token would otherwise be replayed against a fresh
|
||||
// callback and fail with a confusing "session expired".
|
||||
clear(REQUEST_KEY);
|
||||
return request;
|
||||
},
|
||||
|
||||
saveResult: (result: FaydaResult) => write(RESULT_KEY, result),
|
||||
peekResult: (): FaydaResult | null => read<FaydaResult>(RESULT_KEY),
|
||||
clearResult: () => clear(RESULT_KEY),
|
||||
};
|
||||
@@ -1,9 +1,6 @@
|
||||
import { authStorage } from "./auth-storage";
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.[
|
||||
"VITE_BASE_API_URL"
|
||||
] ?? "http://localhost:3000/api";
|
||||
import { BASE_API_URL } from "@ema-platform/api";
|
||||
|
||||
interface RefreshResponse {
|
||||
token: string;
|
||||
|
||||
Reference in New Issue
Block a user