admin-web (dev)
Next.js App Router dashboard for school tenants (admin-web/, local port 3000), served per-subdomain (<subdomain>.nexchool.in) from a single build behind an Nginx wildcard. Stack: Next.js 16, React 19, TypeScript 5, Tailwind 4, TanStack React Query 5, React Hook Form 7, Zod 4, Radix UI, Recharts, Axios, Firebase (push), Sonner (toasts).
App structure
Source lives under admin-web/src/. Routes use two App Router groups:
app/(auth)/— unauthenticated pages:login/,forgot-password/,reset-password/,set-password/(forced reset). Plus a public top-levelapp/enter/page.tsxfor one-click god-login link redemption.app/(dashboard)/— protected route group with its ownlayout.tsx(sidebar + header). Pages includedashboard/,attendance/,academics/,grades/,classes/,students/,teachers/,subjects/,holidays/,school-units/,programmes/,finance/(with its own sub-layout.tsx),transport/,timetable/,notifications/,announcements/,sub-admins/,subscription/,profile/, andschool-setup/.
Supporting directories:
| Directory | Contents |
|---|---|
components/ | Feature-grouped UI (students/, teachers/, finance/, layout/, forms/, tables/, ui/, auth/login/, school-setup/, announcements/, sub-admins/, …) |
hooks/ | React Query hooks (25+), e.g. useStudents.ts, useClasses.ts, useSubjects.ts; index.ts re-exports all |
services/ | API call layer (30+ files), api.ts plus one service per domain (studentsService.ts, authService.ts, …) |
types/ | TypeScript interfaces |
lib/ | auth-api.ts, storage.ts, firebase.ts, navPermissions.ts, forbiddenHandler.ts, branchScope.ts, passwordSchema.ts, validation + reference data, utils.ts |
providers/ | Context providers (Auth, Query, Theme) |
middleware.ts | Next.js middleware — redirects to /login when no token |
middleware.ts checks localStorage for a token: no token redirects to /login, otherwise the dashboard renders.
Services & API client
All HTTP goes through the central Axios instance in services/api.ts. Two interceptors carry auth and tenant context:
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
})
// Request interceptor — inject auth + tenant headers
api.interceptors.request.use((config) => {
const token = getAccessToken() // from localStorage
const tenantId = getTenantId()
if (token) config.headers.Authorization = `Bearer ${token}`
if (tenantId) config.headers['X-Tenant-ID'] = tenantId
return config
})
// Response interceptor — auto token refresh on 401
api.interceptors.response.use(
response => response,
async (error) => {
if (error.response?.status === 401) {
const newToken = await refreshToken()
if (newToken) {
error.config.headers.Authorization = `Bearer ${newToken}`
return api(error.config) // retry original request
}
clearAuth() // refresh failed — logout
window.location.href = '/login'
}
return Promise.reject(error)
}
)Every request injects Authorization: Bearer <token> and X-Tenant-ID: <tenant_id>. A 401 triggers one refresh-and-retry; if refresh fails, auth is cleared and the user is bounced to /login.
Domain services (studentsService, teachersService, financeService, …) wrap api calls and are consumed only through the React Query hooks — components should not call services directly.
403 self-correction
api.ts’s response handling also calls notifyForbidden() on a 403 (via the leaf registry in lib/forbiddenHandler.ts, which has no imports from api.ts or AuthProvider to avoid circular deps), then still throws ApiException so per-call error states run unchanged. AuthProvider registers registerForbiddenHandler(() => refreshUser()) so a stale-permission 403 re-fetches the profile and the sidebar + RouteGuard re-evaluate. Guards:
notifyForbiddenthrottles to one refresh perMIN_REFRESH_INTERVAL_MS(5s), so a burst of 403s refreshes once.- The
403trigger is skipped for the/api/auth/profilecall itself and public auth URLs, so a refresh cannot re-trigger itself. - A
403never logs the user out (only401does). The user keeps their page; only the forbidden action’s call failed.
Data fetching & tenant scoping
Data fetching uses TanStack Query for caching + stale-while-revalidate. Query hooks live in hooks/ and pair with a service:
export function useStudents(params: StudentQueryParams) {
const { tenantId } = useAuth();
return useQuery({
queryKey: ['students', 'list', params, tenantId], // tenantId LAST
queryFn: () => studentsService.getStudents(params),
enabled: !!tenantId,
staleTime: 1000 * 60 * 5,
})
}Tenant scoping is a hard rule
Multi-tenant isolation depends on two non-negotiable rules for every tenant-scoped query:
tenantIdis the last segment of thequeryKey. Two tenants must never share a cache entry.- The query must not fire until tenant is known.
enabledis gated on!!tenantId(plus any caller condition), so nothing fetches before context is ready.
Two accepted ways to satisfy them:
- Preferred: the
useTenantQuery/useTenantInfiniteQuerywrappers (src/hooks/useTenantQuery.ts), which appendtenantIdto the key and gateenabledautomatically.useSubAdmins.tsuses this wrapper. - Existing module hooks were converted in-place with the explicit pattern above (
tenantIdappended manually,enabled: !!tenantId && …).useAnnouncements.tsfollows this manual convention. Either approach is fine — pick one per file.
Mutations invalidate by prefix using keys.all so the match spans all tenant scopes (do not include tenantId in the invalidation key — that defeats prefix matching):
export function useCreateStudent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: studentsService.createStudent,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: studentsKeys.all })
}
})
}Not tenant-scoped (use plain useQuery): pre-auth public endpoints (/api/auth/login, /api/tenant-branding/*), platform-admin endpoints (/api/platform/*), and static lookups fetched once on cold start.
Real-time notifications
hooks/useNotificationInboxStream.ts subscribes to an SSE stream (lib/notificationRealtime.ts); new notifications are pushed into the query cache via queryClient.setQueryData(['notifications'], …) and a chime plays.
Forms & validation
Forms use React Hook Form + Zod on top of a shared validation layer:
src/lib/validation/fields.ts— reusable field validators (email, phone, aadhaar, pincode, ISO date, ranges, length caps) mirroring the server’score/validation.py.src/lib/data/referenceData.ts— reference lists (nationalities, mother tongues, Indian states + UTs).src/lib/passwordSchema.ts— shared password-strength schema (≥ 8 chars + ≥ 1 digit) used by/reset-passwordand/set-password.
Student, Teacher, and Class forms all use this RHF + Zod pattern (Teacher and Class were migrated from manual useState). src/components/ui/combobox.tsx is a searchable single-select for free-list-but-constrained fields (nationality, mother tongue, state).
StudentForm.tsx example of the layout convention: an always-visible Essentials card holds the required fields; everything else is in collapsible FormSections (collapsed on create, expanded on edit). Section bodies stay mounted (hidden via CSS) so RHF registration/validation is unaffected, and a section holding a field with a validation error is force-expanded (derived from form.formState.errors via a FIELD_SECTION map) so a submit is never blocked by a hidden field.
Other RHF + Zod forms: SubjectFormModal.tsx, SubAdminFormModal.tsx (matrix held as plain React state, not RHF), and the AnnouncementComposer.tsx.
Auth / permissions in the UI
AuthProvider (in providers/) hydrates from localStorage on cold start and exposes user, permissions, enabled features, and flags on context. Login and GET /api/auth/profile return is_platform_admin, is_subadmin, is_setup_complete, force_password_reset, and allowed_unit_ids; these are persisted via storage.ts helpers and surfaced as isPlatformAdmin, isSubAdmin, isSetupComplete, forcePasswordReset, and allowedUnitIds.
hasPermission / permission-gated rendering
Gate UI on permissions from context:
// Inline: show a control only when the permission is present
const permissions = usePermissions()
{permissions.includes('student.create') && (
<Button onClick={openCreateModal}>Add Student</Button>
)}
// Page-level guard
function StudentsPage() {
const { hasPermission } = useAuth()
if (!hasPermission('student.read')) return <AccessDenied />
return <StudentsContent />
}A platform super-admin’s profile returns permissions: ["system.manage"]; hasPermission treats system.manage as a superuser shortcut, so god mode passes every gate.
Route → permission map + guards
src/lib/navPermissions.tsis the single source of truth mapping each route to the permissions (ANY-of) needed to enter it (ROUTE_PERMISSIONS,TRANSPORT_NAV_PERMS, helperrequiredPermissionsForPath(pathname)). Each module list includes the module’s.manageperm so the seeded Admin role always passes./dashboard,/profile,/helpare always-allowed;/dashboard/financeand/dashboard/transportmatch by longest-prefix before the bare/dashboard.- Sidebar (
components/layout/Sidebar.tsx) — nav items carry optionalfeature?andpermissions?: string[]; render filter is(!feature || isFeatureEnabled(feature)) && (!permissions || hasAnyPermission(permissions)). - RouteGuard (
components/layout/RouteGuard.tsx, inside the dashboard layout) — client-side, computesrequiredPermissionsForPathandrouter.replace('/dashboard')when the user lacks access. Defense-in-depth (the backend already 403s). Waits forisLoadingto resolve; loop-safe because/dashboardis always-allowed.
Feature gating
Separate from permissions, features are gated via useFeatures():
const { isFeatureEnabled } = useFeatures()
{isFeatureEnabled('transport') && (
<NavItem href="/transport" icon={<BusIcon />}>Transport</NavItem>
)}God mode & setup banners
GodModeBanner(components/layout/GodModeBanner.tsx, mounted in the dashboard layout) — a persistent amber bar shown only whenisPlatformAdmin, reminding them they are editing the tenant’s live data.GET /api/school-setup/statusis super-admin-only. EveryuseSetupStatuscaller is gated onisPlatformAdmin(enabled: isPlatformAdmin); non-super-admins never hit the endpoint (a 403). Other personas derive completeness from theisSetupCompletecontext flag delivered in login/profile. The persona-specific banner copy is decided by the puresetupBannerFor()helper insetupBanner.ts.
Branch scoping
allowedUnitIds (null = unrestricted) drives per-branch access. src/lib/branchScope.ts holds pure decision helpers (isLockedToSingleBranch, resolveDefaultUnitId); ActiveScopeProvider locks the active unit to the single allowed branch and UnitSwitcher hides itself when locked. A backend 403 BranchForbidden renders a graceful empty state on detail pages rather than crashing.