mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-28 10:10:59 +00:00
Merge branch 'feature/fayda-auth-and-email-notifications' of github.com:Tria-plc/emaui into feature/fayda-auth-and-email-notifications
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1001,6 +1001,10 @@ export const am: Translations = {
|
||||
timeExpired: 'ጊዜው አልቋል',
|
||||
resumeExam: 'ፈተና ይቀጥሉ',
|
||||
takeExam: 'ፈተና ይውሰዱ',
|
||||
awaitingAttendance: 'መገኘት በመጠባበቅ ላይ',
|
||||
awaitingAttendanceHint:
|
||||
'ፈተናው ከመከፈቱ በፊት ተቆጣጣሪ መገኘትዎን ማረጋገጥ አለበት።',
|
||||
notSitting: 'አይፈተኑም',
|
||||
attendanceStatus: {
|
||||
REGISTERED: 'አልተጠራም',
|
||||
PRESENT: 'ተገኝቷል',
|
||||
|
||||
@@ -1003,6 +1003,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',
|
||||
|
||||
@@ -76,6 +76,7 @@ export function SignupPage() {
|
||||
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;
|
||||
@@ -89,8 +90,10 @@ export function SignupPage() {
|
||||
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 size="xs" variant="light" color="orange">
|
||||
<Badge component="span" size="xs" variant="light" color="orange">
|
||||
{t('fayda.fieldConflict', 'Already used by another account')}
|
||||
</Badge>
|
||||
),
|
||||
@@ -100,6 +103,7 @@ export function SignupPage() {
|
||||
return {
|
||||
description: (
|
||||
<Badge
|
||||
component="span"
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
@@ -234,9 +238,28 @@ export function SignupPage() {
|
||||
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
|
||||
dispatch(setUser(me));
|
||||
|
||||
// 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();
|
||||
|
||||
if (data.isPhoneNumberVerified) {
|
||||
// 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', {
|
||||
|
||||
@@ -24,6 +24,9 @@ export interface FaydaPrefill {
|
||||
/** 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"`. */
|
||||
@@ -34,6 +37,11 @@ export interface FaydaResult {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user