Files
resolutionflow/docs/archive/2026-02-12-user-creation-password-reset.md
chihlasm 350c977eda feat: add procedural flows with intake forms, navigation, and seed templates
Adds a new "procedural" tree type for linear step-by-step project workflows
(domain controller setup, M365 onboarding, VPN config, etc). Includes intake
form builder, two-panel step navigation, variable resolution, procedural
exports, 3 seed templates, and UI rename from "Trees" to "Flows".

Also archives 19 implemented plan docs and creates deferred features backlog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 04:13:52 -05:00

26 KiB

ResolutionFlow: User Management Plan Comparison & Merged Plan

Part 1: Which Plan Was Better?

Plan 1 (Admin User Lifecycle and Password Reset Expansion) is the stronger initial plan overall.

It reads like a complete technical specification — the kind of document you'd hand to a developer and say "build this." It covers every API contract, every schema field, every security rule, and every edge case in a single cohesive document. It also gets several architectural decisions right that Plan 2 either misses or handles less safely.

That said, Plan 2 has real strengths that Plan 1 lacks, particularly around implementation sequencing and developer-friendliness. More on that below.


Part 2: Side-by-Side Comparison

Architecture & Data Model

Aspect Plan 1 Plan 2 Verdict
Archive columns is_archived, archived_at on users table deleted_at, deleted_by on users table (mirrors Tree model) Plan 2 is better. Using deleted_at/deleted_by follows the soft-delete pattern already established in your Tree model. Consistency across models matters for maintainability. deleted_by also provides audit trail at the data level.
must_change_password Included in single migration with archive fields Gets its own dedicated migration (031) Plan 2 is better. Smaller, focused migrations are safer and easier to debug. One concern per migration is best practice.
Password reset tokens Dedicated password_reset_tokens DB table with hashed token, single-use enforcement Stateless JWT with type: "password_reset" — no DB table Plan 1 is better. A DB-backed token table enables true single-use enforcement and allows admins to revoke outstanding reset tokens. Stateless JWTs can't be invalidated once issued. For a commercial SaaS product, this is the right call.
Temp password strength 16 chars, upper/lower/digit/symbol, excludes ambiguous chars 12+ chars, 1 upper + 1 lower + 1 digit + token_urlsafe fill Plan 1 is better. Longer, more complex, and excluding ambiguous characters (like 0/O, 1/l) is a better UX for someone reading a temp password off a screen or phone.
Reset token TTL 30 minutes 1 hour Plan 1 is better for security. 30 minutes is standard for password reset links. 1 hour is generous.

Security

Aspect Plan 1 Plan 2 Verdict
must_change_password enforcement Hard lock — blocks ALL authenticated requests except /auth/password/change, /auth/logout, /auth/me Frontend redirect only (ProtectedRoute sends to /change-password) Plan 1 is significantly better. Frontend-only enforcement is a security gap. Any API call from Postman, curl, or a script would bypass it entirely. Backend middleware enforcement is essential for a commercial product.
Session invalidation Explicit policy: revoke all refresh tokens on any password change/reset Mentions revoking refresh tokens but less systematic Plan 1 is better. Having this as a named "policy" ensures it's applied consistently everywhere.
Self-protection rules Not explicitly mentioned Admin can't archive/delete themselves; hard delete refuses other super admins Plan 2 is better. These are critical guardrails that Plan 1 assumes but doesn't spell out.
Anti-enumeration Generic success response on forgot endpoint Same, plus explicitly calls out "anti-enumeration" as a design goal Tie. Both handle this correctly.

API Design

Aspect Plan 1 Plan 2 Verdict
Admin user creation Supports two modes: existing account (join by display code) and personal (creates new account). Includes send_email toggle. Single mode: requires account_id (UUID), no personal account creation. Plan 1 is better. Supporting both modes matches your multi-tenant architecture. Creating a user who needs their own personal account is a real use case. Also, using account_display_code instead of raw UUID is much more admin-friendly.
Hard delete Two-step: precheck endpoint (GET) returns blocker counts, then separate DELETE. Requires archived state first. Single DELETE endpoint with optional ?cascade=true/false parameter. Plan 1 is better. The two-step precheck approach is safer — it prevents accidental data loss and gives the admin clear information about what's blocking the delete. A cascade flag on a DELETE endpoint is dangerous for a production SaaS platform.
Hard delete dependency checking Exhaustive list of every FK reference that would block deletion Not specified — just "removes sessions, trees, folder assignments" with cascade Plan 1 is much better. Plan 2's cascade approach would silently destroy audit logs, trees, sessions, and other critical data. Plan 1's approach of blocking when dependencies exist and returning structured blocker counts is the enterprise-grade pattern.
Admin reset modes Two modes: email_link (sends reset email) and temp_password (generates and returns temp) Single mode: always sends reset email Plan 1 is better. Having both options covers the real-world scenario where an admin is on the phone with a user and needs to give them a temp password immediately vs. sending an email for a less urgent reset.
Verify reset token endpoint Not included (token is validated during the reset itself) POST /auth/verify-reset-token — validates JWT, returns {valid, email} Plan 2 is better. This lets the frontend verify the token is still valid before showing the "new password" form, providing a better UX. Without it, the user fills out the form only to learn the token expired on submit.

Frontend

Aspect Plan 1 Plan 2 Verdict
Implementation detail Lists components and behaviors at a high level Specifies exact file paths, store changes, router additions Plan 2 is better. When you're handing this to Claude Code, specific file paths and component names eliminate ambiguity.
Force change UX Dedicated /force-password-change route /change-password route (dual-purpose: forced + voluntary) Plan 2 is better. Using one route for both forced and voluntary password changes is simpler. The component can check must_change_password to adjust its messaging.
Quick Invite Not included Phase 5: "Invite User" button on UsersPage wrapping existing invite logic Plan 2 adds value. This is a nice quality-of-life feature that leverages existing invite infrastructure.
Account Settings integration Mentioned but not detailed Detailed: ChangePasswordPage with current + new + confirm fields Plan 2 is better for implementation clarity.

Structure & Implementation Approach

Aspect Plan 1 Plan 2 Verdict
Document structure Single flat specification — everything in one document Phased approach (5 phases) with clear build order Plan 2 is better. Phased delivery means you can ship and test must_change_password + change password before tackling admin creation, which reduces risk and lets you validate each piece.
Completeness Extremely thorough — covers every edge case, schema, audit event Covers the main paths well but less edge-case detail Plan 1 is better for completeness.
Audit logging Comprehensive list of all new audit event types with naming convention Mentions audit logging per feature but doesn't centralize the event taxonomy Plan 1 is better. Having all audit events listed in one place ensures nothing is missed.
Test plan Detailed acceptance criteria for both backend and frontend Basic verification checklist + manual test scenarios Plan 1 is better for backend testing. Plan 2 is better for manual test flows (the step-by-step scenarios are more practical).
Key files list Not included Explicit list of every file to create or modify Plan 2 is better. This is invaluable for implementation planning and PR scoping.

Part 3: The Merged Plan

What follows takes the best elements from both plans and resolves the conflicts between them.


User Management Enhancement — Merged Implementation Plan

Overview

Implement admin user creation with temporary password, archive/restore and dependency-gated hard delete, self-service password reset, admin-triggered reset, and in-session password change. Built on existing Resend email service, JWT infrastructure, audit logging, and rate limiting.


Phase 1: Foundation — must_change_password + Change Password

This phase ships independently and unlocks all subsequent phases.

Migration 031: add_must_change_password_to_users.py

Add to users table:

  • must_change_password: Boolean, default=False, server_default='false', nullable=False

Backend Changes

Model (backend/app/models/user.py):

  • Add must_change_password mapped column

Schemas:

  • UserResponse (backend/app/schemas/user.py): add must_change_password: bool = False
  • Token (backend/app/schemas/token.py): add must_change_password: bool = False
  • New ChangePasswordRequest in backend/app/schemas/auth_password.py: current_password: str, new_password: str with password complexity validator

Login endpoints (backend/app/api/endpoints/auth.py):

  • Include must_change_password in Token response after login

New endpoint POST /api/v1/auth/password/change in auth.py:

  • Dependency: get_current_active_user
  • Validate current password, reject if new password matches current
  • Hash new password, set must_change_password=False
  • Revoke all refresh tokens for user
  • Audit log: auth.password_change

Backend enforcement middleware (critical — not just frontend):

  • Add middleware or dependency that checks must_change_password on the current user
  • If True, block all authenticated requests EXCEPT allowlisted routes: /auth/password/change, /auth/logout, /auth/me
  • Return 403 with body {"detail": "password_change_required"} for blocked requests

Frontend Changes

New page: ChangePasswordPage.tsx

  • Current password + new password + confirm password form
  • Dual-purpose: handles both forced change (shows warning banner, hides nav) and voluntary change from account settings
  • On success: clears auth state and redirects to login

Auth store (store/authStore.ts):

  • Store must_change_password from login/user response

ProtectedRoute:

  • Check must_change_password AFTER the auth check but BEFORE rendering children
  • If must_change_password === true AND current path is NOT /change-password, redirect to /change-password
  • /change-password is exempted from the redirect so the user can actually change their password

Router (router.tsx):

  • Add /change-password as protected route (requires auth, but exempt from must_change_password redirect)

AccountSettingsPage.tsx:

  • Add "Change Password" section linking to or embedding the change password form

Key Files

  • backend/alembic/versions/031_add_must_change_password_to_users.py
  • backend/app/models/user.py
  • backend/app/schemas/user.py, token.py, auth_password.py (new)
  • backend/app/api/endpoints/auth.py
  • frontend/src/pages/ChangePasswordPage.tsx (new)
  • frontend/src/store/authStore.ts
  • frontend/src/router.tsx
  • frontend/src/pages/AccountSettingsPage.tsx

Phase 2: Admin User Creation (M365-Style)

Backend Changes

Temp password generator (backend/app/core/security.py):

  • Generate 16-character password: upper + lower + digit + symbol, excluding ambiguous characters (0/O, 1/l/I, |)
  • Must pass existing password_complexity validator
  • Never persisted in plaintext, never written to audit logs

New schemas (backend/app/schemas/admin.py):

  • AdminUserCreate: email, name, account_mode (enum: existing | personal), account_display_code (required when mode=existing), account_role (enum: engineer | viewer, required when mode=existing), send_email (bool, default=True)
  • AdminUserCreateResponse: user (UserResponse), temporary_password (str), email_sent (bool)

New endpoint POST /api/v1/admin/users:

  • Dependency: require_super_admin
  • existing mode: validate account exists by display code, validate email unique, create user with must_change_password=True, assign to account with specified role
  • personal mode: create account + user as owner with must_change_password=True. Note: if the subscription system isn't fully wired, personal mode creates Account + User only — subscription assignment is deferred.
  • If send_email=True: send welcome email with temp password via Resend (best-effort, never blocks success)
  • Return user + temp password (shown once to admin)
  • Audit log: user.create_admin (no password value in log)

Frontend Changes

UsersPage.tsx (pages/admin/UsersPage.tsx):

  • "Create User" button opens modal
  • Modal fields: email, name, account mode toggle (existing/personal), account selector by display code (shown when existing), role selector (shown when existing), send email toggle
  • On success: show second modal with temp password, copy-to-clipboard button, and warning text ("This password will not be shown again")

New API function (frontend/src/api/admin.ts):

  • createUser(data: AdminUserCreate): Promise<AdminUserCreateResponse>

Key Files

  • backend/app/core/security.py
  • backend/app/schemas/admin.py
  • backend/app/api/endpoints/admin.py
  • frontend/src/pages/admin/UsersPage.tsx
  • frontend/src/api/admin.ts

Phase 3: Password Reset (Self-Service + Admin-Triggered)

Database

Migration 032: add_password_reset_tokens.py

New table password_reset_tokens:

  • id: UUID, primary key
  • token_hash: String, unique, indexed (store bcrypt/SHA-256 hash of token, not plaintext)
  • user_id: UUID, FK → users.id
  • expires_at: DateTime(timezone=True)
  • used_at: DateTime(timezone=True), nullable (null = unused)
  • created_by_admin_id: UUID, nullable, FK → users.id (null = self-service)
  • created_at: DateTime(timezone=True)

Backend Changes

Token generation (backend/app/core/security.py):

  • create_password_reset_token(user_id, created_by_admin_id=None): Generate JWT with {"sub": user_id, "type": "password_reset", "jti": unique_id, "exp": 30 minutes}. Store hashed jti in password_reset_tokens table. Return the raw JWT.
  • Token is single-use: enforced by checking used_at IS NULL for the hashed jti in the DB

Email (backend/app/core/email.py):

  • send_password_reset_email(): HTML template matching ResolutionFlow branding with reset link {FRONTEND_URL}/reset-password?token={token}. Falls back to http://localhost:5173 when FRONTEND_URL is None and DEBUG=True.

Self-service endpoints (backend/app/api/endpoints/auth.py):

POST /api/v1/auth/password/forgot (public):

  • Rate limit: 3/minute
  • Always returns generic success regardless of email existence (anti-enumeration)
  • If email exists: create reset token, send email (best-effort)
  • Audit log: auth.password_reset.request

POST /api/v1/auth/password/verify-reset-token (public):

  • Validates JWT type, expiry, and that jti exists in DB and is unused
  • Returns {valid: bool, email: string} (allows frontend to show the form or an error before the user fills it out)

POST /api/v1/auth/password/reset (public):

  • Validates token (type, expiry, single-use via DB lookup)
  • Sets new password (with complexity validation), clears must_change_password
  • Marks token as used (used_at = now)
  • Revokes all refresh tokens for user
  • Audit log: auth.password_reset.complete
  • Rate limit: 5/minute

Admin reset endpoint (backend/app/api/endpoints/admin.py):

POST /api/v1/admin/users/{user_id}/password-reset:

  • Dependency: require_super_admin
  • Request body: mode (enum: email_link | temp_password), send_email (bool, default=True)
  • email_link mode: create reset token, send email, set must_change_password=True. Audit: user.password_reset.admin_email
  • temp_password mode: generate temp password, hash and save, set must_change_password=True, return temp password once. Audit: user.password_reset.admin_temp
  • Both modes: revoke all existing refresh tokens

Expired token cleanup: deferred to future maintenance task.

Frontend Changes

New pages:

  • ForgotPasswordPage.tsx: email input, calls forgot endpoint, shows generic success message
  • ResetPasswordPage.tsx: reads ?token= from URL, calls verify endpoint on mount (shows error or form), new password + confirm form, calls reset endpoint

LoginPage.tsx: Add "Forgot password?" link below login form

Router (router.tsx): Add /forgot-password and /reset-password as public routes

Admin UI (pages/admin/UsersPage.tsx or UserDetailPage.tsx):

  • "Reset Password" action with mode picker (Email Link / Temporary Password)
  • email_link result: success toast
  • temp_password result: modal showing temp password with copy button + "won't be shown again" warning

New API functions:

  • auth.ts: forgotPassword(), verifyResetToken(), resetPassword()
  • admin.ts: adminResetUserPassword(userId, mode, sendEmail)

Key Files

  • backend/alembic/versions/032_add_password_reset_tokens.py
  • backend/app/core/security.py
  • backend/app/core/email.py
  • backend/app/schemas/auth_password.py
  • backend/app/api/endpoints/auth.py
  • backend/app/api/endpoints/admin.py
  • frontend/src/pages/ForgotPasswordPage.tsx (new)
  • frontend/src/pages/ResetPasswordPage.tsx (new)
  • frontend/src/pages/LoginPage.tsx
  • frontend/src/api/auth.ts
  • frontend/src/api/admin.ts
  • frontend/src/router.tsx

Phase 4: User Archive (Soft Delete) & Hard Delete

Permissions note: All archive/restore/hard-delete endpoints use require_super_admin (not require_admin). Only super admins can perform these destructive user lifecycle operations.

Database

Migration 033: add_soft_delete_to_users.py

Add to users table (follows same pattern as Tree model):

  • deleted_at: DateTime(timezone=True), nullable, default=NULL
  • deleted_by: UUID, nullable, FK → users.id, default=NULL
  • Index on deleted_at

Backend Changes

User model (backend/app/models/user.py):

  • Add deleted_at, deleted_by fields
  • Add deleted_by_user relationship (same pattern as Tree model's deleted_by relationship)

Archive/Restore endpoints (backend/app/api/endpoints/admin.py):

PUT /api/v1/admin/users/{user_id}/archive:

  • Dependency: require_super_admin
  • Sets deleted_at=now, deleted_by=current_user.id, is_active=False
  • Revokes all refresh tokens for the archived user
  • Prevents self-archive (return 400)
  • Audit log: user.archive

PUT /api/v1/admin/users/{user_id}/restore:

  • Dependency: require_super_admin
  • Clears deleted_at, deleted_by, sets is_active=True
  • Audit log: user.restore

Hard delete endpoints (backend/app/api/endpoints/admin.py) — both use require_super_admin:

GET /api/v1/admin/users/{user_id}/hard-delete-check:

  • Dependency: require_super_admin
  • Returns {can_delete: bool, blockers: {...}} with counts for each blocking FK reference
  • Blocking references checked: accounts.owner_id, sessions.user_id, audit_logs.user_id, invite_codes.created_by_id, invite_codes.used_by_id, account_invites.invited_by_id, account_invites.accepted_by_id, trees.author_id, trees.deleted_by, account_limit_override.created_by_id, feature_flags.created_by_id, platform_settings.updated_by_id

DELETE /api/v1/admin/users/{user_id}/hard-delete:

  • Dependency: require_super_admin
  • Pre-conditions: user must be archived (deleted_at IS NOT NULL) AND precheck must pass (can_delete=true)
  • If blockers exist: return 409 with structured blocker counts
  • If no blockers: delete user row + clean technical auth artifacts (refresh_tokens, password_reset_tokens) in same transaction
  • Prevents deleting other super admins (return 403)
  • Audit log: user.hard_delete

Update user listing:

  • GET /api/v1/admin/users accepts include_archived: bool = Query(False)
  • Default query filters deleted_at IS NULL
  • Archived users cannot authenticate (existing is_active=False check handles this)

UserResponse schema updates:

  • Add deleted_at: Optional[datetime], deleted_by: Optional[UUID]

Frontend Changes

UsersPage.tsx:

  • "Show Archived" toggle filter
  • Archive/Restore action buttons per user (contextual based on state)
  • Hard delete action: first calls precheck endpoint, displays dependency blockers if present, then shows destructive confirmation dialog if no blockers

ConfirmDialog: Strong warning for hard delete ("This action is permanent and cannot be undone")

New API functions (frontend/src/api/admin.ts):

  • archiveUser(userId), restoreUser(userId)
  • hardDeleteCheck(userId), hardDeleteUser(userId)

Key Files

  • backend/alembic/versions/033_add_soft_delete_to_users.py
  • backend/app/models/user.py
  • backend/app/schemas/user.py
  • backend/app/api/endpoints/admin.py
  • frontend/src/pages/admin/UsersPage.tsx
  • frontend/src/api/admin.ts

Phase 5: Quick Invite on Users Page

Thin convenience wrapper around existing invite infrastructure.

Backend

New endpoint POST /api/v1/admin/invites:

  • Dependency: require_super_admin
  • Request: {email, account_display_code, role}
  • Resolves account by display code, creates AccountInvite, sends email via existing EmailService
  • Wraps existing invite logic — no new invite infrastructure

Frontend

UsersPage.tsx: "Invite User" button → modal (email, account display code, role)

  • Calls admin invite endpoint
  • Shows success/error toast

Key Files

  • backend/app/api/endpoints/admin.py
  • frontend/src/pages/admin/UsersPage.tsx
  • frontend/src/api/admin.ts

Security Summary

Concern Approach
Temp passwords 16 chars, upper/lower/digit/symbol, excludes ambiguous chars. Never persisted plaintext. Never in audit logs.
Reset tokens JWT with type: "password_reset", 30-minute TTL, single-use enforced via DB table (password_reset_tokens). Always verify type claim to prevent token misuse.
Anti-enumeration /auth/password/forgot returns identical response regardless of email existence.
must_change_password Backend middleware enforcement — blocks all authenticated routes except allowlist. Frontend redirect is supplementary, not primary.
Session invalidation Revoke ALL refresh tokens on: password change, password reset (self-service or admin), and user archive.
Self-protection Admin cannot archive or delete themselves. Hard delete refuses other super admins.
Rate limiting forgot: 3/min. reset: 5/min. change: 5/min. Admin endpoints use existing admin rate limits + audit logging.

Audit Events

All events include non-sensitive details only (no token or password values).

Event Trigger
auth.password_change User changes own password (forced or voluntary)
auth.password_reset.request Self-service forgot password request
auth.password_reset.complete Self-service reset completed
user.create_admin Admin creates new user
user.archive Admin archives user
user.restore Admin restores archived user
user.hard_delete Admin hard-deletes user
user.password_reset.admin_email Admin triggers email-link reset
user.password_reset.admin_temp Admin generates temp password

Verification & Testing

Automated Tests (pytest)

  • Admin create user (existing account mode): returns temp password, stores hash, sets must_change_password=True, logs audit
  • Admin create user (personal mode): creates account + owner role, logs audit
  • Archive/restore toggles state correctly; archived users excluded from default list; archived users cannot authenticate
  • Hard-delete precheck returns accurate blocker counts; delete rejected with blockers; delete succeeds when archived + no blockers
  • Admin reset email_link mode: creates valid one-time token, best-effort email
  • Admin reset temp_password mode: rotates password, sets must_change_password=True, returns temp, no plaintext persistence
  • Self-service forgot: generic success for existing and non-existing email
  • Reset token: enforces type, expiry, single-use, and complexity validation
  • Verify-reset-token: returns valid/invalid correctly
  • In-session password change: requires correct current password, revokes all refresh tokens
  • must_change_password middleware: blocks non-allowlisted endpoints, allows allowlisted ones
  • Self-protection: admin can't archive/delete self; can't hard-delete other super admins

Frontend Build

  • cd frontend && npm run build passes

Manual Test Flows

  1. Admin creates user → temp password shown → login with temp password → forced to /change-password → set new password → full app access
  2. Forgot password → click link on login page → enter email → receive email → click link → token verified → set new password → login works
  3. Admin sends email reset → user gets email → click link → set new password → login works
  4. Admin generates temp password → admin sees temp password once → gives to user → user logs in → forced to change → works
  5. Archive user → user can't login → admin restores → user can login again
  6. Hard delete → precheck shows blockers → resolve blockers → precheck passes → confirm delete → user record gone

Assumptions & Defaults

  • Reset token TTL: 30 minutes
  • Email delivery is best-effort; never blocks create/reset success responses
  • Archived users remain unique by email; reusing email requires successful hard delete
  • Hard delete requires prior archive state (two-step safety)
  • Existing user list pagination is out of scope unless incidentally touched
  • password_reset_tokens table cleaned up periodically (expired tokens can be pruned via scheduled task — not in initial scope)