17 KiB
Refactor guide: auth, registration, login UI, and user management
This document is the implementation spec for refactoring authentication. It replaces an earlier “as-is only” description with ordered instructions informed by auth-implementation-feedback.md. Use it when changing fe0 login/registration UI (Login.tsx, AuthContext, auth-service) and the supporting be0 + PostgreSQL behavior.
1. Non‑negotiable: security
Blocker: The registration endpoint POST /api/v1/auth/register currently persists UserRoleRow(role=body.role) where role is client-supplied. That is privilege escalation: anyone who can call the API with a valid @ump.edu.vn-style address can send "admin" and become Quản trị viên. Email validation is not a substitute for server-side role authority.
Instruction: Before any cosmetic UI work, stop trusting role from the client on register. Derive roles only on the server (see §5). Treat closing this hole as P0; remaining items are hygiene by comparison.
2. Product rules (target behavior)
-
Email domains:
{name}@umc.edu.vnor{name}@ump.edu.vnonly (normalize: trim, lowercase; server is authoritative; client validation is UX only). -
Roles from identity (no user-facing role picker on login or register):
- A configurable set of emails maps to Quản trị viên (
admininfe0/src/lib/permissions.ts). - Every other allowed email gets Người nộp đơn (
viewer) on first registration. - Hội đồng (
editor) is not self-service: grant/revoke via admin UserManagement (API), not registration.
- A configurable set of emails maps to Quản trị viên (
-
User management: Admins can list users and roles, grant Hội đồng, revoke it, and deactivate accounts as needed. The page must be CRUD-capable from day one (read-only listing alone is insufficient).
3. Current implementation (baseline to remove or replace)
3.1 Backend (be0/src/auth_api.py)
_normalize_ump_email/UMP_EMAIL_RE: only@ump.edu.vn— reject@umc.edu.vntoday.- Register: inserts
User+UserRoleRow(role=body.role)— trusted client role (bug). - Login: returns
rolesfrom DB; no role in HTTP body — correct for API shape, but the frontend then forces a role Select andbuildUserWithSelectedRole(user, selectedRole), which fails if the user picks the wrong row. That is not “no role picker”; it is an inverted UX (identity should drive role; users should not see a selector).
3.2 Frontend
fe0/src/pages/Login.tsx:validateUmpEmail— UMP only;loginRole/regRoleSelects; register sendsrolein JSON.fe0/src/contexts/AuthContext.tsx: After login/register,buildUserWithSelectedRole(..., selectedRole)(and after register,payload.role) — circular with today’s register bug: client chooses → server stores → client “confirms” the same choice. After server-derived roles, this must become trustuser.rolesfrom the API (see §6).fe0/src/lib/auth-service.ts: Register payload includesrole— remove once API ignores it./dashboard/users: linked fromDashboardSidebar.tsx/admin/DashboardSidebar.tsxbut no route infe0/src/app/router/routes.tsx→ 404.
3.3 Database
user_roleenum (001_initiative_schema.sql):applicant,council_member,editor,admin,viewer— five values; auth code only reads/writes three. Collapsingapplicant↔viewerandcouncil_member↔editor(or standardizing on one pair) is a design decision — do not leave five values with only three “real” forever (see §8).
3.4 JWT
- Roles are embedded in the JWT at issue time. If an admin changes
user_rolesin DB, the user sees new permissions only after refresh (or re-login). Acceptable, but: surface this in UserManagement or docs (e.g. “changes apply on next refresh”) and optionally trigger refresh after admin actions on the same browser session if you add that flow later.
4. Configuration: admin emails (avoid brittle hardcoding)
Feedback: Hardcoding the five institutional emails in source is brittle.
Instruction: Load the admin allow-list from environment (e.g. comma-separated AUTH_ADMIN_EMAILS) and/or a seeded DB table (e.g. admin_emails with audit columns). The server’s derivation logic must use one resolved list (env merged with DB, or DB only after migration — pick one approach and document it).
Frontend must not duplicate the list for authorization; at most duplicate domain regex for UX (§5.1).
5. Backend refactor instructions (be0)
5.1 Email allow-list
- Replace “UMP-only” normalization with a function that accepts
@ump.edu.vnor@umc.edu.vn(same local-part rules as today unless product says otherwise). - Reject everything else with 400 and a clear message.
- Ship server change before or with client regex update; server must stay correct if the client is bypassed.
5.2 Registration: derive role; ignore client role
- Remove
rolefrom the public contract or accept but ignore it (document deprecation); never insertUserRoleRowfrom the client. - On register: if normalized email ∈ admin list → ensure
admininuser_roles(and notviewer-only self-service for those accounts — product: admins are allow-listed); else → insertvieweronly. - Do not assign
editorat registration.
5.3 Login and refresh: mandatory role reconciliation
Feedback: “Optionally reconcile on login” is too weak; make it mandatory.
On every login and refresh (and ideally before issuing JWT in those handlers):
- Admin by email rule: If email ∈ admin list and user lacks
admin→ addadminrow inuser_roles. - Removal: If email ∉ admin list and user’s
adminwas only from the email rule → removeadmin.
Critical: If you later addadmingrants via UserManagement (not email-derived), you must not strip those when reconciling. Implement one of:- Two sources of truth: e.g. column
users.is_admin_by_policy BOOLEANvsadminfrom grants table; or - Tag rows: e.g. separate table
user_role_grants(source='email_policy'|'admin_ui')before mutating; or - Rule: only auto-remove
adminif it was created by the policy sync marker you define.
- Two sources of truth: e.g. column
Document the chosen rule in code comments and in this doc.
- Default applicant: If user has no
viewerand is not only admin-only (decide product: admins alsovieweror not), apply your migration policy — usually new users getviewer; existing users need a data migration (§8).
5.4 Rate limiting
- Add basic rate limiting on
/auth/register(per IP or per email) once role assignment is fixed — still an unauthenticated write.
5.5 Admin API (read and write)
Minimum surface for a real UserManagement product:
| Method | Path (example) | Purpose |
|---|---|---|
GET |
/api/v1/admin/users |
List users: id, email, full_name, roles[], is_active, … |
POST |
/api/v1/admin/users/:id/roles |
Grant Hội đồng (editor) — body may specify role |
DELETE |
/api/v1/admin/users/:id/roles/:role |
Revoke editor (and optionally other roles per policy) |
PATCH |
/api/v1/admin/users/:id |
e.g. is_active — deactivate without deleting |
Protect with existing admin checks (e.g. JWT must include admin, _require_admin_user pattern in be0/main.py).
Why writes matter: Viewers never become Hội đồng via registration or the email list; without grant/revoke APIs, the UserManagement page is a read-only museum.
5.6 Password reset (self-service)
POST /api/v1/auth/forgot-password— body{ "email" }. Email is normalized with the same institutional rules as register. For valid domains, the JSON response is always the same generic success message whether or not the account exists (enumeration-safe). Bodies must not carryroleor any privilege field (models useextra="ignore").POST /api/v1/auth/reset-password—{ "token", "newPassword", "newPasswordConfirm" }. One-time token stored only as a hash inpassword_reset_tokens, short TTL (seebe0/src/auth_api.py).- Rate limiting: forgot (per normalized email + per client IP) and reset (per IP), implemented in
be0/src/auth_rate_limit.py. - Outbound mail: configure
SMTP_HOST,SMTP_PORT(default 587),SMTP_USER,SMTP_PASSWORD,AUTH_MAIL_FROM,SMTP_USE_TLS(default on), or useAUTH_MAIL_LOG_ONLY=1to log reset links (development). SetAUTH_PUBLIC_WEB_ORIGINorPUBLIC_WEB_ORIGINso email links point at the SPA (defaulthttp://localhost:8081). - JWT
cv(credential version): columnusers.credential_versionincrements on password/change-passwordand/reset-password; middleware inbe0/main.pyplus/auth/refreshreject tokens whosecvno longer matches. Applybe0/migrations/012_password_reset.sql.
Admins do not set plaintext passwords; a future “Gửi email đặt lại mật khẩu” in UserManagement should call the same forgot-password logic server-side.
6. Frontend refactor instructions (fe0) — login components
6.1 Login.tsx
Remove:
- All
<Vai trò đăng nhập>/loginRolestate andSelect. - All
regRolestate and registration roleSelect. - Imports only used for role pickers (
ROLE_DISPLAY_NAMES, role icons, extraSelectpieces) — prune dead imports.
Keep / adjust:
- Email + password fields; copy should say UMP or UMC once regex allows both.
- Replace
validateUmpEmailwith something likevalidateInstitutionalEmailmatching both domains (still UX; server validates for real).
Behavior:
handleLogin: calllogin(email, password)without a role argument (updateAuthContextsignature — §6.2).handleRegister: callregister({ fullName, email, password, passwordConfirm })withoutrole(update types andauth-service).
Post-login navigation: Keep using resolvePostLoginPath, but pass the resolved active role from context (single derived role — §6.2), e.g. resolvePostLoginPath(user.roles[0], fromPathname) or a small helper getPrimaryRole(user).
6.2 AuthContext.tsx
login: Change to login(email, password) only. After authService.login, build session user from result.user.roles returned by the server — no second argument from the UI.
register: Same: no role in payload; after success, trust result.user.roles from API.
buildUserWithSelectedRole: Refactor or replace:
- Preferred:
buildUserFromAuthPayload(authUser)that sets one active role using a deterministic rule:- If multiple roles exist (e.g.
admin+editor), use highest privilege (e.g.admin>editor>viewer) foruser.roles/ permissions in the shell, or - Keep
availableRolesfor a future internal switcher only if product requires it — but not on the login screen.
- If multiple roles exist (e.g.
- Remove reliance on
localStorage['auth-active-role']for login/register flows unless you keep it only for intentional in-app role switching between already granted roles (optional follow-up). - Eliminate error paths like “wrong role selected” for login — users never select.
Session restore (refreshSession): Same as login: no client-selected role; apply the same deterministic mapping from API roles.
6.3 auth-service.ts
register: Omitrolefrom JSON body once API ignores it.- Types:
AuthUserunchanged if API still returnsroles: Role[].
6.4 Routes and UserManagement page
- Add a real route for
/dashboard/users(or move link to/dashboard/admin/usersand update sidebars consistently). - Implement
UserManagementwithProtectedRouterequiringadmin.users(or equivalent): table + actions calling the admin API from §5.5. - Plan the UI as list + grant editor + revoke editor + deactivate from day one.
6.5 Callers of login / register
- Grep for
login(andregister(acrossfe0(e.g. tests,SignUpModal.tsx) and update signatures.
7. Target data flow (after refactor)
sequenceDiagram
participant UI as Login.tsx
participant Ctx as AuthContext
participant API as auth-service.ts
participant BE as be0 auth_api
participant DB as PostgreSQL
Note over UI,DB: Register (no client role)
UI->>Ctx: register({ fullName, email, passwords })
Ctx->>API: POST /api/v1/auth/register (no role)
API->>BE: body without trusted role
BE->>BE: derive admin vs viewer from email list
BE->>DB: INSERT users + user_roles (server only)
BE-->>API: JWT + user.roles
Ctx->>Ctx: buildUserFromAuthPayload(user)
Note over UI,DB: Login (no role picker)
UI->>Ctx: login(email, password)
Ctx->>API: POST /api/v1/auth/login
API->>BE: email + password
BE->>DB: load user; reconcile policy roles
BE-->>API: JWT + user.roles
Ctx->>Ctx: buildUserFromAuthPayload(user)
8. Data migration and enum cleanup
One-time migration after fixing register:
- For each
usersrow: if email ∈ admin list → ensureadmin(per reconciliation rules); else if no explicit editor grant from admin tooling → normalize tovieweronly as per product. - Existing
editorrows: Preserve as Hội đồng unless product says to wipe and re-grant. - Users who became
adminvia the old bug: Migration should align with email policy: if not in admin list, remove spuriousadmin(with the same caution as §5.3 if you introduce UI-granted admin later).
Enum (applicant / council_member):
- Decide: aliases of
viewer/editorvs canonical names. - Ship a migration that consolidates rows and narrows the enum or renames consistently. Do not finish the auth refactor while five enum values exist but only three are meaningful.
9. Tests (mandatory for rules that regress silently)
Table-driven tests (Python or TS — preferably backend for authority):
- Each admin-configured email → effective roles include
admin(after register and after login). user@ump.edu.vn(not admin) →vieweronly.user@umc.edu.vn(not admin) →vieweronly; invalid domain → 400.- Case and whitespace on email → normalized to same key as policy list.
- Register ignores injected
role: "admin"in JSON for non-admin email (or rejects body field entirely).
10. Suggested order of work
Aligned with auth-implementation-feedback.md:
- Fix register: ignore client
role; derive roles server-side (closes privilege escalation). Ship alone if needed. - Allow
@umc.edu.vnon server, then update client regex/copy. - Remove login/register role selectors; simplify
AuthContext/buildUserWithSelectedRole→buildUserFromAuthPayloadwith a deterministic multi-role rule. - Implement admin API (list + grant/revoke + patch
is_active) with authz. - Build UserManagement page and add
/dashboard/usersroute; fix sidebar 404. - Data migration + enum cleanup.
- Rate limiting on register; tests from §9.
Steps 1–3 are mostly deletion and server logic on the login path. Steps 4–5 are the bulk of new code.
11. File index
| Area | File(s) |
|---|---|
| Login / register UI | fe0/src/pages/Login.tsx |
| Session + role resolution | fe0/src/contexts/AuthContext.tsx |
| HTTP client | fe0/src/lib/auth-service.ts |
| Permissions / labels | fe0/src/lib/permissions.ts |
| Post-login paths | fe0/src/lib/dashboardNavigation.ts |
| Routes | fe0/src/app/router/routes.tsx |
| Sidebars (dead link) | fe0/src/components/DashboardSidebar.tsx, fe0/src/components/admin/DashboardSidebar.tsx |
| Auth API | be0/src/auth_api.py |
| Password reset mail + rate limits + JWT cv middleware | be0/src/auth_mail.py, be0/src/auth_rate_limit.py, be0/src/auth_credential_middleware.py |
| ORM / enum | be0/src/initiative_db/models.py, be0/migrations/001_initiative_schema.sql |
| Admin guard patterns | be0/main.py (_require_admin_user, etc.) |
12. Evaluation notes incorporated from feedback
| Topic | Handling in this refactor |
|---|---|
Client role on register |
Privilege escalation — fix first |
| Login role Select | Inverted spec — remove |
buildUserWithSelectedRole after register |
Circular — trust server roles |
| Email allow-list “sharing” | Server canonical; duplicate small regex on client if needed |
| Reconcile on login | Mandatory, with safe rule for non-email admin grants |
| Admin emails in source | Env or DB table |
| Admin API | Read + write (grant/revoke/deactivate) |
| UserManagement UI | CRUD from day one |
| JWT staleness | Document; optional future refresh UX |
| Migration / enum | Explicit steps §8 |
| Rate limit register | §5.4 |
| Tests | §9 |