Compare commits
6 Commits
e310b6d85b
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed1b1eb80f | ||
|
|
3de5500343 | ||
|
|
fe99381cb7 | ||
|
|
3af105bccd | ||
| 5f34a6f825 | |||
|
|
9395ea11fe |
4
.github/workflows/deploy-dev.yml
vendored
4
.github/workflows/deploy-dev.yml
vendored
@@ -28,4 +28,8 @@ jobs:
|
||||
-i infra/development/hosts.ini \
|
||||
-e "ansible_ssh_private_key_file=~/.ssh/deploy_key" \
|
||||
-e "anthropic_api_key=${{ secrets.ANTHROPIC_API_KEY }}" \
|
||||
-e "entra_tenant_id=${{ secrets.ENTRA_TENANT_ID }}" \
|
||||
-e "entra_client_id=${{ secrets.ENTRA_CLIENT_ID }}" \
|
||||
-e "entra_client_secret=${{ secrets.ENTRA_CLIENT_SECRET }}" \
|
||||
-e "entra_admin_emails=${{ secrets.ENTRA_ADMIN_EMAILS }}" \
|
||||
infra/development/site/deploy-playbook.yml
|
||||
|
||||
4
.github/workflows/deploy-prod.yml
vendored
4
.github/workflows/deploy-prod.yml
vendored
@@ -30,4 +30,8 @@ jobs:
|
||||
-i infra/production/hosts.ini \
|
||||
-e "ansible_ssh_private_key_file=~/.ssh/deploy_key" \
|
||||
-e "anthropic_api_key=${{ secrets.ANTHROPIC_API_KEY }}" \
|
||||
-e "entra_tenant_id=${{ secrets.ENTRA_TENANT_ID }}" \
|
||||
-e "entra_client_id=${{ secrets.ENTRA_CLIENT_ID }}" \
|
||||
-e "entra_client_secret=${{ secrets.ENTRA_CLIENT_SECRET }}" \
|
||||
-e "entra_admin_emails=${{ secrets.ENTRA_ADMIN_EMAILS }}" \
|
||||
infra/production/site/deploy-playbook.yml
|
||||
|
||||
@@ -127,6 +127,7 @@ when the PB binary starts.
|
||||
- `docs/generation-spec.md` — learning content + micro-learning generation
|
||||
- `docs/curriculum-spec.md` — 26-week per-user curriculum engine
|
||||
- `docs/r42-spec.md` — the R42 chatbot
|
||||
- `docs/auth-spec.md` — Azure (Entra ID) login: OIDC via PocketBase, provisioning, roles
|
||||
- `docs/frontend-spec.md` — screens, routing, onboarding
|
||||
- `docs/gamification-spec.md` — points, badges, leaderboard
|
||||
- `micro-learning-spec.md` — micro-learning learner experience
|
||||
|
||||
105
docs/auth-spec.md
Normal file
105
docs/auth-spec.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Authentication — Azure (Entra ID) login
|
||||
|
||||
> Implemented for issue #16. Replaces the previous client-side PIN login.
|
||||
|
||||
## Doel
|
||||
|
||||
Elke gebruiker logt in met zijn Respellion **Microsoft (Azure Entra ID)**-account.
|
||||
Bij de eerste login wordt automatisch een `team_members`-record aangemaakt. Er is
|
||||
geen PIN, geen handmatige user-aanmaak en geen client-side wachtwoordcontrole meer.
|
||||
|
||||
## Architectuur in één oogopslag
|
||||
|
||||
```
|
||||
Browser (SPA) PocketBase (auth) Entra ID
|
||||
───────────── ───────────────── ────────
|
||||
"Sign in with Microsoft"
|
||||
└─ pb.collection('team_members')
|
||||
.authWithOAuth2({provider:'oidc'})
|
||||
──────────────────► opens OIDC popup ───► login.microsoftonline.com
|
||||
token exchange ◄─── (server-side, client secret)
|
||||
◄────────────────── auth record + token
|
||||
pb.authStore (token in localStorage)
|
||||
```
|
||||
|
||||
- **PocketBase is de OIDC-client.** De token-exchange en het client-secret blijven
|
||||
server-side in PocketBase — er is geen aparte backend en geen client-side key.
|
||||
- **`team_members` is een PocketBase `auth`-collection.** De record-`id` blijft de
|
||||
user-identifier die alle voortgangscollecties (`test_results`,
|
||||
`on_demand_attempts`, `micro_learning_completions`, `leaderboard`,
|
||||
`theme_session_completions`) als `user_id` referencen.
|
||||
- **Sessie** = `pb.authStore` (token in `localStorage`), niet langer een
|
||||
`team_members.id` in `sessionStorage`.
|
||||
|
||||
## Belangrijke beslissingen (ADR)
|
||||
|
||||
| ADR | Beslissing | Reden |
|
||||
|---|---|---|
|
||||
| 001 | OIDC-provider tegen Entra v2-endpoints i.p.v. eigen MSAL-flow of header-trust | Sluit aan op "PocketBase = enige backend"; secret server-side; auto-provisioning gratis; werkt los van de perimeter-implementatie |
|
||||
| 002 | `team_members` hergebruikt als auth-collection (zelfde naam) | Alle `user_id`-referenties blijven werken; minimale blast-radius |
|
||||
| 003 | Provisioning + admin-rol via `pb_hooks/team_members.pb.js` | Rol-logica server-side, niet in de client |
|
||||
| 004 | Admin-rol via e-mail-allowlist (`ENTRA_ADMIN_EMAILS`), re-synced elke login | Geen handmatig beheer; allowlist-wijziging werkt bij volgende login |
|
||||
| 005 | API-rules → `@request.auth.id != ""` op alle app-collecties | Geen anonieme toegang meer; admins/users zijn beide geauthenticeerd |
|
||||
|
||||
## Bestanden
|
||||
|
||||
| Bestand | Rol |
|
||||
|---|---|
|
||||
| `pb_migrations/1781000000_team_members_to_auth.js` | Dropt de oude PIN-collection, maakt `team_members` als auth-collection met de OIDC-provider (creds uit env). |
|
||||
| `pb_migrations/1781000001_tighten_api_rules.js` | Zet alle niet-systeem-collecties op `@request.auth.id != ""`. |
|
||||
| `pb_hooks/team_members.pb.js` | Auto-provisioning (`role`, `enrollment_status`, naam-fallback) + admin-rol re-sync. |
|
||||
| `src/lib/azureAuth.js` | Canonieke, unit-geteste helpers (`OIDC_PROVIDER`, `resolveRole`, `parseAdminEmails`, `deriveName`). |
|
||||
| `src/store/AppContext.jsx` | `loginWithAzure()` / `logout()` op basis van `pb.authStore` + `authRefresh()`. |
|
||||
| `src/pages/Login.jsx` | "Sign in with Microsoft"-scherm (geen dropdown/PIN). |
|
||||
| `src/components/admin/TeamManager.jsx` | Roster-beheer: rol wijzigen + verwijderen (geen aanmaken/PIN). |
|
||||
|
||||
> De rol-/allowlist-logica bestaat tweemaal: canoniek in `src/lib/azureAuth.js`
|
||||
> (met tests) en herhaald in `pb_hooks/team_members.pb.js` (PocketBase JSVM kan de
|
||||
> module niet importeren). **Houd ze in sync.**
|
||||
|
||||
## Configuratie (environment)
|
||||
|
||||
Gerenderd in `.env` door de Ansible deploy-playbooks en doorgegeven aan de
|
||||
`pocketbase-learning`-container:
|
||||
|
||||
| Variabele | Betekenis |
|
||||
|---|---|
|
||||
| `ENTRA_TENANT_ID` | Entra tenant-id (of `common`). |
|
||||
| `ENTRA_CLIENT_ID` | App-registration client-id. |
|
||||
| `ENTRA_CLIENT_SECRET` | App-registration client-secret. |
|
||||
| `ENTRA_ADMIN_EMAILS` | Komma-gescheiden e-mail-allowlist die `role=admin` krijgt, bv. `rve@respellion.nl,admin@respellion.nl`. |
|
||||
|
||||
Ansible-variabelen (vault): `entra_tenant_id`, `entra_client_id`,
|
||||
`entra_client_secret`, `entra_admin_emails`.
|
||||
|
||||
## Entra App Registration (eenmalig, buiten de codebase)
|
||||
|
||||
1. **Redirect-URI (Web):** `https://<host>/api/oauth2-redirect`
|
||||
- Labs: `https://learning-platform.labs.respellion.tech/api/oauth2-redirect`
|
||||
- Lokaal dev: `http://localhost:8090/api/oauth2-redirect` (PB-origin)
|
||||
2. **API permissions / scopes:** `openid`, `email`, `profile`.
|
||||
3. Genereer een client-secret en zet de waarden in de Ansible-vault.
|
||||
|
||||
## Uitbreidingspunten
|
||||
|
||||
- **Silent SSO (geen tweede klik):** als bevestigd is dat de perimeter veilige,
|
||||
niet-spoofbare identity-headers doorstuurt, kan een PocketBase-route die headers
|
||||
vertrouwen en direct een token minten (ADR-004-alternatief).
|
||||
- **Least-privilege rules:** schrijf/verwijder op de knowledge-authoring-collecties
|
||||
(`topics`, `relations`, `content`, `sources`, `question_bank`,
|
||||
`curriculum_versions`) verder beperken tot `@request.auth.role = "admin"`. Let op:
|
||||
`micro_learnings` en `theme_sessions` worden door reguliere users geschreven
|
||||
(generate-then-cache). Vereist runtime-verificatie.
|
||||
- **Rol op Entra group-claims** i.p.v. e-mail-allowlist (aanpassing in
|
||||
`pb_hooks/team_members.pb.js` + `src/lib/azureAuth.js`).
|
||||
|
||||
## Bekende aandachtspunten / go-live
|
||||
|
||||
- De OIDC-popup is een top-level navigatie naar `login.microsoftonline.com`; de
|
||||
bestaande Caddy-CSP (`connect-src`) raakt dit niet. Verifieer alsnog bij de
|
||||
eerste deploy.
|
||||
- Verweesde voortgangsrecords van verwijderde (pre-Azure) users zijn acceptabel:
|
||||
zonder geldig Entra-account kan niemand inloggen, dus die records zijn onbereikbaar.
|
||||
- Credential-rotatie: update env + herconfigureer de provider via de PocketBase
|
||||
admin-UI (Settings → Auth providers) of ship een follow-up migratie — de
|
||||
create-migratie draait maar één keer.
|
||||
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'pb_migrations']),
|
||||
globalIgnores(['dist', 'pb_migrations', 'pb_hooks']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
|
||||
@@ -4,6 +4,9 @@ networks:
|
||||
learning-platform:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
|
||||
services:
|
||||
learning-platform:
|
||||
image: git.labs.respellion.tech/respellion/learning-platform/learning-platform:latest
|
||||
@@ -18,11 +21,18 @@ services:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
container_name: pocketbase-learning
|
||||
restart: unless-stopped
|
||||
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data"]
|
||||
working_dir: /pb
|
||||
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations", "--hooksDir=/pb/pb_hooks"]
|
||||
environment:
|
||||
# Azure Entra ID (OIDC) — consumed by pb_migrations/1781000000 and
|
||||
# pb_hooks/team_members.pb.js. Rendered into .env by the deploy playbook.
|
||||
- ENTRA_TENANT_ID=${ENTRA_TENANT_ID}
|
||||
- ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID}
|
||||
- ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET}
|
||||
- ENTRA_ADMIN_EMAILS=${ENTRA_ADMIN_EMAILS}
|
||||
volumes:
|
||||
- pb_data:/pb/pb_data
|
||||
- ./pb_migrations:/pb/pb_migrations
|
||||
- ./pb_hooks:/pb/pb_hooks
|
||||
networks:
|
||||
- learning-platform
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
|
||||
@@ -36,11 +36,27 @@
|
||||
src: compose.yml
|
||||
dest: "{{ dest_dir }}/compose.yml"
|
||||
|
||||
- name: Copy pb_migrations to destination
|
||||
ansible.builtin.copy:
|
||||
src: ../../../pb_migrations
|
||||
dest: "{{ dest_dir }}/"
|
||||
mode: "0755"
|
||||
|
||||
- name: Copy pb_hooks to destination
|
||||
ansible.builtin.copy:
|
||||
src: ../../../pb_hooks
|
||||
dest: "{{ dest_dir }}/"
|
||||
mode: "0755"
|
||||
|
||||
- name: Create .env file for secrets
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ dest_dir }}/.env"
|
||||
content: |
|
||||
ANTHROPIC_API_KEY={{ anthropic_api_key | default('') }}
|
||||
ENTRA_TENANT_ID={{ entra_tenant_id | default('') }}
|
||||
ENTRA_CLIENT_ID={{ entra_client_id | default('') }}
|
||||
ENTRA_CLIENT_SECRET={{ entra_client_secret | default('') }}
|
||||
ENTRA_ADMIN_EMAILS={{ entra_admin_emails | default('') }}
|
||||
mode: '0600'
|
||||
|
||||
- name: Pull latest image
|
||||
|
||||
@@ -22,9 +22,17 @@ services:
|
||||
container_name: pocketbase-learning
|
||||
restart: unless-stopped
|
||||
working_dir: /pb
|
||||
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations"]
|
||||
command: ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations", "--hooksDir=/pb/pb_hooks"]
|
||||
environment:
|
||||
# Azure Entra ID (OIDC) — consumed by pb_migrations/1781000000 and
|
||||
# pb_hooks/team_members.pb.js. Rendered into .env by the deploy playbook.
|
||||
- ENTRA_TENANT_ID=${ENTRA_TENANT_ID}
|
||||
- ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID}
|
||||
- ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET}
|
||||
- ENTRA_ADMIN_EMAILS=${ENTRA_ADMIN_EMAILS}
|
||||
volumes:
|
||||
- pb_data:/pb/pb_data
|
||||
- ./pb_migrations:/pb/pb_migrations
|
||||
- ./pb_hooks:/pb/pb_hooks
|
||||
networks:
|
||||
- learning-platform
|
||||
|
||||
@@ -42,11 +42,21 @@
|
||||
dest: "{{ dest_dir }}/"
|
||||
mode: "0755"
|
||||
|
||||
- name: Copy pb_hooks to destination
|
||||
ansible.builtin.copy:
|
||||
src: ../../../pb_hooks
|
||||
dest: "{{ dest_dir }}/"
|
||||
mode: "0755"
|
||||
|
||||
- name: Create .env file for secrets
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ dest_dir }}/.env"
|
||||
content: |
|
||||
ANTHROPIC_API_KEY={{ anthropic_api_key | default('') }}
|
||||
ENTRA_TENANT_ID={{ entra_tenant_id | default('') }}
|
||||
ENTRA_CLIENT_ID={{ entra_client_id | default('') }}
|
||||
ENTRA_CLIENT_SECRET={{ entra_client_secret | default('') }}
|
||||
ENTRA_ADMIN_EMAILS={{ entra_admin_emails | default('') }}
|
||||
mode: '0600'
|
||||
|
||||
- name: Pull latest image
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
Diversiteit en Inclusiviteit bij Respellion
|
||||
|
||||
Diversiteit en Inclusie Initiatieven
|
||||
* De spreker is betrokken bij een groep genaamd Diverse Leaders in Tech, die zich richt op het bevorderen van diversiteit en inclusie in de technologie-industrie.
|
||||
* Deze groep heeft een enquête van ongeveer 30 vragen ontwikkeld om te beoordelen hoe bedrijven diversiteit en inclusieproblemen aanpakken.
|
||||
* De enquête behandelt onderwerpen zoals beleid, trainingsprogramma's en andere relevante initiatieven.
|
||||
* De groep werkt momenteel samen met Booking.com om hun aanpak te implementeren.
|
||||
* De spreker is geïnteresseerd in het begrijpen van hoe dergelijke initiatieven effectief kunnen worden opgeschaald en geïmplementeerd in grotere organisaties.
|
||||
* Ze verzamelen informatie en ervaringen uit verschillende bronnen om een meer omvattend begrip te ontwikkelen van de uitdagingen en best practices op dit gebied.
|
||||
Bedrijfsmatige Benaderingen van Diversiteit en Inclusie
|
||||
* De spreker bespreekt hun ervaring met diversiteit en inclusie-initiatieven bij CGI, waar ze deelnamen aan de Diversity Inclusion Committee.
|
||||
* Ze vermelden een tweedaagse training voor leiderschap over diversiteit en inclusie, die aanvankelijk aanzienlijke weerstand ondervond van sommige managers die de relevantie ervan voor hun primaire verantwoordelijkheden in twijfel trokken.
|
||||
* Deze weerstand creëerde bezorgdheid op het niveau van de directie over de mogelijke onpopulariteit van dergelijke initiatieven.
|
||||
* De spreker benadrukt het belang van betrokkenheid bij resistente individuen om hun zorgen te begrijpen en hen te helpen inzien hoe ze mogelijk al bijdragen aan inclusiviteit op manieren die ze zich niet realiseerden.
|
||||
Respellion's Benadering van Inclusiviteit
|
||||
* Raymond, van Respellion, beschrijft de unieke benadering van hun bedrijf om inclusiviteit te bevorderen.
|
||||
* Respellion, een jaar oud bedrijf met 15 medewerkers, combineert ervaringen van zowel kleine organisaties als multinationale ondernemingen.
|
||||
* Ze hebben een zelforganiserende cultuur geïmplementeerd met hoge autonomie voor medewerkers en zonder hiërarchische structuur.
|
||||
* Het bedrijf is verdeeld in drie 'huddles' van elk vijf personen, die dienen als vertrouwensgroepen.
|
||||
* Deze huddles hebben regelmatige check-ins en bi-monthly ontwikkelingsplanbesprekingen, die aspecten van professioneel, persoonlijk en fysiek welzijn behandelen.
|
||||
* Deze structuur biedt een veilige ruimte waar kwesties, inclusief die met betrekking tot inclusiviteit en diversiteit, kunnen worden aangekaart en aangepakt.
|
||||
Proactieve Inclusiviteitsmaatregelen
|
||||
* Raymond benadrukt het belang van het proactief aanpakken van inclusiviteitskwesties.
|
||||
* Hij geeft een voorbeeld van hoe Respellion omgaat met dieetvoorkeuren: ze hebben een standaardbeleid van vegetarische opties voor alle evenementen, met de mogelijkheid voor individuen om alternatieven aan te vragen indien nodig.
|
||||
* Deze aanpak verschuift de last van minderheidsgroepen die constant voor zichzelf moeten pleiten.
|
||||
* Raymond benadrukt het belang van het maken van duidelijke uitspraken en het creëren van wrijving voor degenen die niet direct door inclusiviteitskwesties worden getroffen, aangezien dit noodzakelijk is om verandering te stimuleren.
|
||||
Omgaan met Microagressies en Ongepaste Opmerkingen
|
||||
* De discussie behandelt het belang van het onmiddellijk aanpakken van microagressies of ongepaste opmerkingen op de werkplek.
|
||||
* Raymond geeft een voorbeeld van hoe hij een situatie heeft aangepakt waarin een oudere medewerker een seksistische opmerking maakte over secretariële werkzaamheden.
|
||||
* Hij benadrukt de noodzaak voor leiders om dergelijke kwesties openlijk in groepsinstellingen aan te pakken, zelfs als dit ongemak creëert.
|
||||
* Het doel is om het aanpakken van deze kwesties een normaal onderdeel van de werkcultuur te maken, in plaats van ze te vermijden of privé aan te pakken.
|
||||
Ervaringsgericht Leren voor Inclusiviteit
|
||||
* Beide sprekers zijn het erover eens dat ervaringsgericht leren belangrijk is om een echt begrip van inclusiviteitskwesties te bevorderen.
|
||||
* Ze bespreken de beperkingen van traditionele trainingsprogramma's, vooral die via online platforms worden aangeboden.
|
||||
* In plaats daarvan pleiten ze voor benaderingen die individuen in staat stellen te ervaren hoe het voelt om uitgesloten te worden of discriminatie te ondervinden.
|
||||
* Dit kan inhouden dat er rollenspellen, familieconstellaties of andere methoden worden gebruikt die viscerale ervaringen van ongelijkheid of uitsluiting creëren.
|
||||
* De sprekers geloven dat dergelijke ervaringen cruciaal zijn voor het ontwikkelen van empathie en het stimuleren van echte verandering in attitudes en gedragingen.
|
||||
Leiderschap en Coaching voor Inclusiviteit
|
||||
* Raymond benadrukt de rol van leiders als coaches in het bevorderen van een inclusieve omgeving.
|
||||
* Hij suggereert dat organisaties in plaats van grootschalige trainingsprogramma's, zich moeten richten op kleinschalige, intieme coachingsessies.
|
||||
* Dit kan inhouden dat teamcoaches in real-time situaties observeren en ingrijpen, waardoor leiders en teamleden in staat worden gesteld om inclusiviteitskwesties aan te pakken zodra ze zich voordoen.
|
||||
* Het doel is om discussies over inclusiviteit een natuurlijk onderdeel van dagelijkse interacties op de werkplek te maken, in plaats van geïsoleerde trainingsevenementen.
|
||||
Uitdagingen bij het Implementeren van Inclusiviteitsinitiatieven
|
||||
* De sprekers bespreken verschillende uitdagingen bij het implementeren van effectieve inclusiviteitsinitiatieven, waaronder weerstand van sommige medewerkers, de moeilijkheid om ingesleten culturele normen te veranderen, en de beperkingen van een one-size-fits-all benadering van training.
|
||||
* Ze benadrukken de noodzaak van op maat gemaakte, contextspecifieke interventies die rekening houden met de unieke dynamiek van elk team of organisatie.
|
||||
* De conversatie raakt ook het belang van het aanpakken van neurodiversiteit en verschillende leerstijlen bij het ontwerpen van inclusiviteitsprogramma's.
|
||||
|
||||
103
knowledge-base/Respellion Company Introduction.txt
Normal file
103
knowledge-base/Respellion Company Introduction.txt
Normal file
@@ -0,0 +1,103 @@
|
||||
# Respellion: Responsible Rebellion in Softwareontwikkeling
|
||||
|
||||
Respellion is een softwareontwikkelingsbedrijf opgericht door voormalige leiders van QDelft (dat later werd overgenomen door Netcompany Denemarken). Het bedrijf positioneert zichzelf als "responsible rebels", waarbij innovatieve benaderingen worden gecombineerd met een sterk gevoel voor maatschappelijke verantwoordelijkheid. Deze onderscheidende combinatie wordt weerspiegeld in hun naam - een samenvoeging van "verantwoordelijkheid" en "rebellie".
|
||||
|
||||
### Doel en Waarden
|
||||
|
||||
De kern van Respellion's identiteit is hun doel: "Een maatschappij waarin open-source technologie wordt ingezet om op een duurzame manier digitalisering mogelijk te maken." Dit doel is ontstaan uit brainstormsessies waarin de oprichters overwogen wat werkelijk belangrijk voor hen was. Raymond Verhoef beschrijft dit doel als "bestendig", omdat het consequent aansluit bij het werk dat ze uitvoeren.
|
||||
|
||||
Dit doel manifesteert zich concreet in hun klanten en projectkeuzes. Hun werk met het CIBG, diverse Ministeries en de gemeente Den Haag omvat het ontwikkelen van producten die de samenleving direct ten goede komen. Raymond legde uit: "We bouwen software die burgers in staat stelt om elkaar te helpen." Dit concept van helpen strekt zich uit tot zowel het ondersteunen van klanten als het creëren van software die burgers in staat stelt elkaar te ondersteunen.
|
||||
|
||||
Het bedrijf opereert volgens vier kernwaarden die het dagelijks gedrag sturen:
|
||||
|
||||
**Vertrouwen** wordt gedemonstreerd door transparantie en openheid. Tijdens de onboarding deelde Raymond een voorbeeld waarin Patrick te laat kwam op een vergadering met Thomas, die vroeg was opgestaan om aanwezig te zijn. In plaats van de situatie te negeren, erkenden ze het probleem onmiddellijk: "We legden het direct op tafel... omdat we voor elkaar moeten zorgen." Deze transparantie voorkomt dat kleine irritaties zich in de loop van de tijd opbouwen.
|
||||
|
||||
**Moed** houdt in dat men durft af te wijken van traditionele benaderingen en fouten omarmt als leermogelijkheden. De "rebelse" aard van het bedrijf komt tot uiting in hun bereidheid om gevestigde praktijken ter discussie te stellen wanneer ze geloven dat er een betere manier is.
|
||||
|
||||
**Zelfdiscipline** komt tot uiting in het nakomen van toezeggingen en het nemen van verantwoordelijkheid. Raymond benadrukte het belang hiervan "omdat het echt moeilijk is om in lijn te blijven met bepaalde waarden." Bij Respellion betekent dit zich goed voorbereiden op vergaderingen, op tijd komen en toezeggingen nakomen.
|
||||
|
||||
**Ondernemerschap** wordt aangemoedigd omdat iedereen "een stukje van de organisatie" op zich neemt. Met minimale managementrollen moeten teamleden een ondernemende mentaliteit aannemen. Zoals Raymond uitlegde: "Je moet bereid zijn om zelf een stukje van de organisatie op je te nemen."
|
||||
|
||||
Deze waarden komen tot uiting in hun manifest, dat vier gedragsprincipes schetst:
|
||||
|
||||
1. **Voortdurend in beweging blijven om wendbaar te blijven**: Dit wordt belichaamd in hun holacratische structuur, die zich regelmatig aanpast aan veranderende behoeften.
|
||||
|
||||
2. **Samenwerken op basis van gemeenschappelijke belangen in plaats van individueel gewin**: Raymond gaf een concreet voorbeeld: "Als we iets dubbel hebben gefactureerd, moet ik daar dan over zwijgen? Nee. Zou ik dat zelf willen? Nee. Dus ik werk samen op basis van ons gemeenschappelijk belang." Dit principe strekt zich uit tot klantrelaties, waar transparantie soms betekent dat ze hun eigen fouten aanwijzen.
|
||||
|
||||
3. **Werken met een glimlach en een zorgende houding**: Kleine gebaren demonstreren dit principe, zoals bloemen meenemen voor verjaardagen of koekjes voor vergaderingen. Raymond vermeldde: "Ik wil een organisatie zijn waar mensen zich verzorgd voelen."
|
||||
|
||||
4. **Streven naar evenwicht tussen mensen, planeet en winst**: Dit principe beïnvloedt hun klantenselectieproces. Tijdens de onboarding besprak Raymond hoe ze zorgvuldig overwegen of ze werk van bepaalde klanten accepteren op basis van afstemming met hun waarden. Ze wezen bijvoorbeeld een kans bij Holland Casino af tijdens Raymonds vakantie, wat hij waardeerde: "Dat zou ik ook niet hebben gedaan." Ook bespraken ze of ze werk van Shell zouden accepteren, concluderend dat dergelijke beslissingen een expliciete redenering vereisen, ongeacht de uitkomst.
|
||||
|
||||
### Holacracy: Een Dynamische Organisatiestructuur
|
||||
|
||||
Respellion heeft Holacracy geïmplementeerd, een zelfsturend organisatiesysteem dat traditionële hiërarchie vervangt door een flexibelere, adaptieve structuur. Raymond Verhoef, een van de oprichters, beschrijft deze benadering als vergelijkbaar met tuinieren in plaats van bouwen - condities creëren voor groei in plaats van bouwen volgens rigide plannen.
|
||||
|
||||
Belangrijke kenmerken van hun holacratische benadering zijn:
|
||||
- Organisatie via rollen in plaats van functietitels
|
||||
- Besluitvorming gebaseerd op commitment in plaats van consensus
|
||||
- Regelmatige governance-vergaderingen om de organisatiestructuur aan te passen
|
||||
- Tactische vergaderingen om operationele problemen aan te pakken
|
||||
|
||||
Het Holacracy-model stelt Respellion in staat om wendbaar te blijven terwijl het teamleden autonomie biedt. Zoals Raymond uitlegt, wordt "de stem van de minderheid altijd in aanmerking genomen" in besluitvormingsprocessen. Wijzigingen in rollen worden gemaakt tijdens governance-vergaderingen wanneer iemand een kloof identificeert tussen de huidige structuur en wat nodig is om verantwoordelijkheden effectief te vervullen.
|
||||
|
||||
### Coaching Cultuur
|
||||
|
||||
Respellion heeft een onderscheidende coachingcultuur ontwikkeld waarbij professionele en persoonlijke ontwikkeling zijn geïntegreerd. Deze cultuur manifesteert zich door verschillende gestructureerde praktijken:
|
||||
|
||||
1. **Huddle Check-ins**: Tweewekelijkse bijeenkomsten met kleine, consistente groepen voor snelle verbindingen en ondersteuning
|
||||
2. **Elevator Sessies**: Tweemaandelijkse gefaciliteerde coachingsessies gericht op persoonlijke ontwikkeling
|
||||
3. **Celebrations**: Tweemaandelijkse bijeenkomsten om successen te delen en, opmerkelijk, de "meest rebelse mislukking van de maand" - wat leren door falen aanmoedigt
|
||||
4. **Persoonlijke Ontwikkelingsplannen**: Gebouwd rond drie capaciteiten:
|
||||
- Professionele/intellectuele groei
|
||||
- Emotionele ontwikkeling (communicatie, feedback, triggers begrijpen)
|
||||
- Fysiek welzijn
|
||||
|
||||
Het persoonlijke ontwikkelingsproces is gebaseerd op Daniel Ofman's Kernkwaliteiten model, wat teamleden helpt hun sterke punten, valkuilen, uitdagingen en allergieën (reacties op gedragingen tegenovergesteld aan hun uitdagingen) te identificeren. Dit raamwerk biedt een gemeenschappelijke taal voor ontwikkelingsgesprekken.
|
||||
|
||||
### Diensten en Expertise
|
||||
|
||||
Respellion richt zich op drie primaire dienstgebieden:
|
||||
1. **Resultaatgerichte projecten**: Gebruik van Agile methodologieën gecombineerd met gestructureerd projectmanagement
|
||||
2. **High-performance teams**: Het leveren van multidisciplinaire teams met de technische en soft skills om complete oplossingen te leveren
|
||||
3. **Consultancy**: Het bieden van strategisch advies en technische expertise
|
||||
|
||||
Hun expertisegebieden omvatten:
|
||||
- Missiekritische systemen met hoge beschikbaarheid
|
||||
- Continuous Integration/Continuous Deployment (CI/CD)
|
||||
- Privacy en Security by Design
|
||||
- Digitale toegankelijkheid en bruikbaarheid
|
||||
- Agile projectmanagement en coaching
|
||||
- Duurzaam applicatiebeheer
|
||||
|
||||
### Werkomgeving en Medewerkerservaring
|
||||
|
||||
Respellion biedt een onderscheidende werkomgeving voor professionals die autonomie en zingeving zoeken in hun carrière. De combinatie van professionele excellentie met mensgerichte waarden creëert een unieke werkcultuur.
|
||||
|
||||
De medewerkerservaring bij Respellion wordt gekenmerkt door verschillende sleutelelementen:
|
||||
|
||||
- **Echte autonomie**: Via Holacracy nemen teamleden rollen op zich die aansluiten bij hun sterke punten en interesses, in plaats van in voorgedefinieerde functieomschrijvingen te passen. Dit stelt mensen in staat hun werk vorm te geven volgens hun talenten en ambities.
|
||||
|
||||
- **Groeigericht cultuur**: De coachingbenadering zorgt voor continue persoonlijke en professionele ontwikkeling. Regelmatige feedback en gestructureerde ontwikkelingssessies helpen teamleden zich in meerdere dimensies te ontwikkelen.
|
||||
|
||||
- **Doelgericht werk**: Teamleden dragen bij aan maatschappelijk waardevolle projecten met duurzame, open-source technologie, wat betekenis creëert die verder gaat dan commercieel succes.
|
||||
|
||||
- **Veilige leeromgeving**: Het vieren van "meest rebelse mislukkingen" toont een oprechte toewijding aan leren en versterkt psychologische veiligheid binnen teams.
|
||||
|
||||
Teamleden die gedijen bij Respellion waarderen typisch vertrouwen, tonen moed, beoefenen zelfdiscipline en omarmen ondernemerschap. Ze excelleren wanneer ze initiatiefmogelijkheden krijgen en zijn comfortabel met de verantwoordelijkheid die met autonomie komt.
|
||||
|
||||
Voor specialisten in open-source ontwikkeling die willen dat hun werk een bredere positieve impact heeft, biedt Respellion een alternatief voor traditionele bedrijfsstructuren terwijl het professionele nauwkeurigheid en marktconcurrentievermogen behoudt.
|
||||
|
||||
### Open-Source Filosofie
|
||||
|
||||
Respellion is diep toegewijd aan open-source technologie, wat aansluit bij hun duurzaamheidsdoelen. Ze waarderen open-source voor:
|
||||
- Transparantie en vertrouwensopbouwend potentieel
|
||||
- Samenwerkingsmogelijkheden met partners
|
||||
- Verbeterde beveiliging door community toezicht
|
||||
- Kostenefficiëntie en versnelling van innovatie
|
||||
- Flexibiliteit en onafhankelijkheid van leveranciers
|
||||
|
||||
### Huidige Status en Groeiplannen
|
||||
|
||||
Per 2025 heeft Respellion 15 teamleden bereikt en is van plan om te groeien naar 30 collega's in 2026. Deze gerichte groei is ontworpen om hen in staat te stellen complete ontwikkelteams te vormen die kunnen worden ingezet bij klantorganisaties.
|
||||
|
||||
Het bedrijf heeft zich gevestigd bij klanten waaronder gemeenten (Den Haag), overheidsinstanties (CBG, VWS), en andere organisaties zoals IKTU en Vooron. Ze opereren vanuit Haktech, een technologiegericht kantoorgebouw met andere innovatieve bedrijven.
|
||||
BIN
knowledge-base/Welcome-to-Respellion.pdf
Normal file
BIN
knowledge-base/Welcome-to-Respellion.pdf
Normal file
Binary file not shown.
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
|
||||
//
|
||||
// Keep this logic in sync with src/lib/azureAuth.js (resolveRole / parseAdminEmails),
|
||||
// which carries the canonical, unit-tested version for the frontend/tests.
|
||||
|
||||
function resolveRole(email) {
|
||||
const raw = ($os.getenv("ENTRA_ADMIN_EMAILS") || "").toLowerCase();
|
||||
const allow = raw.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
|
||||
return allow.indexOf((email || "").toLowerCase()) !== -1 ? "admin" : "user";
|
||||
}
|
||||
|
||||
// On creation (first login): set defaults before the record is persisted.
|
||||
onRecordCreate(function (e) {
|
||||
const email = e.record.get("email") || "";
|
||||
|
||||
e.record.set("role", resolveRole(email));
|
||||
|
||||
if (!e.record.get("enrollment_status")) {
|
||||
e.record.set("enrollment_status", "not_started");
|
||||
}
|
||||
|
||||
// Fallback name when the IdP supplied no `name` claim.
|
||||
if (!e.record.get("name")) {
|
||||
e.record.set("name", email ? email.split("@")[0] : "Onbekend");
|
||||
}
|
||||
|
||||
e.next();
|
||||
}, "team_members");
|
||||
|
||||
// On every successful auth (incl. OIDC): keep the admin role in sync with the
|
||||
// allow-list, so promoting/demoting an admin only requires an env change.
|
||||
onRecordAuthRequest(function (e) {
|
||||
const email = e.record.get("email") || "";
|
||||
const want = resolveRole(email);
|
||||
if (e.record.get("role") !== want) {
|
||||
e.record.set("role", want);
|
||||
e.app.save(e.record);
|
||||
}
|
||||
e.next();
|
||||
}, "team_members");
|
||||
273
pb_migrations/1781000000_team_members_to_auth.js
Normal file
273
pb_migrations/1781000000_team_members_to_auth.js
Normal file
@@ -0,0 +1,273 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
//
|
||||
// Issue #16 — Azure (Entra ID) login.
|
||||
//
|
||||
// Replaces the PIN-based `base` collection `team_members` with a PocketBase
|
||||
// `auth` collection that authenticates against Azure Entra ID via OIDC.
|
||||
//
|
||||
// The existing PIN-based team_members are intentionally dropped (sign-off from
|
||||
// the product owner — no migration of current users required). The knowledge
|
||||
// base, generated tests and micro-learnings live in OTHER collections and are
|
||||
// left completely untouched by this migration.
|
||||
//
|
||||
// OIDC provider credentials are read from the environment at apply time, so no
|
||||
// secrets are committed to git:
|
||||
// ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET
|
||||
// (set via the Ansible-rendered .env, see infra/*/site/compose.yml).
|
||||
//
|
||||
// To rotate credentials or change the tenant later, update the env and either
|
||||
// re-configure the provider from the PocketBase admin UI (Settings → Auth
|
||||
// providers) or ship a follow-up migration.
|
||||
migrate((app) => {
|
||||
// 1. Drop the old PIN-based collection (takes the PIN records with it).
|
||||
try {
|
||||
const old = app.findCollectionByNameOrId("team_members");
|
||||
app.delete(old);
|
||||
} catch (_) {
|
||||
// Collection did not exist — nothing to drop.
|
||||
}
|
||||
|
||||
const tenant = $os.getenv("ENTRA_TENANT_ID") || "common";
|
||||
const clientId = $os.getenv("ENTRA_CLIENT_ID");
|
||||
const clientSecret = $os.getenv("ENTRA_CLIENT_SECRET");
|
||||
|
||||
// Only configure the OIDC provider when credentials are actually present.
|
||||
// PocketBase rejects an enabled OAuth2 provider with an empty clientId /
|
||||
// clientSecret, which would abort this migration and prevent PocketBase from
|
||||
// starting at all (502 on every /api call). When the secrets are absent the
|
||||
// collection is created without a provider; once the ENTRA_* secrets are set,
|
||||
// configure the provider via the PocketBase admin UI
|
||||
// (Settings → Auth providers → OIDC) or re-apply this migration.
|
||||
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,
|
||||
},
|
||||
] : [];
|
||||
|
||||
// 2. Recreate `team_members` as an auth collection (same name → all existing
|
||||
// `user_id` references in test_results, on_demand_attempts,
|
||||
// micro_learning_completions, leaderboard, theme_session_completions keep
|
||||
// working unchanged).
|
||||
const collection = new Collection({
|
||||
type: "auth",
|
||||
name: "team_members",
|
||||
system: false,
|
||||
|
||||
// Access rules: every legitimate user is authenticated (Azure-gated +
|
||||
// OIDC). Profiles are readable by any authenticated user (leaderboard,
|
||||
// dashboard); a user may only update their own record (onboarding flips
|
||||
// enrollment_status). Creation/deletion happen via OAuth2 / superuser only.
|
||||
listRule: '@request.auth.id != ""',
|
||||
viewRule: '@request.auth.id != ""',
|
||||
createRule: null,
|
||||
updateRule: '@request.auth.id = id',
|
||||
deleteRule: null,
|
||||
authRule: "",
|
||||
manageRule: null,
|
||||
|
||||
// Disable password / OTP / MFA — login is exclusively via Entra OIDC.
|
||||
passwordAuth: { enabled: false, identityFields: ["email"] },
|
||||
otp: { enabled: false, duration: 180, length: 8 },
|
||||
mfa: { enabled: false, duration: 600, rule: "" },
|
||||
|
||||
oauth2: {
|
||||
enabled: 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,
|
||||
],
|
||||
},
|
||||
// NOTE: `team_members` is an AUTH collection owned by the migration
|
||||
// pb_migrations/1781000000_team_members_to_auth.js (Azure/Entra ID login).
|
||||
// Migrations run on PocketBase start — before this script — so the entry
|
||||
// below is only a fallback for environments where migrations have not run;
|
||||
// when the auth collection already exists, this create is skipped.
|
||||
{
|
||||
name: 'team_members',
|
||||
type: 'base',
|
||||
type: 'auth',
|
||||
...OPEN_RULES,
|
||||
passwordAuth: { enabled: false, identityFields: ['email'] },
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'pin', type: 'text', required: false },
|
||||
{ name: 'name', type: 'text', required: false },
|
||||
{ name: 'role', type: 'text', required: false },
|
||||
{ name: 'curriculum_started_at', type: 'date', required: false },
|
||||
{ name: 'enrollment_status', type: 'text', required: false },
|
||||
...AUTODATE_FIELDS,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import { callLLM } from '../../lib/llm';
|
||||
import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools';
|
||||
import { Network, Table2 } from 'lucide-react';
|
||||
import { useGraphData } from '../../hooks/useGraphData';
|
||||
import { filterAiActions } from '../../lib/graphGuard';
|
||||
import GraphControls from './graph/GraphControls';
|
||||
import NodeDetailPanel from './graph/NodeDetailPanel';
|
||||
import GraphTable from './graph/GraphTable';
|
||||
@@ -25,11 +26,21 @@ const SYSTEM_PROMPTS = {
|
||||
full: `You are a strict Data Quality AI maintaining a Knowledge Graph for Respellion.
|
||||
Evaluate the provided topics and relations and emit the actions to take via the emit_graph_actions tool.
|
||||
|
||||
PROTECTED TOPICS — NEVER include these in merges (as deleteId) or in deletions:
|
||||
• Topics with learning_relevance="exclude" are REFERENCE MATERIAL, kept on purpose
|
||||
so R42 can still answer questions about them. They are intentionally outside
|
||||
the theme-learning flow. Do not propose deleting or merging them away.
|
||||
• Topics with relevance_locked=true are admin-pinned. Do not change their
|
||||
learning_relevance and do not delete or merge them.
|
||||
These topics may appear as the keepId in a merge (others fold into them), and
|
||||
may appear as source/target of newRelations, but their own row must survive.
|
||||
|
||||
Rules:
|
||||
1. Identify topics that mean exactly the same thing. Choose one to keep, one to delete (merges).
|
||||
2. Identify topics that are too vague, irrelevant, or malformed (deletions).
|
||||
3. Identify missing logical relations (depends_on, part_of, related_to, executed_by) between conceptually linked topics (newRelations).
|
||||
4. Evaluate learning_relevance. Mark purely operational topics (printer guides, etc.) as "exclude"; low-priority as "peripheral" (relevanceUpdates).
|
||||
Skip topics where relevance_locked=true — omit them from relevanceUpdates entirely.
|
||||
|
||||
Do not return the entire graph — only the actions to take.`,
|
||||
|
||||
@@ -73,6 +84,7 @@ const KnowledgeGraph = () => {
|
||||
const [showExcludeNodes, setShowExcludeNodes] = useState(false);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [analyzeError, setAnalyzeError] = useState(null);
|
||||
const [analyzeNotice, setAnalyzeNotice] = useState(null); // info-level message from last analyze
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
const [viewMode, setViewMode] = useState('graph'); // 'graph' | 'table'
|
||||
|
||||
@@ -305,6 +317,7 @@ const KnowledgeGraph = () => {
|
||||
|
||||
setIsAnalyzing(true);
|
||||
setAnalyzeError(null);
|
||||
setAnalyzeNotice(null);
|
||||
|
||||
try {
|
||||
const [currentTopics, currentRelations] = await Promise.all([
|
||||
@@ -314,13 +327,16 @@ const KnowledgeGraph = () => {
|
||||
|
||||
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 => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
type: t.type,
|
||||
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 }) => ({
|
||||
source, target, type,
|
||||
@@ -336,8 +352,26 @@ const KnowledgeGraph = () => {
|
||||
maxTokens: scope === 'full' ? 4096 : 2048,
|
||||
});
|
||||
|
||||
const actions = llmResult.toolUses[0]?.input;
|
||||
if (!actions) throw new Error('Graph analysis did not emit a tool result.');
|
||||
const rawActions = llmResult.toolUses[0]?.input;
|
||||
if (!rawActions) throw new Error('Graph analysis did not emit a tool result.');
|
||||
|
||||
// ── Safety guard ─────────────────────────────────────────────────────
|
||||
// Strip merges/deletions that target excluded or locked topics. Excluded
|
||||
// topics are reference material kept on purpose for R42; locked topics
|
||||
// are admin-pinned. The AI may suggest removing them anyway — we drop
|
||||
// those suggestions before they reach bulkSave.
|
||||
const { filtered: actions, dropped } = filterAiActions(currentTopics, rawActions);
|
||||
const droppedCount = dropped.deletions.length + dropped.merges.length;
|
||||
if (droppedCount > 0) {
|
||||
// Visible feedback so the admin knows the AI tried to touch protected rows.
|
||||
console.warn(
|
||||
`[graph guard] Blocked ${droppedCount} destructive action(s) on protected topics`,
|
||||
dropped,
|
||||
);
|
||||
setAnalyzeNotice(
|
||||
`Guard blocked ${droppedCount} destructive AI action${droppedCount === 1 ? '' : 's'} on excluded or locked topics. ${dropped.deletions.length} deletion${dropped.deletions.length === 1 ? '' : 's'}, ${dropped.merges.length} merge${dropped.merges.length === 1 ? '' : 's'}.`,
|
||||
);
|
||||
}
|
||||
|
||||
let updatedTopics = [...currentTopics];
|
||||
let updatedRelations = [...currentRelations];
|
||||
@@ -460,9 +494,11 @@ const KnowledgeGraph = () => {
|
||||
<GraphControls
|
||||
showExcludeNodes={showExcludeNodes}
|
||||
onShowExcludeChange={setShowExcludeNodes}
|
||||
excludedCount={topics.filter(t => t.learning_relevance === 'exclude').length}
|
||||
onAnalyze={analyzeGraph}
|
||||
isAnalyzing={isAnalyzing}
|
||||
analyzeError={analyzeError}
|
||||
analyzeNotice={analyzeNotice}
|
||||
disabled={topics.length === 0}
|
||||
onApplied={reload}
|
||||
snapshotMeta={snapshotMeta}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { UserPlus, Trash2, Edit2, Shield, User, CheckCircle } from 'lucide-react';
|
||||
import { Trash2, Shield, User, CheckCircle, Info } from 'lucide-react';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import Input from '../ui/Input';
|
||||
import Tag from '../ui/Tag';
|
||||
import * as db from '../../lib/db';
|
||||
import { pb } from '../../lib/pb';
|
||||
import { useApp } from '../../store/AppContext';
|
||||
|
||||
// Users are provisioned automatically on first Azure (Entra ID) login — admins
|
||||
// no longer create them by hand. This panel lets an admin review the roster,
|
||||
// switch a member between user/admin, and remove members. The admin role is
|
||||
// also re-synced from the ENTRA_ADMIN_EMAILS allow-list on every login (see
|
||||
// pb_hooks/team_members.pb.js), so a manual change here can be overridden on
|
||||
// the member's next sign-in if their e-mail is on/off that list.
|
||||
const TeamManager = () => {
|
||||
const { state } = useApp();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [formData, setFormData] = useState({ name: '', role: 'user', pin: '' });
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const loadUsers = async () => {
|
||||
@@ -23,29 +24,16 @@ const TeamManager = () => {
|
||||
|
||||
useEffect(() => { loadUsers(); }, []);
|
||||
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name || !formData.pin) return;
|
||||
|
||||
if (editingId) {
|
||||
await db.updateTeamMember(editingId, { name: formData.name, role: formData.role, pin: formData.pin });
|
||||
setMessage('User updated successfully.');
|
||||
} else {
|
||||
await db.addTeamMember({ name: formData.name, role: formData.role, pin: formData.pin });
|
||||
setMessage('User added successfully.');
|
||||
}
|
||||
|
||||
await loadUsers();
|
||||
setFormData({ name: '', role: 'user', pin: '' });
|
||||
setIsEditing(false);
|
||||
setEditingId(null);
|
||||
const flash = (msg) => {
|
||||
setMessage(msg);
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
};
|
||||
|
||||
const handleEdit = (user) => {
|
||||
setFormData({ name: user.name, role: user.role, pin: user.pin });
|
||||
setIsEditing(true);
|
||||
setEditingId(user.id);
|
||||
const handleRoleChange = async (user, role) => {
|
||||
if (role === user.role) return;
|
||||
await db.updateTeamMember(user.id, { role });
|
||||
await loadUsers();
|
||||
flash(`${user.name} is now ${role}.`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
@@ -56,24 +44,17 @@ const TeamManager = () => {
|
||||
if (confirm("Are you sure you want to delete this user?")) {
|
||||
await db.deleteTeamMember(id);
|
||||
|
||||
// Also remove from leaderboard
|
||||
// Also remove from leaderboard.
|
||||
try {
|
||||
const entry = await pb.collection('leaderboard').getFirstListItem(`user_id="${id}"`);
|
||||
await pb.collection('leaderboard').delete(entry.id);
|
||||
} catch { /* no leaderboard entry, nothing to do */ }
|
||||
|
||||
await loadUsers();
|
||||
setMessage('User deleted.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
flash('User deleted.');
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
setEditingId(null);
|
||||
setFormData({ name: '', role: 'user', pin: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{message && (
|
||||
@@ -82,58 +63,22 @@ const TeamManager = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="border border-bg-warm">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2 mb-4">
|
||||
{isEditing ? <Edit2 size={20} className="text-teal" /> : <UserPlus size={20} className="text-teal" />}
|
||||
{isEditing ? 'Edit Team Member' : 'Add New Team Member'}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSave} className="flex flex-col sm:flex-row gap-4 items-end">
|
||||
<div className="flex-1 w-full">
|
||||
<Input
|
||||
label="Full Name"
|
||||
placeholder="e.g. Jane Doe"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({...formData, name: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 w-full">
|
||||
<label className="block text-sm font-medium mb-1.5 text-fg">Role</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={e => setFormData({...formData, role: e.target.value})}
|
||||
className="w-full h-11 px-3 rounded-[var(--r-sm)] border border-bg-warm bg-paper text-sm focus:outline-none focus:ring-2 focus:ring-teal/30 focus:border-teal"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 w-full">
|
||||
<Input
|
||||
label="Login PIN"
|
||||
type="text"
|
||||
placeholder="e.g. 1234"
|
||||
value={formData.pin}
|
||||
onChange={e => setFormData({...formData, pin: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button type="submit" className="w-full sm:w-auto h-11 px-6">
|
||||
{isEditing ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
{isEditing && (
|
||||
<Button type="button" variant="outline" onClick={cancelEdit} className="h-11 px-4">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<Card className="border border-bg-warm bg-bg-warm/30">
|
||||
<p className="text-sm text-fg-muted flex items-start gap-2">
|
||||
<Info size={16} className="mt-0.5 shrink-0 text-teal" />
|
||||
Team members are created automatically when they first sign in with their
|
||||
Microsoft account. Admins are granted via the <code>ENTRA_ADMIN_EMAILS</code>{' '}
|
||||
allow-list and re-synced on each login.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-0 border border-bg-warm overflow-hidden">
|
||||
<div className="divide-y divide-bg-warm">
|
||||
{users.length === 0 && (
|
||||
<div className="p-6 text-sm text-fg-muted text-center">
|
||||
No team members yet — they appear here after their first sign-in.
|
||||
</div>
|
||||
)}
|
||||
{users.map(user => (
|
||||
<div key={user.id} className="p-4 flex items-center justify-between hover:bg-bg-warm/30 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -145,19 +90,26 @@ const TeamManager = () => {
|
||||
{user.name}
|
||||
{user.id === state.currentUser?.id && <Tag variant="accent" className="text-[10px] py-0">You</Tag>}
|
||||
</p>
|
||||
<p className="text-sm text-fg-muted">PIN: {user.pin}</p>
|
||||
<p className="text-sm text-fg-muted">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Tag variant={user.role === 'admin' ? 'dark' : 'default'} className="hidden sm:inline-flex">{user.role}</Tag>
|
||||
<button onClick={() => handleEdit(user)} className="p-2 text-fg-muted hover:text-teal transition-colors">
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<select
|
||||
value={user.role || 'user'}
|
||||
onChange={e => handleRoleChange(user, e.target.value)}
|
||||
disabled={user.id === state.currentUser?.id}
|
||||
className="h-9 px-2 rounded-[var(--r-sm)] border border-bg-warm bg-paper text-sm focus:outline-none focus:ring-2 focus:ring-teal/30 focus:border-teal disabled:opacity-40"
|
||||
aria-label={`Role for ${user.name}`}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
disabled={user.id === state.currentUser?.id}
|
||||
className="p-2 text-fg-muted hover:text-red-500 disabled:opacity-30 transition-colors"
|
||||
aria-label={`Delete ${user.name}`}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 SuggestionsQueue from '../SuggestionsQueue';
|
||||
|
||||
@@ -24,9 +24,11 @@ const SCOPE_OPTIONS = [
|
||||
export default function GraphControls({
|
||||
showExcludeNodes,
|
||||
onShowExcludeChange,
|
||||
excludedCount = 0,
|
||||
onAnalyze,
|
||||
isAnalyzing,
|
||||
analyzeError,
|
||||
analyzeNotice,
|
||||
disabled,
|
||||
onApplied,
|
||||
snapshotMeta,
|
||||
@@ -50,7 +52,14 @@ export default function GraphControls({
|
||||
onChange={e => onShowExcludeChange(e.target.checked)}
|
||||
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>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -106,6 +115,16 @@ export default function GraphControls({
|
||||
{analyzeError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{analyzeNotice && (
|
||||
<p
|
||||
className="text-xs text-teal flex items-start gap-1 bg-teal/5 border border-teal/20 rounded-[var(--r-sm)] p-2"
|
||||
title="The AI suggested removing excluded/locked topics. Those suggestions were dropped client-side before saving."
|
||||
>
|
||||
<ShieldCheck size={14} className="shrink-0 mt-0.5" />
|
||||
{analyzeNotice}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SuggestionsQueue onApplied={onApplied} />
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
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';
|
||||
}
|
||||
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' && (
|
||||
<div className="animate-in fade-in duration-300 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl text-teal mb-2">Team Management</h1>
|
||||
<p className="text-fg-muted mb-8">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 />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,37 +2,25 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApp } from '../store/AppContext';
|
||||
import Card from '../components/ui/Card';
|
||||
import Input from '../components/ui/Input';
|
||||
import Select from '../components/ui/Select';
|
||||
import Button from '../components/ui/Button';
|
||||
|
||||
const Login = () => {
|
||||
const { state, login } = useApp();
|
||||
const { loginWithAzure } = useApp();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState('');
|
||||
const [pin, setPin] = useState('');
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const userOptions = [
|
||||
{ value: '', label: 'Select a user...' },
|
||||
...state.users.map(u => ({ value: u.id, label: u.name }))
|
||||
];
|
||||
|
||||
const handleLogin = (e) => {
|
||||
e.preventDefault();
|
||||
const handleAzure = async () => {
|
||||
setError('');
|
||||
|
||||
if (!selectedUser || !pin) {
|
||||
setError('Please fill in all fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = login(selectedUser, pin);
|
||||
if (success) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await loginWithAzure();
|
||||
navigate('/');
|
||||
} else {
|
||||
setError('Incorrect PIN.');
|
||||
} catch (e) {
|
||||
// PocketBase throws when the popup is closed or the token exchange fails.
|
||||
setError(e?.message || 'Signing in with Microsoft failed. Please try again.');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,31 +34,17 @@ const Login = () => {
|
||||
</div>
|
||||
|
||||
<Card className="border border-bg-warm">
|
||||
<form onSubmit={handleLogin} className="flex flex-col gap-5">
|
||||
<Select
|
||||
label="User"
|
||||
options={userOptions}
|
||||
value={selectedUser}
|
||||
onChange={(e) => setSelectedUser(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="PIN Code"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={4}
|
||||
placeholder="••••"
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="text-fg-muted text-sm text-center">
|
||||
Sign in with your Respellion Microsoft account.
|
||||
</p>
|
||||
|
||||
{error && <div className="text-red-500 text-sm font-medium">{error}</div>}
|
||||
{error && <div className="text-red-500 text-sm font-medium text-center">{error}</div>}
|
||||
|
||||
<Button type="submit" className="mt-2 w-full">
|
||||
Sign In
|
||||
<Button onClick={handleAzure} disabled={busy} className="w-full">
|
||||
{busy ? 'Signing in…' : 'Sign in with Microsoft'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext, useReducer, useEffect } from 'react';
|
||||
import * as db from '../lib/db';
|
||||
import { pb } from '../lib/pb';
|
||||
import { OIDC_PROVIDER } from '../lib/azureAuth';
|
||||
import { getPersonalWeekNumber } from '../lib/curriculumService';
|
||||
|
||||
const AppContext = createContext();
|
||||
@@ -50,44 +51,40 @@ export function AppProvider({ children }) {
|
||||
|
||||
useEffect(() => {
|
||||
const loadState = async () => {
|
||||
let members = await db.getTeamMembers();
|
||||
|
||||
if (!members || members.length === 0) {
|
||||
// Restore the session from PocketBase's persistent auth store. The token
|
||||
// is kept in localStorage by the SDK; authRefresh verifies it is still
|
||||
// valid (and that the Entra account hasn't been revoked) and returns the
|
||||
// up-to-date record.
|
||||
if (pb.authStore.isValid && pb.authStore.record?.collectionName === 'team_members') {
|
||||
try {
|
||||
const created = await db.addTeamMember({ name: 'Admin', role: 'admin', pin: '0000' });
|
||||
members = [created];
|
||||
} catch (e) {
|
||||
console.warn('[AppContext] Could not auto-create admin user:', e.message,
|
||||
'— Run scripts/setup-pb-collections.mjs to configure the database.');
|
||||
}
|
||||
}
|
||||
|
||||
const sessionUserId = sessionStorage.getItem('respellion_session');
|
||||
if (sessionUserId) {
|
||||
const user = members.find(u => u.id === sessionUserId);
|
||||
if (user) {
|
||||
dispatch({ type: 'LOGIN', payload: user });
|
||||
const { record } = await pb.collection('team_members').authRefresh();
|
||||
dispatch({ type: 'LOGIN', payload: record });
|
||||
} catch {
|
||||
// Token rejected (expired / account revoked) — drop the session.
|
||||
pb.authStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// The team list (leaderboard, dashboard) requires authentication now, so
|
||||
// only load it once we have a valid session.
|
||||
const members = pb.authStore.isValid ? await db.getTeamMembers() : [];
|
||||
dispatch({ type: 'INIT_APP', payload: { users: members } });
|
||||
};
|
||||
|
||||
loadState().catch(console.error);
|
||||
}, []);
|
||||
|
||||
const login = (userId, pin) => {
|
||||
const user = state.users.find(u => u.id === userId && u.pin === pin);
|
||||
if (user) {
|
||||
sessionStorage.setItem('respellion_session', user.id);
|
||||
dispatch({ type: 'LOGIN', payload: user });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
// Start the Entra (OIDC) login flow. PocketBase opens the provider popup,
|
||||
// handles the token exchange server-side, and auto-provisions the record on
|
||||
// first login (see pb_hooks/team_members.pb.js).
|
||||
const loginWithAzure = async () => {
|
||||
const authData = await pb.collection('team_members').authWithOAuth2({ provider: OIDC_PROVIDER });
|
||||
dispatch({ type: 'LOGIN', payload: authData.record });
|
||||
return authData.record;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
sessionStorage.removeItem('respellion_session');
|
||||
pb.authStore.clear();
|
||||
dispatch({ type: 'LOGOUT' });
|
||||
};
|
||||
|
||||
@@ -107,7 +104,7 @@ export function AppProvider({ children }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={{ state, dispatch, login, logout, enrollCurrentUser }}>
|
||||
<AppContext.Provider value={{ state, dispatch, loginWithAzure, logout, enrollCurrentUser }}>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user