Compare commits
5 Commits
fix/protec
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2628041c12 | ||
| fab7a12f7b | |||
|
|
fe99381cb7 | ||
|
|
3af105bccd | ||
| 5f34a6f825 |
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
|
||||
|
||||
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");
|
||||
308
pb_migrations/1781000000_team_members_to_auth.js
Normal file
308
pb_migrations/1781000000_team_members_to_auth.js
Normal file
@@ -0,0 +1,308 @@
|
||||
/// <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. 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). Per-user progress is reset
|
||||
// anyway, so dropping the FK constraint here is acceptable.
|
||||
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;
|
||||
try { c = app.findCollectionByNameOrId(dep.name); } catch (_) { continue; }
|
||||
// Drop any index that references the team_member_id column.
|
||||
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);
|
||||
}
|
||||
|
||||
// Recreate the unique (team_member_id, session_week) guard on the text column.
|
||||
try {
|
||||
const tsc = app.findCollectionByNameOrId("theme_session_completions");
|
||||
tsc.indexes.push("CREATE UNIQUE INDEX `idx_theme_session_completions_user_week` ON `theme_session_completions` (`team_member_id`, `session_week`)");
|
||||
app.save(tsc);
|
||||
} catch (_) { /* collection missing — skip */ }
|
||||
|
||||
// 2. Drop the old PIN-based team_members (now unreferenced).
|
||||
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,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
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';
|
||||
}
|
||||
@@ -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