docs: sp2 spec + plan (keycloak, grist)
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
# SP2 — Keycloak SSO + Grist Progression Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Learners sign in via auth.saillant.cc, mark chapters as read (stored in Grist), the atom's electrons light up, completion syncs best-effort to Moodle.
|
||||
|
||||
**Architecture:** Zero-dep OIDC code+PKCE client and HMAC-signed session cookie. Grist REST API is the progression store (doc `formations-progression`). A privileged Moodle WS user (`wssync`) mirrors finished books into Moodle completion. Hub/sommaire render read-state server-side; atom-hub lights electrons client-side from the same hubData.
|
||||
|
||||
**Tech Stack:** unchanged (Astro 6 SSR, three pinned, vitest). No new npm dependency — OIDC/JWT-userinfo/HMAC via `node:crypto` + `fetch`.
|
||||
|
||||
**Repo:** `electron-server:/home/electron/lelectron-rare/formations-app`, branch `feat/sp2-auth-progression` off main. Deploy: Tower `~/formations-app` (compose, env `/home/clems/formations-app.env`). Docker runner (`--network=host`, node:22-alpine) for `npm ci && npx vitest run && npm run build` as in SP1.
|
||||
|
||||
**New env vars** (Tower env file; document in ops/README.md): `OIDC_ISSUER` (https://auth.saillant.cc/realms/<realm>), `OIDC_CLIENT_ID=formations-app`, `OIDC_CLIENT_SECRET`, `SESSION_SECRET` (random 32B hex), `APP_BASE_URL=https://formations.saillant.cc`, `GRIST_BASE_URL=https://grist.saillant.cc`, `GRIST_API_KEY`, `GRIST_DOC_ID`, `MOODLE_SYNC_TOKEN`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Provisioning (Keycloak client, Grist doc, Moodle wssync)
|
||||
|
||||
**Files:** Create `ops/provision-sync-role.php`, update `ops/README.md`. Most work is operator-style against live systems; every secret goes ONLY to `/home/clems/formations-app.env` on Tower.
|
||||
|
||||
- [ ] **1.1 Keycloak client.** On electron-server, use kcadm inside the `suite-keycloak` container (admin creds: inspect the container env / `/home/clems/.secrets/saillant-shared.env` for KEYCLOAK_ADMIN[_PASSWORD]). First `kcadm.sh config credentials --server http://localhost:8080 --realm master --user $ADMIN --password $PASS`. List realms (`kcadm.sh get realms --fields realm`); use `electron_rare` if present (fallback: report what exists — do NOT guess another realm without listing). Create client:
|
||||
`kcadm.sh create clients -r electron_rare -s clientId=formations-app -s protocol=openid-connect -s publicClient=false -s standardFlowEnabled=true -s directAccessGrantsEnabled=false -s 'redirectUris=["https://formations.saillant.cc/auth/callback"]' -s 'webOrigins=["https://formations.saillant.cc"]'`
|
||||
then fetch its secret (`kcadm.sh get clients/<id>/client-secret -r electron_rare`). Append `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` to the Tower env file. Verify discovery: `curl -s https://auth.saillant.cc/realms/electron_rare/.well-known/openid-configuration | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["authorization_endpoint"], d["token_endpoint"], d["userinfo_endpoint"], d["end_session_endpoint"])'` — record these 4 URLs in ops/README.md.
|
||||
|
||||
- [ ] **1.2 Grist doc.** Find a working API key: search electron-server (`sudo grep -ri GRIST_API /home/clems /home/electron --include='*.env' --include='*.py' -l | head`) — prior projects (budget enfants, F4L) used one; verify with `curl -H "Authorization: Bearer $KEY" https://grist.saillant.cc/api/orgs`. Create the doc in the default org/workspace (`POST /api/workspaces/<ws>/docs {"name":"formations-progression"}`), then the table:
|
||||
`POST /api/docs/<docId>/apply` with `[["AddTable","Progression",[{"id":"user_sub","type":"Text"},{"id":"email","type":"Text"},{"id":"course_slug","type":"Text"},{"id":"module_n","type":"Int"},{"id":"chapitre_n","type":"Int"},{"id":"lu_le","type":"DateTime"},{"id":"moodle_synced","type":"Bool"}]]]`.
|
||||
Smoke: add + list + delete a record via `/api/docs/<docId>/tables/Progression/records`. Append `GRIST_BASE_URL`, `GRIST_API_KEY`, `GRIST_DOC_ID` to the Tower env file. CAUTION (memory): Grist Bool filters are quirky — filter in app code, not via Grist filter params, except simple equality on Text columns which works.
|
||||
|
||||
- [ ] **1.3 Moodle wssync.** Create `ops/provision-sync-role.php` (same style as provision-ws.php; idempotent): creates role `wssync` (system context) with caps `moodle/user:create`, `moodle/user:viewdetails`, `moodle/user:update`, `enrol/manual:enrol`, `moodle/course:overridecompletion`, `moodle/course:viewparticipants`, `webservice/rest:use`; creates user `wssync` (password env `WSSYNC_PASSWORD`), assigns the role at system context. Run it (docker cp + docker exec on Tower, like SP1). Get token via `login/token.php?service=moodle_mobile_app` → `MOODLE_SYNC_TOKEN` in env file. PROVE the caps: call `core_user_get_users_by_field` (field=username, values[0]=wsreader) with that token → must return the user, not an exception. Also verify `core_completion_override_activity_completion_status` is allowed: call it for wsreader on course 3's first book cmid (get cmid from the fixture `modules[].id`) with `newstate=1`, confirm response, then set back `newstate=0`.
|
||||
|
||||
- [ ] **1.4** Update ops/README.md (all new env vars, kcadm snippets, Grist doc id, token renewal). Commit: `git add ops && git commit -m 'feat: sp2 provisioning (keycloak, grist, wssync)'`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Session + OIDC (TDD on session lib)
|
||||
|
||||
**Files:** Create `src/lib/session.ts` + `src/lib/session.test.ts`, `src/lib/oidc.ts`, `src/pages/connexion.ts`, `src/pages/auth/callback.ts`, `src/pages/deconnexion.ts`; modify `src/components/Nav.astro`.
|
||||
|
||||
- [ ] **2.1 Tests first** (`session.test.ts`): sign→verify roundtrip returns payload; tampered cookie → null; expired (exp in past) → null; missing/garbage → null.
|
||||
|
||||
- [ ] **2.2 `src/lib/session.ts`**:
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export interface SessionUser { sub: string; email: string; name: string; exp: number; }
|
||||
const SECRET = process.env.SESSION_SECRET || '';
|
||||
export const SESSION_COOKIE = 'fsession';
|
||||
|
||||
const b64u = (b: Buffer | string) => Buffer.from(b).toString('base64url');
|
||||
|
||||
export function signSession(user: Omit<SessionUser, 'exp'>, ttlSec = 7 * 86400): string {
|
||||
const payload: SessionUser = { ...user, exp: Math.floor(Date.now() / 1000) + ttlSec };
|
||||
const body = b64u(JSON.stringify(payload));
|
||||
const mac = createHmac('sha256', SECRET).update(body).digest('base64url');
|
||||
return `${body}.${mac}`;
|
||||
}
|
||||
|
||||
export function verifySession(token: string | undefined): SessionUser | null {
|
||||
if (!token || !SECRET) return null;
|
||||
const [body, mac] = token.split('.');
|
||||
if (!body || !mac) return null;
|
||||
const expect = createHmac('sha256', SECRET).update(body).digest();
|
||||
const got = Buffer.from(mac, 'base64url');
|
||||
if (got.length !== expect.length || !timingSafeEqual(got, expect)) return null;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, 'base64url').toString()) as SessionUser;
|
||||
if (!payload.sub || payload.exp < Date.now() / 1000) return null;
|
||||
return payload;
|
||||
} catch { return null; }
|
||||
}
|
||||
```
|
||||
Tests must set `process.env.SESSION_SECRET` BEFORE importing (use `vi.stubEnv` + dynamic import, or read SECRET lazily inside functions — prefer lazy read: `const secret = () => process.env.SESSION_SECRET || '';` adjust code accordingly).
|
||||
|
||||
- [ ] **2.3 `src/lib/oidc.ts`** — discovery cached, PKCE helpers, code exchange, userinfo:
|
||||
|
||||
```ts
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { swrCache } from './cache';
|
||||
|
||||
const ISSUER = () => process.env.OIDC_ISSUER || '';
|
||||
const CLIENT_ID = () => process.env.OIDC_CLIENT_ID || 'formations-app';
|
||||
const CLIENT_SECRET = () => process.env.OIDC_CLIENT_SECRET || '';
|
||||
const APP = () => process.env.APP_BASE_URL || 'https://formations.saillant.cc';
|
||||
|
||||
interface Discovery { authorization_endpoint: string; token_endpoint: string; userinfo_endpoint: string; end_session_endpoint?: string; }
|
||||
const discovery = swrCache(async () => {
|
||||
const res = await fetch(`${ISSUER()}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) throw new Error(`oidc discovery: HTTP ${res.status}`);
|
||||
return (await res.json()) as Discovery;
|
||||
}, 3600_000);
|
||||
|
||||
export const redirectUri = () => `${APP()}/auth/callback`;
|
||||
|
||||
export function pkcePair() {
|
||||
const verifier = randomBytes(32).toString('base64url');
|
||||
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
export async function authUrl(state: string, challenge: string): Promise<string> {
|
||||
const d = await discovery('oidc');
|
||||
const p = new URLSearchParams({
|
||||
client_id: CLIENT_ID(), response_type: 'code', scope: 'openid email profile',
|
||||
redirect_uri: redirectUri(), state, code_challenge: challenge, code_challenge_method: 'S256',
|
||||
});
|
||||
return `${d.authorization_endpoint}?${p}`;
|
||||
}
|
||||
|
||||
export async function exchangeCode(code: string, verifier: string): Promise<{ sub: string; email: string; name: string }> {
|
||||
const d = await discovery('oidc');
|
||||
const res = await fetch(d.token_endpoint, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code', code, redirect_uri: redirectUri(),
|
||||
client_id: CLIENT_ID(), client_secret: CLIENT_SECRET(), code_verifier: verifier,
|
||||
}),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`oidc token: HTTP ${res.status}`);
|
||||
const tok = (await res.json()) as { access_token: string };
|
||||
const ui = await fetch(d.userinfo_endpoint, { headers: { Authorization: `Bearer ${tok.access_token}` }, signal: AbortSignal.timeout(8000) });
|
||||
if (!ui.ok) throw new Error(`oidc userinfo: HTTP ${ui.status}`);
|
||||
const u = (await ui.json()) as { sub: string; email?: string; name?: string; preferred_username?: string };
|
||||
return { sub: u.sub, email: u.email ?? '', name: u.name ?? u.preferred_username ?? u.email ?? 'Apprenant' };
|
||||
}
|
||||
|
||||
export async function logoutUrl(): Promise<string | null> {
|
||||
const d = await discovery('oidc');
|
||||
return d.end_session_endpoint ? `${d.end_session_endpoint}?client_id=${CLIENT_ID()}&post_logout_redirect_uri=${encodeURIComponent(APP())}` : null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **2.4 Routes.** `src/pages/connexion.ts` (APIRoute GET): generate state+pkce, set short-lived httpOnly cookies `oidc_state`, `oidc_verifier` (and `oidc_next` = sanitized `?next=` path starting with `/`), 302 to authUrl. `src/pages/auth/callback.ts`: check `state` vs cookie, exchangeCode → `signSession` → set `fsession` cookie (`httpOnly, secure, sameSite=lax, path=/, maxAge 7d`), clear temp cookies, 302 to `next` or `/`. On any failure: 302 `/connexion-erreur` — create tiny page `src/pages/connexion-erreur.astro` (dark, "La connexion a échoué", link home). `src/pages/deconnexion.ts`: clear cookie, 302 to Keycloak logoutUrl or `/`.
|
||||
|
||||
- [ ] **2.5 Nav.** Add Props `user?: { name: string } | null`; right side shows (light/dark aware): user name + « Se déconnecter » link, else « Se connecter » → `/connexion?next=<current>`. Pages pass `user={verifySession(Astro.cookies.get(SESSION_COOKIE)?.value)}` — add that in catalogue/hub/reading pages.
|
||||
|
||||
- [ ] **2.6** vitest + build green → commit `feat: keycloak oidc login + sessions`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Grist progression + mark-as-read
|
||||
|
||||
**Files:** Create `src/lib/grist.ts` + `src/lib/grist.test.ts`, `src/pages/api/lu.ts`; modify chapter page.
|
||||
|
||||
- [ ] **3.1 `src/lib/grist.ts`** (tests on pure helpers: record mapping + isBookComplete):
|
||||
|
||||
```ts
|
||||
const BASE = () => process.env.GRIST_BASE_URL || 'https://grist.saillant.cc';
|
||||
const KEY = () => process.env.GRIST_API_KEY || '';
|
||||
const DOC = () => process.env.GRIST_DOC_ID || '';
|
||||
|
||||
export interface ReadMark { course_slug: string; module_n: number; chapitre_n: number; moodle_synced: boolean; id: number; }
|
||||
|
||||
async function grist(path: string, init?: RequestInit): Promise<any> {
|
||||
const res = await fetch(`${BASE()}/api/docs/${DOC()}${path}`, {
|
||||
...init,
|
||||
headers: { Authorization: `Bearer ${KEY()}`, 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`grist ${path}: HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function mapRecords(raw: { records: { id: number; fields: Record<string, unknown> }[] }): ReadMark[] {
|
||||
return raw.records.map((r) => ({
|
||||
id: r.id,
|
||||
course_slug: String(r.fields.course_slug ?? ''),
|
||||
module_n: Number(r.fields.module_n ?? 0),
|
||||
chapitre_n: Number(r.fields.chapitre_n ?? 0),
|
||||
moodle_synced: Boolean(r.fields.moodle_synced),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getReads(sub: string, courseSlug: string): Promise<ReadMark[]> {
|
||||
const filter = encodeURIComponent(JSON.stringify({ user_sub: [sub], course_slug: [courseSlug] }));
|
||||
return mapRecords(await grist(`/tables/Progression/records?filter=${filter}`));
|
||||
}
|
||||
|
||||
export async function addRead(sub: string, email: string, courseSlug: string, moduleN: number, chapitreN: number): Promise<void> {
|
||||
const existing = await getReads(sub, courseSlug);
|
||||
if (existing.some((r) => r.module_n === moduleN && r.chapitre_n === chapitreN)) return; // idempotent
|
||||
await grist('/tables/Progression/records', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ records: [{ fields: { user_sub: sub, email, course_slug: courseSlug, module_n: moduleN, chapitre_n: chapitreN, lu_le: Math.floor(Date.now() / 1000), moodle_synced: false } }] }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function markSynced(ids: number[]): Promise<void> {
|
||||
if (!ids.length) return;
|
||||
await grist('/tables/Progression/records', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ records: ids.map((id) => ({ id, fields: { moodle_synced: true } })) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function isBookComplete(reads: ReadMark[], moduleN: number, chapterCount: number): boolean {
|
||||
const seen = new Set(reads.filter((r) => r.module_n === moduleN).map((r) => r.chapitre_n));
|
||||
for (let i = 1; i <= chapterCount; i++) if (!seen.has(i)) return false;
|
||||
return true;
|
||||
}
|
||||
```
|
||||
Tests: `mapRecords` on a realistic payload; `isBookComplete` (complete, holes, empty). NOTE the timestamp: Grist DateTime accepts epoch seconds.
|
||||
|
||||
- [ ] **3.2 `src/pages/api/lu.ts`** (POST, form-encoded or JSON): verify session (401 JSON if anon); body `{slug, module, chapitre}` validated against `bySlug` + integers; `addRead`; fire-and-forget `syncToMoodle(user, slug)` (Task 4 — stub `export async function syncToMoodle(){}` now, real in T4); if request is a form POST (content-type form-urlencoded), 303 redirect back to the chapter (PRG); else JSON `{ok:true}`.
|
||||
|
||||
- [ ] **3.3 Chapter page.** Pass `user` to Base/Nav. End of chapter, before prev/next: if `user` and not yet read → `<form method="POST" action="/api/lu">` hidden inputs slug/module/chapitre + submit « Marquer comme lu ✓ » (copper button); if already read (fetch `getReads` SSR — wrap in try/catch, on Grist failure treat as none-read + small banner « progression momentanément indisponible ») → badge « Chapitre lu ✓ »; if anon → link « Se connecter pour suivre votre progression » → `/connexion?next=<chapter url>`.
|
||||
|
||||
- [ ] **3.4** vitest + build → commit `feat: grist progression + mark as read`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Moodle sync (wssync)
|
||||
|
||||
**Files:** Create `src/lib/moodle-sync.ts`; wire in `api/lu.ts` + hub page retry.
|
||||
|
||||
- [ ] **4.1** `src/lib/moodle-sync.ts` using `MOODLE_SYNC_TOKEN` and the same `ws()` pattern as moodle.ts (factor a shared `wsCall(token, fn, params)` export out of moodle.ts rather than duplicating):
|
||||
- `ensureMoodleUser(email, name): Promise<number>` — `core_user_get_users_by_field` (field email); if absent `core_user_create_users` (username = email localpart sanitized + random suffix on clash, random password, auth manual). Cache (module-level Map email→id).
|
||||
- `ensureEnrolled(userId, courseId)` — `enrol_manual_enrol_users` (roleid 5 = student).
|
||||
- `syncToMoodle(user: {sub,email,name}, courseSlug)` — load reads (getReads) + course books (getCourseBooks); for each module fully read (isBookComplete) whose rows aren't all synced: ensure user+enrol once, then `core_completion_override_activity_completion_status` (cmid = book module id from getCourseBooks — note: `BookModule.id` IS the cmid from core_course_get_contents), `newstate=1`, `userid`; then `markSynced(rowIds of that module)`. All wrapped: any failure → console.error and return (rows stay unsynced).
|
||||
- [ ] **4.2** Hub page: after loading reads SSR, if any row has `moodle_synced=false` → fire-and-forget `syncToMoodle` (don't await render).
|
||||
- [ ] **4.3** Build + tests → commit `feat: best-effort moodle completion sync`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Light the atom + sommaire ✓ + nav state
|
||||
|
||||
**Files:** Modify hub page, `Sommaire.astro`, `src/scripts/atom-hub.ts`, catalogue page.
|
||||
|
||||
- [ ] **5.1 Hub page**: load `reads` (SSR, try/catch → empty), build `readSet` of `"m-c"`; hubData chapters gain `read: boolean`; pass `user` to Nav; under the course blurb show (if user) « X / N chapitres lus · Y % » in copper; Sommaire gets `readSet` prop → ✓ (copper) before read chapter titles, dimmed number otherwise.
|
||||
- [ ] **5.2 atom-hub.ts**: `HubChapter` gains `read?: boolean`. Electron material: read → `COPPER_SOFT` + glow opacity 0.55 (current); unread → color `0x64748b` (slate) + glow opacity 0.15. Module label text: `M1 · <title> — n/total` when any progress. Keep one shared material per state (two materials total, stop cloning per electron — addresses earlier review note). Hover scale unchanged for both.
|
||||
- [ ] **5.3 Catalogue**: pass `user` to Nav (no per-course % on catalogue in SP2 — YAGNI).
|
||||
- [ ] **5.4** Build + vitest → commit `feat: light read electrons + progress`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Deploy + E2E
|
||||
|
||||
- [ ] **6.1** Merge branch → main (ff-only), push Gitea; on Tower `git pull && docker compose up -d --build` (env file already carries the new vars from Task 1).
|
||||
- [ ] **6.2** E2E curl:
|
||||
- `GET /connexion` → 302 Location starts with `https://auth.saillant.cc/realms/` and contains `code_challenge`.
|
||||
- `POST /api/lu` anon → 401.
|
||||
- `GET /auth/callback?state=x&code=y` (bogus) → 302 `/connexion-erreur`; that page renders 200.
|
||||
- hub + chapter pages still 200 anon; chapter shows « Se connecter pour suivre votre progression ».
|
||||
- [ ] **6.3** Owner manual: full SSO login, mark 2-3 chapters, electrons light up, Grist doc shows rows (grist.saillant.cc), after completing a full module check Moodle completion (admin UI or DB `mdl_course_modules_completion`).
|
||||
- [ ] **6.4** Commit any deploy doc tweaks; push.
|
||||
|
||||
## Self-review notes
|
||||
- Spec coverage: SSO (T1.1+T2), Grist store (T1.2+T3), button UX + anon hint (T3.3), Moodle bridge wssync (T1.3+T4), lit electrons + labels + % + sommaire ✓ (T5), error handling (cookies self-validated; Grist failures banner+empty; sync deferred via moodle_synced=false + hub retry), E2E (T6).
|
||||
- Types: `SessionUser`, `ReadMark`, `HubChapter.read` consistent across tasks; `BookModule.id`=cmid noted for completion override.
|
||||
- No new deps; secrets only in Tower env file.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Design: SP2 — accounts (Keycloak SSO) + progression (Grist) + lit electrons
|
||||
|
||||
Date: 2026-06-10
|
||||
Status: approved (owner Q&A; change vs first draft: Grist replaces SQLite)
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Auth = Keycloak SSO**: the app is an OIDC client of `auth.saillant.cc`
|
||||
(realm `electron_rare`), Authorization Code + PKCE, confidential client
|
||||
`formations-app`. `/connexion` → Keycloak → `/auth/callback` → signed
|
||||
httpOnly session cookie (HMAC, zero-dep). `/deconnexion` clears it (+ RP
|
||||
-initiated logout). Learner registration = realm policy (out of app scope).
|
||||
No Moodle login in the app.
|
||||
- **Progression store = Grist** (`grist.saillant.cc`, NOT SQLite): doc
|
||||
`formations-progression`, table `Progression` (columns: `user_sub`,
|
||||
`email`, `course_slug`, `module_n`, `chapitre_n`, `lu_le` (timestamp),
|
||||
`moodle_synced` (bool)). App talks to the Grist REST API with an API key
|
||||
(env). Reads cached in-process (short TTL, keyed per user+course);
|
||||
writes are direct (failure → friendly error, never a crash).
|
||||
- **Mark-as-read = explicit button** at the end of each chapter (logged-in;
|
||||
anonymous users see a "Se connecter pour suivre votre progression" hint).
|
||||
Works without JS (form POST) and enhances with fetch.
|
||||
- **Moodle bridge (for future certificates)**: progression lives in Grist;
|
||||
the app syncs to Moodle best-effort with a privileged WS user `wssync`
|
||||
(created by extending ops/provision-ws.php: custom system role with
|
||||
user-create / manual-enrol / completion-override caps):
|
||||
on first action, ensure a Moodle account matching the Keycloak email +
|
||||
enrol as student; when ALL chapters of a book are read, override that
|
||||
book activity's completion. Failures leave `moodle_synced=false`; rows
|
||||
are retried opportunistically on the user's next hub visit. UX never
|
||||
blocks on Moodle.
|
||||
- **Hub lighting**: hubData chapters gain `read: boolean`; read electrons
|
||||
glow full copper, unread stay dim; per-module progress (n/total) on the
|
||||
orbit label; global % under the course title. The SSR sommaire shows ✓
|
||||
on read chapters. Nav shows "Se connecter" or the user's name.
|
||||
|
||||
## Out of scope (SP3/SP4)
|
||||
|
||||
Quizzes; certificate celebration/PDF; domain swap; FER-direct login.
|
||||
|
||||
## Error handling
|
||||
|
||||
Keycloak down → /connexion fails with a friendly page; existing sessions
|
||||
keep working (cookie self-validated). Grist down → progression reads fall
|
||||
back to "nothing read" + banner; writes show a retry message. Moodle down →
|
||||
sync deferred (flag), no user impact.
|
||||
|
||||
## Testing
|
||||
|
||||
vitest on session sign/verify, grist record mapping, completion threshold;
|
||||
E2E: anon /api/lu → 401; full SSO flow validated manually by owner.
|
||||
Reference in New Issue
Block a user