diff --git a/packages/feature-learner/src/server/admin.ts b/packages/feature-learner/src/server/admin.ts new file mode 100644 index 0000000..cd96123 --- /dev/null +++ b/packages/feature-learner/src/server/admin.ts @@ -0,0 +1,150 @@ +import type { Feature, Role, ServerContext } from "@stc/contracts"; +import { libraryRepo, type Collection } from "@stc/db"; +import { ensureGroup } from "./auth/groups"; +import { badRequest, body, json, notFound, ownerOnly, param } from "./http"; + +const ROLES: Role[] = ["owner", "author", "learner"]; + +/** Owner-only administration: users and roles, groups, course access lists and library curation. */ +export function adminRoutes(ctx: ServerContext, invalidate: (userId?: string) => void): Feature["routes"] { + const { db } = ctx; + const lib = libraryRepo(db); + + return { + "/api/admin/users": { + GET: ownerOnly(async () => { + const users = await db.sql`SELECT id, email, name, created_at FROM auth_user ORDER BY created_at, email`; + const roles = await db.sql`SELECT user_id, role FROM user_roles`; + const members = await db.sql`SELECT user_id, group_id FROM group_members`; + return json(users.map((u: { id: string; email: string; name: string; created_at: string }) => ({ + id: u.id, email: u.email, name: u.name, createdAt: u.created_at, + roles: roles.filter((r: { user_id: string }) => r.user_id === u.id).map((r: { role: string }) => r.role).sort(), + groups: members.filter((m: { user_id: string }) => m.user_id === u.id).map((m: { group_id: string }) => m.group_id).sort(), + }))); + }), + }, + "/api/admin/users/:id/roles": { + PUT: ownerOnly(async (req, rc) => { + const id = param(rc, "id"); + const b = await body<{ roles: Role[] }>(req); + if (!b || !Array.isArray(b.roles) || b.roles.some((r) => !ROLES.includes(r))) return badRequest("roles must be a list of owner, author, learner"); + const exists = await db.sql`SELECT 1 AS x FROM auth_user WHERE id = ${id}`; + if (!exists.length) return notFound(); + const roles = [...new Set(b.roles)]; + if (!roles.includes("owner")) { + const owners = await db.sql`SELECT user_id FROM user_roles WHERE role = 'owner'`; + const others = owners.filter((o: { user_id: string }) => o.user_id !== id); + if (others.length === 0) return json({ error: "the last owner cannot lose the owner role" }, 409); + } + await db.sql.begin(async (tx) => { + await tx`DELETE FROM user_roles WHERE user_id = ${id}`; + for (const r of roles) await tx`INSERT INTO user_roles (user_id, role) VALUES (${id}, ${r})`; + }); + invalidate(id); + return json({ id, roles: roles.sort() }); + }), + }, + "/api/admin/groups": { + GET: ownerOnly(async () => { + const groups = await db.sql`SELECT id, name FROM groups ORDER BY name`; + const members = await db.sql`SELECT group_id, user_id FROM group_members ORDER BY user_id`; + return json(groups.map((g: { id: string; name: string }) => ({ + id: g.id, name: g.name, + members: members.filter((m: { group_id: string }) => m.group_id === g.id).map((m: { user_id: string }) => m.user_id), + }))); + }), + POST: ownerOnly(async (req) => { + const b = await body<{ name: string }>(req); + const name = typeof b?.name === "string" ? b.name.trim() : ""; + if (!name) return badRequest("name is required"); + return json({ id: await ensureGroup(db, name), name }, 201); + }), + }, + "/api/admin/groups/:id": { + DELETE: ownerOnly(async (_req, rc) => { + const id = param(rc, "id"); + await db.sql.begin(async (tx) => { + await tx`DELETE FROM group_members WHERE group_id = ${id}`; + await tx`DELETE FROM learner_group_claims WHERE group_id = ${id}`; + await tx`DELETE FROM course_acl WHERE principal_type = 'group' AND principal_id = ${id}`; + await tx`DELETE FROM groups WHERE id = ${id}`; + }); + invalidate(); + return json({ ok: true }); + }), + }, + "/api/admin/groups/:id/members": { + PUT: ownerOnly(async (req, rc) => { + const id = param(rc, "id"); + const b = await body<{ userIds: string[] }>(req); + if (!b || !Array.isArray(b.userIds)) return badRequest("userIds must be a list"); + const g = await db.sql`SELECT 1 AS x FROM groups WHERE id = ${id}`; + if (!g.length) return notFound(); + await db.sql.begin(async (tx) => { + await tx`DELETE FROM group_members WHERE group_id = ${id}`; + await tx`DELETE FROM learner_group_claims WHERE group_id = ${id}`; + for (const u of new Set(b.userIds.map(String))) await tx`INSERT INTO group_members (group_id, user_id) VALUES (${id}, ${u})`; + }); + invalidate(); + return json({ id, members: [...new Set(b.userIds)].sort() }); + }), + }, + "/api/admin/courses/:id/access": { + GET: ownerOnly(async (_req, rc) => { + const id = param(rc, "id"); + const course = ctx.courses.get(id); + if (!course) return notFound(); + const acl = await db.sql`SELECT principal_type, principal_id FROM course_acl WHERE course_id = ${id} ORDER BY principal_type, principal_id`; + const authors = await db.sql`SELECT user_id FROM course_authors WHERE course_id = ${id} ORDER BY user_id`; + return json({ + visibility: course.manifest?.visibility ?? "restricted", + acl: acl.map((a: { principal_type: string; principal_id: string }) => ({ principal_type: a.principal_type, principal_id: a.principal_id })), + authors: authors.map((a: { user_id: string }) => a.user_id), + }); + }), + PUT: ownerOnly(async (req, rc) => { + const id = param(rc, "id"); + if (!ctx.courses.get(id)) return notFound(); + const b = await body<{ acl?: { principal_type: string; principal_id: string }[]; authors?: string[] }>(req); + if (!b) return badRequest("body required"); + if (b.acl && b.acl.some((a) => (a.principal_type !== "user" && a.principal_type !== "group") || !a.principal_id)) return badRequest("acl entries need principal_type user|group and principal_id"); + await db.sql.begin(async (tx) => { + if (b.acl) { + await tx`DELETE FROM course_acl WHERE course_id = ${id}`; + const seen = new Set(); + for (const a of b.acl) { + const k = `${a.principal_type}\0${a.principal_id}`; + if (seen.has(k)) continue; + seen.add(k); + await tx`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES (${id}, ${a.principal_type}, ${a.principal_id})`; + } + } + if (b.authors) { + await tx`DELETE FROM course_authors WHERE course_id = ${id}`; + for (const u of new Set(b.authors)) await tx`INSERT INTO course_authors (course_id, user_id) VALUES (${id}, ${u})`; + } + }); + return json({ ok: true }); + }), + }, + "/api/admin/library": { + GET: ownerOnly(async () => json({ featured: await lib.getFeatured(), collections: await lib.listCollections() })), + PUT: ownerOnly(async (req) => { + const b = await body<{ featured?: string | null; collections?: Collection[] }>(req); + if (!b) return badRequest("body required"); + if (b.featured !== undefined) await lib.setFeatured(b.featured || null); + if (b.collections) { + const keep = new Set(); + for (const [i, c] of b.collections.entries()) { + if (!c.title?.trim()) return badRequest("every collection needs a title"); + const cid = c.id || c.title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, ""); + keep.add(cid); + await lib.saveCollection({ id: cid, title: c.title.trim(), sort: c.sort ?? i, courseIds: c.courseIds ?? [] }); + } + for (const c of await lib.listCollections()) if (!keep.has(c.id)) await lib.deleteCollection(c.id); + } + return json({ featured: await lib.getFeatured(), collections: await lib.listCollections() }); + }), + }, + }; +} diff --git a/packages/feature-learner/src/server/feature.ts b/packages/feature-learner/src/server/feature.ts index 0b297db..954e50c 100644 --- a/packages/feature-learner/src/server/feature.ts +++ b/packages/feature-learner/src/server/feature.ts @@ -2,6 +2,7 @@ import type { AuthService, Feature, ServerContext } from "@stc/contracts"; import { createAuth } from "./auth"; import { runMigrations } from "./migrate"; import { json } from "./http"; +import { adminRoutes } from "./admin"; /** * The learner feature: auth, library, reader, notes, progress and exercises. @@ -20,6 +21,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic "/api/learner/providers": { GET: async () => json({ emailPassword: true, providers: auth.providers(), sso: await auth.hasSso() }), }, + ...adminRoutes(ctx, auth.service.invalidate), }; return { name: "learner", routes, auth: auth.service }; } diff --git a/packages/feature-learner/test/admin.test.ts b/packages/feature-learner/test/admin.test.ts new file mode 100644 index 0000000..34c4664 --- /dev/null +++ b/packages/feature-learner/test/admin.test.ts @@ -0,0 +1,43 @@ +import { expect } from "bun:test"; +import { forEachDb } from "./helpers"; +import { makeApp } from "./app"; + +forEachDb("admin API is owner-only and manages roles, groups, access and library", async (db) => { + const t = await makeApp(db); + const owner = await t.signUp("owner@example.com"); + const learner = await t.signUp("learner@example.com"); + expect((await t.call("GET", "/api/admin/users")).status).toBe(401); + expect((await t.call("GET", "/api/admin/users", { cookie: learner })).status).toBe(403); + + const users = await (await t.call("GET", "/api/admin/users", { cookie: owner })).json(); + expect(users.map((u: { email: string }) => u.email)).toEqual(["owner@example.com", "learner@example.com"]); + const [o, l] = users; + + // the last owner keeps the role + expect((await t.call("PUT", `/api/admin/users/${o.id}/roles`, { cookie: owner, body: { roles: ["learner"] } })).status).toBe(409); + expect((await t.call("PUT", `/api/admin/users/${l.id}/roles`, { cookie: owner, body: { roles: ["bogus"] } })).status).toBe(400); + const r = await t.call("PUT", `/api/admin/users/${l.id}/roles`, { cookie: owner, body: { roles: ["author", "learner"] } }); + expect((await r.json()).roles).toEqual(["author", "learner"]); + expect((await (await t.call("GET", "/api/me", { cookie: learner })).json()).user.roles).toEqual(["author", "learner"]); + + const g = await (await t.call("POST", "/api/admin/groups", { cookie: owner, body: { name: "DevOps Team" } })).json(); + expect(g.id).toBe("devops-team"); + await t.call("PUT", `/api/admin/groups/${g.id}/members`, { cookie: owner, body: { userIds: [l.id] } }); + const groups = await (await t.call("GET", "/api/admin/groups", { cookie: owner })).json(); + expect(groups).toEqual([{ id: "devops-team", name: "DevOps Team", members: [l.id] }]); + expect((await (await t.call("GET", "/api/me", { cookie: learner })).json()).user.groups).toEqual(["devops-team"]); + + await t.call("PUT", "/api/admin/courses/sample/access", { cookie: owner, body: { acl: [{ principal_type: "group", principal_id: g.id }], authors: [l.id] } }); + const access = await (await t.call("GET", "/api/admin/courses/sample/access", { cookie: owner })).json(); + expect(access).toEqual({ visibility: "public", acl: [{ principal_type: "group", principal_id: "devops-team" }], authors: [l.id] }); + expect((await t.call("GET", "/api/admin/courses/nope/access", { cookie: owner })).status).toBe(404); + + const libr = await (await t.call("PUT", "/api/admin/library", { cookie: owner, body: { featured: "sample", collections: [{ title: "Getting started", courseIds: ["sample"] }] } })).json(); + expect(libr.featured).toBe("sample"); + expect(libr.collections).toEqual([{ id: "getting-started", title: "Getting started", sort: 0, courseIds: ["sample"] }]); + const cleared = await (await t.call("PUT", "/api/admin/library", { cookie: owner, body: { collections: [] } })).json(); + expect(cleared.collections).toEqual([]); + + await t.call("DELETE", `/api/admin/groups/${g.id}`, { cookie: owner }); + expect(await (await t.call("GET", "/api/admin/groups", { cookie: owner })).json()).toEqual([]); +}, 30_000);