Compare commits
13 Commits
fix/issue-
...
1a1351ddcb
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a1351ddcb | |||
|
|
d79e69aad2 | ||
|
|
89d3395a62 | ||
|
|
d5191073f0 | ||
| fab7a12f7b | |||
|
|
fe99381cb7 | ||
|
|
3af105bccd | ||
| 5f34a6f825 | |||
|
|
9395ea11fe | ||
| e310b6d85b | |||
| 2b2921f6ed | |||
|
|
2274de4de7 | ||
|
|
eb08c4ad96 |
4
.github/workflows/deploy-dev.yml
vendored
4
.github/workflows/deploy-dev.yml
vendored
@@ -28,4 +28,8 @@ jobs:
|
|||||||
-i infra/development/hosts.ini \
|
-i infra/development/hosts.ini \
|
||||||
-e "ansible_ssh_private_key_file=~/.ssh/deploy_key" \
|
-e "ansible_ssh_private_key_file=~/.ssh/deploy_key" \
|
||||||
-e "anthropic_api_key=${{ secrets.ANTHROPIC_API_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
|
infra/development/site/deploy-playbook.yml
|
||||||
|
|||||||
4
.github/workflows/deploy-prod.yml
vendored
4
.github/workflows/deploy-prod.yml
vendored
@@ -30,4 +30,8 @@ jobs:
|
|||||||
-i infra/production/hosts.ini \
|
-i infra/production/hosts.ini \
|
||||||
-e "ansible_ssh_private_key_file=~/.ssh/deploy_key" \
|
-e "ansible_ssh_private_key_file=~/.ssh/deploy_key" \
|
||||||
-e "anthropic_api_key=${{ secrets.ANTHROPIC_API_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
|
infra/production/site/deploy-playbook.yml
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ The platform ships a global chatbot avatar called **R42**, rendered as the Respe
|
|||||||
* **PocketBase auto-cancellation is OFF.** Set in `src/lib/pb.js`; never re-enable.
|
* **PocketBase auto-cancellation is OFF.** Set in `src/lib/pb.js`; never re-enable.
|
||||||
* **Go through `callLLM`.** Never call the Anthropic proxy directly; you lose retry, schema validation, and telemetry.
|
* **Go through `callLLM`.** Never call the Anthropic proxy directly; you lose retry, schema validation, and telemetry.
|
||||||
* **AI token budget.** Truncation surfaces as `LLMTruncatedError` (`stop_reason: max_tokens`). For extraction, tighten the prompt's topic cap before raising `max_tokens`.
|
* **AI token budget.** Truncation surfaces as `LLMTruncatedError` (`stop_reason: max_tokens`). For extraction, tighten the prompt's topic cap before raising `max_tokens`.
|
||||||
|
* **PocketBase migrations & the `_migrations` ledger (issue #18).** The deployed Labs/prod databases were originally provisioned WITHOUT `--migrationsDir` (schema came from `scripts/setup-pb-collections.mjs`), so their ledger started empty. `pb_migrations/1000000000_baseline_ledger_sync.js` marks the historical files as applied on such databases — never remove it, never rename an applied migration file (the ledger is filename-based), and give new migrations a timestamp that sorts after the existing ones. A failed migration aborts `pocketbase serve` → container crash-loop → 502 on every `/api/*` call; the deploy playbooks gate on `/api/health` to catch this.
|
||||||
|
* **OIDC provider config lives in a hook, not a migration.** `pb_hooks/entra_oidc.pb.js` reconciles the Entra provider from `ENTRA_*` env on every bootstrap + cron tick. Don't move provider credentials back into a one-shot migration — an apply while the env is empty would permanently disable OAuth2.
|
||||||
|
* **JSVM hook scope.** PocketBase runs each hook callback as an isolated program: top-level functions in a `*.pb.js` file are NOT in scope inside callbacks. Shared helpers live in `pb_hooks/utils.js` and are pulled in per-callback via `require(`${__hooks}/utils.js`)`.
|
||||||
|
|
||||||
## 13. 26-Week Per-User Curriculum System
|
## 13. 26-Week Per-User Curriculum System
|
||||||
The platform uses a **26-week perpetual curriculum cycle**. Every employee covers the knowledge base in focused, thematic weekly blocks, **starting whenever they enroll**.
|
The platform uses a **26-week perpetual curriculum cycle**. Every employee covers the knowledge base in focused, thematic weekly blocks, **starting whenever they enroll**.
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ when the PB binary starts.
|
|||||||
- `docs/generation-spec.md` — learning content + micro-learning generation
|
- `docs/generation-spec.md` — learning content + micro-learning generation
|
||||||
- `docs/curriculum-spec.md` — 26-week per-user curriculum engine
|
- `docs/curriculum-spec.md` — 26-week per-user curriculum engine
|
||||||
- `docs/r42-spec.md` — the R42 chatbot
|
- `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/frontend-spec.md` — screens, routing, onboarding
|
||||||
- `docs/gamification-spec.md` — points, badges, leaderboard
|
- `docs/gamification-spec.md` — points, badges, leaderboard
|
||||||
- `micro-learning-spec.md` — micro-learning learner experience
|
- `micro-learning-spec.md` — micro-learning learner experience
|
||||||
|
|||||||
@@ -46,6 +46,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle_errors {
|
handle_errors {
|
||||||
|
# Don't mask API errors with the SPA shell — return a proper
|
||||||
|
# JSON error so the frontend can display a meaningful message.
|
||||||
|
@api path /api/*
|
||||||
|
respond @api `{"code":{err.status_code},"message":"Backend unavailable"}` {err.status_code} {
|
||||||
|
Content-Type application/json
|
||||||
|
}
|
||||||
|
|
||||||
rewrite * /index.html
|
rewrite * /index.html
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ services:
|
|||||||
container_name: pocketbase-learning
|
container_name: pocketbase-learning
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /pb
|
working_dir: /pb
|
||||||
|
env_file:
|
||||||
|
# Optional: absent .env.local must not block `docker compose up` (issue #18).
|
||||||
|
- path: .env.local
|
||||||
|
required: false
|
||||||
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"]
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
volumes:
|
volumes:
|
||||||
- pb_data:/pb/pb_data
|
- pb_data:/pb/pb_data
|
||||||
- ./pb_migrations:/pb/pb_migrations
|
- ./pb_migrations:/pb/pb_migrations
|
||||||
|
|||||||
117
docs/auth-spec.md
Normal file
117
docs/auth-spec.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Authentication — Azure (Entra ID) login
|
||||||
|
|
||||||
|
> Implemented for issue #16. Replaces the previous client-side PIN login.
|
||||||
|
> Hardened for issue #18 (migratie-ledger-sync + env-onafhankelijke provider-reconciliatie).
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
| 006 | Baseline-ledger-sync migratie voor out-of-band geprovisionede DB's | Labs/prod draaide historisch zonder `--migrationsDir`; replay van de historie crashte PB (issue #18). De vroegst-sorterende migratie markeert de historie als applied wanneer schema bestaat maar de ledger leeg is |
|
||||||
|
| 007 | Migratie = structuur, hook = configuratie | De OIDC-provider wordt bij elke start (en per cron-minuut) gereconcilieerd vanuit `ENTRA_*` env door `pb_hooks/entra_oidc.pb.js`; een one-shot migratie die van deploy-time env afhangt kan OAuth2 anders permanent uitschakelen |
|
||||||
|
| 008 | Hook-helpers via `require(`${__hooks}/utils.js`)` | De JSVM draait elke hook-callback als geïsoleerd programma; top-level functies zijn níet in scope op call-time |
|
||||||
|
|
||||||
|
## Bestanden
|
||||||
|
|
||||||
|
| Bestand | Rol |
|
||||||
|
|---|---|
|
||||||
|
| `pb_migrations/1000000000_baseline_ledger_sync.js` | Detecteert een out-of-band geprovisionede DB (schema bestaat, `_migrations`-ledger leeg) en markeert de historische migraties als applied, zodat de replay niet crasht (issue #18). |
|
||||||
|
| `pb_migrations/1781000000_team_members_to_auth.js` | Converteert relation-velden naar text (data blijft behouden), dropt de oude PIN-collection en maakt `team_members` als auth-collection. Idempotent; provider wordt alleen als fast-path meegenomen als de env al aanwezig is. |
|
||||||
|
| `pb_migrations/1781000001_tighten_api_rules.js` | Zet alle niet-systeem-collecties op `@request.auth.id != ""`. |
|
||||||
|
| `pb_hooks/entra_oidc.pb.js` | Reconcilieert de OIDC-provider vanuit `ENTRA_*` env bij elke start + cron-tick (compare-before-save). |
|
||||||
|
| `pb_hooks/utils.js` | Gedeelde helpers (`resolveRole`, `reconcileEntraOidc`) — per callback binnenhalen via `require()`. |
|
||||||
|
| `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 de `ENTRA_*` secrets (Gitea → Ansible `.env`) en
|
||||||
|
herstart/redeploy — `pb_hooks/entra_oidc.pb.js` reconcilieert de provider
|
||||||
|
automatisch bij elke start en elke cron-minuut. Geen handmatige re-apply meer.
|
||||||
|
- **Migratiebestanden nooit hernoemen nadat ze ergens applied zijn** — de
|
||||||
|
`_migrations`-ledger is filename-based; hernoemen triggert een re-apply.
|
||||||
|
- De deploy-playbooks bevatten een health-gate: als PocketBase na de deploy niet
|
||||||
|
healthy wordt, faalt de pipeline zichtbaar en print hij de containerlogs
|
||||||
|
(issue #18 bleef 2 weken onzichtbaar doordat de deploy "groen" was).
|
||||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
|||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist', 'pb_migrations']),
|
globalIgnores(['dist', 'pb_migrations', 'pb_hooks']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,jsx}'],
|
files: ['**/*.{js,jsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ networks:
|
|||||||
learning-platform:
|
learning-platform:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
learning-platform:
|
learning-platform:
|
||||||
image: git.labs.respellion.tech/respellion/learning-platform/learning-platform:latest
|
image: git.labs.respellion.tech/respellion/learning-platform/learning-platform:latest
|
||||||
@@ -18,11 +21,18 @@ services:
|
|||||||
image: ghcr.io/muchobien/pocketbase:latest
|
image: ghcr.io/muchobien/pocketbase:latest
|
||||||
container_name: pocketbase-learning
|
container_name: pocketbase-learning
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
- pb_data:/pb/pb_data
|
- pb_data:/pb/pb_data
|
||||||
|
- ./pb_migrations:/pb/pb_migrations
|
||||||
|
- ./pb_hooks:/pb/pb_hooks
|
||||||
networks:
|
networks:
|
||||||
- learning-platform
|
- learning-platform
|
||||||
|
|
||||||
volumes:
|
|
||||||
pb_data:
|
|
||||||
|
|||||||
@@ -36,11 +36,27 @@
|
|||||||
src: compose.yml
|
src: compose.yml
|
||||||
dest: "{{ dest_dir }}/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
|
- name: Create .env file for secrets
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: "{{ dest_dir }}/.env"
|
dest: "{{ dest_dir }}/.env"
|
||||||
content: |
|
content: |
|
||||||
ANTHROPIC_API_KEY={{ anthropic_api_key | default('') }}
|
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'
|
mode: '0600'
|
||||||
|
|
||||||
- name: Pull latest image
|
- name: Pull latest image
|
||||||
@@ -56,3 +72,31 @@
|
|||||||
state: present
|
state: present
|
||||||
files: compose.yml
|
files: compose.yml
|
||||||
recreate: always
|
recreate: always
|
||||||
|
|
||||||
|
# A failed migration aborts `pocketbase serve` and the container crash-loops
|
||||||
|
# while `docker compose up` still reports success (issue #18). Gate the
|
||||||
|
# deploy on PocketBase actually serving its health endpoint.
|
||||||
|
- name: Wait for PocketBase to become healthy
|
||||||
|
ansible.builtin.command: docker exec pocketbase-learning wget -q -O - http://127.0.0.1:8090/api/health
|
||||||
|
register: pb_health
|
||||||
|
until: pb_health.rc == 0
|
||||||
|
retries: 18
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Collect PocketBase logs for diagnosis
|
||||||
|
ansible.builtin.command: docker logs --tail 80 pocketbase-learning
|
||||||
|
register: pb_logs
|
||||||
|
when: pb_health is failed
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Abort deploy — PocketBase is not healthy
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: |
|
||||||
|
PocketBase did not become healthy after the deploy (health endpoint unreachable).
|
||||||
|
Recent container logs:
|
||||||
|
{{ pb_logs.stdout | default('') }}
|
||||||
|
{{ pb_logs.stderr | default('') }}
|
||||||
|
when: pb_health is failed
|
||||||
|
|||||||
@@ -22,9 +22,17 @@ services:
|
|||||||
container_name: pocketbase-learning
|
container_name: pocketbase-learning
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /pb
|
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:
|
volumes:
|
||||||
- pb_data:/pb/pb_data
|
- pb_data:/pb/pb_data
|
||||||
- ./pb_migrations:/pb/pb_migrations
|
- ./pb_migrations:/pb/pb_migrations
|
||||||
|
- ./pb_hooks:/pb/pb_hooks
|
||||||
networks:
|
networks:
|
||||||
- learning-platform
|
- learning-platform
|
||||||
|
|||||||
@@ -42,11 +42,21 @@
|
|||||||
dest: "{{ dest_dir }}/"
|
dest: "{{ dest_dir }}/"
|
||||||
mode: "0755"
|
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
|
- name: Create .env file for secrets
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: "{{ dest_dir }}/.env"
|
dest: "{{ dest_dir }}/.env"
|
||||||
content: |
|
content: |
|
||||||
ANTHROPIC_API_KEY={{ anthropic_api_key | default('') }}
|
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'
|
mode: '0600'
|
||||||
|
|
||||||
- name: Pull latest image
|
- name: Pull latest image
|
||||||
@@ -62,3 +72,31 @@
|
|||||||
state: present
|
state: present
|
||||||
files: compose.yml
|
files: compose.yml
|
||||||
recreate: always
|
recreate: always
|
||||||
|
|
||||||
|
# A failed migration aborts `pocketbase serve` and the container crash-loops
|
||||||
|
# while `docker compose up` still reports success (issue #18). Gate the
|
||||||
|
# deploy on PocketBase actually serving its health endpoint.
|
||||||
|
- name: Wait for PocketBase to become healthy
|
||||||
|
ansible.builtin.command: docker exec pocketbase-learning wget -q -O - http://127.0.0.1:8090/api/health
|
||||||
|
register: pb_health
|
||||||
|
until: pb_health.rc == 0
|
||||||
|
retries: 18
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Collect PocketBase logs for diagnosis
|
||||||
|
ansible.builtin.command: docker logs --tail 80 pocketbase-learning
|
||||||
|
register: pb_logs
|
||||||
|
when: pb_health is failed
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Abort deploy — PocketBase is not healthy
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: |
|
||||||
|
PocketBase did not become healthy after the deploy (health endpoint unreachable).
|
||||||
|
Recent container logs:
|
||||||
|
{{ pb_logs.stdout | default('') }}
|
||||||
|
{{ pb_logs.stderr | default('') }}
|
||||||
|
when: pb_health is failed
|
||||||
|
|||||||
34
pb_hooks/entra_oidc.pb.js
Normal file
34
pb_hooks/entra_oidc.pb.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #18 — Reconcile the Entra (Azure) OIDC provider from the environment.
|
||||||
|
//
|
||||||
|
// The team_members→auth migration (pb_migrations/1781000000) is structure-only
|
||||||
|
// and runs exactly once. Provider CONFIGURATION lives here instead, so that:
|
||||||
|
//
|
||||||
|
// * an environment whose migration applied while the ENTRA_* secrets were
|
||||||
|
// absent gets its provider enabled on the next startup (no re-apply),
|
||||||
|
// * rotating ENTRA_CLIENT_SECRET / changing the tenant only requires new env
|
||||||
|
// values and a container restart,
|
||||||
|
// * a fresh database gets its provider on the first cron tick right after
|
||||||
|
// the migrations created the collection (onBootstrap fires BEFORE the
|
||||||
|
// migrations run — there is no post-migration lifecycle hook in the JSVM,
|
||||||
|
// hence the cron fallback).
|
||||||
|
//
|
||||||
|
// The reconciler compares before saving, so both hooks are cheap no-ops when
|
||||||
|
// everything is already in sync.
|
||||||
|
|
||||||
|
onBootstrap((e) => {
|
||||||
|
e.next();
|
||||||
|
const { reconcileEntraOidc } = require(`${__hooks}/utils.js`);
|
||||||
|
// reconcileEntraOidc logs a warn-once itself when the ENTRA_* env is absent.
|
||||||
|
console.log("entra_oidc reconcile (bootstrap): " + reconcileEntraOidc(e.app));
|
||||||
|
});
|
||||||
|
|
||||||
|
cronAdd("entraOidcReconcile", "* * * * *", () => {
|
||||||
|
const { reconcileEntraOidc } = require(`${__hooks}/utils.js`);
|
||||||
|
const result = reconcileEntraOidc($app);
|
||||||
|
// Only log state changes — this tick runs every minute.
|
||||||
|
if (result === "updated") {
|
||||||
|
console.log("entra_oidc reconcile (cron): OIDC provider (re)configured from ENTRA_* env");
|
||||||
|
}
|
||||||
|
});
|
||||||
55
pb_hooks/team_members.pb.js
Normal file
55
pb_hooks/team_members.pb.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/// <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
|
||||||
|
//
|
||||||
|
// 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. Keep the logic in sync with
|
||||||
|
// src/lib/azureAuth.js (resolveRole / parseAdminEmails), which carries the
|
||||||
|
// canonical, unit-tested version for the frontend/tests.
|
||||||
|
|
||||||
|
// 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");
|
||||||
104
pb_hooks/utils.js
Normal file
104
pb_hooks/utils.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/// <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. (Deliberate exception: the warn-once latch below, which
|
||||||
|
// exists precisely BECAUSE the registry is shared — it dedupes the env-missing
|
||||||
|
// warning across the bootstrap hook and the per-minute cron tick.)
|
||||||
|
//
|
||||||
|
// Keep resolveRole in sync with src/lib/azureAuth.js's resolveRole (the
|
||||||
|
// canonical, unit-tested version used by the frontend).
|
||||||
|
|
||||||
|
let warnedEnvMissing = false;
|
||||||
|
|
||||||
|
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";
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile the Entra (Azure) OIDC provider on the team_members auth
|
||||||
|
* collection from the ENTRA_* environment variables (issue #18).
|
||||||
|
*
|
||||||
|
* Idempotent: compares the desired provider config against the stored one
|
||||||
|
* and only saves when something actually changed, so it is safe to call on
|
||||||
|
* every bootstrap and cron tick. Keeping this OUT of the one-shot migration
|
||||||
|
* means late or rotated secrets are picked up on the next start/tick without
|
||||||
|
* any manual re-apply.
|
||||||
|
*
|
||||||
|
* @param {core.App} app
|
||||||
|
* @returns {"collection-missing"|"not-auth-yet"|"env-missing"|"in-sync"|"updated"}
|
||||||
|
*/
|
||||||
|
reconcileEntraOidc: function (app) {
|
||||||
|
let col;
|
||||||
|
try {
|
||||||
|
col = app.findCollectionByNameOrId("team_members");
|
||||||
|
} catch (_) {
|
||||||
|
return "collection-missing"; // migration has not created it yet
|
||||||
|
}
|
||||||
|
if (col.type !== "auth") {
|
||||||
|
return "not-auth-yet"; // pre-conversion PIN-era collection
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = $os.getenv("ENTRA_TENANT_ID") || "common";
|
||||||
|
const clientId = $os.getenv("ENTRA_CLIENT_ID") || "";
|
||||||
|
const clientSecret = $os.getenv("ENTRA_CLIENT_SECRET") || "";
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
if (!warnedEnvMissing) {
|
||||||
|
warnedEnvMissing = true;
|
||||||
|
console.log(
|
||||||
|
"WARN entra_oidc: ENTRA_CLIENT_ID / ENTRA_CLIENT_SECRET not set — Microsoft login stays " +
|
||||||
|
"disabled until the env vars are provided (they are reconciled automatically on startup)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return "env-missing";
|
||||||
|
}
|
||||||
|
warnedEnvMissing = false; // env restored — re-arm the warning for future rotations
|
||||||
|
|
||||||
|
const desired = {
|
||||||
|
name: "oidc",
|
||||||
|
clientId: clientId,
|
||||||
|
clientSecret: clientSecret,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
const providers = (col.oauth2 && col.oauth2.providers) || [];
|
||||||
|
const current = providers.length === 1 ? providers[0] : null;
|
||||||
|
const inSync = !!current &&
|
||||||
|
col.oauth2.enabled === true &&
|
||||||
|
current.name === desired.name &&
|
||||||
|
current.clientId === desired.clientId &&
|
||||||
|
current.clientSecret === desired.clientSecret &&
|
||||||
|
current.authURL === desired.authURL &&
|
||||||
|
current.tokenURL === desired.tokenURL &&
|
||||||
|
current.userInfoURL === desired.userInfoURL &&
|
||||||
|
current.displayName === desired.displayName;
|
||||||
|
if (inSync) {
|
||||||
|
return "in-sync";
|
||||||
|
}
|
||||||
|
|
||||||
|
col.oauth2.enabled = true;
|
||||||
|
col.oauth2.providers = [desired];
|
||||||
|
app.save(col);
|
||||||
|
return "updated";
|
||||||
|
},
|
||||||
|
};
|
||||||
138
pb_migrations/1000000000_baseline_ledger_sync.js
Normal file
138
pb_migrations/1000000000_baseline_ledger_sync.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #18 — Baseline ledger sync for out-of-band provisioned databases.
|
||||||
|
//
|
||||||
|
// The Labs/production PocketBase originally ran WITHOUT --migrationsDir: its
|
||||||
|
// schema was created by scripts/setup-pb-collections.mjs / the admin UI, so its
|
||||||
|
// _migrations ledger is empty. When the migrations dir was mounted for the
|
||||||
|
// first time (Azure SSO, PR #17), PocketBase tried to replay the entire
|
||||||
|
// migration history against that existing schema and crash-looped on the very
|
||||||
|
// first file ("Collection name must be unique").
|
||||||
|
//
|
||||||
|
// This migration sorts before every other file. When it detects that the
|
||||||
|
// legacy schema already exists but the ledger has never tracked it, it marks
|
||||||
|
// the historical migrations below as applied so the replay is skipped and the
|
||||||
|
// chain continues with the genuinely new migrations (team_members_to_auth,
|
||||||
|
// tighten_api_rules). On fresh databases and on databases that already have a
|
||||||
|
// populated ledger it is a no-op.
|
||||||
|
//
|
||||||
|
// NOTE for future agents: never rename a migration file after it has been
|
||||||
|
// applied anywhere — the ledger is filename-based. New migrations must sort
|
||||||
|
// after 1781100001 and be appended to environments through a normal deploy.
|
||||||
|
const HISTORICAL_MIGRATIONS = [
|
||||||
|
"1778948471_created_content.js",
|
||||||
|
"1778948471_created_quiz_banks.js",
|
||||||
|
"1778948471_created_relations.js",
|
||||||
|
"1778948471_created_sources.js",
|
||||||
|
"1778948471_created_team_members.js",
|
||||||
|
"1778948471_created_topics.js",
|
||||||
|
"1778948472_created_leaderboard.js",
|
||||||
|
"1778948472_created_learn_progress.js",
|
||||||
|
"1778948472_created_quiz_cache.js",
|
||||||
|
"1778948472_created_quiz_results.js",
|
||||||
|
"1778948472_created_settings.js",
|
||||||
|
"1778954289_created_test_col2.js",
|
||||||
|
"1778954310_deleted_relations.js",
|
||||||
|
"1778954310_deleted_topics.js",
|
||||||
|
"1778954310_deleted_users.js",
|
||||||
|
"1778954311_deleted_content.js",
|
||||||
|
"1778954311_deleted_leaderboard.js",
|
||||||
|
"1778954311_deleted_learn_progress.js",
|
||||||
|
"1778954311_deleted_quiz_banks.js",
|
||||||
|
"1778954311_deleted_quiz_cache.js",
|
||||||
|
"1778954311_deleted_quiz_results.js",
|
||||||
|
"1778954311_deleted_settings.js",
|
||||||
|
"1778954311_deleted_sources.js",
|
||||||
|
"1778954311_deleted_team_members.js",
|
||||||
|
"1778954311_deleted_test_col2.js",
|
||||||
|
"1778954317_created_content.js",
|
||||||
|
"1778954317_created_leaderboard.js",
|
||||||
|
"1778954317_created_learn_progress.js",
|
||||||
|
"1778954317_created_quiz_banks.js",
|
||||||
|
"1778954317_created_quiz_cache.js",
|
||||||
|
"1778954317_created_quiz_results.js",
|
||||||
|
"1778954317_created_relations.js",
|
||||||
|
"1778954317_created_settings.js",
|
||||||
|
"1778954317_created_sources.js",
|
||||||
|
"1778954317_created_team_members.js",
|
||||||
|
"1778954317_created_topics.js",
|
||||||
|
"1779005271_created_test_col3.js",
|
||||||
|
"1779005289_created_test_col4.js",
|
||||||
|
"1779005309_deleted_content.js",
|
||||||
|
"1779005309_deleted_learn_progress.js",
|
||||||
|
"1779005309_deleted_quiz_banks.js",
|
||||||
|
"1779005309_deleted_quiz_cache.js",
|
||||||
|
"1779005309_deleted_quiz_results.js",
|
||||||
|
"1779005309_deleted_relations.js",
|
||||||
|
"1779005309_deleted_sources.js",
|
||||||
|
"1779005309_deleted_team_members.js",
|
||||||
|
"1779005309_deleted_topics.js",
|
||||||
|
"1779005310_deleted_leaderboard.js",
|
||||||
|
"1779005310_deleted_settings.js",
|
||||||
|
"1779005310_deleted_test_col3.js",
|
||||||
|
"1779005310_deleted_test_col4.js",
|
||||||
|
"1779005316_created_content.js",
|
||||||
|
"1779005316_created_quiz_banks.js",
|
||||||
|
"1779005316_created_relations.js",
|
||||||
|
"1779005316_created_sources.js",
|
||||||
|
"1779005316_created_topics.js",
|
||||||
|
"1779005317_created_leaderboard.js",
|
||||||
|
"1779005317_created_learn_progress.js",
|
||||||
|
"1779005317_created_quiz_cache.js",
|
||||||
|
"1779005317_created_quiz_results.js",
|
||||||
|
"1779005317_created_settings.js",
|
||||||
|
"1779005317_created_team_members.js",
|
||||||
|
"1779127586_created_curriculum.js",
|
||||||
|
"1779127759_collections_snapshot.js",
|
||||||
|
"1779200000_updated_sources.js",
|
||||||
|
"1780000001_updated_topics.js",
|
||||||
|
"1780500000_updated_topics_relevance_locked.js",
|
||||||
|
"1780500001_normalize_relation_types.js",
|
||||||
|
"1780500002_created_llm_calls.js",
|
||||||
|
"1780600000_curriculum_v2.js",
|
||||||
|
"1780700000_sources_progress.js",
|
||||||
|
"1780800000_created_micro_learnings.js",
|
||||||
|
"1780800001_deleted_legacy_collections.js",
|
||||||
|
"1780800002_update_micro_learnings_rules.js",
|
||||||
|
"1780900000_team_members_enrollment.js",
|
||||||
|
"1780900001_created_test_results.js",
|
||||||
|
"1781000000_created_theme_sessions.js",
|
||||||
|
"1781100000_created_question_bank.js",
|
||||||
|
"1781100001_created_on_demand_attempts.js",
|
||||||
|
];
|
||||||
|
|
||||||
|
migrate((app) => {
|
||||||
|
// Fresh database? (no legacy schema) -> let the normal chain run.
|
||||||
|
try {
|
||||||
|
app.findCollectionByNameOrId("content");
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ledger already tracks the history? -> normally migrated DB, nothing to do.
|
||||||
|
const probe = new DynamicModel({ count: 0 });
|
||||||
|
app.db()
|
||||||
|
.newQuery("SELECT COUNT(*) AS count FROM _migrations WHERE file = {:file}")
|
||||||
|
.bind({ file: "1778948471_created_content.js" })
|
||||||
|
.one(probe);
|
||||||
|
if (probe.count > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Out-of-band provisioned database: schema exists, ledger is empty.
|
||||||
|
// Mark the historical migrations as applied so they are not replayed.
|
||||||
|
const applied = Date.now();
|
||||||
|
for (const file of HISTORICAL_MIGRATIONS) {
|
||||||
|
app.db()
|
||||||
|
.newQuery("INSERT OR IGNORE INTO _migrations (file, applied) VALUES ({:file}, {:applied})")
|
||||||
|
.bind({ file: file, applied: applied })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
"baseline_ledger_sync: detected out-of-band provisioned database — marked " +
|
||||||
|
HISTORICAL_MIGRATIONS.length + " historical migrations as applied"
|
||||||
|
);
|
||||||
|
}, (_app) => {
|
||||||
|
// Down: intentionally a no-op. The ledger rows describe environment-specific
|
||||||
|
// bookkeeping; removing them would re-trigger the replay this fix prevents.
|
||||||
|
});
|
||||||
352
pb_migrations/1781000000_team_members_to_auth.js
Normal file
352
pb_migrations/1781000000_team_members_to_auth.js
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Issue #16 / #18 — 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.
|
||||||
|
//
|
||||||
|
// This migration is STRUCTURE ONLY and does not depend on the environment:
|
||||||
|
// the OIDC provider credentials (ENTRA_TENANT_ID / ENTRA_CLIENT_ID /
|
||||||
|
// ENTRA_CLIENT_SECRET) are reconciled into the collection on every startup by
|
||||||
|
// pb_hooks/entra_oidc.pb.js. That keeps the one-shot migration deterministic:
|
||||||
|
// applying it while the secrets are absent can no longer permanently disable
|
||||||
|
// OAuth2 (issue #18). As a fast path the provider is also attached here when
|
||||||
|
// the env vars are already present at apply time.
|
||||||
|
migrate((app) => {
|
||||||
|
// Idempotency guard: if team_members is already an auth collection (e.g. the
|
||||||
|
// conversion happened through an earlier partial rollout), leave it alone.
|
||||||
|
let existing = null;
|
||||||
|
try {
|
||||||
|
existing = app.findCollectionByNameOrId("team_members");
|
||||||
|
} catch (_) {
|
||||||
|
existing = null; // collection absent — nothing to convert, only to create
|
||||||
|
}
|
||||||
|
if (existing && existing.type === "auth") {
|
||||||
|
console.log("team_members_to_auth: team_members is already an auth collection — skipping conversion.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Detach relation fields that point at team_members. PocketBase refuses
|
||||||
|
// to delete a collection that other collections relate to, so before we
|
||||||
|
// can drop the old PIN-based team_members we convert those relations into
|
||||||
|
// plain text fields (the id is stored as text — consistent with how
|
||||||
|
// user_id is stored in test_results / on_demand_attempts). The column
|
||||||
|
// values are preserved by the conversion.
|
||||||
|
const deps = [
|
||||||
|
{ name: "micro_learning_completions", relId: "rel_team_member_id", textId: "txt_mlc_team_member" },
|
||||||
|
{ name: "theme_session_completions", relId: "rel_tsc_team_member", textId: "txt_tsc_team_member" },
|
||||||
|
];
|
||||||
|
for (const dep of deps) {
|
||||||
|
let c = null;
|
||||||
|
try {
|
||||||
|
c = app.findCollectionByNameOrId(dep.name);
|
||||||
|
} catch (_) {
|
||||||
|
console.log("team_members_to_auth: " + dep.name + " does not exist yet — nothing to detach.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!c.fields.getById(dep.relId)) {
|
||||||
|
console.log("team_members_to_auth: " + dep.name + "." + dep.relId + " already detached — skipping.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// PocketBase diffs fields by ID, so swapping the relation field for a text
|
||||||
|
// field drops and recreates the underlying column. Back the values up and
|
||||||
|
// restore them after the schema save — the ids must survive (issue #18).
|
||||||
|
const backup = "_issue18_tm_" + dep.name;
|
||||||
|
app.db().newQuery("DROP TABLE IF EXISTS `" + backup + "`").execute();
|
||||||
|
app.db().newQuery(
|
||||||
|
"CREATE TABLE `" + backup + "` AS SELECT `id`, `team_member_id` AS `v` FROM `" + dep.name + "`"
|
||||||
|
).execute();
|
||||||
|
|
||||||
|
// Drop any index that references the team_member_id column (it is rebuilt
|
||||||
|
// on the text column below).
|
||||||
|
c.indexes = (c.indexes || []).filter((idx) => idx.indexOf("team_member_id") === -1);
|
||||||
|
// Replace the relation field with a text field of the same name.
|
||||||
|
c.fields.removeById(dep.relId);
|
||||||
|
c.fields.add(new Field({
|
||||||
|
type: "text",
|
||||||
|
id: dep.textId,
|
||||||
|
name: "team_member_id",
|
||||||
|
required: false,
|
||||||
|
min: 0,
|
||||||
|
max: 0,
|
||||||
|
}));
|
||||||
|
app.save(c);
|
||||||
|
|
||||||
|
app.db().newQuery(
|
||||||
|
"UPDATE `" + dep.name + "` SET `team_member_id` = COALESCE(" +
|
||||||
|
"(SELECT `v` FROM `" + backup + "` WHERE `" + backup + "`.`id` = `" + dep.name + "`.`id`), '')"
|
||||||
|
).execute();
|
||||||
|
app.db().newQuery("DROP TABLE `" + backup + "`").execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recreate the unique (team_member_id, session_week) guard on the text column.
|
||||||
|
try {
|
||||||
|
const tsc = app.findCollectionByNameOrId("theme_session_completions");
|
||||||
|
const idx = "CREATE UNIQUE INDEX `idx_theme_session_completions_user_week` ON `theme_session_completions` (`team_member_id`, `session_week`)";
|
||||||
|
if ((tsc.indexes || []).indexOf(idx) === -1) {
|
||||||
|
tsc.indexes.push(idx);
|
||||||
|
app.save(tsc);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
console.log("team_members_to_auth: theme_session_completions does not exist yet — no index to rebuild.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Drop the old PIN-based team_members (now unreferenced). NOT wrapped in
|
||||||
|
// a try/catch: if this fails there is an unknown relation left and the
|
||||||
|
// migration must fail loudly instead of masking the error (issue #18).
|
||||||
|
if (existing) {
|
||||||
|
app.delete(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: attach the OIDC provider right away when the credentials are
|
||||||
|
// already present at apply time. When they are absent the collection is
|
||||||
|
// created without a provider and pb_hooks/entra_oidc.pb.js configures it on
|
||||||
|
// the next startup / cron tick once the ENTRA_* env vars are supplied.
|
||||||
|
const tenant = $os.getenv("ENTRA_TENANT_ID") || "common";
|
||||||
|
const clientId = $os.getenv("ENTRA_CLIENT_ID") || "";
|
||||||
|
const clientSecret = $os.getenv("ENTRA_CLIENT_SECRET") || "";
|
||||||
|
const oidcProviders = (clientId && clientSecret) ? [
|
||||||
|
{
|
||||||
|
name: "oidc",
|
||||||
|
clientId: clientId,
|
||||||
|
clientSecret: clientSecret,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
] : [];
|
||||||
|
if (oidcProviders.length === 0) {
|
||||||
|
console.log(
|
||||||
|
"team_members_to_auth: ENTRA_CLIENT_ID / ENTRA_CLIENT_SECRET not set — creating the auth " +
|
||||||
|
"collection without an OIDC provider. pb_hooks/entra_oidc.pb.js will configure the provider " +
|
||||||
|
"automatically once the env vars are present (no re-apply needed)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 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: oidcProviders.length > 0,
|
||||||
|
// Map the OIDC userinfo claims onto our fields.
|
||||||
|
mappedFields: {
|
||||||
|
id: "",
|
||||||
|
name: "name",
|
||||||
|
username: "",
|
||||||
|
avatarURL: "",
|
||||||
|
},
|
||||||
|
providers: oidcProviders,
|
||||||
|
},
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
57
pb_migrations/1781000001_tighten_api_rules.js
Normal file
57
pb_migrations/1781000001_tighten_api_rules.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -102,17 +102,21 @@ const COLLECTIONS = [
|
|||||||
...AUTODATE_FIELDS,
|
...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',
|
name: 'team_members',
|
||||||
type: 'base',
|
type: 'auth',
|
||||||
...OPEN_RULES,
|
...OPEN_RULES,
|
||||||
|
passwordAuth: { enabled: false, identityFields: ['email'] },
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'name', type: 'text', required: true },
|
{ name: 'name', type: 'text', required: false },
|
||||||
{ name: 'pin', type: 'text', required: false },
|
|
||||||
{ name: 'role', type: 'text', required: false },
|
{ name: 'role', type: 'text', required: false },
|
||||||
{ name: 'curriculum_started_at', type: 'date', required: false },
|
{ name: 'curriculum_started_at', type: 'date', required: false },
|
||||||
{ name: 'enrollment_status', type: 'text', required: false },
|
{ name: 'enrollment_status', type: 'text', required: false },
|
||||||
...AUTODATE_FIELDS,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import * as d3 from 'd3';
|
|||||||
import * as db from '../../lib/db';
|
import * as db from '../../lib/db';
|
||||||
import { callLLM } from '../../lib/llm';
|
import { callLLM } from '../../lib/llm';
|
||||||
import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools';
|
import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools';
|
||||||
|
import { Network, Table2 } from 'lucide-react';
|
||||||
import { useGraphData } from '../../hooks/useGraphData';
|
import { useGraphData } from '../../hooks/useGraphData';
|
||||||
|
import { filterAiActions } from '../../lib/graphGuard';
|
||||||
import GraphControls from './graph/GraphControls';
|
import GraphControls from './graph/GraphControls';
|
||||||
import NodeDetailPanel from './graph/NodeDetailPanel';
|
import NodeDetailPanel from './graph/NodeDetailPanel';
|
||||||
|
import GraphTable from './graph/GraphTable';
|
||||||
|
|
||||||
// ── Edge visual style per relation type ────────────────────────────────────────
|
// ── Edge visual style per relation type ────────────────────────────────────────
|
||||||
// stroke — line colour
|
// stroke — line colour
|
||||||
@@ -23,11 +26,21 @@ const SYSTEM_PROMPTS = {
|
|||||||
full: `You are a strict Data Quality AI maintaining a Knowledge Graph for Respellion.
|
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.
|
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:
|
Rules:
|
||||||
1. Identify topics that mean exactly the same thing. Choose one to keep, one to delete (merges).
|
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).
|
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).
|
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).
|
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.`,
|
Do not return the entire graph — only the actions to take.`,
|
||||||
|
|
||||||
@@ -71,11 +84,13 @@ const KnowledgeGraph = () => {
|
|||||||
const [showExcludeNodes, setShowExcludeNodes] = useState(false);
|
const [showExcludeNodes, setShowExcludeNodes] = useState(false);
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
const [analyzeError, setAnalyzeError] = useState(null);
|
const [analyzeError, setAnalyzeError] = useState(null);
|
||||||
|
const [analyzeNotice, setAnalyzeNotice] = useState(null); // info-level message from last analyze
|
||||||
const [isRestoring, setIsRestoring] = useState(false);
|
const [isRestoring, setIsRestoring] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState('graph'); // 'graph' | 'table'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
topics, relations, snapshotMeta,
|
topics, relations, snapshotMeta,
|
||||||
reload, updateTopic, deleteTopic, addRelation, removeRelation, bulkSave, restoreSnapshot,
|
reload, updateTopic, bulkUpdateTopics, deleteTopic, addRelation, removeRelation, bulkSave, restoreSnapshot,
|
||||||
} = useGraphData();
|
} = useGraphData();
|
||||||
|
|
||||||
// ── Canvas sizing ────────────────────────────────────────────────────────────
|
// ── Canvas sizing ────────────────────────────────────────────────────────────
|
||||||
@@ -94,6 +109,7 @@ const KnowledgeGraph = () => {
|
|||||||
// ── D3 force graph ───────────────────────────────────────────────────────────
|
// ── D3 force graph ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (viewMode !== 'graph') return;
|
||||||
if (!svgRef.current || topics.length === 0) return;
|
if (!svgRef.current || topics.length === 0) return;
|
||||||
|
|
||||||
const { width, height } = dimensions;
|
const { width, height } = dimensions;
|
||||||
@@ -236,7 +252,7 @@ const KnowledgeGraph = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => simulation.stop();
|
return () => simulation.stop();
|
||||||
}, [dimensions, topics, relations, showExcludeNodes]);
|
}, [dimensions, topics, relations, showExcludeNodes, viewMode]);
|
||||||
|
|
||||||
// ── Pan/zoom canvas to a node ────────────────────────────────────────────────
|
// ── Pan/zoom canvas to a node ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -301,6 +317,7 @@ const KnowledgeGraph = () => {
|
|||||||
|
|
||||||
setIsAnalyzing(true);
|
setIsAnalyzing(true);
|
||||||
setAnalyzeError(null);
|
setAnalyzeError(null);
|
||||||
|
setAnalyzeNotice(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [currentTopics, currentRelations] = await Promise.all([
|
const [currentTopics, currentRelations] = await Promise.all([
|
||||||
@@ -310,13 +327,16 @@ const KnowledgeGraph = () => {
|
|||||||
|
|
||||||
const tier = scope === 'full' ? 'reasoning' : 'standard';
|
const tier = scope === 'full' ? 'reasoning' : 'standard';
|
||||||
|
|
||||||
// For relevance scope, include relevance_locked so the model can skip those.
|
// 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';
|
||||||
const compactTopics = currentTopics.map(t => ({
|
const compactTopics = currentTopics.map(t => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
label: t.label,
|
label: t.label,
|
||||||
type: t.type,
|
type: t.type,
|
||||||
learning_relevance: t.learning_relevance,
|
learning_relevance: t.learning_relevance,
|
||||||
...(scope === 'relevance' && t.relevance_locked ? { relevance_locked: true } : {}),
|
...(sendLocked && t.relevance_locked ? { relevance_locked: true } : {}),
|
||||||
}));
|
}));
|
||||||
const compactRelations = currentRelations.map(({ source, target, type }) => ({
|
const compactRelations = currentRelations.map(({ source, target, type }) => ({
|
||||||
source, target, type,
|
source, target, type,
|
||||||
@@ -332,8 +352,26 @@ const KnowledgeGraph = () => {
|
|||||||
maxTokens: scope === 'full' ? 4096 : 2048,
|
maxTokens: scope === 'full' ? 4096 : 2048,
|
||||||
});
|
});
|
||||||
|
|
||||||
const actions = llmResult.toolUses[0]?.input;
|
const rawActions = llmResult.toolUses[0]?.input;
|
||||||
if (!actions) throw new Error('Graph analysis did not emit a tool result.');
|
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'}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let updatedTopics = [...currentTopics];
|
let updatedTopics = [...currentTopics];
|
||||||
let updatedRelations = [...currentRelations];
|
let updatedRelations = [...currentRelations];
|
||||||
@@ -396,17 +434,58 @@ const KnowledgeGraph = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full flex flex-col md:flex-row">
|
<div className="relative w-full h-full flex flex-col md:flex-row">
|
||||||
{/* D3 canvas */}
|
{/* Main view: graph canvas or table */}
|
||||||
<div
|
<div className="flex-1 flex flex-col border-r border-bg-warm min-w-0">
|
||||||
ref={wrapperRef}
|
{/* View-mode toggle */}
|
||||||
className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing border-r border-bg-warm"
|
<div className="flex items-center gap-1 px-3 py-2 border-b border-bg-warm bg-paper">
|
||||||
>
|
<button
|
||||||
{topics.length === 0 ? (
|
onClick={() => setViewMode('graph')}
|
||||||
<div className="h-full flex items-center justify-center text-fg-muted p-8 text-center">
|
className={`flex items-center gap-1.5 px-3 py-1 text-xs rounded-[var(--r-sm)] transition-colors ${
|
||||||
No knowledge graph data yet. Upload source material in the Sources tab first.
|
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' ? (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className="flex-1 h-[400px] md:h-full cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<svg ref={svgRef} className="w-full h-full" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<svg ref={svgRef} className="w-full h-full" />
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -415,9 +494,11 @@ const KnowledgeGraph = () => {
|
|||||||
<GraphControls
|
<GraphControls
|
||||||
showExcludeNodes={showExcludeNodes}
|
showExcludeNodes={showExcludeNodes}
|
||||||
onShowExcludeChange={setShowExcludeNodes}
|
onShowExcludeChange={setShowExcludeNodes}
|
||||||
|
excludedCount={topics.filter(t => t.learning_relevance === 'exclude').length}
|
||||||
onAnalyze={analyzeGraph}
|
onAnalyze={analyzeGraph}
|
||||||
isAnalyzing={isAnalyzing}
|
isAnalyzing={isAnalyzing}
|
||||||
analyzeError={analyzeError}
|
analyzeError={analyzeError}
|
||||||
|
analyzeNotice={analyzeNotice}
|
||||||
disabled={topics.length === 0}
|
disabled={topics.length === 0}
|
||||||
onApplied={reload}
|
onApplied={reload}
|
||||||
snapshotMeta={snapshotMeta}
|
snapshotMeta={snapshotMeta}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 Card from '../ui/Card';
|
||||||
import Button from '../ui/Button';
|
|
||||||
import Input from '../ui/Input';
|
|
||||||
import Tag from '../ui/Tag';
|
import Tag from '../ui/Tag';
|
||||||
import * as db from '../../lib/db';
|
import * as db from '../../lib/db';
|
||||||
import { pb } from '../../lib/pb';
|
import { pb } from '../../lib/pb';
|
||||||
import { useApp } from '../../store/AppContext';
|
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 TeamManager = () => {
|
||||||
const { state } = useApp();
|
const { state } = useApp();
|
||||||
const [users, setUsers] = useState([]);
|
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 [message, setMessage] = useState('');
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
@@ -23,29 +24,16 @@ const TeamManager = () => {
|
|||||||
|
|
||||||
useEffect(() => { loadUsers(); }, []);
|
useEffect(() => { loadUsers(); }, []);
|
||||||
|
|
||||||
const handleSave = async (e) => {
|
const flash = (msg) => {
|
||||||
e.preventDefault();
|
setMessage(msg);
|
||||||
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);
|
setTimeout(() => setMessage(''), 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (user) => {
|
const handleRoleChange = async (user, role) => {
|
||||||
setFormData({ name: user.name, role: user.role, pin: user.pin });
|
if (role === user.role) return;
|
||||||
setIsEditing(true);
|
await db.updateTeamMember(user.id, { role });
|
||||||
setEditingId(user.id);
|
await loadUsers();
|
||||||
|
flash(`${user.name} is now ${role}.`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id) => {
|
||||||
@@ -56,24 +44,17 @@ const TeamManager = () => {
|
|||||||
if (confirm("Are you sure you want to delete this user?")) {
|
if (confirm("Are you sure you want to delete this user?")) {
|
||||||
await db.deleteTeamMember(id);
|
await db.deleteTeamMember(id);
|
||||||
|
|
||||||
// Also remove from leaderboard
|
// Also remove from leaderboard.
|
||||||
try {
|
try {
|
||||||
const entry = await pb.collection('leaderboard').getFirstListItem(`user_id="${id}"`);
|
const entry = await pb.collection('leaderboard').getFirstListItem(`user_id="${id}"`);
|
||||||
await pb.collection('leaderboard').delete(entry.id);
|
await pb.collection('leaderboard').delete(entry.id);
|
||||||
} catch { /* no leaderboard entry, nothing to do */ }
|
} catch { /* no leaderboard entry, nothing to do */ }
|
||||||
|
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
setMessage('User deleted.');
|
flash('User deleted.');
|
||||||
setTimeout(() => setMessage(''), 3000);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEdit = () => {
|
|
||||||
setIsEditing(false);
|
|
||||||
setEditingId(null);
|
|
||||||
setFormData({ name: '', role: 'user', pin: '' });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{message && (
|
{message && (
|
||||||
@@ -82,58 +63,22 @@ const TeamManager = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="border border-bg-warm">
|
<Card className="border border-bg-warm bg-bg-warm/30">
|
||||||
<h2 className="text-xl font-bold flex items-center gap-2 mb-4">
|
<p className="text-sm text-fg-muted flex items-start gap-2">
|
||||||
{isEditing ? <Edit2 size={20} className="text-teal" /> : <UserPlus size={20} className="text-teal" />}
|
<Info size={16} className="mt-0.5 shrink-0 text-teal" />
|
||||||
{isEditing ? 'Edit Team Member' : 'Add New Team Member'}
|
Team members are created automatically when they first sign in with their
|
||||||
</h2>
|
Microsoft account. Admins are granted via the <code>ENTRA_ADMIN_EMAILS</code>{' '}
|
||||||
|
allow-list and re-synced on each login.
|
||||||
<form onSubmit={handleSave} className="flex flex-col sm:flex-row gap-4 items-end">
|
</p>
|
||||||
<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>
|
||||||
|
|
||||||
<Card className="p-0 border border-bg-warm overflow-hidden">
|
<Card className="p-0 border border-bg-warm overflow-hidden">
|
||||||
<div className="divide-y divide-bg-warm">
|
<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 => (
|
{users.map(user => (
|
||||||
<div key={user.id} className="p-4 flex items-center justify-between hover:bg-bg-warm/30 transition-colors">
|
<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">
|
<div className="flex items-center gap-4">
|
||||||
@@ -145,19 +90,26 @@ const TeamManager = () => {
|
|||||||
{user.name}
|
{user.name}
|
||||||
{user.id === state.currentUser?.id && <Tag variant="accent" className="text-[10px] py-0">You</Tag>}
|
{user.id === state.currentUser?.id && <Tag variant="accent" className="text-[10px] py-0">You</Tag>}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-fg-muted">PIN: {user.pin}</p>
|
<p className="text-sm text-fg-muted">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Tag variant={user.role === 'admin' ? 'dark' : 'default'} className="hidden sm:inline-flex">{user.role}</Tag>
|
<select
|
||||||
<button onClick={() => handleEdit(user)} className="p-2 text-fg-muted hover:text-teal transition-colors">
|
value={user.role || 'user'}
|
||||||
<Edit2 size={16} />
|
onChange={e => handleRoleChange(user, e.target.value)}
|
||||||
</button>
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(user.id)}
|
onClick={() => handleDelete(user.id)}
|
||||||
disabled={user.id === state.currentUser?.id}
|
disabled={user.id === state.currentUser?.id}
|
||||||
className="p-2 text-fg-muted hover:text-red-500 disabled:opacity-30 transition-colors"
|
className="p-2 text-fg-muted hover:text-red-500 disabled:opacity-30 transition-colors"
|
||||||
|
aria-label={`Delete ${user.name}`}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { RotateCcw, RefreshCw, AlertCircle, ChevronDown } from 'lucide-react';
|
import { RotateCcw, RefreshCw, AlertCircle, ChevronDown, ShieldCheck } from 'lucide-react';
|
||||||
import Button from '../../ui/Button';
|
import Button from '../../ui/Button';
|
||||||
import SuggestionsQueue from '../SuggestionsQueue';
|
import SuggestionsQueue from '../SuggestionsQueue';
|
||||||
|
|
||||||
@@ -24,9 +24,11 @@ const SCOPE_OPTIONS = [
|
|||||||
export default function GraphControls({
|
export default function GraphControls({
|
||||||
showExcludeNodes,
|
showExcludeNodes,
|
||||||
onShowExcludeChange,
|
onShowExcludeChange,
|
||||||
|
excludedCount = 0,
|
||||||
onAnalyze,
|
onAnalyze,
|
||||||
isAnalyzing,
|
isAnalyzing,
|
||||||
analyzeError,
|
analyzeError,
|
||||||
|
analyzeNotice,
|
||||||
disabled,
|
disabled,
|
||||||
onApplied,
|
onApplied,
|
||||||
snapshotMeta,
|
snapshotMeta,
|
||||||
@@ -50,7 +52,14 @@ export default function GraphControls({
|
|||||||
onChange={e => onShowExcludeChange(e.target.checked)}
|
onChange={e => onShowExcludeChange(e.target.checked)}
|
||||||
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
|
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
|
||||||
/>
|
/>
|
||||||
Show Excluded Nodes (Reference Material)
|
<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>
|
</label>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -106,6 +115,16 @@ export default function GraphControls({
|
|||||||
{analyzeError}
|
{analyzeError}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<SuggestionsQueue onApplied={onApplied} />
|
<SuggestionsQueue onApplied={onApplied} />
|
||||||
|
|||||||
466
src/components/admin/graph/GraphTable.jsx
Normal file
466
src/components/admin/graph/GraphTable.jsx
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -104,6 +104,23 @@ export function useGraphData() {
|
|||||||
return true;
|
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. */
|
/** Remove a specific (source, target, type) relation triple. */
|
||||||
const removeRelation = useCallback(async (source, target, type) => {
|
const removeRelation = useCallback(async (source, target, type) => {
|
||||||
await db.removeRelation(source, target, type);
|
await db.removeRelation(source, target, type);
|
||||||
@@ -165,6 +182,7 @@ export function useGraphData() {
|
|||||||
snapshotMeta,
|
snapshotMeta,
|
||||||
reload,
|
reload,
|
||||||
updateTopic,
|
updateTopic,
|
||||||
|
bulkUpdateTopics,
|
||||||
deleteTopic,
|
deleteTopic,
|
||||||
addRelation,
|
addRelation,
|
||||||
removeRelation,
|
removeRelation,
|
||||||
|
|||||||
57
src/lib/__tests__/azureAuth.test.js
Normal file
57
src/lib/__tests__/azureAuth.test.js
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
97
src/lib/__tests__/curriculumService.test.js
Normal file
97
src/lib/__tests__/curriculumService.test.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
125
src/lib/__tests__/graphGuard.test.js
Normal file
125
src/lib/__tests__/graphGuard.test.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
src/lib/azureAuth.js
Normal file
56
src/lib/azureAuth.js
Normal file
@@ -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';
|
||||||
|
}
|
||||||
@@ -60,8 +60,16 @@ export function buildThemeTopicMap(topics) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates a 26-week schedule against the provided topics.
|
* Validates a 26-week schedule against the provided topics.
|
||||||
* Checks for exactly 26 weeks, duration range, theme existence, and topic existence.
|
*
|
||||||
* Returns { valid: boolean, errors: string[] }
|
* 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[] }
|
||||||
*/
|
*/
|
||||||
export function validateSchedule(schedule, topics) {
|
export function validateSchedule(schedule, topics) {
|
||||||
const errors = []; // Hard errors — schedule is unusable
|
const errors = []; // Hard errors — schedule is unusable
|
||||||
@@ -71,10 +79,13 @@ export function validateSchedule(schedule, topics) {
|
|||||||
errors.push(`Schedule must contain exactly 26 weeks. Found ${schedule?.length || 0}.`);
|
errors.push(`Schedule must contain exactly 26 weeks. Found ${schedule?.length || 0}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validThemes = new Set(topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude').map(t => t.theme || 'General'));
|
const learningTopics = topics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
||||||
|
const validThemes = new Set(learningTopics.map(t => t.theme || 'General'));
|
||||||
const validTopicIds = new Set(topics.map(t => t.id));
|
const validTopicIds = new Set(topics.map(t => t.id));
|
||||||
|
const learningTopicIds = new Set(learningTopics.map(t => t.id));
|
||||||
|
|
||||||
const scheduledThemes = new Set();
|
const scheduledThemes = new Set();
|
||||||
|
const scheduledTopicIds = new Set();
|
||||||
|
|
||||||
for (let i = 0; i < (schedule || []).length; i++) {
|
for (let i = 0; i < (schedule || []).length; i++) {
|
||||||
const week = schedule[i];
|
const week = schedule[i];
|
||||||
@@ -94,20 +105,30 @@ export function validateSchedule(schedule, topics) {
|
|||||||
if (!validTopicIds.has(tId)) {
|
if (!validTopicIds.has(tId)) {
|
||||||
errors.push(`Week ${week.week_number} references unknown topic_id: ${tId}`);
|
errors.push(`Week ${week.week_number} references unknown topic_id: ${tId}`);
|
||||||
}
|
}
|
||||||
|
scheduledTopicIds.add(tId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme coverage — soft warnings, not hard errors
|
// Theme coverage — only warn when merging wasn't required. When themes_kb > 26
|
||||||
// When there are more themes than 26 weeks, the AI must merge some.
|
// the AI is *required* to merge, so absent theme labels are expected.
|
||||||
const missingThemes = [];
|
if (validThemes.size <= 26) {
|
||||||
for (const t of validThemes) {
|
const missingThemes = [];
|
||||||
if (!scheduledThemes.has(t)) {
|
for (const t of validThemes) {
|
||||||
missingThemes.push(t);
|
if (!scheduledThemes.has(t)) {
|
||||||
|
missingThemes.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (missingThemes.length > 0) {
|
||||||
|
warnings.push(`${missingThemes.length} theme(s) not scheduled: ${missingThemes.join(', ')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (missingThemes.length > 0) {
|
|
||||||
warnings.push(`${missingThemes.length} theme(s) not directly scheduled (topics may appear under merged themes): ${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.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors, warnings };
|
return { valid: errors.length === 0, errors, warnings };
|
||||||
@@ -224,7 +245,9 @@ Rules:
|
|||||||
throw new Error(`Generated schedule failed validation after retry:\n- ${validationResult.errors.join('\n- ')}`);
|
throw new Error(`Generated schedule failed validation after retry:\n- ${validationResult.errors.join('\n- ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log warnings but don't fail
|
// 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.
|
||||||
if (validationResult.warnings.length > 0) {
|
if (validationResult.warnings.length > 0) {
|
||||||
console.warn('[Curriculum] Schedule generated with warnings:', validationResult.warnings);
|
console.warn('[Curriculum] Schedule generated with warnings:', validationResult.warnings);
|
||||||
}
|
}
|
||||||
|
|||||||
81
src/lib/graphGuard.js
Normal file
81
src/lib/graphGuard.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -197,7 +197,7 @@ const Admin = () => {
|
|||||||
{activeTab === 'team' && (
|
{activeTab === 'team' && (
|
||||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||||
<h1 className="text-3xl text-teal mb-2">Team Management</h1>
|
<h1 className="text-3xl text-teal mb-2">Team Management</h1>
|
||||||
<p className="text-fg-muted mb-8">Manage team members, roles, and login PINs.</p>
|
<p className="text-fg-muted mb-8">Review team members and manage their roles. Accounts are created automatically via Microsoft (Azure) sign-in.</p>
|
||||||
<TeamManager />
|
<TeamManager />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,37 +2,25 @@ import { useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useApp } from '../store/AppContext';
|
import { useApp } from '../store/AppContext';
|
||||||
import Card from '../components/ui/Card';
|
import Card from '../components/ui/Card';
|
||||||
import Input from '../components/ui/Input';
|
|
||||||
import Select from '../components/ui/Select';
|
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const { state, login } = useApp();
|
const { loginWithAzure } = useApp();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [selectedUser, setSelectedUser] = useState('');
|
|
||||||
const [pin, setPin] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const userOptions = [
|
const handleAzure = async () => {
|
||||||
{ value: '', label: 'Select a user...' },
|
|
||||||
...state.users.map(u => ({ value: u.id, label: u.name }))
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleLogin = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError('');
|
setError('');
|
||||||
|
setBusy(true);
|
||||||
if (!selectedUser || !pin) {
|
try {
|
||||||
setError('Please fill in all fields.');
|
await loginWithAzure();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = login(selectedUser, pin);
|
|
||||||
if (success) {
|
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} else {
|
} catch (e) {
|
||||||
setError('Incorrect PIN.');
|
// 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 = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border border-bg-warm">
|
<Card className="border border-bg-warm">
|
||||||
<form onSubmit={handleLogin} className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<Select
|
<p className="text-fg-muted text-sm text-center">
|
||||||
label="User"
|
Sign in with your Respellion Microsoft account.
|
||||||
options={userOptions}
|
</p>
|
||||||
value={selectedUser}
|
|
||||||
onChange={(e) => setSelectedUser(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
{error && <div className="text-red-500 text-sm font-medium text-center">{error}</div>}
|
||||||
label="PIN Code"
|
|
||||||
type="password"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
maxLength={4}
|
|
||||||
placeholder="••••"
|
|
||||||
value={pin}
|
|
||||||
onChange={(e) => setPin(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && <div className="text-red-500 text-sm font-medium">{error}</div>}
|
<Button onClick={handleAzure} disabled={busy} className="w-full">
|
||||||
|
{busy ? 'Signing in…' : 'Sign in with Microsoft'}
|
||||||
<Button type="submit" className="mt-2 w-full">
|
|
||||||
Sign In
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useContext, useReducer, useEffect } from 'react';
|
import { createContext, useContext, useReducer, useEffect } from 'react';
|
||||||
import * as db from '../lib/db';
|
import * as db from '../lib/db';
|
||||||
import { pb } from '../lib/pb';
|
import { pb } from '../lib/pb';
|
||||||
|
import { OIDC_PROVIDER } from '../lib/azureAuth';
|
||||||
import { getPersonalWeekNumber } from '../lib/curriculumService';
|
import { getPersonalWeekNumber } from '../lib/curriculumService';
|
||||||
|
|
||||||
const AppContext = createContext();
|
const AppContext = createContext();
|
||||||
@@ -50,44 +51,40 @@ export function AppProvider({ children }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadState = async () => {
|
const loadState = async () => {
|
||||||
let members = await db.getTeamMembers();
|
// Restore the session from PocketBase's persistent auth store. The token
|
||||||
|
// is kept in localStorage by the SDK; authRefresh verifies it is still
|
||||||
if (!members || members.length === 0) {
|
// 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 {
|
try {
|
||||||
const created = await db.addTeamMember({ name: 'Admin', role: 'admin', pin: '0000' });
|
const { record } = await pb.collection('team_members').authRefresh();
|
||||||
members = [created];
|
dispatch({ type: 'LOGIN', payload: record });
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.warn('[AppContext] Could not auto-create admin user:', e.message,
|
// Token rejected (expired / account revoked) — drop the session.
|
||||||
'— Run scripts/setup-pb-collections.mjs to configure the database.');
|
pb.authStore.clear();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 } });
|
dispatch({ type: 'INIT_APP', payload: { users: members } });
|
||||||
};
|
};
|
||||||
|
|
||||||
loadState().catch(console.error);
|
loadState().catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = (userId, pin) => {
|
// Start the Entra (OIDC) login flow. PocketBase opens the provider popup,
|
||||||
const user = state.users.find(u => u.id === userId && u.pin === pin);
|
// handles the token exchange server-side, and auto-provisions the record on
|
||||||
if (user) {
|
// first login (see pb_hooks/team_members.pb.js).
|
||||||
sessionStorage.setItem('respellion_session', user.id);
|
const loginWithAzure = async () => {
|
||||||
dispatch({ type: 'LOGIN', payload: user });
|
const authData = await pb.collection('team_members').authWithOAuth2({ provider: OIDC_PROVIDER });
|
||||||
return true;
|
dispatch({ type: 'LOGIN', payload: authData.record });
|
||||||
}
|
return authData.record;
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
sessionStorage.removeItem('respellion_session');
|
pb.authStore.clear();
|
||||||
dispatch({ type: 'LOGOUT' });
|
dispatch({ type: 'LOGOUT' });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,7 +104,7 @@ export function AppProvider({ children }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContext.Provider value={{ state, dispatch, login, logout, enrollCurrentUser }}>
|
<AppContext.Provider value={{ state, dispatch, loginWithAzure, logout, enrollCurrentUser }}>
|
||||||
{children}
|
{children}
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user