Skip to content

Repository files navigation

web-alumni

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.


What it does

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.


Tech stack

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.


Installation

npm install
cp .env.example .env      # NUXT_PUBLIC_API_BASE → where api-alumni listens
npm run dev

NUXT_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           # Vitest

Architecture

app/
  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.

The API client

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.

Server-driven lists

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),
})

Permissions in the UI

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.


Testing

npm test

Vitest, 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.


Technical decisions

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".


Trade-offs

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.


Future improvement

  • A Loading component, 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 localStorage entirely.
  • 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages