Compare commits

..

1 Commits

Author SHA1 Message Date
RaymondVerhoef
20449f0bc1 fix: gebruik theme_session_completions voor learnDone op dashboard
getLearnDone was een deprecated stub die altijd false teruggaf nadat
de learn_progress-collectie is gedropt. Hierdoor toonde het dashboard
permanent 'Complete your theme session first', zelfs als de theme
session al voltooid was, en werd cycle progress nooit opgeteld.

Alle drie call-sites vervangen door getThemeSessionCompletion (de
huidige bron van waarheid), en de dode stubs verwijderd uit db.js.

Closes #9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:49:12 +02:00
31 changed files with 205 additions and 1779 deletions

View File

@@ -28,23 +28,4 @@ jobs:
-i infra/development/hosts.ini \
-e "ansible_ssh_private_key_file=~/.ssh/deploy_key" \
-e "anthropic_api_key=${{ secrets.ANTHROPIC_API_KEY }}" \
-e "entra_tenant_id=${{ secrets.ENTRA_TENANT_ID }}" \
-e "entra_client_id=${{ secrets.ENTRA_CLIENT_ID }}" \
-e "entra_client_secret=${{ secrets.ENTRA_CLIENT_SECRET }}" \
-e "entra_admin_emails=${{ secrets.ENTRA_ADMIN_EMAILS }}" \
infra/development/site/deploy-playbook.yml
# TEMP DIAGNOSTIC — investigating a 502 from pocketbase-learning on this
# environment. Remove this step once resolved.
- name: TEMP DIAGNOSTIC - pocketbase-learning status and logs
if: always()
run: |
ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key -p 4484 root@46.224.220.37 "
cd /opt/learning-platform &&
echo '--- docker compose ps ---' &&
docker compose ps &&
echo '--- pocketbase-learning logs (last 200 lines) ---' &&
docker compose logs --tail=200 pocketbase-learning &&
echo '--- learning-platform (caddy) logs (last 50 lines) ---' &&
docker compose logs --tail=50 learning-platform
"

View File

@@ -30,8 +30,4 @@ jobs:
-i infra/production/hosts.ini \
-e "ansible_ssh_private_key_file=~/.ssh/deploy_key" \
-e "anthropic_api_key=${{ secrets.ANTHROPIC_API_KEY }}" \
-e "entra_tenant_id=${{ secrets.ENTRA_TENANT_ID }}" \
-e "entra_client_id=${{ secrets.ENTRA_CLIENT_ID }}" \
-e "entra_client_secret=${{ secrets.ENTRA_CLIENT_SECRET }}" \
-e "entra_admin_emails=${{ secrets.ENTRA_ADMIN_EMAILS }}" \
infra/production/site/deploy-playbook.yml

View File

@@ -127,7 +127,6 @@ when the PB binary starts.
- `docs/generation-spec.md` — learning content + micro-learning generation
- `docs/curriculum-spec.md` — 26-week per-user curriculum engine
- `docs/r42-spec.md` — the R42 chatbot
- `docs/auth-spec.md` — Azure (Entra ID) login: OIDC via PocketBase, provisioning, roles
- `docs/frontend-spec.md` — screens, routing, onboarding
- `docs/gamification-spec.md` — points, badges, leaderboard
- `micro-learning-spec.md` — micro-learning learner experience

View File

@@ -12,11 +12,7 @@ services:
container_name: pocketbase-learning
restart: unless-stopped
working_dir: /pb
env_file:
- .env.local
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations"]
ports:
- "8090:8090"
volumes:
- pb_data:/pb/pb_data
- ./pb_migrations:/pb/pb_migrations

View File

@@ -1,105 +0,0 @@
# Authentication — Azure (Entra ID) login
> Implemented for issue #16. Replaces the previous client-side PIN login.
## Doel
Elke gebruiker logt in met zijn Respellion **Microsoft (Azure Entra ID)**-account.
Bij de eerste login wordt automatisch een `team_members`-record aangemaakt. Er is
geen PIN, geen handmatige user-aanmaak en geen client-side wachtwoordcontrole meer.
## Architectuur in één oogopslag
```
Browser (SPA) PocketBase (auth) Entra ID
───────────── ───────────────── ────────
"Sign in with Microsoft"
└─ pb.collection('team_members')
.authWithOAuth2({provider:'oidc'})
──────────────────► opens OIDC popup ───► login.microsoftonline.com
token exchange ◄─── (server-side, client secret)
◄────────────────── auth record + token
pb.authStore (token in localStorage)
```
- **PocketBase is de OIDC-client.** De token-exchange en het client-secret blijven
server-side in PocketBase — er is geen aparte backend en geen client-side key.
- **`team_members` is een PocketBase `auth`-collection.** De record-`id` blijft de
user-identifier die alle voortgangscollecties (`test_results`,
`on_demand_attempts`, `micro_learning_completions`, `leaderboard`,
`theme_session_completions`) als `user_id` referencen.
- **Sessie** = `pb.authStore` (token in `localStorage`), niet langer een
`team_members.id` in `sessionStorage`.
## Belangrijke beslissingen (ADR)
| ADR | Beslissing | Reden |
|---|---|---|
| 001 | OIDC-provider tegen Entra v2-endpoints i.p.v. eigen MSAL-flow of header-trust | Sluit aan op "PocketBase = enige backend"; secret server-side; auto-provisioning gratis; werkt los van de perimeter-implementatie |
| 002 | `team_members` hergebruikt als auth-collection (zelfde naam) | Alle `user_id`-referenties blijven werken; minimale blast-radius |
| 003 | Provisioning + admin-rol via `pb_hooks/team_members.pb.js` | Rol-logica server-side, niet in de client |
| 004 | Admin-rol via e-mail-allowlist (`ENTRA_ADMIN_EMAILS`), re-synced elke login | Geen handmatig beheer; allowlist-wijziging werkt bij volgende login |
| 005 | API-rules → `@request.auth.id != ""` op alle app-collecties | Geen anonieme toegang meer; admins/users zijn beide geauthenticeerd |
## Bestanden
| Bestand | Rol |
|---|---|
| `pb_migrations/1781000000_team_members_to_auth.js` | Dropt de oude PIN-collection, maakt `team_members` als auth-collection met de OIDC-provider (creds uit env). |
| `pb_migrations/1781000001_tighten_api_rules.js` | Zet alle niet-systeem-collecties op `@request.auth.id != ""`. |
| `pb_hooks/team_members.pb.js` | Auto-provisioning (`role`, `enrollment_status`, naam-fallback) + admin-rol re-sync. |
| `src/lib/azureAuth.js` | Canonieke, unit-geteste helpers (`OIDC_PROVIDER`, `resolveRole`, `parseAdminEmails`, `deriveName`). |
| `src/store/AppContext.jsx` | `loginWithAzure()` / `logout()` op basis van `pb.authStore` + `authRefresh()`. |
| `src/pages/Login.jsx` | "Sign in with Microsoft"-scherm (geen dropdown/PIN). |
| `src/components/admin/TeamManager.jsx` | Roster-beheer: rol wijzigen + verwijderen (geen aanmaken/PIN). |
> De rol-/allowlist-logica bestaat tweemaal: canoniek in `src/lib/azureAuth.js`
> (met tests) en herhaald in `pb_hooks/team_members.pb.js` (PocketBase JSVM kan de
> module niet importeren). **Houd ze in sync.**
## Configuratie (environment)
Gerenderd in `.env` door de Ansible deploy-playbooks en doorgegeven aan de
`pocketbase-learning`-container:
| Variabele | Betekenis |
|---|---|
| `ENTRA_TENANT_ID` | Entra tenant-id (of `common`). |
| `ENTRA_CLIENT_ID` | App-registration client-id. |
| `ENTRA_CLIENT_SECRET` | App-registration client-secret. |
| `ENTRA_ADMIN_EMAILS` | Komma-gescheiden e-mail-allowlist die `role=admin` krijgt, bv. `rve@respellion.nl,admin@respellion.nl`. |
Ansible-variabelen (vault): `entra_tenant_id`, `entra_client_id`,
`entra_client_secret`, `entra_admin_emails`.
## Entra App Registration (eenmalig, buiten de codebase)
1. **Redirect-URI (Web):** `https://<host>/api/oauth2-redirect`
- Labs: `https://learning-platform.labs.respellion.tech/api/oauth2-redirect`
- Lokaal dev: `http://localhost:8090/api/oauth2-redirect` (PB-origin)
2. **API permissions / scopes:** `openid`, `email`, `profile`.
3. Genereer een client-secret en zet de waarden in de Ansible-vault.
## Uitbreidingspunten
- **Silent SSO (geen tweede klik):** als bevestigd is dat de perimeter veilige,
niet-spoofbare identity-headers doorstuurt, kan een PocketBase-route die headers
vertrouwen en direct een token minten (ADR-004-alternatief).
- **Least-privilege rules:** schrijf/verwijder op de knowledge-authoring-collecties
(`topics`, `relations`, `content`, `sources`, `question_bank`,
`curriculum_versions`) verder beperken tot `@request.auth.role = "admin"`. Let op:
`micro_learnings` en `theme_sessions` worden door reguliere users geschreven
(generate-then-cache). Vereist runtime-verificatie.
- **Rol op Entra group-claims** i.p.v. e-mail-allowlist (aanpassing in
`pb_hooks/team_members.pb.js` + `src/lib/azureAuth.js`).
## Bekende aandachtspunten / go-live
- De OIDC-popup is een top-level navigatie naar `login.microsoftonline.com`; de
bestaande Caddy-CSP (`connect-src`) raakt dit niet. Verifieer alsnog bij de
eerste deploy.
- Verweesde voortgangsrecords van verwijderde (pre-Azure) users zijn acceptabel:
zonder geldig Entra-account kan niemand inloggen, dus die records zijn onbereikbaar.
- Credential-rotatie: update env + herconfigureer de provider via de PocketBase
admin-UI (Settings → Auth providers) of ship een follow-up migratie — de
create-migratie draait maar één keer.

View File

@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'pb_migrations', 'pb_hooks']),
globalIgnores(['dist', 'pb_migrations']),
{
files: ['**/*.{js,jsx}'],
extends: [

View File

@@ -4,9 +4,6 @@ networks:
learning-platform:
external: true
volumes:
pb_data:
services:
learning-platform:
image: git.labs.respellion.tech/respellion/learning-platform/learning-platform:latest
@@ -21,18 +18,11 @@ services:
image: ghcr.io/muchobien/pocketbase:latest
container_name: pocketbase-learning
restart: unless-stopped
working_dir: /pb
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations", "--hooksDir=/pb/pb_hooks"]
environment:
# Azure Entra ID (OIDC) — consumed by pb_migrations/1781000000 and
# pb_hooks/team_members.pb.js. Rendered into .env by the deploy playbook.
- ENTRA_TENANT_ID=${ENTRA_TENANT_ID}
- ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID}
- ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET}
- ENTRA_ADMIN_EMAILS=${ENTRA_ADMIN_EMAILS}
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data"]
volumes:
- pb_data:/pb/pb_data
- ./pb_migrations:/pb/pb_migrations
- ./pb_hooks:/pb/pb_hooks
networks:
- learning-platform
volumes:
pb_data:

View File

@@ -36,27 +36,11 @@
src: compose.yml
dest: "{{ dest_dir }}/compose.yml"
- name: Copy pb_migrations to destination
ansible.builtin.copy:
src: ../../../pb_migrations
dest: "{{ dest_dir }}/"
mode: "0755"
- name: Copy pb_hooks to destination
ansible.builtin.copy:
src: ../../../pb_hooks
dest: "{{ dest_dir }}/"
mode: "0755"
- name: Create .env file for secrets
ansible.builtin.copy:
dest: "{{ dest_dir }}/.env"
content: |
ANTHROPIC_API_KEY={{ anthropic_api_key | default('') }}
ENTRA_TENANT_ID={{ entra_tenant_id | default('') }}
ENTRA_CLIENT_ID={{ entra_client_id | default('') }}
ENTRA_CLIENT_SECRET={{ entra_client_secret | default('') }}
ENTRA_ADMIN_EMAILS={{ entra_admin_emails | default('') }}
mode: '0600'
- name: Pull latest image

View File

@@ -22,17 +22,9 @@ services:
container_name: pocketbase-learning
restart: unless-stopped
working_dir: /pb
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations", "--hooksDir=/pb/pb_hooks"]
environment:
# Azure Entra ID (OIDC) — consumed by pb_migrations/1781000000 and
# pb_hooks/team_members.pb.js. Rendered into .env by the deploy playbook.
- ENTRA_TENANT_ID=${ENTRA_TENANT_ID}
- ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID}
- ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET}
- ENTRA_ADMIN_EMAILS=${ENTRA_ADMIN_EMAILS}
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations"]
volumes:
- pb_data:/pb/pb_data
- ./pb_migrations:/pb/pb_migrations
- ./pb_hooks:/pb/pb_hooks
networks:
- learning-platform

View File

@@ -42,21 +42,11 @@
dest: "{{ dest_dir }}/"
mode: "0755"
- name: Copy pb_hooks to destination
ansible.builtin.copy:
src: ../../../pb_hooks
dest: "{{ dest_dir }}/"
mode: "0755"
- name: Create .env file for secrets
ansible.builtin.copy:
dest: "{{ dest_dir }}/.env"
content: |
ANTHROPIC_API_KEY={{ anthropic_api_key | default('') }}
ENTRA_TENANT_ID={{ entra_tenant_id | default('') }}
ENTRA_CLIENT_ID={{ entra_client_id | default('') }}
ENTRA_CLIENT_SECRET={{ entra_client_secret | default('') }}
ENTRA_ADMIN_EMAILS={{ entra_admin_emails | default('') }}
mode: '0600'
- name: Pull latest image

View File

@@ -1,56 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Issue #16 — Auto-provisioning & role assignment for Azure (Entra ID) users.
//
// PocketBase auto-creates a `team_members` auth record on the first OIDC login
// (name is filled from the OIDC `name` claim via the collection's mappedFields).
// These hooks add the application-specific bits:
//
// * default `role` from an admin allow-list (env ENTRA_ADMIN_EMAILS),
// * default `enrollment_status = "not_started"` so new users go through the
// existing onboarding screen,
// * re-sync the admin role on every login so allow-list changes take effect
// without manual edits.
//
// The allow-list is a comma-separated list of e-mail addresses, e.g.:
// ENTRA_ADMIN_EMAILS=rve@respellion.nl,admin@respellion.nl
//
// Keep this logic in sync with src/lib/azureAuth.js (resolveRole / parseAdminEmails),
// which carries the canonical, unit-tested version for the frontend/tests.
//
// resolveRole itself lives in pb_hooks/utils.js and is pulled in per-callback
// via require() — PocketBase's JSVM runs each hook callback below as its own
// isolated program, so a shared top-level function here would not be in
// scope at call time. See pb_hooks/utils.js for details.
// On creation (first login): set defaults before the record is persisted.
onRecordCreate(function (e) {
const { resolveRole } = require(`${__hooks}/utils.js`);
const email = e.record.get("email") || "";
e.record.set("role", resolveRole(email));
if (!e.record.get("enrollment_status")) {
e.record.set("enrollment_status", "not_started");
}
// Fallback name when the IdP supplied no `name` claim.
if (!e.record.get("name")) {
e.record.set("name", email ? email.split("@")[0] : "Onbekend");
}
e.next();
}, "team_members");
// On every successful auth (incl. OIDC): keep the admin role in sync with the
// allow-list, so promoting/demoting an admin only requires an env change.
onRecordAuthRequest(function (e) {
const { resolveRole } = require(`${__hooks}/utils.js`);
const email = e.record.get("email") || "";
const want = resolveRole(email);
if (e.record.get("role") !== want) {
e.record.set("role", want);
e.app.save(e.record);
}
e.next();
}, "team_members");

View File

@@ -1,29 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Shared helpers for pb_hooks/*.pb.js callbacks.
//
// PocketBase's JSVM serializes and runs each registered hook callback as its
// own isolated program, so a plain top-level function declared in a *.pb.js
// file is NOT in scope inside a callback at call time. Reusable helpers must
// instead live in a plain (non *.pb.js, so it isn't auto-loaded as a hook)
// module and be pulled in per-callback via require(`${__hooks}/utils.js`).
// See: https://github.com/pocketbase/pocketbase/discussions/3599
//
// Loaded modules use a shared registry across callback invocations, so keep
// this file stateless.
//
// Keep resolveRole in sync with src/lib/azureAuth.js's resolveRole (the
// canonical, unit-tested version used by the frontend).
module.exports = {
/**
* Resolve a user's role from their e-mail and the ENTRA_ADMIN_EMAILS allow-list.
* @param {string} email
* @returns {"admin"|"user"}
*/
resolveRole: function (email) {
const raw = ($os.getenv("ENTRA_ADMIN_EMAILS") || "").toLowerCase();
const allow = raw.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
return allow.indexOf(String(email || "").toLowerCase()) !== -1 ? "admin" : "user";
},
};

View File

@@ -1,299 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Issue #16 — Azure (Entra ID) login.
//
// Replaces the PIN-based `base` collection `team_members` with a PocketBase
// `auth` collection that authenticates against Azure Entra ID via OIDC.
//
// The existing PIN-based team_members are intentionally dropped (sign-off from
// the product owner — no migration of current users required). The knowledge
// base, generated tests and micro-learnings live in OTHER collections and are
// left completely untouched by this migration.
//
// OIDC provider credentials are read from the environment at apply time, so no
// secrets are committed to git:
// ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET
// (set via the Ansible-rendered .env, see infra/*/site/compose.yml).
//
// To rotate credentials or change the tenant later, update the env and either
// re-configure the provider from the PocketBase admin UI (Settings → Auth
// providers) or ship a follow-up migration.
migrate((app) => {
// 1. Drop the old PIN-based collection (takes the PIN records with it).
// Other collections (micro_learning_completions, theme_session_completions)
// have relation fields pointing to the old team_members — PocketBase refuses
// to delete a referenced collection. Remove those relation fields first,
// delete the old collection, create the new auth collection, then re-add
// the relation fields pointing to the new collection.
const relDeps = [
{ collection: "micro_learning_completions", fieldId: "rel_team_member_id", fieldName: "team_member_id" },
{ collection: "theme_session_completions", fieldId: "rel_tsc_team_member", fieldName: "team_member_id" },
];
// Remove blocking relation fields so the old collection can be deleted.
for (const dep of relDeps) {
try {
const col = app.findCollectionByNameOrId(dep.collection);
col.fields.removeById(dep.fieldId);
app.save(col);
} catch (_) { /* collection doesn't exist yet — skip */ }
}
try {
const old = app.findCollectionByNameOrId("team_members");
app.delete(old);
} catch (_) {
// Collection did not exist — nothing to drop.
}
const tenant = $os.getenv("ENTRA_TENANT_ID") || "common";
// 2. Recreate `team_members` as an auth collection (same name → all existing
// `user_id` references in test_results, on_demand_attempts,
// micro_learning_completions, leaderboard, theme_session_completions keep
// working unchanged).
const collection = new Collection({
type: "auth",
name: "team_members",
system: false,
// Access rules: every legitimate user is authenticated (Azure-gated +
// OIDC). Profiles are readable by any authenticated user (leaderboard,
// dashboard); a user may only update their own record (onboarding flips
// enrollment_status). Creation/deletion happen via OAuth2 / superuser only.
listRule: '@request.auth.id != ""',
viewRule: '@request.auth.id != ""',
createRule: null,
updateRule: '@request.auth.id = id',
deleteRule: null,
authRule: "",
manageRule: null,
// Disable password / OTP / MFA — login is exclusively via Entra OIDC.
passwordAuth: { enabled: false, identityFields: ["email"] },
otp: { enabled: false, duration: 180, length: 8 },
mfa: { enabled: false, duration: 600, rule: "" },
oauth2: {
enabled: true,
// Map the OIDC userinfo claims onto our fields.
mappedFields: {
id: "",
name: "name",
username: "",
avatarURL: "",
},
providers: [
{
name: "oidc",
clientId: $os.getenv("ENTRA_CLIENT_ID"),
clientSecret: $os.getenv("ENTRA_CLIENT_SECRET"),
authURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/authorize",
tokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
userInfoURL: "https://graph.microsoft.com/oidc/userinfo",
displayName: "Microsoft",
pkce: true,
},
],
},
fields: [
// ── System auth fields ──────────────────────────────────────────────
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cost": 0,
"hidden": true,
"id": "password901924565",
"max": 0,
"min": 8,
"name": "password",
"pattern": "",
"presentable": false,
"required": true,
"system": true,
"type": "password"
},
{
"autogeneratePattern": "[a-zA-Z0-9]{50}",
"hidden": true,
"id": "text2504183744",
"max": 60,
"min": 30,
"name": "tokenKey",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": true,
"type": "text"
},
{
"exceptDomains": null,
"hidden": false,
"id": "email3885137012",
"name": "email",
"onlyDomains": null,
"presentable": false,
"required": true,
"system": true,
"type": "email"
},
{
"hidden": false,
"id": "bool1547992806",
"name": "emailVisibility",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
},
{
"hidden": false,
"id": "bool256245529",
"name": "verified",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
},
// ── Application fields ───────────────────────────────────────────────
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 255,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1466534506",
"max": 0,
"min": 0,
"name": "role",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "date_curriculum_started_at",
"name": "curriculum_started_at",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text_enrollment_status",
"max": 0,
"min": 0,
"name": "enrollment_status",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
indexes: [
"CREATE UNIQUE INDEX `idx_tokenKey_team_members` ON `team_members` (`tokenKey`)",
"CREATE UNIQUE INDEX `idx_email_team_members` ON `team_members` (`email`) WHERE `email` != ''"
],
});
app.save(collection);
// 3. Re-add the relation fields pointing to the new auth collection.
for (const dep of relDeps) {
try {
const col = app.findCollectionByNameOrId(dep.collection);
const newField = new Field({
id: dep.fieldId,
name: dep.fieldName,
type: "relation",
required: true,
collectionId: collection.id,
cascadeDelete: true,
maxSelect: 1,
});
col.fields.add(newField);
app.save(col);
} catch (_) { /* collection doesn't exist — skip */ }
}
}, (app) => {
// Down: drop the auth collection and recreate the original PIN-based base
// collection (without data — the original records are gone).
try {
const c = app.findCollectionByNameOrId("team_members");
app.delete(c);
} catch (_) { /* already gone */ }
const base = new Collection({
type: "base",
name: "team_members",
listRule: "",
viewRule: "",
createRule: "",
updateRule: "",
deleteRule: "",
fields: [
{ "type": "text", "name": "id", "system": true, "primaryKey": true, "required": true,
"min": 15, "max": 15, "pattern": "^[a-z0-9]+$", "autogeneratePattern": "[a-z0-9]{15}",
"id": "text3208210256" },
{ "type": "text", "name": "name", "required": true, "min": 0, "max": 0, "id": "text1579384326" },
{ "type": "text", "name": "pin", "required": false, "min": 0, "max": 0, "id": "text3045404147" },
{ "type": "text", "name": "role", "required": false, "min": 0, "max": 0, "id": "text1466534506" },
{ "type": "date", "name": "curriculum_started_at", "required": false, "id": "date_curriculum_started_at" },
{ "type": "text", "name": "enrollment_status", "required": false, "min": 0, "max": 0, "id": "text_enrollment_status" },
],
});
app.save(base);
});

View File

@@ -1,57 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Issue #16 — Lock down API rules now that authentication is real.
//
// Before Azure login the app had no real auth, so every collection rule was
// "" (public). With mandatory Entra OIDC login, every legitimate caller is
// authenticated, so we require `@request.auth.id != ""` on all non-system,
// non-`team_members` collections.
//
// This intentionally does NOT delete or restructure any data — it only changes
// access rules. The knowledge base, generated tests and micro-learnings stay
// exactly as they are; they simply become unreadable to anonymous callers.
//
// `team_members` is configured by its own migration (1781000000) and skipped
// here. System collections (`_superusers`, `_mfas`, `_otps`, …) are skipped.
//
// NOTE (follow-up): for least-privilege we could further restrict write/delete
// on the knowledge-authoring collections (topics, relations, content, sources,
// question_bank, curriculum_versions) to `@request.auth.role = "admin"`. That
// is deferred because micro_learnings / theme_sessions are written by regular
// users (generate-then-cache) and the full matrix needs runtime verification.
// The trust boundary today is: perimeter Azure gate + authenticated employees.
const AUTHED = '@request.auth.id != ""';
migrate((app) => {
const collections = app.findAllCollections();
for (const c of collections) {
if (c.system) continue;
if (c.name === "team_members") continue;
c.viewRule = AUTHED;
c.listRule = AUTHED;
// View collections only support list/view rules.
if (c.type !== "view") {
c.createRule = AUTHED;
c.updateRule = AUTHED;
c.deleteRule = AUTHED;
}
app.save(c);
}
}, (app) => {
// Down: restore fully public rules (the pre-Azure baseline).
const collections = app.findAllCollections();
for (const c of collections) {
if (c.system) continue;
if (c.name === "team_members") continue;
c.viewRule = "";
c.listRule = "";
if (c.type !== "view") {
c.createRule = "";
c.updateRule = "";
c.deleteRule = "";
}
app.save(c);
}
});

View File

@@ -102,21 +102,17 @@ const COLLECTIONS = [
...AUTODATE_FIELDS,
],
},
// NOTE: `team_members` is an AUTH collection owned by the migration
// pb_migrations/1781000000_team_members_to_auth.js (Azure/Entra ID login).
// Migrations run on PocketBase start — before this script — so the entry
// below is only a fallback for environments where migrations have not run;
// when the auth collection already exists, this create is skipped.
{
name: 'team_members',
type: 'auth',
type: 'base',
...OPEN_RULES,
passwordAuth: { enabled: false, identityFields: ['email'] },
fields: [
{ name: 'name', type: 'text', required: false },
{ name: 'name', type: 'text', required: true },
{ name: 'pin', type: 'text', required: false },
{ name: 'role', type: 'text', required: false },
{ name: 'curriculum_started_at', type: 'date', required: false },
{ name: 'enrollment_status', type: 'text', required: false },
...AUTODATE_FIELDS,
],
},
{

View File

@@ -3,12 +3,9 @@ import * as d3 from 'd3';
import * as db from '../../lib/db';
import { callLLM } from '../../lib/llm';
import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools';
import { Network, Table2 } from 'lucide-react';
import { useGraphData } from '../../hooks/useGraphData';
import { filterAiActions } from '../../lib/graphGuard';
import GraphControls from './graph/GraphControls';
import NodeDetailPanel from './graph/NodeDetailPanel';
import GraphTable from './graph/GraphTable';
// ── Edge visual style per relation type ────────────────────────────────────────
// stroke — line colour
@@ -26,21 +23,11 @@ const SYSTEM_PROMPTS = {
full: `You are a strict Data Quality AI maintaining a Knowledge Graph for Respellion.
Evaluate the provided topics and relations and emit the actions to take via the emit_graph_actions tool.
PROTECTED TOPICS — NEVER include these in merges (as deleteId) or in deletions:
• Topics with learning_relevance="exclude" are REFERENCE MATERIAL, kept on purpose
so R42 can still answer questions about them. They are intentionally outside
the theme-learning flow. Do not propose deleting or merging them away.
• Topics with relevance_locked=true are admin-pinned. Do not change their
learning_relevance and do not delete or merge them.
These topics may appear as the keepId in a merge (others fold into them), and
may appear as source/target of newRelations, but their own row must survive.
Rules:
1. Identify topics that mean exactly the same thing. Choose one to keep, one to delete (merges).
2. Identify topics that are too vague, irrelevant, or malformed (deletions).
3. Identify missing logical relations (depends_on, part_of, related_to, executed_by) between conceptually linked topics (newRelations).
4. Evaluate learning_relevance. Mark purely operational topics (printer guides, etc.) as "exclude"; low-priority as "peripheral" (relevanceUpdates).
Skip topics where relevance_locked=true — omit them from relevanceUpdates entirely.
Do not return the entire graph — only the actions to take.`,
@@ -84,13 +71,11 @@ const KnowledgeGraph = () => {
const [showExcludeNodes, setShowExcludeNodes] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analyzeError, setAnalyzeError] = useState(null);
const [analyzeNotice, setAnalyzeNotice] = useState(null); // info-level message from last analyze
const [isRestoring, setIsRestoring] = useState(false);
const [viewMode, setViewMode] = useState('graph'); // 'graph' | 'table'
const {
topics, relations, snapshotMeta,
reload, updateTopic, bulkUpdateTopics, deleteTopic, addRelation, removeRelation, bulkSave, restoreSnapshot,
reload, updateTopic, deleteTopic, addRelation, removeRelation, bulkSave, restoreSnapshot,
} = useGraphData();
// ── Canvas sizing ────────────────────────────────────────────────────────────
@@ -109,7 +94,6 @@ const KnowledgeGraph = () => {
// ── D3 force graph ───────────────────────────────────────────────────────────
useEffect(() => {
if (viewMode !== 'graph') return;
if (!svgRef.current || topics.length === 0) return;
const { width, height } = dimensions;
@@ -252,7 +236,7 @@ const KnowledgeGraph = () => {
});
return () => simulation.stop();
}, [dimensions, topics, relations, showExcludeNodes, viewMode]);
}, [dimensions, topics, relations, showExcludeNodes]);
// ── Pan/zoom canvas to a node ────────────────────────────────────────────────
@@ -317,7 +301,6 @@ const KnowledgeGraph = () => {
setIsAnalyzing(true);
setAnalyzeError(null);
setAnalyzeNotice(null);
try {
const [currentTopics, currentRelations] = await Promise.all([
@@ -327,16 +310,13 @@ const KnowledgeGraph = () => {
const tier = scope === 'full' ? 'reasoning' : 'standard';
// For full + relevance scopes the model needs to see relevance_locked so
// it can honor the "do not touch protected topics" rule. (relations-scope
// only emits newRelations, so the flag is irrelevant there.)
const sendLocked = scope === 'full' || scope === 'relevance';
// For relevance scope, include relevance_locked so the model can skip those.
const compactTopics = currentTopics.map(t => ({
id: t.id,
label: t.label,
type: t.type,
learning_relevance: t.learning_relevance,
...(sendLocked && t.relevance_locked ? { relevance_locked: true } : {}),
...(scope === 'relevance' && t.relevance_locked ? { relevance_locked: true } : {}),
}));
const compactRelations = currentRelations.map(({ source, target, type }) => ({
source, target, type,
@@ -352,26 +332,8 @@ const KnowledgeGraph = () => {
maxTokens: scope === 'full' ? 4096 : 2048,
});
const rawActions = llmResult.toolUses[0]?.input;
if (!rawActions) throw new Error('Graph analysis did not emit a tool result.');
// ── Safety guard ─────────────────────────────────────────────────────
// Strip merges/deletions that target excluded or locked topics. Excluded
// topics are reference material kept on purpose for R42; locked topics
// are admin-pinned. The AI may suggest removing them anyway — we drop
// those suggestions before they reach bulkSave.
const { filtered: actions, dropped } = filterAiActions(currentTopics, rawActions);
const droppedCount = dropped.deletions.length + dropped.merges.length;
if (droppedCount > 0) {
// Visible feedback so the admin knows the AI tried to touch protected rows.
console.warn(
`[graph guard] Blocked ${droppedCount} destructive action(s) on protected topics`,
dropped,
);
setAnalyzeNotice(
`Guard blocked ${droppedCount} destructive AI action${droppedCount === 1 ? '' : 's'} on excluded or locked topics. ${dropped.deletions.length} deletion${dropped.deletions.length === 1 ? '' : 's'}, ${dropped.merges.length} merge${dropped.merges.length === 1 ? '' : 's'}.`,
);
}
const actions = llmResult.toolUses[0]?.input;
if (!actions) throw new Error('Graph analysis did not emit a tool result.');
let updatedTopics = [...currentTopics];
let updatedRelations = [...currentRelations];
@@ -434,34 +396,10 @@ const KnowledgeGraph = () => {
return (
<div className="relative w-full h-full flex flex-col md:flex-row">
{/* Main view: graph canvas or table */}
<div className="flex-1 flex flex-col border-r border-bg-warm min-w-0">
{/* View-mode toggle */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-bg-warm bg-paper">
<button
onClick={() => setViewMode('graph')}
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded-[var(--r-sm)] transition-colors ${
viewMode === 'graph' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50'
}`}
title="Force-directed graph view"
>
<Network size={13} /> Graph
</button>
<button
onClick={() => setViewMode('table')}
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded-[var(--r-sm)] transition-colors ${
viewMode === 'table' ? 'bg-bg-warm text-teal font-medium' : 'text-fg-muted hover:bg-bg-warm/50'
}`}
title="Sortable table with bulk edit"
>
<Table2 size={13} /> Table
</button>
</div>
{viewMode === 'graph' ? (
{/* D3 canvas */}
<div
ref={wrapperRef}
className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing"
className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing border-r border-bg-warm"
>
{topics.length === 0 ? (
<div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
@@ -471,34 +409,15 @@ const KnowledgeGraph = () => {
<svg ref={svgRef} className="w-full h-full" />
)}
</div>
) : (
<div className="flex-1 min-h-[400px] overflow-hidden">
{topics.length === 0 ? (
<div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
No knowledge graph data yet. Upload source material in the Sources tab first.
</div>
) : (
<GraphTable
topics={topics}
relations={relations}
onBulkUpdate={bulkUpdateTopics}
onSelectNode={setSelectedNode}
/>
)}
</div>
)}
</div>
{/* Sidebar */}
<div className="w-full md:w-80 bg-paper p-6 overflow-y-auto border-t md:border-t-0 border-bg-warm flex flex-col">
<GraphControls
showExcludeNodes={showExcludeNodes}
onShowExcludeChange={setShowExcludeNodes}
excludedCount={topics.filter(t => t.learning_relevance === 'exclude').length}
onAnalyze={analyzeGraph}
isAnalyzing={isAnalyzing}
analyzeError={analyzeError}
analyzeNotice={analyzeNotice}
disabled={topics.length === 0}
onApplied={reload}
snapshotMeta={snapshotMeta}

View File

@@ -1,20 +1,19 @@
import { useState, useEffect } from 'react';
import { Trash2, Shield, User, CheckCircle, Info } from 'lucide-react';
import { UserPlus, Trash2, Edit2, Shield, User, CheckCircle } from 'lucide-react';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Input from '../ui/Input';
import Tag from '../ui/Tag';
import * as db from '../../lib/db';
import { pb } from '../../lib/pb';
import { useApp } from '../../store/AppContext';
// Users are provisioned automatically on first Azure (Entra ID) login — admins
// no longer create them by hand. This panel lets an admin review the roster,
// switch a member between user/admin, and remove members. The admin role is
// also re-synced from the ENTRA_ADMIN_EMAILS allow-list on every login (see
// pb_hooks/team_members.pb.js), so a manual change here can be overridden on
// the member's next sign-in if their e-mail is on/off that list.
const TeamManager = () => {
const { state } = useApp();
const [users, setUsers] = useState([]);
const [isEditing, setIsEditing] = useState(false);
const [editingId, setEditingId] = useState(null);
const [formData, setFormData] = useState({ name: '', role: 'user', pin: '' });
const [message, setMessage] = useState('');
const loadUsers = async () => {
@@ -24,16 +23,29 @@ const TeamManager = () => {
useEffect(() => { loadUsers(); }, []);
const flash = (msg) => {
setMessage(msg);
const handleSave = async (e) => {
e.preventDefault();
if (!formData.name || !formData.pin) return;
if (editingId) {
await db.updateTeamMember(editingId, { name: formData.name, role: formData.role, pin: formData.pin });
setMessage('User updated successfully.');
} else {
await db.addTeamMember({ name: formData.name, role: formData.role, pin: formData.pin });
setMessage('User added successfully.');
}
await loadUsers();
setFormData({ name: '', role: 'user', pin: '' });
setIsEditing(false);
setEditingId(null);
setTimeout(() => setMessage(''), 3000);
};
const handleRoleChange = async (user, role) => {
if (role === user.role) return;
await db.updateTeamMember(user.id, { role });
await loadUsers();
flash(`${user.name} is now ${role}.`);
const handleEdit = (user) => {
setFormData({ name: user.name, role: user.role, pin: user.pin });
setIsEditing(true);
setEditingId(user.id);
};
const handleDelete = async (id) => {
@@ -44,17 +56,24 @@ const TeamManager = () => {
if (confirm("Are you sure you want to delete this user?")) {
await db.deleteTeamMember(id);
// Also remove from leaderboard.
// Also remove from leaderboard
try {
const entry = await pb.collection('leaderboard').getFirstListItem(`user_id="${id}"`);
await pb.collection('leaderboard').delete(entry.id);
} catch { /* no leaderboard entry, nothing to do */ }
await loadUsers();
flash('User deleted.');
setMessage('User deleted.');
setTimeout(() => setMessage(''), 3000);
}
};
const cancelEdit = () => {
setIsEditing(false);
setEditingId(null);
setFormData({ name: '', role: 'user', pin: '' });
};
return (
<div className="space-y-8">
{message && (
@@ -63,22 +82,58 @@ const TeamManager = () => {
</div>
)}
<Card className="border border-bg-warm bg-bg-warm/30">
<p className="text-sm text-fg-muted flex items-start gap-2">
<Info size={16} className="mt-0.5 shrink-0 text-teal" />
Team members are created automatically when they first sign in with their
Microsoft account. Admins are granted via the <code>ENTRA_ADMIN_EMAILS</code>{' '}
allow-list and re-synced on each login.
</p>
<Card className="border border-bg-warm">
<h2 className="text-xl font-bold flex items-center gap-2 mb-4">
{isEditing ? <Edit2 size={20} className="text-teal" /> : <UserPlus size={20} className="text-teal" />}
{isEditing ? 'Edit Team Member' : 'Add New Team Member'}
</h2>
<form onSubmit={handleSave} className="flex flex-col sm:flex-row gap-4 items-end">
<div className="flex-1 w-full">
<Input
label="Full Name"
placeholder="e.g. Jane Doe"
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
required
/>
</div>
<div className="flex-1 w-full">
<label className="block text-sm font-medium mb-1.5 text-fg">Role</label>
<select
value={formData.role}
onChange={e => setFormData({...formData, role: e.target.value})}
className="w-full h-11 px-3 rounded-[var(--r-sm)] border border-bg-warm bg-paper text-sm focus:outline-none focus:ring-2 focus:ring-teal/30 focus:border-teal"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="flex-1 w-full">
<Input
label="Login PIN"
type="text"
placeholder="e.g. 1234"
value={formData.pin}
onChange={e => setFormData({...formData, pin: e.target.value})}
required
/>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<Button type="submit" className="w-full sm:w-auto h-11 px-6">
{isEditing ? 'Update' : 'Add'}
</Button>
{isEditing && (
<Button type="button" variant="outline" onClick={cancelEdit} className="h-11 px-4">
Cancel
</Button>
)}
</div>
</form>
</Card>
<Card className="p-0 border border-bg-warm overflow-hidden">
<div className="divide-y divide-bg-warm">
{users.length === 0 && (
<div className="p-6 text-sm text-fg-muted text-center">
No team members yet they appear here after their first sign-in.
</div>
)}
{users.map(user => (
<div key={user.id} className="p-4 flex items-center justify-between hover:bg-bg-warm/30 transition-colors">
<div className="flex items-center gap-4">
@@ -90,26 +145,19 @@ const TeamManager = () => {
{user.name}
{user.id === state.currentUser?.id && <Tag variant="accent" className="text-[10px] py-0">You</Tag>}
</p>
<p className="text-sm text-fg-muted">{user.email}</p>
<p className="text-sm text-fg-muted">PIN: {user.pin}</p>
</div>
</div>
<div className="flex items-center gap-3">
<select
value={user.role || 'user'}
onChange={e => handleRoleChange(user, e.target.value)}
disabled={user.id === state.currentUser?.id}
className="h-9 px-2 rounded-[var(--r-sm)] border border-bg-warm bg-paper text-sm focus:outline-none focus:ring-2 focus:ring-teal/30 focus:border-teal disabled:opacity-40"
aria-label={`Role for ${user.name}`}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<Tag variant={user.role === 'admin' ? 'dark' : 'default'} className="hidden sm:inline-flex">{user.role}</Tag>
<button onClick={() => handleEdit(user)} className="p-2 text-fg-muted hover:text-teal transition-colors">
<Edit2 size={16} />
</button>
<button
onClick={() => handleDelete(user.id)}
disabled={user.id === state.currentUser?.id}
className="p-2 text-fg-muted hover:text-red-500 disabled:opacity-30 transition-colors"
aria-label={`Delete ${user.name}`}
>
<Trash2 size={16} />
</button>

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { RotateCcw, RefreshCw, AlertCircle, ChevronDown, ShieldCheck } from 'lucide-react';
import { RotateCcw, RefreshCw, AlertCircle, ChevronDown } from 'lucide-react';
import Button from '../../ui/Button';
import SuggestionsQueue from '../SuggestionsQueue';
@@ -24,11 +24,9 @@ const SCOPE_OPTIONS = [
export default function GraphControls({
showExcludeNodes,
onShowExcludeChange,
excludedCount = 0,
onAnalyze,
isAnalyzing,
analyzeError,
analyzeNotice,
disabled,
onApplied,
snapshotMeta,
@@ -52,14 +50,7 @@ export default function GraphControls({
onChange={e => onShowExcludeChange(e.target.checked)}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
/>
<span>
Show Excluded Nodes (Reference Material)
{excludedCount > 0 && (
<span className="ml-1 text-xs text-fg-muted/80">
· {excludedCount} {showExcludeNodes ? 'visible' : 'hidden'}
</span>
)}
</span>
</label>
<div className="space-y-2">
@@ -115,16 +106,6 @@ export default function GraphControls({
{analyzeError}
</p>
)}
{analyzeNotice && (
<p
className="text-xs text-teal flex items-start gap-1 bg-teal/5 border border-teal/20 rounded-[var(--r-sm)] p-2"
title="The AI suggested removing excluded/locked topics. Those suggestions were dropped client-side before saving."
>
<ShieldCheck size={14} className="shrink-0 mt-0.5" />
{analyzeNotice}
</p>
)}
</div>
<SuggestionsQueue onApplied={onApplied} />

View File

@@ -1,466 +0,0 @@
import { useMemo, useState } from 'react';
import { ArrowUp, ArrowDown, ArrowUpDown, X, Search, ChevronRight, ChevronDown, Lock, Unlock } from 'lucide-react';
import Button from '../../ui/Button';
const TYPES = ['concept', 'role', 'process'];
const RELEVANCE = ['core', 'standard', 'peripheral', 'exclude'];
const SORTABLE = ['label', 'type', 'learning_relevance', 'theme', 'complexity_weight', 'difficulty', 'relations'];
const GROUP_OPTIONS = [
{ value: 'none', label: 'No grouping' },
{ value: 'type', label: 'Type' },
{ value: 'learning_relevance', label: 'Relevance' },
{ value: 'theme', label: 'Theme' },
{ value: 'difficulty', label: 'Difficulty' },
];
const RELEVANCE_RANK = { core: 0, standard: 1, peripheral: 2, exclude: 3 };
const DIFFICULTY_RANK = { beginner: 0, intermediate: 1, advanced: 2 };
function compareValues(a, b, key) {
if (key === 'learning_relevance') {
return (RELEVANCE_RANK[a] ?? 99) - (RELEVANCE_RANK[b] ?? 99);
}
if (key === 'difficulty') {
return (DIFFICULTY_RANK[a] ?? 99) - (DIFFICULTY_RANK[b] ?? 99);
}
if (typeof a === 'number' && typeof b === 'number') return a - b;
return String(a ?? '').localeCompare(String(b ?? ''), undefined, { sensitivity: 'base' });
}
function SortIcon({ active, dir }) {
if (!active) return <ArrowUpDown size={11} className="text-fg-muted/60 inline-block ml-1" />;
return dir === 'asc'
? <ArrowUp size={11} className="text-teal inline-block ml-1" />
: <ArrowDown size={11} className="text-teal inline-block ml-1" />;
}
/**
* Table view of the knowledge graph.
*
* Supports sort (per column), grouping (per key), free-text label filter,
* multi-row selection, and bulk-change of type / learning_relevance / lock.
*
* Persistence is delegated to onBulkUpdate(ids, patch). The parent (KnowledgeGraph)
* provides this from useGraphData.bulkUpdateTopics.
*/
export default function GraphTable({
topics,
relations,
onBulkUpdate,
onSelectNode,
}) {
const [sortKey, setSortKey] = useState('label');
const [sortDir, setSortDir] = useState('asc');
const [groupBy, setGroupBy] = useState('none');
const [query, setQuery] = useState('');
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [collapsed, setCollapsed] = useState(() => new Set());
// Bulk-action form state
const [bulkType, setBulkType] = useState('');
const [bulkRelevance, setBulkRelevance] = useState('');
const [bulkLockOnRelevance, setBulkLockOnRelevance] = useState(true);
const [isApplying, setIsApplying] = useState(false);
const [feedback, setFeedback] = useState(null);
// Pre-compute relation counts per topic so the column is cheap to render.
const relationCount = useMemo(() => {
const counts = new Map();
for (const r of relations) {
counts.set(r.source, (counts.get(r.source) || 0) + 1);
counts.set(r.target, (counts.get(r.target) || 0) + 1);
}
return counts;
}, [relations]);
// Filter + sort
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const rows = topics
.map(t => ({
...t,
learning_relevance: t.learning_relevance || 'standard',
type: t.type || 'concept',
difficulty: t.difficulty || 'intermediate',
complexity_weight: t.complexity_weight ?? 3,
theme: t.theme || '',
relations: relationCount.get(t.id) || 0,
}))
.filter(t => !q || t.label?.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q));
const sorted = [...rows].sort((a, b) => {
const cmp = compareValues(a[sortKey], b[sortKey], sortKey);
return sortDir === 'asc' ? cmp : -cmp;
});
return sorted;
}, [topics, relationCount, query, sortKey, sortDir]);
// Group rows (flat array if groupBy === 'none')
const groups = useMemo(() => {
if (groupBy === 'none') return [{ key: '__all__', label: null, rows: filtered }];
const map = new Map();
for (const t of filtered) {
const k = t[groupBy] || '—';
if (!map.has(k)) map.set(k, []);
map.get(k).push(t);
}
return [...map.entries()]
.sort(([a], [b]) => compareValues(a, b, groupBy))
.map(([key, rows]) => ({ key, label: String(key), rows }));
}, [filtered, groupBy]);
// ── Selection helpers ─────────────────────────────────────────────────────
const allVisibleIds = useMemo(() => filtered.map(t => t.id), [filtered]);
const visibleSelected = useMemo(
() => allVisibleIds.filter(id => selectedIds.has(id)).length,
[allVisibleIds, selectedIds],
);
const allVisibleChecked = visibleSelected === allVisibleIds.length && allVisibleIds.length > 0;
const someVisibleChecked = visibleSelected > 0 && !allVisibleChecked;
const toggleOne = (id) => {
setSelectedIds(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const toggleAllVisible = () => {
setSelectedIds(prev => {
const next = new Set(prev);
if (allVisibleChecked) {
for (const id of allVisibleIds) next.delete(id);
} else {
for (const id of allVisibleIds) next.add(id);
}
return next;
});
};
const toggleGroup = (groupRows) => {
setSelectedIds(prev => {
const next = new Set(prev);
const ids = groupRows.map(r => r.id);
const allOn = ids.every(id => next.has(id));
for (const id of ids) (allOn ? next.delete(id) : next.add(id));
return next;
});
};
const clearSelection = () => setSelectedIds(new Set());
// ── Sorting ───────────────────────────────────────────────────────────────
const onSort = (key) => {
if (!SORTABLE.includes(key)) return;
if (sortKey === key) {
setSortDir(d => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir('asc');
}
};
// ── Bulk actions ──────────────────────────────────────────────────────────
const runBulk = async (patch, label) => {
if (selectedIds.size === 0) return;
setIsApplying(true);
setFeedback(null);
try {
const n = await onBulkUpdate([...selectedIds], patch);
setFeedback(`${label}${n} topic${n === 1 ? '' : 's'} updated.`);
} catch (e) {
setFeedback(`Failed: ${e.message || 'unknown error'}`);
} finally {
setIsApplying(false);
}
};
const applyBulkType = () => {
if (!bulkType) return;
runBulk({ type: bulkType }, `Type → ${bulkType}`);
};
const applyBulkRelevance = () => {
if (!bulkRelevance) return;
const patch = { learning_relevance: bulkRelevance };
if (bulkLockOnRelevance) patch.relevance_locked = true;
runBulk(patch, `Relevance → ${bulkRelevance}${bulkLockOnRelevance ? ' (locked)' : ''}`);
};
const applyLock = (locked) => {
runBulk({ relevance_locked: locked }, locked ? 'Locked relevance' : 'Unlocked relevance');
};
// ── Render ────────────────────────────────────────────────────────────────
const totalSel = selectedIds.size;
const isCollapsed = (key) => collapsed.has(key);
const toggleCollapse = (key) => {
setCollapsed(prev => {
const next = new Set(prev);
next.has(key) ? next.delete(key) : next.add(key);
return next;
});
};
return (
<div className="h-full flex flex-col bg-paper">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3 p-3 border-b border-bg-warm bg-bg">
<div className="relative">
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Filter by label or description…"
className="pl-7 pr-3 py-1.5 text-sm border border-bg-warm rounded-[var(--r-sm)] bg-paper w-64"
/>
</div>
<label className="text-xs text-fg-muted flex items-center gap-1.5">
Group by
<select
value={groupBy}
onChange={e => setGroupBy(e.target.value)}
className="border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 text-xs bg-paper"
>
{GROUP_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<span className="text-xs text-fg-muted ml-auto">
{filtered.length} of {topics.length} topics
{totalSel > 0 && <> · <span className="text-teal font-medium">{totalSel} selected</span></>}
</span>
</div>
{/* Bulk action bar */}
{totalSel > 0 && (
<div className="flex flex-wrap items-center gap-2 p-3 border-b border-bg-warm bg-teal/5">
<span className="text-xs font-medium text-teal mr-1">Bulk:</span>
{/* Type */}
<div className="flex items-center gap-1">
<select
value={bulkType}
onChange={e => setBulkType(e.target.value)}
className="border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 text-xs bg-paper"
disabled={isApplying}
>
<option value=""> set type </option>
{TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<Button
onClick={applyBulkType}
disabled={!bulkType || isApplying}
className="px-2 py-1 text-xs"
>Apply</Button>
</div>
{/* Relevance */}
<div className="flex items-center gap-1">
<select
value={bulkRelevance}
onChange={e => setBulkRelevance(e.target.value)}
className="border border-bg-warm rounded-[var(--r-sm)] px-2 py-1 text-xs bg-paper"
disabled={isApplying}
>
<option value=""> set relevance </option>
{RELEVANCE.map(r => <option key={r} value={r}>{r}</option>)}
</select>
<label className="flex items-center gap-1 text-xs text-fg-muted cursor-pointer">
<input
type="checkbox"
checked={bulkLockOnRelevance}
onChange={e => setBulkLockOnRelevance(e.target.checked)}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
/>
lock
</label>
<Button
onClick={applyBulkRelevance}
disabled={!bulkRelevance || isApplying}
className="px-2 py-1 text-xs"
>Apply</Button>
</div>
{/* Lock toggles */}
<button
onClick={() => applyLock(true)}
disabled={isApplying}
className="flex items-center gap-1 px-2 py-1 text-xs border border-bg-warm rounded-[var(--r-sm)] bg-paper hover:bg-bg-warm/50 disabled:opacity-40"
title="Lock relevance for selected topics"
>
<Lock size={11} /> Lock
</button>
<button
onClick={() => applyLock(false)}
disabled={isApplying}
className="flex items-center gap-1 px-2 py-1 text-xs border border-bg-warm rounded-[var(--r-sm)] bg-paper hover:bg-bg-warm/50 disabled:opacity-40"
title="Unlock relevance for selected topics"
>
<Unlock size={11} /> Unlock
</button>
<button
onClick={clearSelection}
className="ml-auto flex items-center gap-1 px-2 py-1 text-xs text-fg-muted hover:text-fg"
>
<X size={12} /> Clear
</button>
{feedback && (
<span className="basis-full text-xs text-fg-muted">{feedback}</span>
)}
</div>
)}
{/* Table */}
<div className="flex-1 overflow-auto">
<table className="w-full text-sm">
<thead className="bg-bg sticky top-0 z-10">
<tr className="text-left border-b border-bg-warm">
<th className="px-3 py-2 w-8">
<input
type="checkbox"
checked={allVisibleChecked}
ref={el => { if (el) el.indeterminate = someVisibleChecked; }}
onChange={toggleAllVisible}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
title={allVisibleChecked ? 'Deselect all visible' : 'Select all visible'}
/>
</th>
{[
['label', 'Label'],
['type', 'Type'],
['learning_relevance', 'Relevance'],
['theme', 'Theme'],
['difficulty', 'Difficulty'],
['complexity_weight', 'Weight'],
['relations', 'Rel.'],
].map(([key, label]) => (
<th
key={key}
onClick={() => onSort(key)}
className="px-3 py-2 text-xs uppercase tracking-wider text-fg-muted cursor-pointer select-none hover:text-teal"
>
{label}<SortIcon active={sortKey === key} dir={sortDir} />
</th>
))}
<th className="px-3 py-2 text-xs uppercase tracking-wider text-fg-muted">Lock</th>
</tr>
</thead>
<tbody>
{groups.map(group => (
<Group
key={group.key}
group={group}
groupBy={groupBy}
collapsed={isCollapsed(group.key)}
onToggleCollapse={() => toggleCollapse(group.key)}
onToggleGroup={() => toggleGroup(group.rows)}
selectedIds={selectedIds}
onToggleOne={toggleOne}
onSelectNode={onSelectNode}
/>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={9} className="px-3 py-12 text-center text-fg-muted text-sm">
No topics match the current filter.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
function Group({
group, groupBy, collapsed, onToggleCollapse, onToggleGroup,
selectedIds, onToggleOne, onSelectNode,
}) {
const groupHeader = groupBy !== 'none' && group.label != null;
const groupSelectedCount = group.rows.filter(r => selectedIds.has(r.id)).length;
const allGroupSelected = groupSelectedCount === group.rows.length && group.rows.length > 0;
const someGroupSelected = groupSelectedCount > 0 && !allGroupSelected;
return (
<>
{groupHeader && (
<tr className="bg-bg-warm/40 border-b border-bg-warm">
<td className="px-3 py-1.5 w-8">
<input
type="checkbox"
checked={allGroupSelected}
ref={el => { if (el) el.indeterminate = someGroupSelected; }}
onChange={onToggleGroup}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
/>
</td>
<td colSpan={8} className="px-3 py-1.5 text-xs font-medium text-fg-muted">
<button
onClick={onToggleCollapse}
className="inline-flex items-center gap-1 text-fg-muted hover:text-teal"
>
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
<span className="uppercase tracking-wider">{group.label}</span>
<span className="text-fg-muted/70 normal-case">· {group.rows.length}</span>
</button>
</td>
</tr>
)}
{!collapsed && group.rows.map(t => (
<Row
key={t.id}
topic={t}
checked={selectedIds.has(t.id)}
onToggle={() => onToggleOne(t.id)}
onSelectNode={onSelectNode}
/>
))}
</>
);
}
function Row({ topic, checked, onToggle, onSelectNode }) {
return (
<tr
className={`border-b border-bg-warm/60 hover:bg-bg-warm/30 transition-colors ${checked ? 'bg-teal/5' : ''}`}
>
<td className="px-3 py-2 w-8">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
/>
</td>
<td className="px-3 py-2">
<button
onClick={() => onSelectNode?.(topic)}
className="text-left hover:text-teal underline-offset-2 hover:underline"
title={topic.description || ''}
>
{topic.label}
</button>
</td>
<td className="px-3 py-2 font-mono text-xs">{topic.type}</td>
<td className="px-3 py-2 font-mono text-xs">{topic.learning_relevance}</td>
<td className="px-3 py-2 text-xs text-fg-muted">{topic.theme || '—'}</td>
<td className="px-3 py-2 text-xs">{topic.difficulty}</td>
<td className="px-3 py-2 text-xs text-center">{topic.complexity_weight}</td>
<td className="px-3 py-2 text-xs text-center text-fg-muted">{topic.relations}</td>
<td className="px-3 py-2 text-center">
{topic.relevance_locked
? <Lock size={12} className="text-teal inline" />
: <Unlock size={12} className="text-fg-muted/40 inline" />}
</td>
</tr>
);
}

View File

@@ -104,23 +104,6 @@ export function useGraphData() {
return true;
}, []);
/**
* Apply the same patch to many topics in one call. `ids` is the set of
* topic IDs to update; `patch` is the partial-topic to merge into each one
* (e.g. `{ type: 'process' }` or `{ learning_relevance: 'core', relevance_locked: true }`).
*
* Persists each topic via db.upsertTopic in parallel and mirrors into state.
* Returns the number of topics actually written (selected ∩ existing).
*/
const bulkUpdateTopics = useCallback(async (ids, patch) => {
const idSet = new Set(ids);
const targets = topicsRef.current.filter(t => idSet.has(t.id));
const updated = targets.map(t => ({ ...t, ...patch }));
await Promise.all(updated.map(t => db.upsertTopic(t)));
setTopics(prev => prev.map(t => (idSet.has(t.id) ? { ...t, ...patch } : t)));
return updated.length;
}, []);
/** Remove a specific (source, target, type) relation triple. */
const removeRelation = useCallback(async (source, target, type) => {
await db.removeRelation(source, target, type);
@@ -182,7 +165,6 @@ export function useGraphData() {
snapshotMeta,
reload,
updateTopic,
bulkUpdateTopics,
deleteTopic,
addRelation,
removeRelation,

View File

@@ -1,57 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
OIDC_PROVIDER,
parseAdminEmails,
resolveRole,
deriveName,
} from '../azureAuth';
describe('azureAuth', () => {
it('exposes the OIDC provider name used by authWithOAuth2', () => {
expect(OIDC_PROVIDER).toBe('oidc');
});
describe('parseAdminEmails', () => {
it('returns [] for empty/undefined input', () => {
expect(parseAdminEmails()).toEqual([]);
expect(parseAdminEmails('')).toEqual([]);
expect(parseAdminEmails(' ')).toEqual([]);
});
it('lower-cases, trims, drops blanks and de-duplicates', () => {
expect(parseAdminEmails(' RVE@respellion.nl , admin@respellion.nl ,, rve@respellion.nl'))
.toEqual(['rve@respellion.nl', 'admin@respellion.nl']);
});
});
describe('resolveRole', () => {
it('returns admin for an allow-listed e-mail (case-insensitive)', () => {
expect(resolveRole('RVE@respellion.nl', 'rve@respellion.nl')).toBe('admin');
});
it('returns user for a non-listed e-mail', () => {
expect(resolveRole('jane@respellion.nl', 'rve@respellion.nl')).toBe('user');
});
it('returns user when the allow-list is empty', () => {
expect(resolveRole('rve@respellion.nl', '')).toBe('user');
expect(resolveRole('rve@respellion.nl')).toBe('user');
});
});
describe('deriveName', () => {
it('prefers the OIDC name claim', () => {
expect(deriveName({ name: 'Raymond Verhoef' }, 'rve@respellion.nl')).toBe('Raymond Verhoef');
});
it('falls back to the e-mail prefix when no name claim', () => {
expect(deriveName({}, 'rve@respellion.nl')).toBe('rve');
expect(deriveName(undefined, 'jane.doe@respellion.nl')).toBe('jane.doe');
});
it('falls back to "Onbekend" with no usable input', () => {
expect(deriveName({}, '')).toBe('Onbekend');
expect(deriveName(null, null)).toBe('Onbekend');
});
});
});

View File

@@ -1,97 +0,0 @@
import { describe, expect, it } from 'vitest';
import { validateSchedule } from '../curriculumService';
const week = (n, theme, topicIds, duration = 30) => ({
week_number: n,
theme,
topic_ids: topicIds,
estimated_duration: duration,
week_rationale: 'r',
});
const makeTopic = (id, theme) => ({
id,
theme,
type: 'concept',
learning_relevance: 'include',
});
const buildScheduleFromTopics = (topics) => {
const ids = topics.map(t => t.id);
return Array.from({ length: 26 }, (_, i) => {
const chunk = ids.slice(i * 2, i * 2 + 2);
return week(i + 1, topics[i * 2]?.theme || 'General', chunk.length ? chunk : [ids[0]]);
});
};
describe('validateSchedule', () => {
it('does not warn about missing themes when merging was required (themes_kb > 26)', () => {
const topics = [];
for (let i = 0; i < 60; i++) topics.push(makeTopic(`t${i}`, `Theme ${i % 30}`));
const schedule = buildScheduleFromTopics(topics);
const result = validateSchedule(schedule, topics);
expect(result.valid).toBe(true);
const themeWarning = result.warnings.find(w => w.includes('not scheduled'));
expect(themeWarning).toBeUndefined();
});
it('warns about missing themes only when merging was not required', () => {
const topics = [];
for (let i = 0; i < 10; i++) topics.push(makeTopic(`t${i}`, `Theme ${i}`));
const schedule = Array.from({ length: 26 }, (_, i) =>
week(i + 1, 'Theme 0', ['t0'])
);
const result = validateSchedule(schedule, topics);
const themeWarning = result.warnings.find(w => w.includes('not scheduled'));
expect(themeWarning).toBeDefined();
expect(themeWarning).toMatch(/9 theme/);
});
it('warns when learning topics are absent from every week (real coverage gap)', () => {
const topics = [
makeTopic('t1', 'A'),
makeTopic('t2', 'A'),
makeTopic('t3', 'B'),
];
const schedule = Array.from({ length: 26 }, (_, i) =>
week(i + 1, 'A', ['t1'])
);
const result = validateSchedule(schedule, topics);
const coverageWarning = result.warnings.find(w => w.includes('not covered'));
expect(coverageWarning).toBeDefined();
expect(coverageWarning).toMatch(/2 learning topic/);
});
it('ignores topics with type=fact or learning_relevance=exclude in coverage', () => {
const topics = [
makeTopic('t1', 'A'),
{ ...makeTopic('t2', 'A'), type: 'fact' },
{ ...makeTopic('t3', 'B'), learning_relevance: 'exclude' },
];
const schedule = Array.from({ length: 26 }, (_, i) =>
week(i + 1, 'A', ['t1'])
);
const result = validateSchedule(schedule, topics);
expect(result.warnings.find(w => w.includes('not covered'))).toBeUndefined();
});
it('flags hard errors: wrong week count, bad duration, unknown topic_id', () => {
const topics = [makeTopic('t1', 'A')];
const schedule = [week(1, 'A', ['t1'])];
const result = validateSchedule(schedule, topics);
expect(result.valid).toBe(false);
expect(result.errors[0]).toMatch(/exactly 26 weeks/);
const fullSchedule = Array.from({ length: 26 }, (_, i) => week(i + 1, 'A', ['t1']));
fullSchedule[2].estimated_duration = 5;
fullSchedule[5].topic_ids = ['missing-id'];
const result2 = validateSchedule(fullSchedule, topics);
expect(result2.valid).toBe(false);
expect(result2.errors.some(e => e.includes('out-of-range duration'))).toBe(true);
expect(result2.errors.some(e => e.includes('unknown topic_id'))).toBe(true);
});
});

View File

@@ -1,125 +0,0 @@
import { describe, expect, it } from 'vitest';
import { isProtectedTopic, filterAiActions } from '../graphGuard';
const topic = (id, extras = {}) => ({
id,
label: `Topic ${id}`,
type: 'concept',
learning_relevance: 'standard',
relevance_locked: false,
...extras,
});
describe('isProtectedTopic', () => {
it('flags excluded topics', () => {
expect(isProtectedTopic(topic('a', { learning_relevance: 'exclude' }))).toBe(true);
});
it('flags locked topics', () => {
expect(isProtectedTopic(topic('a', { relevance_locked: true }))).toBe(true);
});
it('flags topics that are both excluded and locked', () => {
expect(
isProtectedTopic(topic('a', { learning_relevance: 'exclude', relevance_locked: true })),
).toBe(true);
});
it('does not flag standard / core / peripheral topics', () => {
expect(isProtectedTopic(topic('a', { learning_relevance: 'core' }))).toBe(false);
expect(isProtectedTopic(topic('a', { learning_relevance: 'standard' }))).toBe(false);
expect(isProtectedTopic(topic('a', { learning_relevance: 'peripheral' }))).toBe(false);
});
it('treats null/undefined as not protected', () => {
expect(isProtectedTopic(null)).toBe(false);
expect(isProtectedTopic(undefined)).toBe(false);
});
});
describe('filterAiActions', () => {
const topics = [
topic('safe'),
topic('excluded', { learning_relevance: 'exclude' }),
topic('locked', { relevance_locked: true }),
topic('both', { learning_relevance: 'exclude', relevance_locked: true }),
topic('other'),
];
it('drops deletions that target excluded topics', () => {
const { filtered, dropped } = filterAiActions(topics, {
deletions: ['safe', 'excluded', 'other'],
merges: [],
});
expect(filtered.deletions).toEqual(['safe', 'other']);
expect(dropped.deletions).toEqual(['excluded']);
});
it('drops deletions that target locked topics', () => {
const { filtered, dropped } = filterAiActions(topics, {
deletions: ['locked'],
merges: [],
});
expect(filtered.deletions).toEqual([]);
expect(dropped.deletions).toEqual(['locked']);
});
it('drops merges whose deleteId is protected', () => {
const merges = [
{ keepId: 'safe', deleteId: 'other' }, // ok
{ keepId: 'safe', deleteId: 'excluded' }, // drop — excluded
{ keepId: 'other', deleteId: 'locked' }, // drop — locked
{ keepId: 'safe', deleteId: 'both' }, // drop — both flags
];
const { filtered, dropped } = filterAiActions(topics, { merges, deletions: [] });
expect(filtered.merges).toEqual([{ keepId: 'safe', deleteId: 'other' }]);
expect(dropped.merges).toHaveLength(3);
expect(dropped.merges.map(m => m.deleteId)).toEqual(['excluded', 'locked', 'both']);
});
it('keeps merges where a protected topic is the keepId (canonical survivor)', () => {
const merges = [
{ keepId: 'excluded', deleteId: 'safe' }, // ok — protected is survivor
{ keepId: 'locked', deleteId: 'other' }, // ok — protected is survivor
];
const { filtered, dropped } = filterAiActions(topics, { merges, deletions: [] });
expect(filtered.merges).toEqual(merges);
expect(dropped.merges).toEqual([]);
});
it('passes through other action keys untouched', () => {
const actions = {
deletions: [],
merges: [],
newRelations: [{ source: 'a', target: 'b', type: 'related_to' }],
relevanceUpdates: [{ id: 'safe', learning_relevance: 'peripheral' }],
};
const { filtered } = filterAiActions(topics, actions);
expect(filtered.newRelations).toEqual(actions.newRelations);
expect(filtered.relevanceUpdates).toEqual(actions.relevanceUpdates);
});
it('tolerates missing actions keys', () => {
const { filtered, dropped } = filterAiActions(topics, {});
expect(filtered.deletions).toEqual([]);
expect(filtered.merges).toEqual([]);
expect(dropped.deletions).toEqual([]);
expect(dropped.merges).toEqual([]);
});
it('tolerates null actions', () => {
const { filtered, dropped } = filterAiActions(topics, null);
expect(filtered.deletions).toEqual([]);
expect(filtered.merges).toEqual([]);
expect(dropped.deletions).toEqual([]);
expect(dropped.merges).toEqual([]);
});
it('drops references to unknown ids by treating them as not protected (delete pass-through)', () => {
// If the AI hallucinates an id, the deletion is harmless downstream (no
// topic by that id exists). We do NOT block on unknown ids — that would
// mask real bugs. Verify pass-through behavior.
const { filtered } = filterAiActions(topics, { deletions: ['ghost-id'], merges: [] });
expect(filtered.deletions).toEqual(['ghost-id']);
});
});

View File

@@ -1,56 +0,0 @@
/**
* Azure (Entra ID) authentication helpers.
*
* Canonical, unit-tested home for the small pieces of auth logic shared by the
* frontend. The PocketBase hook `pb_hooks/team_members.pb.js` re-implements
* `resolveRole` / `parseAdminEmails` in the JSVM runtime (it cannot import this
* module) — keep the two in sync.
*
* Login itself goes through PocketBase's built-in OAuth2 flow:
* pb.collection('team_members').authWithOAuth2({ provider: OIDC_PROVIDER })
* The OIDC provider ("oidc") is configured against Entra in migration
* 1781000000_team_members_to_auth.js.
*/
/** Provider name registered on the `team_members` auth collection. */
export const OIDC_PROVIDER = 'oidc';
/**
* Parse the ENTRA_ADMIN_EMAILS-style comma-separated allow-list into a
* normalised (lower-cased, trimmed, de-duplicated, empty-free) array.
* @param {string} [csv]
* @returns {string[]}
*/
export function parseAdminEmails(csv) {
if (!csv) return [];
const seen = new Set();
for (const part of String(csv).toLowerCase().split(',')) {
const email = part.trim();
if (email) seen.add(email);
}
return [...seen];
}
/**
* Resolve a user's role from their e-mail and the admin allow-list.
* @param {string} email
* @param {string} [adminEmailsCsv]
* @returns {'admin'|'user'}
*/
export function resolveRole(email, adminEmailsCsv) {
const allow = parseAdminEmails(adminEmailsCsv);
return allow.includes(String(email || '').trim().toLowerCase()) ? 'admin' : 'user';
}
/**
* Derive a display name from OIDC claims, falling back to the e-mail prefix.
* @param {{ name?: string }} [claims]
* @param {string} [email]
* @returns {string}
*/
export function deriveName(claims, email) {
const fromClaim = claims && typeof claims.name === 'string' ? claims.name.trim() : '';
if (fromClaim) return fromClaim;
if (email && email.includes('@')) return email.split('@')[0];
return 'Onbekend';
}

View File

@@ -60,16 +60,8 @@ export function buildThemeTopicMap(topics) {
/**
* Validates a 26-week schedule against the provided topics.
*
* Warning policy:
* - Theme names not appearing as a week label are NOT a warning when the KB
* has more than 26 themes — the AI is required to merge in that case, and
* topic_ids from the merged themes are carried through under the chosen
* week label. We only warn about missing themes when merging wasn't needed.
* - Real coverage is measured on TOPICS: a topic that exists in the KB but
* is absent from every week's topic_ids is a genuine gap and gets a warning.
*
* Returns { valid: boolean, errors: string[], warnings: string[] }
* Checks for exactly 26 weeks, duration range, theme existence, and topic existence.
* Returns { valid: boolean, errors: string[] }
*/
export function validateSchedule(schedule, topics) {
const errors = []; // Hard errors — schedule is unusable
@@ -79,13 +71,10 @@ export function validateSchedule(schedule, topics) {
errors.push(`Schedule must contain exactly 26 weeks. Found ${schedule?.length || 0}.`);
}
const learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
const validThemes = new Set(learningTopics.map(t => t.theme || 'General'));
const validThemes = new Set(topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude').map(t => t.theme || 'General'));
const validTopicIds = new Set(topics.map(t => t.id));
const learningTopicIds = new Set(learningTopics.map(t => t.id));
const scheduledThemes = new Set();
const scheduledTopicIds = new Set();
for (let i = 0; i < (schedule || []).length; i++) {
const week = schedule[i];
@@ -105,14 +94,12 @@ export function validateSchedule(schedule, topics) {
if (!validTopicIds.has(tId)) {
errors.push(`Week ${week.week_number} references unknown topic_id: ${tId}`);
}
scheduledTopicIds.add(tId);
}
}
}
// Theme coverage — only warn when merging wasn't required. When themes_kb > 26
// the AI is *required* to merge, so absent theme labels are expected.
if (validThemes.size <= 26) {
// Theme coverage — soft warnings, not hard errors
// When there are more themes than 26 weeks, the AI must merge some.
const missingThemes = [];
for (const t of validThemes) {
if (!scheduledThemes.has(t)) {
@@ -120,15 +107,7 @@ export function validateSchedule(schedule, topics) {
}
}
if (missingThemes.length > 0) {
warnings.push(`${missingThemes.length} theme(s) not scheduled: ${missingThemes.join(', ')}`);
}
}
// Topic coverage — the real signal. Topics carried under a merged theme are
// still covered; topics absent from every week are not.
const missingTopicCount = [...learningTopicIds].filter(id => !scheduledTopicIds.has(id)).length;
if (missingTopicCount > 0) {
warnings.push(`${missingTopicCount} learning topic(s) not covered by any week of the schedule.`);
warnings.push(`${missingThemes.length} theme(s) not directly scheduled (topics may appear under merged themes): ${missingThemes.join(', ')}`);
}
return { valid: errors.length === 0, errors, warnings };
@@ -245,9 +224,7 @@ Rules:
throw new Error(`Generated schedule failed validation after retry:\n- ${validationResult.errors.join('\n- ')}`);
}
// Log warnings but don't fail. With themes_kb > 26 the AI must merge themes,
// so most "missing theme" noise is filtered upstream in validateSchedule —
// anything that lands here is a genuine coverage gap worth surfacing.
// Log warnings but don't fail
if (validationResult.warnings.length > 0) {
console.warn('[Curriculum] Schedule generated with warnings:', validationResult.warnings);
}
@@ -373,7 +350,7 @@ export async function getYearProgress(userId, personalWeekNumber) {
let completed = 0;
for (let w = cycleStartWeek; w <= cycleEndWeek; w++) {
const done = await db.getLearnDone(userId, w);
const done = await db.getThemeSessionCompletion(userId, w);
if (done) completed++;
}

View File

@@ -326,13 +326,6 @@ export function setCachedQuiz(userId, weekNumber, quiz) {
return Promise.resolve(quiz);
}
// ── Learn Progress (DEPRECATED — collection dropped) ────────────────────────
/** @deprecated learn_progress collection has been dropped. */
export async function getLearnDone() { return false; }
/** @deprecated learn_progress collection has been dropped. */
export async function setLearnDone() { return null; }
// ── Theme Sessions ──────────────────────────────────────────────────────────
/**

View File

@@ -1,81 +0,0 @@
/**
* Knowledge-graph safety guards.
*
* The "Full Analysis" pass lets the LLM propose `merges` (collapse two topics
* into one) and `deletions` (drop a topic entirely). Both are destructive and
* persisted via bulkSave → db.saveTopics, which wipes-and-rewrites the
* `topics` collection.
*
* Two classes of topic must NEVER be removed by an AI suggestion:
*
* 1. `learning_relevance === 'exclude'`
* Reference material — kept on purpose, surfaced only to R42 search,
* intentionally excluded from theme topics. An admin made this choice;
* the AI's "irrelevant" judgement must not override it.
*
* 2. `relevance_locked === true`
* Admin pinned this row's relevance. By extension the row itself is
* also pinned — deleting it would be a louder change than re-scoring it.
*
* `filterAiActions` strips any merge/deletion that would touch a protected id
* BEFORE it reaches bulkSave. The model still gets to suggest them (we can't
* stop it generating), but those suggestions are dropped client-side.
*
* The function is intentionally pure so it is trivially unit-testable.
*/
/**
* @param {{ learning_relevance?: string, relevance_locked?: boolean }} topic
*/
export function isProtectedTopic(topic) {
if (!topic) return false;
if (topic.learning_relevance === 'exclude') return true;
if (topic.relevance_locked === true) return true;
return false;
}
/**
* Strip protected topics out of `actions.deletions` and `actions.merges`.
*
* Merges have two ids:
* - `keepId` — the survivor
* - `deleteId` — gets removed and its relations re-pointed
*
* If `deleteId` is protected we drop the entire merge (we will not delete a
* protected topic, even to consolidate it). If `keepId` is protected we keep
* the merge — folding others into a protected canonical topic is fine.
*
* @param {object[]} currentTopics
* @param {{ merges?: {keepId: string, deleteId: string}[], deletions?: string[], newRelations?: object[], relevanceUpdates?: object[] }} actions
* @returns {{ filtered: object, dropped: { deletions: string[], merges: object[] } }}
*/
export function filterAiActions(currentTopics, actions) {
const byId = new Map(currentTopics.map(t => [t.id, t]));
const isProtected = (id) => isProtectedTopic(byId.get(id));
const droppedDeletions = [];
const keptDeletions = [];
for (const id of actions?.deletions ?? []) {
if (isProtected(id)) droppedDeletions.push(id);
else keptDeletions.push(id);
}
const droppedMerges = [];
const keptMerges = [];
for (const m of actions?.merges ?? []) {
if (isProtected(m?.deleteId)) droppedMerges.push(m);
else keptMerges.push(m);
}
return {
filtered: {
...(actions || {}),
deletions: keptDeletions,
merges: keptMerges,
},
dropped: {
deletions: droppedDeletions,
merges: droppedMerges,
},
};
}

View File

@@ -197,7 +197,7 @@ const Admin = () => {
{activeTab === 'team' && (
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
<h1 className="text-3xl text-teal mb-2">Team Management</h1>
<p className="text-fg-muted mb-8">Review team members and manage their roles. Accounts are created automatically via Microsoft (Azure) sign-in.</p>
<p className="text-fg-muted mb-8">Manage team members, roles, and login PINs.</p>
<TeamManager />
</div>
)}

View File

@@ -31,13 +31,14 @@ const Dashboard = () => {
if (!currentUser) return;
const load = async () => {
const [topic, learnDone, testResult, allUsers, leaderboard] = await Promise.all([
const [topic, learnCompletion, testResult, allUsers, leaderboard] = await Promise.all([
getAssignedTopic(currentUser.id, weekNumber),
db.getLearnDone(currentUser.id, weekNumber),
db.getThemeSessionCompletion(currentUser.id, weekNumber),
db.getQuizResult(currentUser.id, weekNumber),
db.getTeamMembers(),
db.getLeaderboard(),
]);
const learnDone = !!learnCompletion;
const nonAdminIds = allUsers.filter(u => u.role !== 'admin').map(u => u.id);
const filtered = leaderboard.filter(u => nonAdminIds.includes(u.user_id));
@@ -48,7 +49,7 @@ const Dashboard = () => {
const activity = [];
for (let w = weekNumber; w >= Math.max(1, weekNumber - 3); w--) {
const [pastLearn, pastTest, pastTopic] = await Promise.all([
db.getLearnDone(currentUser.id, w),
db.getThemeSessionCompletion(currentUser.id, w),
db.getQuizResult(currentUser.id, w),
getAssignedTopic(currentUser.id, w),
]);

View File

@@ -2,25 +2,37 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useApp } from '../store/AppContext';
import Card from '../components/ui/Card';
import Input from '../components/ui/Input';
import Select from '../components/ui/Select';
import Button from '../components/ui/Button';
const Login = () => {
const { loginWithAzure } = useApp();
const { state, login } = useApp();
const navigate = useNavigate();
const [selectedUser, setSelectedUser] = useState('');
const [pin, setPin] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const handleAzure = async () => {
const userOptions = [
{ value: '', label: 'Select a user...' },
...state.users.map(u => ({ value: u.id, label: u.name }))
];
const handleLogin = (e) => {
e.preventDefault();
setError('');
setBusy(true);
try {
await loginWithAzure();
if (!selectedUser || !pin) {
setError('Please fill in all fields.');
return;
}
const success = login(selectedUser, pin);
if (success) {
navigate('/');
} catch (e) {
// PocketBase throws when the popup is closed or the token exchange fails.
setError(e?.message || 'Signing in with Microsoft failed. Please try again.');
setBusy(false);
} else {
setError('Incorrect PIN.');
}
};
@@ -34,17 +46,31 @@ const Login = () => {
</div>
<Card className="border border-bg-warm">
<div className="flex flex-col gap-5">
<p className="text-fg-muted text-sm text-center">
Sign in with your Respellion Microsoft account.
</p>
<form onSubmit={handleLogin} className="flex flex-col gap-5">
<Select
label="User"
options={userOptions}
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
/>
{error && <div className="text-red-500 text-sm font-medium text-center">{error}</div>}
<Input
label="PIN Code"
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
placeholder="••••"
value={pin}
onChange={(e) => setPin(e.target.value)}
/>
<Button onClick={handleAzure} disabled={busy} className="w-full">
{busy ? 'Signing in…' : 'Sign in with Microsoft'}
{error && <div className="text-red-500 text-sm font-medium">{error}</div>}
<Button type="submit" className="mt-2 w-full">
Sign In
</Button>
</div>
</form>
</Card>
</div>
</div>

View File

@@ -1,7 +1,6 @@
import { createContext, useContext, useReducer, useEffect } from 'react';
import * as db from '../lib/db';
import { pb } from '../lib/pb';
import { OIDC_PROVIDER } from '../lib/azureAuth';
import { getPersonalWeekNumber } from '../lib/curriculumService';
const AppContext = createContext();
@@ -51,40 +50,44 @@ export function AppProvider({ children }) {
useEffect(() => {
const loadState = async () => {
// Restore the session from PocketBase's persistent auth store. The token
// is kept in localStorage by the SDK; authRefresh verifies it is still
// valid (and that the Entra account hasn't been revoked) and returns the
// up-to-date record.
if (pb.authStore.isValid && pb.authStore.record?.collectionName === 'team_members') {
let members = await db.getTeamMembers();
if (!members || members.length === 0) {
try {
const { record } = await pb.collection('team_members').authRefresh();
dispatch({ type: 'LOGIN', payload: record });
} catch {
// Token rejected (expired / account revoked) — drop the session.
pb.authStore.clear();
const created = await db.addTeamMember({ name: 'Admin', role: 'admin', pin: '0000' });
members = [created];
} catch (e) {
console.warn('[AppContext] Could not auto-create admin user:', e.message,
'— Run scripts/setup-pb-collections.mjs to configure the database.');
}
}
const sessionUserId = sessionStorage.getItem('respellion_session');
if (sessionUserId) {
const user = members.find(u => u.id === sessionUserId);
if (user) {
dispatch({ type: 'LOGIN', payload: user });
}
}
// The team list (leaderboard, dashboard) requires authentication now, so
// only load it once we have a valid session.
const members = pb.authStore.isValid ? await db.getTeamMembers() : [];
dispatch({ type: 'INIT_APP', payload: { users: members } });
};
loadState().catch(console.error);
}, []);
// Start the Entra (OIDC) login flow. PocketBase opens the provider popup,
// handles the token exchange server-side, and auto-provisions the record on
// first login (see pb_hooks/team_members.pb.js).
const loginWithAzure = async () => {
const authData = await pb.collection('team_members').authWithOAuth2({ provider: OIDC_PROVIDER });
dispatch({ type: 'LOGIN', payload: authData.record });
return authData.record;
const login = (userId, pin) => {
const user = state.users.find(u => u.id === userId && u.pin === pin);
if (user) {
sessionStorage.setItem('respellion_session', user.id);
dispatch({ type: 'LOGIN', payload: user });
return true;
}
return false;
};
const logout = () => {
pb.authStore.clear();
sessionStorage.removeItem('respellion_session');
dispatch({ type: 'LOGOUT' });
};
@@ -104,7 +107,7 @@ export function AppProvider({ children }) {
};
return (
<AppContext.Provider value={{ state, dispatch, loginWithAzure, logout, enrollCurrentUser }}>
<AppContext.Provider value={{ state, dispatch, login, logout, enrollCurrentUser }}>
{children}
</AppContext.Provider>
);