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>
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_passwordmapped column
Schemas:
UserResponse(backend/app/schemas/user.py): addmust_change_password: bool = FalseToken(backend/app/schemas/token.py): addmust_change_password: bool = False- New
ChangePasswordRequestinbackend/app/schemas/auth_password.py:current_password: str,new_password: strwith password complexity validator
Login endpoints (backend/app/api/endpoints/auth.py):
- Include
must_change_passwordin 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_passwordon the current user - If
True, block all authenticated requests EXCEPT allowlisted routes:/auth/password/change,/auth/logout,/auth/me - Return
403with 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_passwordfrom login/user response
ProtectedRoute:
- Check
must_change_passwordAFTER the auth check but BEFORE rendering children - If
must_change_password === trueAND current path is NOT/change-password, redirect to/change-password /change-passwordis exempted from the redirect so the user can actually change their password
Router (router.tsx):
- Add
/change-passwordas 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.pybackend/app/models/user.pybackend/app/schemas/user.py,token.py,auth_password.py(new)backend/app/api/endpoints/auth.pyfrontend/src/pages/ChangePasswordPage.tsx(new)frontend/src/store/authStore.tsfrontend/src/router.tsxfrontend/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_complexityvalidator - 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 existingmode: validate account exists by display code, validate email unique, create user withmust_change_password=True, assign to account with specified rolepersonalmode: create account + user as owner withmust_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.pybackend/app/schemas/admin.pybackend/app/api/endpoints/admin.pyfrontend/src/pages/admin/UsersPage.tsxfrontend/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 keytoken_hash: String, unique, indexed (store bcrypt/SHA-256 hash of token, not plaintext)user_id: UUID, FK → users.idexpires_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 hashedjtiinpassword_reset_tokenstable. Return the raw JWT.- Token is single-use: enforced by checking
used_at IS NULLfor the hashedjtiin 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 tohttp://localhost:5173whenFRONTEND_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
jtiexists 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_linkmode: create reset token, send email, setmust_change_password=True. Audit:user.password_reset.admin_emailtemp_passwordmode: generate temp password, hash and save, setmust_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 messageResetPasswordPage.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_linkresult: success toasttemp_passwordresult: 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.pybackend/app/core/security.pybackend/app/core/email.pybackend/app/schemas/auth_password.pybackend/app/api/endpoints/auth.pybackend/app/api/endpoints/admin.pyfrontend/src/pages/ForgotPasswordPage.tsx(new)frontend/src/pages/ResetPasswordPage.tsx(new)frontend/src/pages/LoginPage.tsxfrontend/src/api/auth.tsfrontend/src/api/admin.tsfrontend/src/router.tsx
Phase 4: User Archive (Soft Delete) & Hard Delete
Permissions note: All archive/restore/hard-delete endpoints use
require_super_admin(notrequire_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=NULLdeleted_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_byfields - Add
deleted_by_userrelationship (same pattern as Tree model'sdeleted_byrelationship)
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, setsis_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/usersacceptsinclude_archived: bool = Query(False)- Default query filters
deleted_at IS NULL - Archived users cannot authenticate (existing
is_active=Falsecheck 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.pybackend/app/models/user.pybackend/app/schemas/user.pybackend/app/api/endpoints/admin.pyfrontend/src/pages/admin/UsersPage.tsxfrontend/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 existingEmailService - 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.pyfrontend/src/pages/admin/UsersPage.tsxfrontend/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_linkmode: creates valid one-time token, best-effort email - Admin reset
temp_passwordmode: rotates password, setsmust_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_passwordmiddleware: 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 buildpasses
Manual Test Flows
- Admin creates user → temp password shown → login with temp password → forced to /change-password → set new password → full app access
- Forgot password → click link on login page → enter email → receive email → click link → token verified → set new password → login works
- Admin sends email reset → user gets email → click link → set new password → login works
- Admin generates temp password → admin sees temp password once → gives to user → user logs in → forced to change → works
- Archive user → user can't login → admin restores → user can login again
- 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_tokenstable cleaned up periodically (expired tokens can be pruned via scheduled task — not in initial scope)