feat: Azure (Entra ID) login as user login (#16)

Replaces the client-side PIN login with real authentication against Azure
Entra ID via PocketBase's built-in OAuth2 (OIDC) flow. Every Azure user is
auto-provisioned as a team_member on first login.

- pb_migrations: team_members becomes a PocketBase auth collection (OIDC
  provider configured from env); all collections require @request.auth.id
- pb_hooks: provisioning of role (ENTRA_ADMIN_EMAILS allow-list) and
  enrollment_status, with admin-role re-sync on every login
- frontend: "Sign in with Microsoft" login, pb.authStore-based session,
  TeamManager manages roster/roles (no PIN/manual create)
- infra: Entra env wiring + pb_hooks mount for dev (Labs) and prod
- src/lib/azureAuth.js + tests for the canonical role/allow-list logic
- docs/auth-spec.md

Knowledge base, generated tests and micro-learnings are left untouched.
Existing PIN users are dropped (no migration required, per sign-off).

Closes #16

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
RaymondVerhoef
2026-06-23 11:41:08 +02:00
parent 5f34a6f825
commit 3af105bccd
17 changed files with 734 additions and 170 deletions

View 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");