Engineeringadmin-web (dev)

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-level app/enter/page.tsx for one-click god-login link redemption.
  • app/(dashboard)/ — protected route group with its own layout.tsx (sidebar + header). Pages include dashboard/, 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/, and school-setup/.

Supporting directories:

DirectoryContents
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.tsNext.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:

  • notifyForbidden throttles to one refresh per MIN_REFRESH_INTERVAL_MS (5s), so a burst of 403s refreshes once.
  • The 403 trigger is skipped for the /api/auth/profile call itself and public auth URLs, so a refresh cannot re-trigger itself.
  • A 403 never logs the user out (only 401 does). 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:

  1. tenantId is the last segment of the queryKey. Two tenants must never share a cache entry.
  2. The query must not fire until tenant is known. enabled is gated on !!tenantId (plus any caller condition), so nothing fetches before context is ready.

Two accepted ways to satisfy them:

  • Preferred: the useTenantQuery / useTenantInfiniteQuery wrappers (src/hooks/useTenantQuery.ts), which append tenantId to the key and gate enabled automatically. useSubAdmins.ts uses this wrapper.
  • Existing module hooks were converted in-place with the explicit pattern above (tenantId appended manually, enabled: !!tenantId && …). useAnnouncements.ts follows 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’s core/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-password and /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.ts is the single source of truth mapping each route to the permissions (ANY-of) needed to enter it (ROUTE_PERMISSIONS, TRANSPORT_NAV_PERMS, helper requiredPermissionsForPath(pathname)). Each module list includes the module’s .manage perm so the seeded Admin role always passes. /dashboard, /profile, /help are always-allowed; /dashboard/finance and /dashboard/transport match by longest-prefix before the bare /dashboard.
  • Sidebar (components/layout/Sidebar.tsx) — nav items carry optional feature? and permissions?: string[]; render filter is (!feature || isFeatureEnabled(feature)) && (!permissions || hasAnyPermission(permissions)).
  • RouteGuard (components/layout/RouteGuard.tsx, inside the dashboard layout) — client-side, computes requiredPermissionsForPath and router.replace('/dashboard') when the user lacks access. Defense-in-depth (the backend already 403s). Waits for isLoading to resolve; loop-safe because /dashboard is 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 when isPlatformAdmin, reminding them they are editing the tenant’s live data.
  • GET /api/school-setup/status is super-admin-only. Every useSetupStatus caller is gated on isPlatformAdmin (enabled: isPlatformAdmin); non-super-admins never hit the endpoint (a 403). Other personas derive completeness from the isSetupComplete context flag delivered in login/profile. The persona-specific banner copy is decided by the pure setupBannerFor() helper in setupBanner.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.