diff --git a/ADMIN.md b/ADMIN.md index b0a3db5..650e0a8 100644 --- a/ADMIN.md +++ b/ADMIN.md @@ -61,12 +61,9 @@ docker compose exec backend python -m app.cli reprocess-doc 32 **Why documents get stuck:** If the Celery worker restarts while processing a PDF, the task is lost and the document stays at "processing" forever. `fix-stuck-docs` resets these to "ready" so they can be reprocessed. -### Courses & Quizzes +### Quizzes ```bash -# List all courses -docker compose exec backend python -m app.cli list-courses - # List quizzes (active only) docker compose exec backend python -m app.cli list-quizzes @@ -115,9 +112,32 @@ cat backups/daily/pedquiz-YYYYMMDD-HHMMSS.sql.gz | gunzip | \ | Role | Capabilities | |------|-------------| -| `user` | Take quizzes, study flashcards, enroll in courses, create quizzes from question bank | -| `moderator` | All user abilities + create courses, upload PDFs, manage documents | -| `admin` | All moderator abilities + admin dashboard, user management, model config, system settings | +| `user` | Sit sessions, study shared decks, read articles, keep collections and folders. No bank content unless a `CategoryGrant` says otherwise. | +| `moderator` | The whole bank — every question, article, category, deck, image library — plus the editorial queue. Nothing that configures the site. | +| `admin` | Everything, including models, people, site policy and the Handbook. | + +**Roles come from the identity provider.** With `OIDC_ROLE_CLAIM` set, group +membership at `sso.pedshub.com` decides the role and is reapplied at every +sign-in; `pedshub-admins` and `pedshub-moderators` are the groups. The in-app +role endpoints answer 409 while that is on, because a role set here would be +overwritten at the next sign-in. The sync refuses to demote the last admin. + +**Grants are per-user and in-app.** `/access` gives somebody a category branch, +an image library or a folder; it makes them an editor of what is inside and +adds the Questions and Images menu entries. Never self-assignable, never from a +claim. + +**The bank has no owners.** Questions, articles, categories, decks, documents +and shared quizzes carry `user_id = NULL`. Authorship confers no rights. +Attempts, notes, favourites, collections, folders and personal sittings stay +with their person. + +## Signing in + +SSO only, via Authentik at `sso.pedshub.com`. No sign-up form, no invite codes, +no email sign-in codes — the provider does all three. `settings:sso_only` in +Redis closes every password door. Accounts are created on first sign-in, matched +by email address; a different address is a different account. ## Common Issues @@ -316,5 +336,5 @@ OIDC_PROVIDER_NAME=Authentik - JWT tokens auto-refresh via sliding expiration (12h age or <1h remaining) - Rate limiting on login (10 attempts per IP per 15 min) via Redis - Email verification required for new accounts (SSO users are auto-verified) -- Quiz reminders skip deleted quizzes and course quizzes +- Quiz reminders skip deleted quizzes - Users can opt out of reminders in Settings > Notifications diff --git a/CLAUDE.md b/CLAUDE.md index 878bbbf..ccd63c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,15 @@ # PedsHub — AI Synopsis for Codebase Work ## What this is -PedsHub is a pediatric medical learning platform with an integrated LMS. Admins upload PREP exam PDFs, AI extracts MCQ questions (or generates flashcards), and users study them with an AI tutor. Any user can create courses, quizzes, and questions. Everything runs in Docker. +PedsHub is a pediatric question bank and reading library. Educators upload PREP +exam PDFs, AI proposes MCQs and flashcards from them, a person reviews what is +worth keeping, and learners sit it as sessions with an AI tutor beside them. +Sign-in is through Authentik at `sso.pedshub.com`; the companion scribe app is +at `app.pedshub.com`. Everything runs in Docker. + +The LMS — courses, modules, lessons, enrolments — was removed. Anything in this +file or the code that still mentions a course is stale; say so rather than +building against it. ## Critical rules - **Never restart services while a Celery task is running** — check `docker compose logs celery --tail=5` first @@ -28,21 +36,59 @@ Browser → Nginx (frontend) → FastAPI (4 uvicorn workers) └── Celery (2 fork workers: PDF processing, quiz extraction, flashcard generation, classification, embedding regeneration) └── db-backup (daily PostgreSQL dumps, 14 daily / 4 weekly / 6 monthly retention, ./backups/) -## Course/LMS system -Any user can create courses (not just moderators). Course structure: Course → Modules → Lessons. -- **Lesson types**: text (markdown), video (Vimeo/YouTube/local), document, quiz (from question bank), live_session (BBB/Zoom/Meet) -- **Enrollment**: users enroll in published courses, progress tracked per-lesson -- **Video**: auto-detect provider from URL (vimeo.com → vimeo, youtube.com → youtube) -- **BBB**: full API integration (create/join/end). Config: `BBB_SERVER_URL` + `BBB_SECRET` in .env -- **AI**: generate/refine lesson text via `POST /courses/{id}/lessons/{id}/ai-generate` -- **Status**: draft (creator-only) → published (visible to all) → archived -- **Subscription gate**: `requires_subscription` flag on courses (402 on enroll if set — Stripe integration placeholder) -- **Course quizzes**: fully decoupled from main quiz system. `POST /courses/{id}/quiz` copies questions and creates an independent quiz with `course_id` set. Hidden from main quizzes page, search, dashboard stats, and attempt history. Creator sets mode (timed/study), time limit, max attempts, questions per attempt (random pool), and `allow_review` (whether students can review answers). Results page is course-aware — shows "Back to Course" instead of retake/all quizzes/delete. Users see attempt history + review links on the course page only. -- **User roles**: `admin`, `moderator`, `user`. Only moderators and admins can create courses. Any user can create quizzes from the question bank. -- **Enrollee analytics**: `GET /courses/{id}/enrollees` returns progress + quiz scores. `GET /courses/{id}/enrollees/export` exports CSV. -- **Question ownership**: questions have `user_id` and `is_shared`. Users see shared + own questions. `PATCH /questions/{id}/share` toggles visibility. -- **Rich editor**: Milkdown (ProseMirror-based markdown WYSIWYG) for lesson content. Supports GFM tables, code blocks, LaTeX math (`$formula$`). No JSX parsing issues with `<` or `{`. -``` +## Who may do what + +Three roles, and one kind of grant. + +- **admin** — everything, including Settings: models, people, site policy. +- **moderator** — the whole bank: every question, article, category, card + deck, image library, and the editorial queue. Nothing that configures the + site. +- **user** — their own sittings, notes, starred questions and folders. No + bank content at all unless a grant says otherwise. + +Roles come from the identity provider's groups, never from this app. With +`OIDC_ROLE_CLAIM` set, `services/sso_roles.apply()` runs on every SSO sign-in +and brings the role into line with the person's groups — so removal at the +provider takes the role away here too. The two in-app role endpoints answer 409 +while that mapping is on, because a role set here would be silently overwritten +at the next sign-in. The one demotion the sync refuses is the last +administrator. + +`CategoryGrant` is the per-user half, given inside this app by a moderator or +admin (`/access`). It names a category branch, an image library or a folder, +and makes the holder an editor of what is inside it — which also makes the +Questions and Images menu entries appear. It never comes from a claim and is +never self-assignable. + +**Nothing in the bank has an owner.** Questions, articles, categories, decks, +documents, media and shared quizzes all carry `user_id = NULL`, and every +creation path writes NULL. Authorship confers no rights anywhere: `may_edit_question` +and `can_edit_article` ask the role and the grants and nothing else. What keeps +an owner is what is genuinely one person's — attempts, notes, favourites, +collections, folders, study-plan progress, and the unshared quizzes that are +somebody's own sittings. See migration `q6a7b8c9d0e1`. + +## Signing in + +SSO only, through Authentik at `sso.pedshub.com`. There is no sign-up form, no +invite codes and no email sign-in codes — all three were this app doing the +provider's job, and all three were removed (migration `r7b8c9d0e1f2` drops +their tables). + +- `GET /auth/sso/login` → 302 to the provider. It must stay `async` and the + redirect must be awaited; authlib's Starlette client returns a coroutine, and + returning it unawaited is a 500 on every click. +- `GET /auth/sso/callback` matches on the email claim, refuses an explicit + `email_verified: false`, applies the role from groups, then parks the access + token in Redis under a one-time code (`sso:exchange:`, 60s) and + redirects with `?code=`. The token never travels in a URL — nginx logs the + request line, so it would be written to disk on every sign-in. +- `POST /auth/sso/exchange` spends the code once (GETDEL) and returns the token. +- `settings:sso_only` in Redis closes every password door: login, register, + forgot/reset password, resend verification, and setting a password through + `PUT /auth/me`. `GET /auth/signup-policy` reports it so the forms decline to + draw themselves rather than being refused after the fact. ## Key directories ``` @@ -73,9 +119,6 @@ frontend/src/ FlashcardsPage.jsx — Browse decks + card browser with search FlashcardStudyPage.jsx — Flip cards, got-it/review, keyboard nav, progress QuizPage.jsx — Take quiz (exam/study mode), timer, progress save to Redis - CoursesPage.jsx — Browse/enroll courses, my courses, create courses - CourseDetailPage.jsx — Student view: modules, lessons, video player, progress - CourseEditorPage.jsx — Course creator: modules, lessons, AI content, question bank browser AdminPage.jsx — Model config, user management, settings components/ Navbar.jsx — Auth-aware nav with jobs badge @@ -89,8 +132,8 @@ frontend/src/ | users | Accounts with role (admin/moderator/user) | — | | pdf_documents | Uploaded PDFs | user_id → users | | sections | Page ranges within a document | document_id → pdf_documents | -| quizzes | Quiz metadata (course_id set = course-only, allow_review controls student access) | section_id → sections (nullable), user_id → users, course_id → courses (nullable) | -| questions | MCQ questions with pgvector embedding | source_quiz_id → quizzes (nullable), user_id → users (nullable) | +| quizzes | A sitting or a shared bank test. `is_shared=1` is the bank's and ownerless; `is_shared=0` is somebody's own session | section_id → sections (nullable), user_id → users (nullable) | +| questions | MCQ questions with pgvector embedding. `user_id` is always NULL — the bank has no owners | source_quiz_id → quizzes (nullable) | | quiz_question_links | Quiz ↔ Question many-to-many | quiz_id, question_id | | flashcard_decks | Flashcard deck metadata | section_id → sections, user_id → users | | flashcards | Individual cards (front/back) | deck_id → flashcard_decks | @@ -98,11 +141,6 @@ frontend/src/ | question_tag_links | Question ↔ Tag | question_id, tag_id | | flashcard_tag_links | Flashcard ↔ Tag | flashcard_id, tag_id | | quiz_attempts | User quiz sessions with score | quiz_id, user_id | -| courses | LMS courses (draft/published/archived) | user_id → users | -| course_modules | Sections within a course | course_id → courses | -| course_lessons | Items within a module (text/video/quiz/live) | module_id → course_modules, quiz_id → quizzes | -| course_enrollments | User enrollment + progress tracking | course_id, user_id (unique) | -| course_lesson_progress | Per-lesson completion status | enrollment_id, lesson_id (unique) | ## Common patterns - **Tag filtering SQL**: `WHERE tag_id = ANY(:tag_ids) GROUP BY ... HAVING COUNT(DISTINCT tag_id) = :cnt` — AND logic across tags @@ -161,8 +199,8 @@ docker compose exec backend alembic downgrade -1 - Don't `docker compose restart` expecting code changes to apply — must rebuild - Don't use `window.confirm()` — user hates browser popups, use inline confirmation or the Dialog component - Don't use MDXEditor — it's an MDX parser that chokes on `<` and `{` in medical content. Milkdown (CommonMark) is used instead. -- Don't create quizzes via `POST /questions/from-bank` for courses — use `POST /courses/{id}/quiz` which copies questions and hides the quiz from the main page. -- Don't show course quiz data on the main quizzes page, dashboard stats, or attempt history — course quizzes are fully decoupled. Filter with `Quiz.course_id.is_(None)`. -- Don't show "from pool of N" to users on course quiz display — just show the number of questions per attempt. -- Don't allow users to delete course quiz attempts — the backend returns 403. - Don't put documents listing on the dashboard — it's in Settings page under Nextcloud. +- Don't gate anything on `x.user_id == current_user.id` for bank content. Authorship confers nothing; ask the role or a grant. +- Don't stamp a creator on new bank content — write `user_id=None`. +- Don't reintroduce invite codes or email sign-in codes. The identity provider does that. +- Don't put a token in a URL. nginx logs the request line. diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 70690d6..5b514cd 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -578,10 +578,16 @@ def sso_config(): from app.config import settings as cfg sso_enabled = bool(cfg.OIDC_PROVIDER_URL and cfg.OIDC_CLIENT_ID) sso_settings = _get_sso_settings() + from app.services import sso_roles + return { "sso_enabled": sso_enabled, "sso_only": sso_settings["sso_only"], "provider_name": cfg.OIDC_PROVIDER_NAME if sso_enabled else None, + # Whether roles come from the provider's groups. Says only that they + # do, never which groups — enough for the access page to stop offering + # a control that can now only answer 409. + "roles_from_provider": sso_roles.is_configured(cfg), } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1a3e44e..821ccbb 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -44,6 +44,7 @@ const CollectionsPage = lazyPage(() => import('./pages/CollectionsPage')) const EditorialPage = lazyPage(() => import('./pages/EditorialPage')) const AccessPage = lazyPage(() => import('./pages/AccessPage')) const HandbookPage = lazyPage(() => import('./pages/HandbookPage')) +const HelpPage = lazyPage(() => import('./pages/HelpPage')) const ToolsPage = lazyPage(() => import('./pages/ToolsPage')) const StudyPlansPage = lazyPage(() => import('./pages/StudyPlansPage')) const StudyPlanPage = lazyPage(() => import('./pages/StudyPlanPage')) @@ -151,10 +152,11 @@ function Forbidden() { // are not signed in. A role you do not have is worth saying out loud. export function RequireAuthForTest(props) { return RequireAuth(props) } -function RequireAuth({ moderator = false }) { +function RequireAuth({ moderator = false, admin = false }) { const { user, loading } = useAuth() if (loading) return if (!user) return + if (admin && user.role !== 'admin') return if (moderator && user.role !== 'admin' && user.role !== 'moderator') return return } @@ -240,9 +242,20 @@ function AppRoutes() { Settings now; the old address still works for anyone who bookmarked it. */} } /> - {/* How the site works, for anyone who maintains it — an educator - with one branch needs it as much as an administrator does, so - it is not moderator-only. */} + {/* What a learner or an educator actually asks. The Handbook is + the same material plus how the tutor is prompted and what + changing the embedding model breaks — reference for whoever + runs the place, noise for everybody else — so it moved behind + the admin door and this stayed in front of it. */} + } /> + + + + {/* Administrators only. The Handbook is how the site is run — the + tutor's prompt, what changing the embedding model breaks — and + the part of it people actually ask about is at /help. */} + }> + }> } /> diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index b07d221..8ba2b02 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -138,6 +138,10 @@ function AccountMenu({ user, onLogout }) { administrator, so a second entry pointing into the same page was two names for one place. */} setOpen(false)}>Settings + {/* How the site works, for the people who use it. Not in the footer: + a question you have while working is answered from where you are, + not from the bottom of the page. */} + setOpen(false)}>Help diff --git a/frontend/src/pages/AccessPage.jsx b/frontend/src/pages/AccessPage.jsx index f01a983..8209f33 100644 --- a/frontend/src/pages/AccessPage.jsx +++ b/frontend/src/pages/AccessPage.jsx @@ -144,6 +144,19 @@ export default function AccessPage() { : api.delete(`/access/${selectedId}/grants/${kind}/${targetId}`)), on ? 'Could not grant that' : 'Could not remove that') + //: Whether the identity provider decides roles. Asked once; the endpoint is + //: public and says only that it does, never which groups. + const [rolesFromProvider, setRolesFromProvider] = useState(false) + const [providerName, setProviderName] = useState('your identity provider') + useEffect(() => { + api.get('/auth/sso/config') + .then(res => { + setRolesFromProvider(res.data?.roles_from_provider === true) + setProviderName(res.data?.provider_name || 'your identity provider') + }) + .catch(() => {}) + }, []) + const setRole = (role) => change( () => api.put(`/access/${selectedId}/role`, { role }), 'Could not change that') @@ -233,6 +246,15 @@ export default function AccessPage() {

An administrator already manages everything, and that is not changed here.

+ ) : rolesFromProvider ? ( + /* The switch is at the provider. A control here would set a + role that is overwritten from their groups at their next + sign-in, and a control that can only fail is worse than no + control — so it says where to go instead. */ +

+ Whether this person manages everything is decided by their groups at + {' '}{providerName}. The branches below are set here. +

) : (

Everything

diff --git a/frontend/src/pages/AccessPage.test.jsx b/frontend/src/pages/AccessPage.test.jsx index 31d7375..c1f4d57 100644 --- a/frontend/src/pages/AccessPage.test.jsx +++ b/frontend/src/pages/AccessPage.test.jsx @@ -184,3 +184,23 @@ describe('question folders as a grantable thing', () => { expect(await screen.findByText('0 branches, 1 folder')).toBeInTheDocument() }) }) + +it('does not offer a role switch when the provider decides roles', async () => { + // A control that can only answer 409 is worse than no control: it invites + // somebody to make a change that appears to work, holds for an afternoon, + // and is overwritten from their groups at their next sign-in. + vi.clearAllMocks() + api.get.mockImplementation(url => { + if (url === '/access/') return Promise.resolve({ data: users() }) + if (url === '/access/tree') return Promise.resolve({ data: TREE }) + if (url === '/auth/sso/config') { + return Promise.resolve({ data: { sso_enabled: true, roles_from_provider: true, + provider_name: 'PedsHub SSO' } }) + } + return Promise.resolve({ data: {} }) + }) + mount() + await pick('Ada') + expect(screen.queryByLabelText('Give access to everything')).toBeNull() + expect(await screen.findByText(/decided by their groups/)).toBeInTheDocument() +}) diff --git a/frontend/src/pages/HandbookPage.jsx b/frontend/src/pages/HandbookPage.jsx index b1e6f6a..3f28aae 100644 --- a/frontend/src/pages/HandbookPage.jsx +++ b/frontend/src/pages/HandbookPage.jsx @@ -15,6 +15,8 @@ import BackLink from '../components/BackLink' */ const SECTIONS = [ + { id: 'faq', label: 'Common questions' }, + { id: 'educators', label: 'For educators' }, { id: 'links', label: 'Article links' }, { id: 'tips', label: 'Tips in the prose' }, { id: 'tutor', label: 'The AI tutor' }, @@ -70,6 +72,139 @@ export default function HandbookPage() { +
+

Common questions

+

+ For everybody who uses PedsHub. The sections after this one are for + people who write the material. +

+ +

How do I sign in?

+

+ Through PedsHub SSO. The Sign in button hands you to{' '} + sso.pedshub.com, which asks for your address and emails + you a code; typing it there brings you back signed in. There is no + separate PedsHub password, and no sign-up form — an administrator + sends you an invitation link from the sign-in service itself. +

+ +

What does "Studying for" at the top do?

+

+ It names your exam, and it scopes the site to it: which questions the + bank offers, which filters appear, and what your performance page is + measured against. Material tied to no exam stays visible whichever + you pick, so choosing one never hides the general reading. +

+ +

Qbank, Sessions, Collections — which is which?

+

+ Qbank is where a session is built: pick topics, a + length and a mode, and start. Sessions is every + sitting you have done, with the analysis of how each went.{' '} + Collections is what you have put aside — starred + questions and any folders you keep. +

+ +

Study mode or exam mode?

+

+ Study mode marks each question as you answer it and shows the + explanation there and then. Exam mode holds everything back until the + block is finished, and runs a clock at the pace of a real paper. +

+ +

Where does my performance come from?

+

+ Your own answers, and nobody else's. It is on the Sessions page, + alongside a per-topic reading of where you stand and what to study + next. Sittings marked as repetitions are analysed but left out of the + figures that say how much of the bank you know. +

+ +

Reading, Cards and Study plans

+

+ Reading is the article library, browsed by topic; a + question's explanation links into it. Cards are the + flashcard decks shared with you, studied one at a time with a verdict + on each. Study plans are ordered blocks of questions + and reading, published by an educator, which you work through in + order. +

+ +

What is "Make a deck"?

+

+ A link out to the companion app at app.pedshub.com, where + a deck is written from your own material. The same sign-in works on + both sides. +

+ +

Something in a question looks wrong.

+

+ Report it from the question itself. It reaches the people who + maintain the bank with the question attached, which is quicker than + describing which one you meant. +

+
+ +
+

For educators

+

+ Two things decide what you may change: your role, + which comes from your groups at PedsHub SSO, and your{' '} + grants, which an administrator gives you inside this + app. +

+ +

What a moderator has

+

+ Every question, article, category, card deck and image library, and + the editorial queue. Nothing under Settings that configures the site + itself — models, people, site policy — which is an administrator's. +

+ +

What a grant gives

+

+ A grant names a branch of the topic tree, an image library, or a + folder. Holding one makes you an editor of what is inside it: the + questions filed there, the articles filed there, and the images in + that library. It appears as menu entries — Questions and Images — + the moment it is given, and takes them away again when it is + withdrawn. It does not open Editorial, which is the whole library's + review queue. +

+ +

What nothing in the bank belongs to

+

+ Anybody. Questions, articles, categories, decks and uploaded + documents have no owner: writing one does not make it yours, and + losing a colleague does not orphan it. What is yours is your own + sittings, your notes, your starred questions and your folders. +

+ +

Writing an article

+

+ Editorial holds the queue and every draft. A draft is opened from + there, at its own address, and stays out of Reading until it is + published — Reading is what a learner sees, and shows no drafts to + anybody. Drafting with AI is offered there too; the result is a draft + like any other, for a person to read before it goes out. +

+ +

Making a study plan

+

+ Study plans, then New plan. Add blocks, attach reading to + them, and publish when it is worth working through. A plan can also + be built from an examining board's published outline: as papers, each + block drawn to the board's weights, or as domains, one block per + content area in the board's order. +

+ +

What a moderator cannot do

+

+ Change anybody's role — that is decided by groups at PedsHub SSO — + or reach the model configuration, the people list, or site policy. +

+
+