Platform
Web and mobile clients
Two Vite multi-page apps and one Flutter app. All three call the same REST API with the same token. Section changes on the web are real page loads; React Router only handles sub-views inside a section.
Web routing
Each top-level section (courses, assessments, resources, profile) is its own HTML entry and its own bundle. Links between sections are plain anchors, so the browser reloads. Inside a section React Router switches views such as list and detail without reloading.
Web routing
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: { rollupOptions: { input: {
main: resolve(__dirname, 'index.html'),
courses: resolve(__dirname, 'courses/index.html'),
assessments: resolve(__dirname, 'assessments/index.html'),
resources: resolve(__dirname, 'resources/index.html'),
profile: resolve(__dirname, 'profile/index.html'),
}}},
});location /courses/ { try_files $uri $uri/ /courses/index.html; }
location /assessments/ { try_files $uri $uri/ /assessments/index.html; }
location /resources/ { try_files $uri $uri/ /resources/index.html; }
location /profile/ { try_files $uri $uri/ /profile/index.html; }
location / { try_files $uri $uri/ /index.html; }| Function or file | Does |
|---|---|
pages/courses/main.tsx | Entry for the courses section. Renders Sidebar and CoursesApp into the root. |
CoursesApp | BrowserRouter with basename /courses and the nested routes for list and detail. |
fetchNav() | GET nav_items with their roles on every page load, so admin edits never show stale. |
NavRenderer | Renders anchors, not Link, for top-level items. Persists the open group in localStorage under sidebar:openSection. |
api.ts | fetch wrapper that adds the token and redirects to login on 401. |
RoleGate role="trainer" | Renders a branch of a page for one role. Convenience only; the API is the real check. |
A logged-out visitor may see empty page chrome before the API answers 401. That leaks nothing. Do not mistake it for access control.
Each section sets its own basename so sub-view URLs stay real and shareable without colliding.
A single API origin is enough. If the admin console moves to its own subdomain, a cross-subdomain cookie carries the session.
Screens by role
| Role | Web | Mobile |
|---|---|---|
| trainee | profile, courses, resources, assessments, feedback, certificate download | same screens |
| trainer | profile, courses, resource upload, questionnaire builder and review, trainee monitoring | profile, questionnaire builder and review, trainee monitoring, resource upload |
| admin | admin console: approvals, courses, assessments overview, competency, dropdowns, notices | not planned |
Mobile routing
Flutter uses go_router. Its redirect callback plays the same role as authorizeRoute on the server: it decides whether a screen may be built. The API check stays authoritative.
Flutter startup and redirect
final router = GoRouter(
refreshListenable: authState, // re-run redirect when auth changes
redirect: (context, state) => appRedirect(authState, state.matchedLocation),
routes: [ /* login, pending, courses, assessments, ... */ ],
);
String? appRedirect(AuthState a, String loc) {
if (!a.signedIn) return loc == '/login' ? null : '/login';
if (!a.approved) return loc == '/pending' ? null : '/pending';
if (!a.roleAllows(loc)) return '/courses'; // role table mirrors nav_item_roles
return null;
}| Piece | Does |
|---|---|
dio | REST client. An interceptor adds the token and, on 401, calls refresh once and replays the request. |
flutter_secure_storage | Keeps the access and refresh tokens. |
AuthRepository.refresh() | Exchanges the refresh token. Failure signs the user out and routes to /login. |
appRedirect(auth, location) | The redirect rule above. |