Adds live-class session scheduling on lessons (join link, date/time, timezone) with automatic 24h and 1h reminder emails plus a calendar invite (.ics), a time-change notification with an updated invite, a teacher-authored welcome email and session invite on enrollment, and a "Notify students" quiz nudge. LMS gets an upcoming live-sessions card and Join buttons, plus a reusable live-session card component (3 states) in @cio/ui with a Storybook story. The sidebar content tree now reflects sequential progression locking like the content list, and the course-completion confetti regression is fixed. Org-based emails no longer reference ClassroomIO by name. Docs add live-class, welcome-email, and course-progression guides, expand the certificates guide (design, signatures, corrected labels), move plan-required callouts to the top, flatten how-to-guides to the docs root with redirects, and add screenshots. Includes an add-docs-image skill.
Super Admin Panel PRD
Purpose
Build a password-gated super admin panel that lets the ClassroomIO operator manage all users across the database without needing a user account. Use cases:
- User management — view a paginated, searchable table of every user; open individual profile pages with stats
- AI chat inspection — view all AI assistant conversations for any user and copy an entire chat transcript with one click
- Credit management — top up AI credits for any org directly from the panel
- Org creation — create a new organization and assign an existing user as its admin
- Impersonation — "Login as" any user to act on their behalf on
app.classroomio.com
The panel has no user account of its own. Access is controlled by a single SUPER_ADMIN_PASSWORD env var, mirroring the existing /admin/queues pattern. It ships as a separate app (apps/admin/) so it never bloats the user-facing dashboard bundle and can be deployed independently.
Confirmed Decisions
- Separate app:
apps/admin/— own SvelteKit app, ownpackage.json, own deployment. Zero code shared withapps/dashboard/at the bundle level. - Auth pattern:
X-Super-Admin-Keyrequest header checked againstSUPER_ADMIN_PASSWORDenv var via timing-safe comparison on the API side. Same approach asQUEUE_DASHBOARD_PASSWORDinapps/api/src/routes/admin/queues.ts. - Password gate UX: Custom login form in the admin app. Password stored in
sessionStorage— survives page refresh, cleared on tab close. - API proxy:
apps/admin/src/hooks.server.tsforwards/api/*→PRIVATE_SERVER_URL. Client code calls/api/admin/usersetc. No tenant routing complexity needed — this is internal only. - Impersonation: Reuses
mintLoginLinkTokenfrompackages/db/src/auth/login-link.ts. Admin opens the resulting URL (https://app.classroomio.com/api/auth/login-link?token=...&redirect=/home) in a new tab. TTL: 60 minutes. - AI credits:
aiCreditBalanceis per-org. The UI shows a user's admin orgs with current credit balance; operator enters an amount to add. Calls existingupsertCreditBalancequery. - Org creation: Reuses
createOrganization+createOrganizationMemberquery pattern, accepting a targetuserIdinstead of the session user.
Architecture
flowchart LR
subgraph adminApp ["apps/admin/ (standalone SvelteKit)"]
Gate[Password Gate\nsessionStorage]
UsersPage[Users Table]
ProfilePage[User Profile\n+ Chats + Credits]
NewOrgPage[Create Org Form]
Proxy["hooks.server.ts\n/api/* → PRIVATE_SERVER_URL"]
end
subgraph api ["apps/api/ /admin/*"]
Middleware[superAdminMiddleware\nX-Super-Admin-Key]
UsersRouter[/admin/users/**]
OrgsRouter[/admin/orgs/**]
end
subgraph existing [Existing packages]
LoginLink[mintLoginLinkToken]
CreditBalance[upsertCreditBalance]
OrgCreate[createOrganization]
end
Gate -->|header via Proxy| Middleware
Middleware --> UsersRouter
Middleware --> OrgsRouter
UsersRouter -->|impersonate| LoginLink
OrgsRouter -->|credits| CreditBalance
OrgsRouter -->|create| OrgCreate
Phase 1: API Layer
1a. Env Config
packages/core/src/config/env.ts — add alongside QUEUE_DASHBOARD_PASSWORD:
SUPER_ADMIN_PASSWORD: z.string().optional(),
1b. Super Admin Middleware
apps/api/src/middlewares/super-admin.ts (new file)
import type { Context, Next } from 'hono';
import { env } from '@cio/core/config/env';
export async function superAdminMiddleware(c: Context, next: Next): Promise<Response | void> {
const expected = env.SUPER_ADMIN_PASSWORD;
if (!expected) {
if (env.NODE_ENV !== 'production') return next();
return c.json({ success: false, error: 'Super admin not configured' }, 503);
}
const key = c.req.header('X-Super-Admin-Key');
if (!key || !timingSafeEqual(key, expected)) {
return c.json({ success: false, error: 'Unauthorized' }, 401);
}
return next();
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let mismatch = 0;
for (let i = 0; i < a.length; i++) {
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return mismatch === 0;
}
1c. DB Queries
packages/db/src/queries/admin/users.ts (new file)
listUsers({ page, limit, search }: { page: number; limit: number; search?: string })
→ Promise<{ users: AdminUserRow[]; total: number }>
- LEFT JOIN
user+profileonprofile.id = user.id - Subquery count of
organizationmemberper user →orgCount - Subquery count of
groupmemberper profile →courseCount - WHERE
user.name ILIKE '%search%' OR user.email ILIKE '%search%'when search provided - ORDER BY
user.createdAt DESC, LIMIT / OFFSET for pagination
getUserWithStats(userId: string) → Promise<AdminUserWithStats | null>
- Same joins plus: admin org count (
roleId = 1), total org count, total course enrollments - Include each admin org's
aiCreditBalance(for the credits section)
packages/db/src/queries/admin/chats.ts (new file)
listUserChats(userId: string) → Promise<ChatSummary[]>
// SELECT id, title, course_id, updated_at, jsonb_array_length(messages) as message_count
// FROM ai_chat_conversation WHERE user_id = userId ORDER BY updated_at DESC
getUserChat(conversationId: string, userId: string) → Promise<ChatConversation | null>
// Full row including messages JSONB WHERE id = conversationId AND user_id = userId
packages/db/src/queries/admin/orgs.ts (new file)
createOrgWithAdmin({ name, siteName, profileId })
→ Promise<{ org: Organization; member: OrganizationMember }>
// Transaction: createOrganization + createOrganizationMember(roleId = ROLE.ADMIN, verified = true)
// Same pattern as apps/api/src/services/organization.ts
1d. Validation
packages/utils/src/validation/admin/admin.ts (new file)
export const ZListUsersQuery = z.object({
page: z.string().transform(Number).pipe(z.number().min(1)).default('1'),
limit: z.string().transform(Number).pipe(z.number().min(1).max(100)).default('20'),
search: z.string().optional()
});
export const ZAddCredits = z.object({ amount: z.number().int().positive() });
export const ZCreateAdminOrg = z.object({
name: z.string().min(5),
siteName: z.string().min(5),
ownerUserId: z.string().uuid()
});
1e. Services
apps/api/src/services/admin/users.ts (new file)
listAdminUsers(params)— callslistUsersquerygetAdminUserStats(userId)— callsgetUserWithStats; throwsAppError404 if not foundlistAdminUserChats(userId)— callslistUserChatsgetAdminUserChat(conversationId, userId)— callsgetUserChat; throws 404 if not foundimpersonateUser(userId)— fetchesuser.emailfrom DB, callsmintLoginLinkToken({ userId, email, ttlMinutes: 60 }), returns{ loginUrl }
apps/api/src/services/admin/orgs.ts (new file)
addOrgCredits(orgId, amount)— callsupsertCreditBalance(orgId, amount)frompackages/db/src/queries/agent/token-usage.tscreateOrgForUser(data)— resolvesownerUserId→profileId, checks siteName uniqueness (409 if taken), callscreateOrgWithAdmin
1f. Routes
apps/api/src/routes/admin/users.ts (new file)
GET /users listAdminUsers (ZListUsersQuery)
GET /users/:userId getAdminUserStats
POST /users/:userId/impersonate impersonateUser → { loginUrl }
GET /users/:userId/chats listAdminUserChats
GET /users/:userId/chats/:chatId getAdminUserChat
apps/api/src/routes/admin/orgs.ts (new file)
POST /orgs createOrgForUser (ZCreateAdminOrg)
POST /orgs/:orgId/credits addOrgCredits (ZAddCredits)
apps/api/src/routes/admin/index.ts (new file)
import { Hono } from '@api/utils/hono';
import { superAdminMiddleware } from '@api/middlewares/super-admin';
import { adminUsersRouter } from './users';
import { adminOrgsRouter } from './orgs';
export const adminRouter = new Hono()
.use('/*', superAdminMiddleware)
.route('/', adminUsersRouter)
.route('/', adminOrgsRouter);
1g. Register in app.ts
import { adminRouter } from '@api/routes/admin';
// in the RPC chain:
.route('/admin', adminRouter)
mountQueueDashboard(app) stays untouched — its /admin/queues/* paths don't conflict.
1h. Build
pnpm --filter @cio/api build
Phase 2: apps/admin — Standalone SvelteKit App
2a. App scaffold
apps/admin/ — new workspace package
apps/admin/
package.json
svelte.config.js
vite.config.ts
tsconfig.json
.env.example
src/
app.html
hooks.server.ts ← forwards /api/* to PRIVATE_SERVER_URL
routes/
+layout.svelte ← password gate + nav
+layout.ts ← ssr: false
+page.svelte ← redirect to /users
users/
+page.svelte ← paginated users table
[userId]/
+page.svelte ← user profile
orgs/
new/
+page.svelte ← create org form
lib/
api.svelte.ts ← AdminApi class
types.ts ← plain TS types (no RPC inference — standalone app)
package.json:
{
"name": "@cio/admin",
"version": "0.0.1",
"scripts": {
"dev": "vite dev --port 5174",
"build": "vite build",
"preview": "vite preview",
"start": "node build/index.js"
},
"dependencies": {
"@sveltejs/kit": "...",
"@sveltejs/adapter-node": "...",
"svelte": "..."
}
}
svelte.config.js: adapter-node, no CSP complexity, alias $lib → ./src/lib
.env.example:
PRIVATE_SERVER_URL=http://localhost:3002
PUBLIC_APP_URL=https://app.classroomio.com
2b. API proxy
src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
const API_PREFIX = '/api';
export const handle: Handle = async ({ event, resolve }) => {
if (event.url.pathname.startsWith(API_PREFIX)) {
const upstream = process.env.PRIVATE_SERVER_URL;
if (!upstream) return new Response('API not configured', { status: 502 });
const upstreamUrl = new URL(
event.url.pathname.slice(API_PREFIX.length) + event.url.search,
upstream
);
const headers = new Headers(event.request.headers);
headers.set('host', upstreamUrl.host);
return fetch(upstreamUrl, {
method: event.request.method,
headers,
body: event.request.method !== 'GET' ? event.request.body : undefined,
// @ts-ignore
duplex: 'half'
});
}
return resolve(event);
};
2c. API class
src/lib/api.svelte.ts
AdminApi class — stores password: string, appends 'X-Super-Admin-Key': password header to every fetch.
Methods:
listUsers(page, search?)→GET /api/admin/usersgetUser(userId)→GET /api/admin/users/:userIdloginAsUser(userId)→POST /api/admin/users/:userId/impersonate→window.open(data.loginUrl, '_blank')listChats(userId)→GET /api/admin/users/:userId/chatsgetChat(userId, chatId)→GET /api/admin/users/:userId/chats/:chatIdaddCredits(orgId, amount)→POST /api/admin/orgs/:orgId/creditscreateOrg(data)→POST /api/admin/orgs
Uses plain fetch (no Hono RPC client — standalone app has no @cio/api dep). Types defined locally in src/lib/types.ts.
2d. Password gate layout
src/routes/+layout.ts: export const ssr = false;
src/routes/+layout.svelte:
- On mount: read
sessionStorage.getItem('super-admin-pw') - If absent: render centered password form (input + submit button)
- On submit:
GET /api/admin/users?limit=1withX-Super-Admin-Keyheader- 401 → show inline error
- 200 → store in sessionStorage, show children
- Pass password to all child pages via Svelte context (
setContext) - Top nav: "ClassroomIO Admin" | Users | Create Org | Sign out (clears sessionStorage + reloads)
2e. Pages
/ (+page.svelte): redirect to /users
/users (+page.svelte) — Users table
DataTable columns: Avatar | Name | Email | Joined | Orgs | Courses | Verified | Banned | Actions
- Search input (debounced 300 ms), pagination controls
- Row actions: "View" →
/users/[id]; "Login as" inline button
/users/[userId] (+page.svelte) — User profile
- Header: avatar, full name, email, joined date, verified/banned badges + "Login as user" button
- Stats grid (4 tiles): Orgs (member), Orgs (admin), Courses enrolled, Account status
- AI Chats: conversation list → click to expand inline messages → "Copy chat" button formats as plain text and calls
navigator.clipboard.writeText - Orgs & Credits: table of admin orgs with current credit balance + "Add credits" per row (inline number input → confirm)
/orgs/new (+page.svelte) — Create org
Fields: Org name, Site name, Owner user ID. On submit → success state with "Login as owner" shortcut.
Files to Create
API (apps/api)
| File | Purpose |
|---|---|
packages/core/src/config/env.ts |
+1 line: SUPER_ADMIN_PASSWORD |
packages/db/src/queries/admin/users.ts |
listUsers, getUserWithStats |
packages/db/src/queries/admin/chats.ts |
listUserChats, getUserChat |
packages/db/src/queries/admin/orgs.ts |
createOrgWithAdmin |
packages/utils/src/validation/admin/admin.ts |
ZListUsersQuery, ZAddCredits, ZCreateAdminOrg |
apps/api/src/middlewares/super-admin.ts |
superAdminMiddleware |
apps/api/src/services/admin/users.ts |
User + chat + impersonation services |
apps/api/src/services/admin/orgs.ts |
Credits + org creation services |
apps/api/src/routes/admin/users.ts |
User/chat/impersonate routes |
apps/api/src/routes/admin/orgs.ts |
Org/credits routes |
apps/api/src/routes/admin/index.ts |
Aggregate admin router |
API (modified)
| File | Change |
|---|---|
apps/api/src/app.ts |
Add import { adminRouter } + .route('/admin', adminRouter) |
apps/admin (all new)
| File | Purpose |
|---|---|
apps/admin/package.json |
Package config (@cio/admin) |
apps/admin/svelte.config.js |
adapter-node, $lib alias |
apps/admin/vite.config.ts |
Vite + SvelteKit |
apps/admin/tsconfig.json |
TypeScript config |
apps/admin/.env.example |
PRIVATE_SERVER_URL, PUBLIC_APP_URL |
apps/admin/src/app.html |
HTML shell |
apps/admin/src/hooks.server.ts |
/api/* → PRIVATE_SERVER_URL proxy |
apps/admin/src/lib/api.svelte.ts |
AdminApi class |
apps/admin/src/lib/types.ts |
Request/response types |
apps/admin/src/routes/+layout.ts |
ssr = false |
apps/admin/src/routes/+layout.svelte |
Password gate + nav |
apps/admin/src/routes/+page.svelte |
Redirect to /users |
apps/admin/src/routes/users/+page.svelte |
Users table |
apps/admin/src/routes/users/[userId]/+page.svelte |
User profile |
apps/admin/src/routes/orgs/new/+page.svelte |
Create org |
Verification
cd apps/admin && pnpm dev— app starts on port 5174- Navigate to
localhost:5174— password gate form appears - Wrong password → inline error; correct password → land on
/users - User table loads with search and pagination
- Open a user profile → stats, chats, and orgs sections load
- Expand an AI chat → messages render; "Copy chat" copies plain-text transcript
- Add credits to an org → balance refreshes
- Create an org via
/orgs/new→ verify row in DB - "Login as user" → new tab opens at
app.classroomio.comas that user pnpm buildinapps/admin/— builds independently with no dashboard code in the bundle