diff --git a/CLAUDE.md b/CLAUDE.md index fa1ece7..1a1602b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,6 +127,7 @@ 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 diff --git a/docs/auth-spec.md b/docs/auth-spec.md new file mode 100644 index 0000000..c81fbe3 --- /dev/null +++ b/docs/auth-spec.md @@ -0,0 +1,105 @@ +# 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:///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. diff --git a/eslint.config.js b/eslint.config.js index 9baab19..155f1dc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist', 'pb_migrations']), + globalIgnores(['dist', 'pb_migrations', 'pb_hooks']), { files: ['**/*.{js,jsx}'], extends: [ diff --git a/infra/development/site/compose.yml b/infra/development/site/compose.yml index 874599c..0448f97 100644 --- a/infra/development/site/compose.yml +++ b/infra/development/site/compose.yml @@ -4,6 +4,9 @@ networks: learning-platform: external: true +volumes: + pb_data: + services: learning-platform: image: git.labs.respellion.tech/respellion/learning-platform/learning-platform:latest @@ -18,11 +21,18 @@ services: image: ghcr.io/muchobien/pocketbase:latest container_name: pocketbase-learning restart: unless-stopped - command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data"] + 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} volumes: - pb_data:/pb/pb_data + - ./pb_migrations:/pb/pb_migrations + - ./pb_hooks:/pb/pb_hooks networks: - learning-platform - -volumes: - pb_data: diff --git a/infra/development/site/deploy-playbook.yml b/infra/development/site/deploy-playbook.yml index b98dbe6..8d9d8b9 100644 --- a/infra/development/site/deploy-playbook.yml +++ b/infra/development/site/deploy-playbook.yml @@ -36,11 +36,27 @@ 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 diff --git a/infra/production/site/compose.yml b/infra/production/site/compose.yml index d73a01a..0448f97 100644 --- a/infra/production/site/compose.yml +++ b/infra/production/site/compose.yml @@ -22,9 +22,17 @@ 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"] + 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} volumes: - pb_data:/pb/pb_data - ./pb_migrations:/pb/pb_migrations + - ./pb_hooks:/pb/pb_hooks networks: - learning-platform diff --git a/infra/production/site/deploy-playbook.yml b/infra/production/site/deploy-playbook.yml index ae85ca7..8d9d8b9 100644 --- a/infra/production/site/deploy-playbook.yml +++ b/infra/production/site/deploy-playbook.yml @@ -42,11 +42,21 @@ 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 diff --git a/pb_hooks/team_members.pb.js b/pb_hooks/team_members.pb.js new file mode 100644 index 0000000..4551ddf --- /dev/null +++ b/pb_hooks/team_members.pb.js @@ -0,0 +1,55 @@ +/// +// +// 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. + +function resolveRole(email) { + const raw = ($os.getenv("ENTRA_ADMIN_EMAILS") || "").toLowerCase(); + const allow = raw.split(",").map(function (s) { return s.trim(); }).filter(Boolean); + return allow.indexOf((email || "").toLowerCase()) !== -1 ? "admin" : "user"; +} + +// On creation (first login): set defaults before the record is persisted. +onRecordCreate(function (e) { + 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 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"); diff --git a/pb_migrations/1781000000_team_members_to_auth.js b/pb_migrations/1781000000_team_members_to_auth.js new file mode 100644 index 0000000..7283571 --- /dev/null +++ b/pb_migrations/1781000000_team_members_to_auth.js @@ -0,0 +1,262 @@ +/// +// +// 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). + 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); +}, (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); +}); diff --git a/pb_migrations/1781000001_tighten_api_rules.js b/pb_migrations/1781000001_tighten_api_rules.js new file mode 100644 index 0000000..49de293 --- /dev/null +++ b/pb_migrations/1781000001_tighten_api_rules.js @@ -0,0 +1,57 @@ +/// +// +// 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); + } +}); diff --git a/scripts/setup-pb-collections.mjs b/scripts/setup-pb-collections.mjs index d22ff16..3f8fb30 100644 --- a/scripts/setup-pb-collections.mjs +++ b/scripts/setup-pb-collections.mjs @@ -102,17 +102,21 @@ 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: 'base', + type: 'auth', ...OPEN_RULES, + passwordAuth: { enabled: false, identityFields: ['email'] }, fields: [ - { name: 'name', type: 'text', required: true }, - { name: 'pin', type: 'text', required: false }, + { name: 'name', 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, ], }, { diff --git a/src/components/admin/TeamManager.jsx b/src/components/admin/TeamManager.jsx index 578f623..f41fc56 100644 --- a/src/components/admin/TeamManager.jsx +++ b/src/components/admin/TeamManager.jsx @@ -1,19 +1,20 @@ import { useState, useEffect } from 'react'; -import { UserPlus, Trash2, Edit2, Shield, User, CheckCircle } from 'lucide-react'; +import { Trash2, Shield, User, CheckCircle, Info } 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 () => { @@ -23,29 +24,16 @@ const TeamManager = () => { useEffect(() => { loadUsers(); }, []); - 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); + const flash = (msg) => { + setMessage(msg); setTimeout(() => setMessage(''), 3000); }; - const handleEdit = (user) => { - setFormData({ name: user.name, role: user.role, pin: user.pin }); - setIsEditing(true); - setEditingId(user.id); + 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 handleDelete = async (id) => { @@ -56,24 +44,17 @@ 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(); - setMessage('User deleted.'); - setTimeout(() => setMessage(''), 3000); + flash('User deleted.'); } }; - const cancelEdit = () => { - setIsEditing(false); - setEditingId(null); - setFormData({ name: '', role: 'user', pin: '' }); - }; - return (
{message && ( @@ -82,58 +63,22 @@ const TeamManager = () => {
)} - -

- {isEditing ? : } - {isEditing ? 'Edit Team Member' : 'Add New Team Member'} -

- -
-
- setFormData({...formData, name: e.target.value})} - required - /> -
-
- - -
-
- setFormData({...formData, pin: e.target.value})} - required - /> -
-
- - {isEditing && ( - - )} -
-
+ +

+ + Team members are created automatically when they first sign in with their + Microsoft account. Admins are granted via the ENTRA_ADMIN_EMAILS{' '} + allow-list and re-synced on each login. +

+ {users.length === 0 && ( +
+ No team members yet — they appear here after their first sign-in. +
+ )} {users.map(user => (
@@ -145,19 +90,26 @@ const TeamManager = () => { {user.name} {user.id === state.currentUser?.id && You}

-

PIN: {user.pin}

+

{user.email}

- {user.role} - + diff --git a/src/lib/__tests__/azureAuth.test.js b/src/lib/__tests__/azureAuth.test.js new file mode 100644 index 0000000..1f16176 --- /dev/null +++ b/src/lib/__tests__/azureAuth.test.js @@ -0,0 +1,57 @@ +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'); + }); + }); +}); diff --git a/src/lib/azureAuth.js b/src/lib/azureAuth.js new file mode 100644 index 0000000..300cfa9 --- /dev/null +++ b/src/lib/azureAuth.js @@ -0,0 +1,56 @@ +/** + * 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'; +} diff --git a/src/pages/Admin/index.jsx b/src/pages/Admin/index.jsx index 8f5d78e..d4306ea 100644 --- a/src/pages/Admin/index.jsx +++ b/src/pages/Admin/index.jsx @@ -197,7 +197,7 @@ const Admin = () => { {activeTab === 'team' && (

Team Management

-

Manage team members, roles, and login PINs.

+

Review team members and manage their roles. Accounts are created automatically via Microsoft (Azure) sign-in.

)} diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index 767936f..0c66fd4 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -2,37 +2,25 @@ 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 { state, login } = useApp(); + const { loginWithAzure } = useApp(); const navigate = useNavigate(); - - const [selectedUser, setSelectedUser] = useState(''); - const [pin, setPin] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); - const userOptions = [ - { value: '', label: 'Select a user...' }, - ...state.users.map(u => ({ value: u.id, label: u.name })) - ]; - - const handleLogin = (e) => { - e.preventDefault(); + const handleAzure = async () => { setError(''); - - if (!selectedUser || !pin) { - setError('Please fill in all fields.'); - return; - } - - const success = login(selectedUser, pin); - if (success) { + setBusy(true); + try { + await loginWithAzure(); navigate('/'); - } else { - setError('Incorrect PIN.'); + } 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); } }; @@ -46,31 +34,17 @@ const Login = () => {
-
- setPin(e.target.value)} - /> +
+

+ Sign in with your Respellion Microsoft account. +

- {error &&
{error}
} + {error &&
{error}
} - - +
diff --git a/src/store/AppContext.jsx b/src/store/AppContext.jsx index c2ce8ec..56dd66d 100644 --- a/src/store/AppContext.jsx +++ b/src/store/AppContext.jsx @@ -1,6 +1,7 @@ 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(); @@ -50,44 +51,40 @@ export function AppProvider({ children }) { useEffect(() => { const loadState = async () => { - let members = await db.getTeamMembers(); - - if (!members || members.length === 0) { + // 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') { try { - 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 }); + 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(); } } + // 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); }, []); - 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; + // 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 logout = () => { - sessionStorage.removeItem('respellion_session'); + pb.authStore.clear(); dispatch({ type: 'LOGOUT' }); }; @@ -107,7 +104,7 @@ export function AppProvider({ children }) { }; return ( - + {children} );