Naalya Handbook
Feature Tour

Platform Console

The operator console for onboarding schools, editing shared curriculum versions, and managing platform operators.

Every other surface in the app belongs to a single school. The Platform Console is the one place that sits above all of them — the operator's control plane for the whole multi-tenant platform. It's where a school first comes into existence, where the shared curriculum templates that schools build on are authored, and where the handful of people who run the platform itself are managed.

It lives under /platform, and the door is guarded: the route's beforeLoad calls requirePermission({ userType: 'platform_admin' }), so only platform admins ever get in. Everyone else is bounced before the page renders.

src/routes/_authenticated/platform/route.tsx
export const Route = createFileRoute('/_authenticated/platform')({
  beforeLoad: requirePermission({ userType: 'platform_admin' }),
  component: RouteComponent,
})

A different shell

The console has its own sidebar and navbar — PlatformSidebar and PlatformNavbar, not the staff-hub ones. If you find yourself in a layout that says Platform in the corner, you're in the operator world, looking at platform-level data rather than one school's. See Routing and App Shell for how shells are picked.

Three areas

The sidebar (platform.sidebar.tsx) lays out everything the console does, plus a Hub landing page with platform-wide stats:

  • Schools — onboard new tenants and configure existing ones.
  • Curriculum — author the versioned academic templates schools build on.
  • Operators — manage the platform-admin accounts themselves.

Each is mostly a list screen that opens into a detail screen — a familiar shape you'll meet again and again. Let's walk them in turn.

Schools

A school is a tenant: its own users, its own data, its own branding, all isolated from every other school. The Schools list at /platform/schools is the registry of every tenant on the platform, rendered as a data table with a status badge and a row menu to suspend or re-activate each one.

The interesting action is Provision school. It opens a dialog that does two things in one call — creates the empty tenant and its first super-admin staff user — then tells you the new admin can sign in via OTP to take it from there:

src/routes/_authenticated/platform/schools/index.tsx
provisionSchool(value, {
  onSuccess: () => {
    toastManager.add({
      title: `School "${value.name}" provisioned`,
      description: `${value.adminEmail} can now sign in via OTP to set it up.`,
      type: 'success',
    })
  },
})

Suspending a school is deliberately heavy — it denies access to every user in that tenant — so the confirm surface makes you type the school's reference to go through. That friction is the point.

School detail

Clicking a school opens /platform/schools/$schoolId, a richer screen than the rest of the console. It shows stat tiles, the tenant's config and branding, and tabbed tables of the people inside it — Staff, Students, Guardians, Guests — each its own data table under src/components/data-tables/platform/.

Those people-tables are the one spot in the console that paginate against the server rather than loading everything at once. A shared useCursorParams hook holds the cursor, and each "Next" hands the server the nextCursor from the previous page:

src/components/data-tables/platform/student/datatable.tsx
const { params, onSearch, onNext, onPrev, canPrev } = useCursorParams()
// ...
<CursorPaginationFooter
  onNext={() => onNext(students?.nextCursor)}
  canPrev={canPrev}
/>

Cursors, not page numbers

Cursor pagination means you can only step forward and back — there's no "jump to page 9". Search and filters reset you to the first page. This is the platform-console flavor of the data-table story; school-scoped tables elsewhere often load smaller sets in one go.

Curriculum

This is the most conceptually interesting area, so it's worth slowing down. A curriculum version is a reusable template — a national edition like an O-Level or A-Level structure, authored once at the platform level. Schools don't write their own academic structure from scratch; their campuses instantiate the shape (subjects, streams, and so on) from one of these versions. Author the template once, and every tenant inherits a consistent academic skeleton.

The list at /platform/curriculum renders versions as a grid of cards, each tagged with its cycle (O-Level / A-Level) and a status badge — Draft, Active, or Superseded. That status is how an edition is rolled out and later retired without breaking the schools already standing on it.

A version is the root of a small tree. The CurriculumQuery class exposes the whole hierarchy — versions → subjects → themes → topics — with the usual list/get/create/update/delete on each level:

src/queries/curriculum/curriculum.query.ts
readonly listVersions = async () => this.exec(this.op.listCurriculumVersions())
readonly listSubjects = async (versionId: string) =>
  this.exec(this.op.listCurriculumSubjects({ params: { versionId } }))
readonly listThemes = async (subjectId: string) =>
  this.exec(this.op.listCurriculumThemes({ params: { subjectId } }))

Deleting a version is a cascade — it takes its subjects, themes, and topics with it — so the delete confirm makes you type the edition name, and the query layer invalidates the entire subtree afterward.

Reads are open, writes are gated

Any authenticated user can read the curriculum tree — schools need it to build on. But every write button in this area is wrapped in Can action="manage" resource="curriculum", and the backend enforces the same rule. Reading and editing are two different permissions here. See Permissions.

Operators

The smallest area, and the most self-referential: Operators are the platform admins themselves — accounts with no tenant of their own. The list at /platform/operators is a plain data table of email, name, and created date, with a dialog to create a new one. As with provisioning a school, a freshly created operator signs in via OTP.

One quirk to know about: the listPlatformAdmins endpoint has no OpenAPI schema, so the generated client types its rows as unknown. The route declares a small local Operator type for the handful of fields the table actually reads — a reminder that not every endpoint is fully typed for you.

Where to go next

On this page