Skip to content

Spec 05 Auth

github-actions[bot] edited this page Oct 20, 2026 · 2 revisions

Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next

5. Auth

Signing in (§5.1–§5.3) says who a user is; the user's role (§1.1) says what they may do. The permission catalogue, the built-in grants, the Admin wildcard, the hidden keys and the pseudo-keys (self, workspace.teacher, workspace.admin) are in docs/plan/70-redesign-overview.md §4.1. A role opens the Admin console (workspace.admin) when it holds people.users.manage, people.roles.manage, system.audit.read, system.settings.write, ai.credits.manage or scope.all. PermissionKey in api/openapi.yaml equals the rows of app.permissions, and the server refuses to start when the database lacks a key it was built with.

  • Enforcement. Every operation that requires the bearer token declares x-permission in api/openapi.yaml: a key, a pseudo-key, or a list met by any one of its keys (70 §4.2). httpx.RequirePermission enforces it on every request, after RequireAuth; the path is not the gate. The server refuses to start when an operation declares nothing, declares a value outside the catalogue, or declares one that does not belong to its path tree (/teacher/*, /admin/*, /app/*, /auth/*, /me/*).
  • Open operations do not require the bearer token, declare no permission and pass untouched. There are eleven: login, Google sign-in, refresh, logout, the two set-password operations (POST /auth/set-password/check and POST /auth/set-password, §5.4), POST /join/preview, GET /public/status, GET /public/organization, GET /public/organization/logo and the integrity beacon (POST /app/attempts/:id/events). theOpenEleven in core/router/tests/auth_middleware_test.go pins the list.
  • Refusals. No valid token, an unknown or disabled user, or a token older than the user's session epoch (§5.2) → 401 UNAUTHORIZED with WWW-Authenticate, which the single-flight refresh settles. An account still on a temporary password (mustChangePassword) → 403 PASSWORD_CHANGE_REQUIRED from every operation but getCurrentUser and changePassword, the two that declare x-allows-temporary-password: true, before its permissions are weighed and whatever the console (T-R5.9b). An unmet permission → 403 FORBIDDEN, with no detail.
  • The principal. Each gated request resolves the caller's role, permissions, disabled_at and session epoch through an in-process cache that keeps a user for 10 seconds. A write on the same machine forgets the entry at once, so the change applies on that machine's next request and on any other within 10 seconds. Polling does not query the database on every request.
  • Scope is separate from permission. Repositories filter by access.Scope: another teacher's id answers as a missing one does, and their rows never appear in a list or a count. A student is visible to every teacher who reaches them: a member of a class the teacher teaches, an account the teacher created, or an individual target of an assignment the teacher created. scope.all, which only the Admin holds, lifts the filter for a read or a write by id, and in the lists of classes, students and attempts. It does not lift it in the teacher workspace's six content lists (listTests, listQuestions, listQuestionGroups, listMedia, listWordImports, listAssignments, with their facets, tags and counts): those hold the caller's own rows for every caller, the Admin included, and for assignments that is those the caller created or that target a class the caller teaches (DG-53, T-R4.54). Both teacher home operations, getDashboard and getTeacherSummary, pass .Own(), so their figures cover only the caller's teaching, admins included; the repository retains its wide scope.all reading, which the Admin console does not use (its readings are its own, §13.8). The Admin reaches another teacher's content by id and, since R5, in the Admin console (§5.7, §8).

5.1 Methods

Two, both landing on the same users row keyed by verified email:

  1. Email + password — student accounts created by staff (people.students.create), and staff sign-in.
  2. Google Sign-In — the primary path for students, and the only path for self-join (§6.3).

A user may have both. Linking rule: a Google sign-in whose ID token carries email_verified: true matching an existing user links to that user. An unverified email is rejected outright — no link, no create. This closes an account-takeover path.

5.2 Session model

  • Access token: JWT, ~15 min, held in memory (Zustand). Never localStorage, never sessionStorage. It carries the user, its times and the session epoch (sep) and no role; a token minted before v0.10.0 also names one, which is ignored.
  • Refresh token: opaque, rotating, httpOnly; Secure; SameSite=Lax; Path=/auth cookie. Stored server-side as a hash (§13.5).
  • On 401 the client calls POST /auth/refresh once and retries. The refresh is single-flight.
  • A refused refresh (401 or 403), or a second 401, ends the session.
    • A refusal ends only the session its request was sent under. When the store's access token is no longer the one the refused request carried (a sign-in or a refresh replaced it, or the user signed out), nothing is expired or cleared, and the 401 returns to its caller.
    • A user who was signed in keeps the page: the access token is dropped, the user stays, and the "Vui lòng đăng nhập lại" overlay covers the page (§9).
      • A sign-out closes that overlay if it was raised while the sign-out was on its way.
      • After the user's own sign-out, that tab's guard sends a visitor to /login without next until its next sign-in. The marker is in memory, so a reload does not preserve it. An expired session keeps its return path. A second tab left open after sign-out can still offer next through its expired-session card; that path remains unresolved.
    • "Đăng nhập" clears the session, keeps the answer drafts, and goes to /login?next=<path>.
    • While that overlay is up, a 401 returns to its caller without another refresh.
  • A refresh that fails without refusing (a network failure, a 503 or another 5xx) is not a lost session. Waiting requests fail retryably and nobody is signed out.
  • App load: GET /auth/me, under the boot splash (§9).
    • 401 → signed out, unless a sign-in finished while the request was out.
    • A network failure (no HTTP response at all, on /auth/me or on the refresh it triggers) → the splash's offline state, which retries.
    • Any other failure (a 5xx, a 503 other than MAINTENANCE, or a 4xx other than 401 and 403) → on / and the signed-in routes, the unexpected-error page with the server's requestId; on the public routes the splash fades out and the page renders as usual.
    • 503 MAINTENANCE → the maintenance overlay.
  • Every request sends Accept-Language set to the app's locale, so server messages match the UI rather than the browser.
  • Reuse detection: presenting an already-rotated token revokes the whole family and forces re-login.
  • Races. A sign-in stores its refresh token under a lock on the user and compares the account with what it read: if the password, the session epoch or the disabled state changed meanwhile, a password sign-in answers INVALID_CREDENTIALS and a Google sign-in reads the account again. A rotation and a logout take the lock an update of the user takes, so one user's rotations, reuse detections and logouts run one at a time and none overlaps a reset, a disable or a password change: a session never survives the event that should have ended it.
  • Session epoch. users.session_epoch ends live sessions at once. Sign-in, Google sign-in and refresh put the user's current epoch in the access token (claim sep; a token without it reads as 0), and a token older than the user's epoch is refused (§5). In R2 two writes move it, each in the same transaction that revokes every refresh family the student has: disabling a student (updateStudent with disabled: true) and resetting a student's password (resetStudentPassword, and each student of resetStudentsPasswords). Enabling the account again does not bring those sessions back. R5's sign-out-everywhere, role changes, disables and resets move it too (T-R5.9b), and so do set-password links: using a link does, and so does issuing a reset to an account whose owner did not ask for it (70 §4.2).
  • Signed-in devices. A session is a refresh-token family that is neither revoked nor expired. GET /auth/sessions returns the caller's own, at most 100, the one the request's refresh cookie names first with current: true: a handle (familyId), a device label ("Mac · Chrome", built at read time from closed lists of systems and browsers and never from the user agent's own text, or null), a deviceKind for the icon, a location ("City, CC", or null) and lastUsedAt. The location and the time are those of the latest sign-in or refresh, so the list shows where a device last refreshed, not where it first signed in: a rotation takes the label of its own request, and copies its predecessor's only when the request has none. The label is built from CF-IPCity and CF-IPCountry only when CLIENT_IP_HEADER is CF-Connecting-IP and the request carries that header; under any other configuration it is null. It reaches no log and no audit row. DELETE /auth/sessions/{familyId} ends one session, and POST /auth/sessions/revoke-others every one but the caller's. Each is one command: it takes the lock a rotation takes, revokes the family, moves the caller's session epoch, audits it and, after the commit, forgets the cached principal. A revoked device's access token stops working at once on that machine and within 10 seconds on any other; the caller's own next request answers 401 and recovers through the single-flight refresh, because the caller's family stays live and a refresh does not read the epoch. The caller's session is the one the refresh cookie names. A request whose cookie names no live session of the caller, such as one cleared by a sign-out in another tab or a bearer token alone, is answered 401 UNAUTHORIZED by both writes, which write nothing; in the client that is a refresh that fails, and the sign-in overlay shows. An id that is another user's, revoked, expired or unknown answers 404; the caller's own answers 409 SESSION_IS_CURRENT (signing out is POST /auth/logout). A revoke-others that ends nothing writes nothing and moves no epoch.
  • A disabled user is refused on the next request. Every gated request reads disabled_at through the principal cache (§5): at once on the machine that made the change, within 10 seconds on any other. Refresh refuses a disabled user and revokes the family, so the client's refresh ends the session.

5.3 Google flow

  1. Frontend loads GIS and requests an authorization code (not implicit ID-token) with PKCE.
  2. POST /auth/google { code, codeVerifier, redirectUri, joinCode? }.
  3. Backend exchanges with Google, verifies the ID token (iss, aud, exp, signature via JWKS), reads sub, email, email_verified, name, picture.
  4. Resolution order:
    • identity exists → log in;
    • verified email matches a user → link identity, log in;
    • no match and a valid joinCode is present → create account + enroll (§6.3);
    • no match, no join code → 403 ACCOUNT_NOT_PROVISIONED.
  5. Returns { accessToken, user }, where user is the CurrentUser (§7), and sets the refresh cookie.

VITE_GOOGLE_CLIENT_ID is public config. The client secret lives only in the backend.

5.4 Guards and edge cases

  • RequireSession → /login?next=<path> when unauthenticated.

  • The web guards read the signed-in user's workspaces and permissions (CurrentUser, §7), never the role record (it names the user's role on screen and decides nothing), through features/auth/permissions.ts (can and useCan, hasWorkspace and useWorkspace). They decide what the SPA shows; the server enforces the permissions on every request (§5).

    • TeacherWorkspace holds /teacher/*, the teacher console since R4, for the teacher workspace. Anyone else gets a 403 page, not a redirect (a redirect hides the misconfiguration).
    • AdminWorkspace holds /admin/*, the Admin console since R5, for the admin workspace. A path under it that is not a console route is a teacher address from before R4 and goes to the redirect below. A console route for a user without the workspace answers the 403 page, except /admin and /admin/classes, which are also pages of the teacher's tree and redirect to /teacher and /teacher/classes.
    • RequirePermission sits inside it and above AdminLayout. It reads handle.permission of the leaf route (a key, a pseudo-key or a list met by any one of its keys, as x-permission is) and renders the 403 page when the user lacks it; a console route that declares none is refused too.
    • StudentArea holds /app/* for the app workspace. A user without it who has the teacher or admin workspace is redirected to their own console (homePathFor); a user with neither gets the 403 page.
    • homePathFor sends a built-in Admin (scope.all) who holds both consoles to preferences.workspace, /admin when it is unset; any other holder of the teacher workspace to /teacher; a user with only the admin workspace to /admin; and anyone else to /app. The switcher and the account menu write preferences.workspace when the user crosses to the other console. Any holder of learning.take_tests who also has the admin workspace (an Admin with "Take tests" turned on, or a custom role holding ai.credits.manage and "Take tests") lands in a console and may also open /app; the switcher and the account menu draw only the two consoles, so the student app is reached by address (DG-218).
    • A v0.9.x address under /admin/* that is not a console route redirects to the same path under /teacher, in any letter case, keeping its query and hash (/admin/classes/:id to /teacher/classes/:id, /admin/settings/profile to /teacher/settings/profile); /admin/assignments/:id/attempts and /teacher/assignments/:id/attempts go to the assignment's Students tab. The redirect is the web's and stays, for the teacher's own bookmarks: it does not depend on the server's v0.7.0 alias, which v0.12.0 removes (T-R5.36), and no release is planned to remove it.
    • learnsOnly (the student app is the user's only workspace) keeps v0.7.0's rules on /join and the Settings role label (§6.2).
  • mustChangePassword: true → all routes redirect to /change-password. Google-only users never hit this.

  • Logout: POST /auth/logout (revokes refresh token), clear store, queryClient.clear(), → /login. The server revokes the family under a budget of its own, ten seconds, that the end of the request does not cancel: a tab closed or a connection lost during sign-out still ends the session, whose cookie stays in a browser that never saw the answer. A refresh does the opposite and runs on the request's own context (docs/plan/30-risks.md R-06).

  • The frontend applies accepted server locale/theme/display zone/larger text over browser mirrors, with omitted defaults vi/light/Asia/Ho_Chi_Minh/false and no default-materialization PATCH. Anonymous choices remain local until a sign-in whose account lacks that key: a valid device choice (one made on this browser, not a value copied from an account) is then applied and saved to the account once, and a failed save retries silently at the next sign-in. Authenticated controls preview pending changes, restore acknowledged presentation on failure and retain an explicit Retry. A browser-unsupported account zone retains its exact server value, reports compatibility and temporarily presents Vietnam time without saving that fallback. Actor departure invalidates stale asynchronous effects.

  • The frontend orders refresh/login/Google-login/logout cookie sends and global draft cleanup before replacement admission. Refresh remains single-flight. A20s transport/admission UI deadline and10s cleanup deadline show pending status without abandoning raw ownership; late timed-out login never auto-admits. Permanent hangs may keep admission closed. Cross-tab ordering is not promised by this design.

  • Password reset in v1: a holder of people.students.reset_password sets a temporary password (word-word-word-NN, three words and two digits, about 24.4 bits) from the student detail page, under the shared-student rule below, or for up to 40 students at once from the student list. From R5 an emailed set-password link (below) is another way to set a password: the Admin console issues it (createUser and the two Admin resets with delivery: email_link, and a user import delivered by email, §5.7), and a Google-only account can ask for its own from Settings. A teacher's reset of a student still sets a temporary password. There is still no reset form for an account to ask for one while signed out (§17.1).

  • New passwords have three rules:

    • at least 8 characters;
    • at least one number or symbol ([\p{N}\p{P}\p{S}]);
    • different from the current password (the temporary one, while mustChangePassword is set).

    The contract enforces the first two (400 VALIDATION_FAILED) and the server the third (400 PASSWORD_UNCHANGED). Existing passwords are never re-validated. /change-password shows the rules and a strength meter.

  • /forgot-password makes no request of its own; it reads the centre's public answer (§5.6) like the sign-in page. It tells a Google user that no password is needed, and everyone else to ask the front desk (the centre's phone and hours, else §4's build values, when configured) or their teacher; while password sign-in is off it goes to /login.

  • Set-password links (R5, T-R5.3). A link carries a 256-bit token in the URL fragment (/set-password#token=…), which the page removes from the address before it sends anything, so the token reaches neither a server log nor a Referer. Only the token's SHA-256 is stored; the token itself waits, sealed, in the email outbox until the message is sent (docs/plan/20-data-model.md §49). Issuing a link revokes the account's earlier unused links, so one is live at a time, and it lasts 24 hours and works once. POST /auth/set-password/check names the account (email, fullName, purpose, expiresAt). POST /auth/set-password sets the password under the rules above (a weak one is 400 VALIDATION_FAILED and spends the token's budget, not the token), clears mustChangePassword, stamps passwordChangedAt, revokes the account's other links and every refresh family, moves session_epoch, forgets the cached permissions and signs the account in, and audits user.password_set with the link's purpose. Two submits of one link set the password once; the other is 410. An unknown, used, expired or revoked link and the link of a disabled account all answer the same 410 SET_PASSWORD_LINK_INVALID with the same body, so a link says nothing about why it fails. Disabling an account revokes its unused links in the same transaction that revokes its refresh families, and enabling it again restores none: what was out in the world when access ended stays ended, and an Admin who wants the person back sends a new link through Reset. A token is refused before any password is hashed. While the centre has password sign-in off, using a link is 422 PASSWORD_SIGN_IN_DISABLED before the token is looked at, an invite sends a sign-in invite and no link, and a reset is refused. A reset issued for someone else ends the account's sessions as every reset does; POST /auth/set-password/request, the signed-in account's own request from Settings, keeps the session it is made from, and using the link ends the others. A purpose: invite link is an account's first password; reset is any later one. A Google link is never removed by setting a password. The set-password page and Settings read the centre's public answer, asking the browser to revalidate it and keeping it for thirty seconds, so the page shows no form and Settings offers no link for a password the centre no longer accepts. The page waits for the answer three seconds and never changes its mind once it has decided; Settings draws the control until the answer says off. When the answer is late, fails or was stale, the form or the control shows and the server's 422 is the backstop.

  • Guards that are not permissions (70 §4.3), enforced by the server. A guard on a student runs after the student is found in the caller's scope, so another teacher's student answers 404, never 403.

    • Strict student targets. Every student read and write (the Students list and record, update, reset, bulk reset and delete, class membership, individual assignment targets, and the dashboard's counts) accepts only a role in app.student_like_roles: the built-in Student, or a custom role granted nothing but learning.take_tests. An Admin with "Take tests" turned on is never a student target. For an assignment's individual targets the rule applies to a target being added: a student who is already a target stays on the assignment when they are disabled or their role stops being student-like, so the teacher can still publish, edit and close it (#210).
    • The subset rule. updateStudent, resetStudentPassword, resetStudentsPasswords (for each student in it) and deleteUser need the target's permissions, without learning.take_tests, to be a subset of the caller's (access.CanActOn); otherwise 403 FORBIDDEN. Since R5 it also covers every Admin write to an account (§5.7: create, role change, disable, enable, sign-out, resets, transfer and every user-import row, refused with 403 TARGET_ROLE_NOT_ALLOWED), every role write (§15) and the AI credit writes that name a teacher.
    • Disabling. updateStudent with disabled, either value, also needs people.users.manage; otherwise 403 FORBIDDEN.
    • Shared students. A reset, single or bulk, or a new email, by a caller without people.users.manage needs a student no one else reaches: every class they are in, archived ones included, is the caller's; no other account created them; no other account's assignment targets them individually; and a student in no class was created by the caller. Otherwise 403 STUDENT_SHARED, and nothing is written. Sending the address the student already has is not a change. A new address could take the account over through Google sign-in, which links by email (§5.1).
    • The last Admin. The users_last_admin trigger refuses any demotion, disable or delete that would leave no active Admin, whatever the path. Since R5 a role change or a disable that would do so answers 409 LAST_ADMIN (§5.7): the command locks every active Admin in id order first, so two Admins ending each other's access at once leave one, and the trigger stays behind it.
    • Not oneself. A role change, a disable and a reset of the caller's own account answer 422 CANNOT_ACT_ON_SELF; one's own password changes through Change password, and one's own sign-out everywhere is allowed.
    • The built-in Student role cannot lose learning.take_tests.

5.5 API reference session

  • /docs and /docs/openapi.json are served by the API beside the contract and open only to a holder of system.api_reference, a hidden key only the Admin holds (70 §4.1). A browser navigating there sends no bearer token, so they check a separate cookie instead.
  • POST /admin/docs-session (openDocsSession, x-permission: system.api_reference, rate-limited per signed-in user, §6.5) sets quizzivy_docs: Path=/docs; HttpOnly; Secure; SameSite=Strict; Max-Age=900. Its value is a JWT for the docs audience that carries the caller's session epoch, signed with a key derived from the access-token key: a docs token is never accepted as an access token, and an access token never opens the docs.
  • GET /admin/api-reference (getApiReferenceSummary) and GET /admin/api-reference/openapi.json (downloadOpenApi), both x-permission: system.api_reference, rate-limited per signed-in user and reached with the bearer token, not the docs session, back the console's API reference page. The first reports the embedded contract's OpenAPI and document versions, its operation count and the count under each tag, read once per process; the second downloads api/openapi.yaml converted to JSON unchanged (server/gen/contract/openapi.json, written by make gen: the operation ids and descriptions as authored, not the PascalCased embedded spec the docs page renders) as quizzivy-openapi.json with Cache-Control: no-store. The body is declared as a byte stream so the generated server writes the JSON as it is.
  • The gate resolves the cookie's user through the same principal cache as the API (§5). A missing, tampered or expired cookie, an unknown or disabled user, or a cookie older than the user's session epoch → 401; a role without system.api_reference → 403, both in the error envelope with no page or contract leaked. POST /auth/logout clears quizzivy_docs together with the refresh cookie, so signing out also ends an open docs session.
  • The SPA's admin settings open the reference: the new tab is opened synchronously in the click, opener is cleared, and only then is the session requested and the tab pointed at /docs.
  • DOCS_PUBLIC=true skips only the cookie check, for local development. The server refuses to start with it when APP_ENV=production; SRI, the page's CSP and the rate limit apply everywhere.

5.6 Organization settings and the sign-in policy

  • One settings document for the installation (app.organization_settings, docs/plan/20-data-model.md §51): profile (name 1 to 120, shortName, website, contactEmail, frontDeskPhone, frontDeskHours, address; an optional text empty after trimming is null), logos (light and dark), sign-in (google, password, forcePasswordChange, joinCodes, allowedGoogleDomains) and retention (keepAnswersMonths, keepFocusEventsMonths, anonymiseDisabledAfterMonths). GET /admin/settings reads it and PUT /admin/settings replaces it, both system.settings.write, Admin only. A save names the expectedRevision it read and answers 409 STALE_WRITE when someone saved since; it writes an organization.updated audit row in the same transaction, naming the changed fields and no values. A save that changes nothing writes nothing: the revision stays and no audit row is added.
  • Field rules. The name and short name hold no line break, other control character or invisible (format) character. A website is a domain (quizzivy.com) or an https address, with no spaces and no credentials; http, other schemes and a bare word are refused. A contact email is a local part and a domain with a dot, as an address and nothing wrapped around it.
  • The front door. GET /public/organization (open, Cache-Control: public, max-age=300, 120 a minute and 2,000 an hour per address) returns only what the sign-in page prints: name, short name, website, contact, hours, address, the logo URLs, which of Google and password sign-in are on, and whether joining by code is on. It carries no counts, plan, domain list, retention value or revision.
  • Logos. POST /admin/settings/logo (multipart {file, variant}, 1 MiB, 20 an hour per signed-in user) checks and stores a logo content-addressed at branding/{sha256}.png; it applies only when a PUT references it. The server accepts a PNG and nothing else: it is square within 2%, 256 to 2048 px a side, decoded under the shared image gate and encoded afresh, which drops its metadata. An SVG answers 415 MEDIA_TYPE_UNSUPPORTED, because proving an SVG cheap to draw means resolving its references as three browsers do; the settings page instead draws the Admin's SVG to a 1024 x 1024 canvas in their browser and uploads the PNG (D-019). GET /public/organization/logo serves the bytes as an attachment under nosniff, immutable when v is the current hash. A logo object that no setting names is removed by the daily housekeeping (branding_objects) once it is 24 hours old, so the save that names a fresh upload still finds it; identical bytes are one object. The database still admits the type image/svg+xml, a value the application never writes and reads as no logo.
  • The switches. Password off answers 403 PASSWORD_SIGN_IN_DISABLED to every password sign-in alike; Google off answers 403 GOOGLE_SIGN_IN_DISABLED to sign-in and linking; a non-empty domain list refuses a Google email outside it with 403 GOOGLE_DOMAIN_NOT_ALLOWED; unlinking the last way in answers 409 LAST_LOGIN_METHOD. "Force password change" decides mustChangePassword for every temporary password issued afterwards, and leaves existing flags alone. Joining by code off makes /join/preview, POST /app/classes/join and Google-with-a-code answer exactly as an unknown code does.
  • Lockout. A save that would leave the Admin saving it, or every active built-in Admin, with no way in is refused with 422 SIGN_IN_POLICY_LOCKOUT, judged inside the save's transaction. POST /admin/settings/sign-in-impact previews the judgement and writes nothing.
  • Propagation. Each server keeps the document for at most 30 seconds and replaces it at once when it saved, so another server serves the old policy for up to 30 seconds after a change.
  • The settings page. System settings is /admin/settings/:section in the Admin console: Organization (also the bare address), Sign-in, Data & privacy (data) and Plan & usage (plan); an address for any other section is replaced by the bare one. The sections edit one form over the whole document and share one unsaved-changes bar; they stay mounted, so an edit survives a visit to another, and leaving the page with unsaved changes asks first. Save changes sends the whole document at the revision the form was last reset from, never a newer one that a background read has seen, so a save made elsewhere answers 409 STALE_WRITE: the bar shows the server's message with Reload, which reads the document again and replaces the form. A rule the server refuses (400 VALIDATION_FAILED) is shown on its field and opens the section that holds the first such field. Once a save succeeds the front door is read again past the browser's five minutes (GET /public/organization with cache: no-cache, then every query under its key is invalidated), so the sign-in page and the teacher join-code callout follow the setting at once.
  • The Sign-in section. The four switches and the Google domains, typed as chips: Enter, a comma or a space adds one, which is kept lower-case, without a leading @, once; Backspace in the empty input removes the last. While the edited policy differs from the saved one, and has a method on and only valid domains, the page asks POST /admin/settings/sign-in-impact a moment after the last edit and warns "{n} người sẽ không đăng nhập được nữa." (with the Admins among them). A policy with no method, or one the server counts as locking out the caller or every Admin, pauses Save changes, which stays focusable and says why in the bar and under the card; a 422 SIGN_IN_POLICY_LOCKOUT from a save is shown the same way until the policy changes.
  • The logo dialog. "Thay logo" takes a required logo for a light ground and an optional one for a dark ground, each a PNG of at most 1 MiB or an SVG, square within 2% and at least 256 x 256. A file the browser can tell is unusable is refused on its field before anything is sent; an SVG is drawn to a 1024 x 1024 canvas and sent as the PNG it exports (D-019); a refusal from the server shows its message on the field until another file is chosen. A stored logo shows in the Organization tile at once and applies when the settings are saved.
  • Data & privacy (T-R5.28b). The three retention choices join the one form: answers kept 1 year, 2 years or for ever; focus-loss events 6 months, 1 year, 13 months (the default) or 2 years; disabled accounts anonymised never, after 1 year or after 2. The nightly sweep applies them (§13.3). Saving a retention that keeps anything for less time first asks POST /admin/settings/retention-impact what tonight's sweep would remove and lists it in a confirmation; a retention that keeps everything as long or longer saves without asking. "Export all data", beside the form and not part of it, asks (under system.data_export, which only the Admin holds) for a ZIP of the centre's data with or without the attempt answers (§15); the email that says it is ready links to /admin/exports/:id (§8).
  • Plan & usage is read-only, from GET /admin/usage: the plan and its renewal when PLAN_NAME is set, a meter each for seats, media storage, import storage and teachers (the figure alone where no limit is configured; no limit is enforced, DG-55), and the month's AI credits while the assistant is on.

5.7 Admin user management (R5)

The Admin console's Users screen (§8) reaches every account whatever its role, under people.users.manage; a role change also needs people.roles.manage and a transfer scope.all. The operations are in §15.

  • Reading. listUsers is the table: every account, newest first, in five tabs (All; Teachers, everyone who is neither the built-in Admin nor a strict student target; Students, the strict student targets of §5.4; Admins, the built-in Admin role; Disabled), a search over the name and the email together without case or accents (users_search_trgm_idx, from three characters), an exact email, and the status chip. getUser adds who created the account, up to 20 classes, what it owns, the tests it is taking, up to 8 recent events and can, the guards' answer for this caller and this account, which the screen follows to show or hide each action.
  • Creating. createUser adds an account of any role the caller may give (the subset rule, §5.4; never the Assistant, 422 ROLE_NOT_ASSIGNABLE), with delivery: email_link creates it with no password and queues an invite link in the same transaction; temporary_password shows the password once and marks it for change unless the centre allows keeping it (§5.6). A student can be enrolled in a class at once, by a caller with scope.all. An address in use in any letter case is 409 EMAIL_TAKEN. Names are read as NFC, trimmed, with white space collapsed, and refused when empty, over 200 characters or holding a control, format or filler character; an address that holds a space or an invisible character is refused by one rule in Add user, the student forms and the import.
  • Ending access. A role change, a disable, a sign-out everywhere and each kind of reset revoke the account's refresh families and move its session_epoch in the transaction of the change, and forget its cached principal after it, so its next request is refused, or authorised by the new role, at once on this machine and within ten seconds on another. A disable also revokes its unused set-password links and fails the messages still queued for it; enabling it again restores none of them. A reset either sets a temporary password or clears the password and queues a reset link, and ends the account's older links. The guards run in this order: the account exists (404), the subset rule (403 TARGET_ROLE_NOT_ALLOWED), not oneself (§5.4), the last Admin (409 LAST_ADMIN); a reset of a disabled account is 409 ACCOUNT_DISABLED, and a reset or a temporary password while password sign-in is off 422 PASSWORD_SIGN_IN_DISABLED. A role change to a role that opens no teacher workspace, for an account that owns classes, tests, questions, media or assignments, is 409 USER_OWNS_CONTENT with the counts. A write whose account or role kept changing under it is 409 STALE_WRITE after three tries, with nothing written. The bulk disable and reset (up to 100 accounts; 40 for temporary passwords) run one transaction per account and answer what was done and, per account refused, its code. Each write is audited (user.role_changed, user.disabled, user.enabled, user.signed_out_everywhere, user.password_reset), and a write that changes nothing writes no row.
  • Transfer content (DG-56). transferUserContent gives everything a disabled account owns, every class it teaches and every test, question, question group, media asset and assignment, deleted and archived ones too, to another active holder of workspace.teacher, in one transaction; who made each row stays. Unfinished Word imports stay with their creator, and the students the account created whom the recipient does not reach are reported as left behind. A live account is 409 USER_NOT_DISABLED, a recipient that cannot take it 422 INVALID_RECIPIENT. With notify the recipient is told (content.transferred).
  • Importing users (T-R5.14). A CSV or XLSX file of at most 5 MiB and 2,000 rows (full_name, email, role, class) is previewed, then stored as a job with its accepted rows; the import worker creates the accounts 25 to a transaction, each audited as user.created, re-reading the importer's rights from the database at every batch. An address that already has an account is skipped and never changed, and the file's text is not stored. Delivery is by email_link (one invite per created account) or by sheet: the temporary passwords, sealed under SECRETS_KEY, are handed to the importer once as a CSV within 24 hours and erased in the same transaction, and erased unread when the 24 hours end. Every cell a spreadsheet would run as a formula is written with a leading ', and an address of that kind is refused when the file is read.
  • Activity and statuses (DG-54, T-R5.4). An account is active on a day when it makes an authenticated request that day: the principal resolver stamps users.last_active_at and a row of user_activity_days, at most once per user every five minutes per instance and again at the first request of a new local day in APP_TIMEZONE, written in batches every 30 seconds or at 200 entries, so an idle API writes nothing. A sign-in stamps last_sign_in_at and its method (password, google or set_password_link), and a change of one's own password password_changed_at, which Settings shows as "Changed …". An account's status is the first of disabled, must_change_password, never_signed_in (last_sign_in_at is null) and active; the Users table and the teacher's Students list (accountStatus, neverSignedIn) read it from the server, and getAdminOverview counts by the same order.

Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next


Generated from docs/quizzivy-spec-v0.3.md. Edit that file in the repository; changes made on this page are overwritten.

Home

Start here

Product

Implementation plan

Redesign (Phase R)

Setup and operations

Design

Team

Code READMEs

Clone this wiki locally