Frontend for the alumni verification portal — the SPA an alumnus fills their data in, and the secretariat reviews it from.
Nuxt 4 (SPA, ssr: false) · Vue 3 · TypeScript · Tailwind CSS · Pinia
Its backend is api-alumni. That repository's README explains
the problem being solved and the verification workflow; this one covers the
client.
Two audiences that never overlap:
| page | who | what |
|---|---|---|
/alumni |
any signed-in account | your own alumni record: fill it in over several visits, submit it, read why it was rejected, correct it and resubmit |
/management/alumni |
the secretariat | the review queue — search, filter by status, sort, paginate, approve or reject with a reason |
/management/users |
admins | accounts and their roles |
/management/roles |
Super-Admin | roles and the permissions attached to them |
/management/permissions |
admins | the catalogue, read only |
/profile |
anyone | own account and password |
The own-record page carries no permission requirement. Every account owns an alumni record, staff included, and the endpoints behind it only ever act on the caller's own row — there is no id to tamper with.
| Nuxt | 4.4, ssr: false |
| Vue | 3.5 |
| Pinia | 3.0 |
| Tailwind CSS | 4.1 |
| ofetch | via Nuxt's $fetch, wrapped in plugins/api.ts |
| Vitest | unit and component tests |
| Cypress | scaffolded, not yet used |
The UI is built on the Midone template. Its demo pages (/docs/*, dashboards,
e-commerce) are still in the tree and unused — nothing in the app links to them.
npm install
cp .env.example .env # NUXT_PUBLIC_API_BASE → where api-alumni listens
npm run devNUXT_PUBLIC_API_BASE is the only thing configurable. It must match the port
the API is on, and the API's own FRONTEND_URL must point back here — that
is the single allowed CORS origin, and a wildcard cannot be used with
credentials.
npm run dev # dev server
npm run build # production build
npm run preview # serve the build
npm test # Vitestapp/
pages/ file-based routing; `definePageMeta({ public: true })` opts out of auth
layouts/
components/ app-level components
features/ the modals — ModalFormUser, ModalFormRole, ModalRejectAlumni
base/ui/ the design system: Button, Input, Table, Dialog, Badge, …
services/ one composable per resource, wrapping $api
stores/ Pinia — auth, flash, theme
composables/ useServerList and friends
types/ entities mirroring the API's *resource* structs
plugins/api.ts the HTTP client: auth header, refresh on 401, error handling
middleware/ global route guards
utils/ pure helpers, unit tested
Services never talk to $fetch directly, and pages never talk to services'
internals. A page uses useServerList for anything paginated, which is what
keeps the URL the single source of truth.
plugins/api.ts wraps every call. It attaches the bearer token, sends
credentials: 'include' so the httpOnly cookies travel, and branches per status:
| status | behaviour |
|---|---|
| 401 | refresh the token and replay — unless the endpoint is in NO_REFRESH_ENDPOINTS |
| 403 | depends on the code: ERR_ACTION_UNAUTHORIZED flashes and stays, ERR_ACCOUNT_INACTIVE clears the session |
| 422 | flash it; errors is keyed by input name and FormFeedback renders each one next to its field |
| 429 | flash it with the wait from Retry-After. Never retried |
| 5xx | flash the backend's message, which is already stripped of internals |
A response with no JSON body at all — a proxy 502, a connection cut — falls back to a generic message rather than flashing nothing.
Concurrent 401s are queued behind a single refresh, so a page issuing five requests at once does not fire five refreshes.
useServerList enforces one rule: the URL is the single source of truth.
Inputs only ever write to the query string, and one watcher turns the query
string into a request. That keeps back/forward and shared links working, and
makes a double fetch impossible.
const { filters, hasActiveFilters, reset, refresh } = useServerList({
filters: { search: '', status: '', sort: '', order: '' },
debounce: ['search'],
fetch: (page, perPage, applied) => getAll(page, perPage, applied),
})useAuthStore().can('management.alumni.review') decides what the UI offers.
Every one of those permissions is checked again by the API, so hiding a button is
a convenience and never the control — a hand-typed URL still answers 403.
npm testVitest, covering the parts where a bug is invisible until a user hits it:
useServerList— the URL/state sync, including that a tab change plus a filter reset still costs one request rather than two.utils/alumni— the year and completeness rules, with a DOM-level test that types into a real input.
That second suite exists because of a real bug. The year inputs were
type="number", and Vue's v-model casts to a number on those regardless of
the string the Input component declares its model as. The year reached the
completeness check as a number, .trim() threw, and because the throw happened
inside a computed the render read, the component's update aborted and the DOM
froze with the submit button stuck disabled — typing appeared to do nothing.
They are type="text" with inputmode="numeric" now.
Cypress is scaffolded but only holds the template's example spec.
Years are held as strings. See above — type="number" makes the model a
number behind TypeScript's back, and TypeScript cannot see it because the
component declares string. Widening Input's model would have been the other
fix, but it is a shared base component and every existing v-model over a string
would stop typechecking.
editable and submittable come from the API, never re-derived from the
status string. The backend checks both again on every write, so duplicating the
rule here is only a way for the two to drift apart.
"Ajukan Verifikasi" always saves first. The submit endpoint takes no body and judges whatever is stored, so submitting an edited-but-unsaved form would verify the wrong data — or bounce as incomplete while the screen plainly shows every field filled in.
ModalConfirm is the one confirmation dialog, with ModalDelete a thin
wrapper carrying the delete wording. Approving uses ModalConfirm directly:
approval is terminal on the backend, so it earns the same pause a delete gets.
Sync semantics are preserved exactly. The API reads an absent role_ids,
[], and a populated list as three different instructions, so the forms omit the
key when they mean "leave it alone" and send [] when they mean "revoke
everything".
The access token is kept in localStorage as well as a cookie. The route
guard runs before hydration, where localStorage is not reliable, so the cookie
is what it reads. Keeping both is a deliberate simplification with a real cost:
anything that can run script in the page can read the token. Moving to a
httpOnly-cookie-only session means the guard can no longer see it, so the app
would bootstrap from /profiles/me instead.
ofetch rather than Axios. Nuxt ships $fetch, and adding Axios would mean
two HTTP clients in one app. The interceptor behaviour is equivalent.
Inline SVG spinners are duplicated across ten files. There is no Loading
component yet; there should be.
Notifications use an inline alert, not a toast. SimpleAlert is rendered
once in app/themes/Layout.vue and driven by the flash store, so every page gets
it without asking. A Toast component exists in the design system and is used
only by template demo pages.
- A
Loadingcomponent, replacing the ten copies of the same spinner. - Toast notifications for actions that succeed away from the top of the page.
- Cookie-only sessions, dropping
localStorageentirely. - Cypress end-to-end coverage of the critical flow: register → fill in → submit → approve.
- Deleting the unused Midone demo pages, which are most of
app/pages. - Component tests for the modals and the alumni form, which currently have none.