The registrations table mirrored the server's old attendance deny-list —
ABSENT/WITHDRAWN/DISQUALIFIED — so the default REGISTERED fell through and
"Take exam" appeared before any invigilator had confirmed the candidate was
there. It is an allow-list now, PRESENT and LATE only, matching
ExamAttemptService.MAY_SIT.
A blocked candidate gets a reason rather than an empty cell: "Awaiting
attendance" with a tooltip while nobody has called the register, "Not sitting"
once attendance was taken and they are not. useExamAttempt does the same for
the direct-link path, and translates attendance_not_confirmed /
candidate_not_present instead of showing a raw error key on a Start button
that cannot work. The server refuses either way — this only makes the refusal
legible.
ScheduleExamModal no longer promises an admission number: scheduling makes a
sitting available, and the candidate registers for it themselves.
VITE_BASE_API_URL with a localhost:3001/api fallback was re-derived in six
files across libs/api, libs/auth, the portal and the backoffice — and a seventh
site (UserManagementPage) read the env raw with no fallback at all, handing the
IAM user-management app an undefined apiUrl whenever no .env was present.
BASE_API_URL in base-query-with-reauth.ts is now the single definition,
exported from @ema-platform/api; every other site imports it. Trims the env
value and treats blank as unset, matching how the backend reads its own keys.
The Fayda redirect URI registered for local testing is
http://localhost:3000/callback, matched exactly by the provider, so the portal
has to be the thing listening there. Its dev and preview servers move from 4200
to 3000 and the API moves to 3001.
Every hardcoded fallback to http://localhost:3000/api follows — six copies of
the same default across libs/api, libs/auth, the portal and the backoffice —
otherwise a developer without a .env would have had the app calling itself.
e2e is unaffected: it binds its own ports (3011/4302/4303) explicitly.
Fayda matches the registered redirect URI exactly, and the value being
registered first is a bare /callback. The descriptive path stays so the route
still reads as part of signup once that can be changed.
Adds "Continue with Fayda" to the existing signup page. It is an alternative
way to fill the form, not a second signup: the applicant still submits to
/auth/signup-with-pwd through the same schema, the same validation and the same
redirect to OTP verification, and signing up without Fayda is unchanged.
The page asks the API for an authorization URL, keeps the returned handle and
state in sessionStorage for the round trip, and FaydaCallbackPage hands the
code back to /auth/fayda/callback. No Fayda protocol logic lives here — the
PKCE verifier, the client key and the token exchange stay on the server. Once
the account exists the page posts the verification token to /auth/fayda/link so
the identity is recorded against it; that call is best-effort, since the
account is already usable without it.
Prefilled fields carry a "From Fayda" badge and stay editable, and a value
that already belongs to another account is badged as such so the applicant can
see which one to change rather than reading a single opaque signup error.
Cancellation, an expired session, a mismatched state and an incomplete callback
each get their own message.
Amharic strings are a first pass and want a native speaker's review.
Both themes set `fontFamily: Inter`, and Inter carries no Ge'ez glyphs. So
every Amharic string in a bilingual system — 45-73 KB of locale data per app —
has been rendering in whatever font the OS happened to substitute: different on
Windows, macOS and Android, and matching neither the design nor each other.
Noto Sans Ethiopic goes directly after Inter in one stack rather than being
swapped in under `[lang='am']`. Browsers fall back per glyph, not per element,
so a single stack renders Latin in Inter and Ge'ez in Noto automatically —
including within one string. That is the case that matters here: a registry is
full of mixed-script lines like an Amharic name beside a Latin IMO number, and
a language-scoped swap renders half of those in the wrong face.
Heading line-heights go up a step at the same time. Ge'ez has taller ascenders
and deeper descenders than Latin, so headings set to Inter's natural leading
clip once Ethiopic is actually being rendered. This was deliberately held back
from the theme refactor so that change could prove it altered nothing.
The gallery gains Ge'ez headings and a mixed-script line, so the baselines
cover the case rather than only Latin.
Follow-up: both fonts still load from Google Fonts via a render-blocking CSS
@import. Self-hosting with a subset and preload links is worth doing —
government networks frequently make fonts.googleapis.com slow or unreachable,
and silent Amharic fallback is precisely the failure this commit fixes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>