feat: user management — admin create, password reset, archive/delete, quick invite

Phase 1: must_change_password enforcement + change password endpoint/page
Phase 2: Admin user creation (M365-style) with temp password
Phase 3: Password reset (self-service forgot + admin-triggered)
Phase 4: User archive (soft delete) + hard delete with precheck
Phase 5: Quick invite from admin Users page

Also fixes:
- Auto-create subscription for accounts missing one
- Hard delete precheck ignores sole-member personal accounts
- Seed script patches tree nodes for validation compliance

Migrations: 031 (must_change_password), 032 (password_reset_tokens), 033 (user soft delete)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-13 01:42:51 -05:00
parent b8f25f19eb
commit ad59446332
32 changed files with 3064 additions and 38 deletions

View File

@@ -2,7 +2,7 @@
> **Purpose:** This file documents bugs, fixes, and gotchas encountered during development.
> **For Claude Code:** Read this file at the start of each session to avoid repeating past mistakes.
> **Last Updated:** February 1, 2026
> **Last Updated:** February 12, 2026
---
@@ -724,6 +724,172 @@ python -m scripts.seed_trees ...
---
## SQLAlchemy / Database
### Two Foreign Keys to Same Table Require `foreign_keys=` ⚠️ CRITICAL
**Problem:** SQLAlchemy raises `AmbiguousForeignKeysError` when a model has two ForeignKey columns pointing to the same table (e.g., `deleted_by` and `author_id` both referencing `users.id`).
**Cause:** SQLAlchemy can't figure out which FK to use for each relationship.
**Solution:** Add `foreign_keys=` parameter on BOTH relationship sides:
```python
# In User model
deleted_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id"), nullable=True
)
# In Tree model with two user FKs
author: Mapped["User"] = relationship("User", foreign_keys=[author_id])
deleted_by_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[deleted_by])
```
**Files affected:** Any model with multiple FK references to the same table (User, Tree)
---
### Circular FK Between Tables Requires Nullable FK
**Problem:** `Account.owner_id → users.id` and `User.account_id → accounts.id` creates a circular dependency. `CREATE TABLE` fails because neither table can be created first.
**Cause:** Both tables reference each other, so neither can exist before the other.
**Solution:** Make one FK nullable and set it after both records exist:
```python
# Create Account first (owner_id=NULL)
account = Account(name="My Account")
db.add(account)
await db.flush()
# Create User with account_id
user = User(account_id=account.id, ...)
db.add(user)
await db.flush()
# Now set owner_id
account.owner_id = user.id
await db.commit()
```
**Files affected:** `backend/app/models/account.py`, registration endpoint
---
## Authentication / Security
### Backend Enforcement of `must_change_password` via Dependency Injection
**Problem:** Need to force users to change their password before accessing any endpoint, but some endpoints (like the change-password endpoint itself) must be exempt.
**Solution:** Add a check in `get_current_active_user` dependency with a route allowlist:
```python
async def get_current_active_user(request: Request, ...):
user = ... # existing auth logic
# Check must_change_password (with route allowlist)
if user.must_change_password:
allowed = ["/auth/password/change", "/auth/logout", "/auth/me"]
if not any(request.url.path.endswith(p) for p in allowed):
raise HTTPException(403, detail="password_change_required")
return user
```
**Key insight:** This requires adding `request: Request` parameter to the dependency. The frontend checks the 403 detail string and redirects to `/change-password`.
**Files affected:** `backend/app/api/deps.py`, `frontend/src/components/layout/ProtectedRoute.tsx`
---
### Password Reset Tokens: DB-Backed Single-Use via Hashed JTI
**Pattern:** Password reset tokens use JWT for transport but are tracked in the database for single-use enforcement.
**How it works:**
1. Generate JWT with `type: "password_reset"`, `jti: uuid4()`, `exp: 30min`
2. Store `SHA-256(jti)` in `password_reset_tokens` table
3. On reset: decode JWT → hash jti → look up in DB → check `used_at IS NULL`
4. After use: set `used_at = now()` to prevent reuse
**Why not just JWT?** JWTs are stateless — you can't revoke them. DB tracking ensures each token is used exactly once.
**Files affected:** `backend/app/models/password_reset_token.py`, `backend/app/core/security.py`
---
### Anti-Enumeration on Forgot Password Endpoint
**Pattern:** The `POST /auth/password/forgot` endpoint always returns the same generic success message, regardless of whether the email exists.
**Why:** If the endpoint returned "email not found" for non-existent users, an attacker could use it to enumerate valid email addresses.
```python
# Always return success, even if email doesn't exist
return {"message": "If an account exists with that email, a reset link has been sent."}
```
**Files affected:** `backend/app/api/endpoints/auth.py`
---
## Seed Scripts
### Seed Tree Data Missing Required Validation Fields
**Problem:** All 18 seed trees fail to create with validation errors: "Action nodes must have a non-empty action" and "Solution nodes must have a non-empty solution".
**Cause:** The tree validation (added after the seed scripts were written) requires:
- Action nodes: `action` field (not just `description`)
- Solution nodes: `solution` field (not just `description`)
- Decision nodes with children: at least 2 branches
The seed data uses `title` and `description` but not the specific `action`/`solution` fields.
**Solution:** Add a recursive `_fix_node_fields()` function in the seeder that patches nodes before sending to the API:
```python
def _fix_node_fields(node):
if node["type"] == "action" and not node.get("action"):
node["action"] = node.get("title") or node.get("description") or "Action"
elif node["type"] == "solution" and not node.get("solution"):
node["solution"] = node.get("title") or node.get("description") or "Solution"
elif node["type"] == "decision":
children = node.get("children", [])
if len(children) == 1: # Add fallback branch
children.append({"id": "..._fallback", "type": "solution", ...})
for child in node.get("children", []):
_fix_node_fields(child)
```
**Files affected:** `backend/scripts/seed_trees_v2.py`
---
## Admin Features
### Two-Step Hard Delete Pattern
**Pattern:** Hard-deleting a user requires two API calls — a precheck followed by the actual delete.
1. `GET /admin/users/{id}/hard-delete-check` → returns `{can_delete: bool, blockers: {owned_accounts: 3, sessions: 12, ...}}`
2. `DELETE /admin/users/{id}/hard-delete` → only succeeds if user is archived AND precheck passes
**Pre-conditions enforced:**
- User must be archived (`deleted_at IS NOT NULL`) before hard delete
- User must have no blockers (owned accounts, authored trees, sessions, audit logs, invite codes)
- Cannot delete yourself or other super admins
- Audit log entry created BEFORE the delete (since user won't exist after)
**Files affected:** `backend/app/api/endpoints/admin.py`
---
### Admin User Creation with Temp Password (M365-Style)
**Pattern:** Admin creates a user → gets a one-time temp password → user must change on first login.
**Key design decisions:**
- Temp password is generated server-side (`secrets`-based, 16 chars, guaranteed complexity)
- Temp password is returned once in the response, never stored as plaintext
- `must_change_password=True` is set on the user, enforced at the dependency level
- Welcome email with temp password is best-effort (never blocks user creation)
- Two modes: "existing account" (join with role) or "personal" (new account created)
**Files affected:** `backend/app/api/endpoints/admin.py`, `backend/app/core/security.py`
---
## Adding New Lessons
When you encounter and fix a bug, add it here with:

View File

@@ -0,0 +1,28 @@
"""add must_change_password to users
Revision ID: 031
Revises: 030
Create Date: 2026-02-12
Adds must_change_password boolean column to users table.
When True, user is required to change their password before accessing the app.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '031'
down_revision: Union[str, None] = '030'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('must_change_password', sa.Boolean(), nullable=False, server_default='false'))
def downgrade() -> None:
op.drop_column('users', 'must_change_password')

View File

@@ -0,0 +1,37 @@
"""add password_reset_tokens table
Revision ID: 032
Revises: 031
Create Date: 2026-02-12
New table for DB-backed password reset tokens (single-use, hashed JTI).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '032'
down_revision: Union[str, None] = '031'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'password_reset_tokens',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('token_hash', sa.String(64), unique=True, nullable=False, index=True),
sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=False, index=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_by_admin_id', UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table('password_reset_tokens')

View File

@@ -0,0 +1,32 @@
"""add soft delete to users
Revision ID: 033
Revises: 032
Create Date: 2026-02-12
Adds deleted_at and deleted_by columns to users table for soft delete (archive).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '033'
down_revision: Union[str, None] = '032'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('users', sa.Column('deleted_by', UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True))
op.create_index('ix_users_deleted_at', 'users', ['deleted_at'])
def downgrade() -> None:
op.drop_index('ix_users_deleted_at', table_name='users')
op.drop_column('users', 'deleted_by')
op.drop_column('users', 'deleted_at')

View File

@@ -1,6 +1,6 @@
from typing import Annotated, Optional
from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -10,6 +10,13 @@ from app.core.security import decode_token
from app.models.user import User
from app.models.plan_limits import PlanLimits
# Routes that are allowed even when must_change_password is True
_PASSWORD_CHANGE_ALLOWLIST = {
"/api/v1/auth/password/change",
"/api/v1/auth/logout",
"/api/v1/auth/me",
}
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
@@ -65,16 +72,26 @@ async def get_refresh_token_payload(
async def get_current_active_user(
request: Request,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
"""Ensure user is active (not disabled). Auto-downgrades expired trials."""
"""Ensure user is active (not disabled). Auto-downgrades expired trials.
Enforces must_change_password — blocks all routes except allowlist."""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account has been deactivated"
)
# Enforce must_change_password (backend hard lock)
if current_user.must_change_password:
if request.url.path not in _PASSWORD_CHANGE_ALLOWLIST:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="password_change_required"
)
# Lightweight trial expiry check
if current_user.account_id:
from app.models.subscription import Subscription

View File

@@ -1,3 +1,5 @@
import secrets
import string
from datetime import datetime, timezone, timedelta
from typing import Annotated, Optional
from uuid import UUID
@@ -8,14 +10,21 @@ from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.audit import log_audit
from app.core.config import settings
from app.core.security import get_password_hash, generate_temp_password, create_password_reset_token, decode_token, hash_token
from app.core.email import EmailService
from app.models.user import User
from app.models.refresh_token import RefreshToken
from app.models.password_reset_token import PasswordResetToken
from app.models.account import Account
from app.models.subscription import Subscription
from app.models.session import Session
from app.models.audit_log import AuditLog
from app.models.invite_code import InviteCode
from app.models.account_invite import AccountInvite
from app.models.tree import Tree
from app.schemas.user import UserResponse, RoleUpdate, AccountRoleUpdate
from app.schemas.admin import MoveUserAccount
from app.schemas.admin import MoveUserAccount, AdminUserCreate, AdminUserCreateResponse, AdminPasswordReset, AdminPasswordResetResponse, HardDeleteCheckResponse
from app.schemas.subscription import SubscriptionPlanUpdate, ExtendTrialRequest
from app.schemas.user_detail import (
UserDetailResponse, AccountSummary, SubscriptionSummary,
@@ -34,11 +43,14 @@ async def list_users(
limit: int = Query(100, ge=1, le=100),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
role: Optional[str] = Query(None, description="Filter by role"),
account_id: Optional[UUID] = Query(None, description="Filter by account")
account_id: Optional[UUID] = Query(None, description="Filter by account"),
include_archived: bool = Query(False, description="Include archived (soft-deleted) users"),
):
"""List all users (super admin only)."""
query = select(User)
if not include_archived:
query = query.where(User.deleted_at.is_(None))
if is_active is not None:
query = query.where(User.is_active == is_active)
if role:
@@ -53,6 +65,137 @@ async def list_users(
return users
def _generate_display_code() -> str:
"""Generate a random 8-character alphanumeric display code."""
chars = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(chars) for _ in range(8))
@router.post("/users", response_model=AdminUserCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
data: AdminUserCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Create a new user with a temporary password (super admin only).
Supports two modes:
- existing: Join an existing account (resolved by display_code)
- personal: Create a new personal account for the user
"""
# Validate mode-specific fields
if data.account_mode == "existing":
if not data.account_display_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="account_display_code is required for existing mode",
)
if not data.account_role:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="account_role is required for existing mode",
)
# Check email uniqueness
result = await db.execute(select(User).where(User.email == data.email))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Generate temp password
temp_password = generate_temp_password()
password_hash = get_password_hash(temp_password)
if data.account_mode == "existing":
# Resolve account by display code
result = await db.execute(
select(Account).where(Account.display_code == data.account_display_code)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Account not found for the given display code",
)
new_user = User(
email=data.email,
password_hash=password_hash,
name=data.name,
role="engineer",
account_id=account.id,
account_role=data.account_role,
must_change_password=True,
)
db.add(new_user)
await db.flush()
else:
# Personal mode: create new account + user as owner
new_account = Account(
name=f"{data.name}'s Account",
display_code=_generate_display_code(),
)
db.add(new_account)
await db.flush()
new_user = User(
email=data.email,
password_hash=password_hash,
name=data.name,
role="engineer",
account_id=new_account.id,
account_role="owner",
must_change_password=True,
)
db.add(new_user)
await db.flush()
new_account.owner_id = new_user.id
# Create free subscription for the new account
new_subscription = Subscription(
account_id=new_account.id,
plan="free",
status="active",
)
db.add(new_subscription)
await log_audit(
db, current_user.id, "user.create_admin", "user", new_user.id,
{"email": data.email, "account_mode": data.account_mode},
)
await db.commit()
await db.refresh(new_user)
# Send welcome email (best-effort)
email_sent = False
if data.send_email:
email_sent = await EmailService.send_welcome_email(
to_email=data.email,
temp_password=temp_password,
)
return AdminUserCreateResponse(
user={
"id": str(new_user.id),
"email": new_user.email,
"name": new_user.name,
"role": new_user.role,
"is_active": new_user.is_active,
"is_super_admin": new_user.is_super_admin,
"account_id": str(new_user.account_id) if new_user.account_id else None,
"account_role": new_user.account_role,
"must_change_password": new_user.must_change_password,
"created_at": new_user.created_at.isoformat() if new_user.created_at else None,
},
temporary_password=temp_password,
email_sent=email_sent,
)
@router.get("/users/{user_id}", response_model=UserDetailResponse)
async def get_user(
user_id: UUID,
@@ -162,6 +305,7 @@ async def get_user(
is_super_admin=user.is_super_admin,
is_team_admin=getattr(user, "is_team_admin", False),
created_at=user.created_at,
deleted_at=user.deleted_at,
account=account_summary, subscription=subscription_summary,
invite_code_used=invite_code_used,
recent_sessions=recent_sessions, total_sessions=total_sessions,
@@ -321,7 +465,14 @@ async def _get_user_subscription(user_id: UUID, db: AsyncSession) -> tuple[User,
)
subscription = sub_result.scalar_one_or_none()
if not subscription:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subscription not found")
# Auto-create a free subscription for accounts that predate the subscription system
subscription = Subscription(
account_id=user.account_id,
plan="free",
status="active",
)
db.add(subscription)
await db.flush()
return user, subscription
@@ -372,3 +523,357 @@ async def extend_user_trial(
await db.commit()
return {"plan": subscription.plan, "status": subscription.status,
"current_period_end": subscription.current_period_end}
@router.post("/users/{user_id}/password-reset", response_model=AdminPasswordResetResponse)
async def admin_reset_password(
user_id: UUID,
data: AdminPasswordReset,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Admin-triggered password reset (super admin only).
Two modes:
- email_link: sends a reset email to the user
- temp_password: generates a temp password and returns it once
"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
# Revoke all refresh tokens
rt_result = await db.execute(
select(RefreshToken).where(
RefreshToken.user_id == user.id,
RefreshToken.revoked_at.is_(None)
)
)
for rt in rt_result.scalars().all():
rt.revoked_at = datetime.now(timezone.utc)
user.must_change_password = True
if data.mode == "email_link":
# Create reset token and send email
raw_token = create_password_reset_token(str(user.id))
payload = decode_token(raw_token)
if payload and payload.get("jti"):
token_record = PasswordResetToken(
token_hash=hash_token(payload["jti"]),
user_id=user.id,
expires_at=datetime.fromtimestamp(payload["exp"], tz=timezone.utc),
created_by_admin_id=current_user.id,
)
db.add(token_record)
await log_audit(db, current_user.id, "user.password_reset.admin_email", "user", user.id)
await db.commit()
email_sent = False
if settings.FRONTEND_URL:
reset_url = f"{settings.FRONTEND_URL}/reset-password?token={raw_token}"
email_sent = await EmailService.send_password_reset_email(
to_email=user.email, reset_url=reset_url,
)
return AdminPasswordResetResponse(
message="Password reset email sent" if email_sent else "Reset token created (email not configured)",
email_sent=email_sent,
)
else: # temp_password
temp_pw = generate_temp_password()
user.password_hash = get_password_hash(temp_pw)
await log_audit(db, current_user.id, "user.password_reset.admin_temp", "user", user.id)
await db.commit()
return AdminPasswordResetResponse(
message="Temporary password generated",
temporary_password=temp_pw,
email_sent=False,
)
@router.put("/users/{user_id}/archive", response_model=UserResponse)
async def archive_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Archive (soft delete) a user (super admin only)."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user.id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot archive yourself")
if user.deleted_at:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User is already archived")
user.deleted_at = datetime.now(timezone.utc)
user.deleted_by = current_user.id
user.is_active = False
# Revoke all refresh tokens
rt_result = await db.execute(
select(RefreshToken).where(RefreshToken.user_id == user.id, RefreshToken.revoked_at.is_(None))
)
for rt in rt_result.scalars().all():
rt.revoked_at = datetime.now(timezone.utc)
await log_audit(db, current_user.id, "user.archive", "user", user.id)
await db.commit()
await db.refresh(user)
return user
@router.put("/users/{user_id}/restore", response_model=UserResponse)
async def restore_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Restore an archived user (super admin only)."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if not user.deleted_at:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="User is not archived")
user.deleted_at = None
user.deleted_by = None
user.is_active = True
await log_audit(db, current_user.id, "user.restore", "user", user.id)
await db.commit()
await db.refresh(user)
return user
@router.get("/users/{user_id}/hard-delete-check", response_model=HardDeleteCheckResponse)
async def hard_delete_check(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Check if a user can be hard-deleted (super admin only). Returns blockers."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
blockers: dict = {}
# Check if user owns any accounts with OTHER members (true blocker).
# Sole-member accounts (e.g. personal accounts) are cleaned up during delete.
owned_account_ids_result = await db.execute(
select(Account.id).where(Account.owner_id == user_id)
)
owned_account_ids = [row[0] for row in owned_account_ids_result.all()]
shared_accounts = 0
for acc_id in owned_account_ids:
other_members = (await db.execute(
select(func.count()).select_from(User).where(
User.account_id == acc_id, User.id != user_id
)
)).scalar() or 0
if other_members > 0:
shared_accounts += 1
if shared_accounts > 0:
blockers["owned_accounts_with_other_members"] = shared_accounts
# Check authored trees
authored_trees = (await db.execute(
select(func.count()).select_from(Tree).where(Tree.author_id == user_id)
)).scalar() or 0
if authored_trees > 0:
blockers["authored_trees"] = authored_trees
# Check sessions
sessions_count = (await db.execute(
select(func.count()).select_from(Session).where(Session.user_id == user_id)
)).scalar() or 0
if sessions_count > 0:
blockers["sessions"] = sessions_count
# Check audit logs
audit_count = (await db.execute(
select(func.count()).select_from(AuditLog).where(AuditLog.user_id == user_id)
)).scalar() or 0
if audit_count > 0:
blockers["audit_logs"] = audit_count
# Check invite codes created
invites_created = (await db.execute(
select(func.count()).select_from(InviteCode).where(InviteCode.created_by_id == user_id)
)).scalar() or 0
if invites_created > 0:
blockers["invite_codes_created"] = invites_created
# Check account invites
account_invites = (await db.execute(
select(func.count()).select_from(AccountInvite).where(AccountInvite.invited_by_id == user_id)
)).scalar() or 0
if account_invites > 0:
blockers["account_invites_created"] = account_invites
return HardDeleteCheckResponse(
can_delete=len(blockers) == 0,
blockers=blockers,
)
@router.delete("/users/{user_id}/hard-delete", status_code=status.HTTP_204_NO_CONTENT)
async def hard_delete_user(
user_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Permanently delete a user (super admin only). User must be archived first."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user.id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot delete yourself")
if user.is_super_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot hard-delete a super admin")
if not user.deleted_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User must be archived before hard-deleting"
)
# Run precheck
precheck = await hard_delete_check(user_id, db, current_user)
if not precheck.can_delete:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot delete: user has dependencies ({', '.join(precheck.blockers.keys())})"
)
# Audit BEFORE delete
await log_audit(db, current_user.id, "user.hard_delete", "user", user.id,
{"email": user.email, "name": user.name})
from sqlalchemy import delete as sa_delete
# Delete technical artifacts
await db.execute(sa_delete(RefreshToken).where(RefreshToken.user_id == user_id))
await db.execute(sa_delete(PasswordResetToken).where(PasswordResetToken.user_id == user_id))
# Clean up sole-member owned accounts (personal accounts)
owned_accounts_result = await db.execute(
select(Account).where(Account.owner_id == user_id)
)
for account in owned_accounts_result.scalars().all():
# Null out owner_id first (RESTRICT FK)
account.owner_id = None
await db.flush()
# Delete subscription if exists
await db.execute(sa_delete(Subscription).where(Subscription.account_id == account.id))
# Delete the account
await db.delete(account)
# Delete the user
await db.delete(user)
await db.commit()
@router.post("/invites", status_code=status.HTTP_201_CREATED)
async def admin_create_invite(
data: dict,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Quick-invite a user to an account (super admin only).
Body: {email, account_display_code, role}
Creates an AccountInvite and sends the invite email.
"""
email = data.get("email")
account_display_code = data.get("account_display_code")
role = data.get("role", "engineer")
if not email or not account_display_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="email and account_display_code are required",
)
if role not in ("engineer", "viewer"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="role must be 'engineer' or 'viewer'",
)
# Resolve account
result = await db.execute(
select(Account).where(Account.display_code == account_display_code)
)
account = result.scalar_one_or_none()
if not account:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Account with display code '{account_display_code}' not found",
)
# Check if email already has a pending invite to this account
existing = await db.execute(
select(AccountInvite).where(
AccountInvite.account_id == account.id,
AccountInvite.email == email,
AccountInvite.accepted_by_id.is_(None),
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A pending invite already exists for this email and account",
)
# Generate invite code
code = secrets.token_urlsafe(16)
invite = AccountInvite(
account_id=account.id,
invited_by_id=current_user.id,
email=email,
code=code,
role=role,
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
)
db.add(invite)
await log_audit(
db, current_user.id, "user.invite_admin", "account_invite", invite.id,
{"email": email, "account_id": str(account.id), "role": role},
)
await db.commit()
# Send email (best-effort)
email_sent = await EmailService.send_account_invite_email(
to_email=email,
code=code,
account_name=account.name or account_display_code,
role=role,
)
return {
"id": str(invite.id),
"email": email,
"code": code,
"role": role,
"account_display_code": account_display_code,
"email_sent": email_sent,
}

View File

@@ -14,6 +14,7 @@ from app.core.security import (
get_password_hash,
create_access_token,
create_refresh_token,
create_password_reset_token,
decode_token,
hash_token,
)
@@ -25,7 +26,17 @@ from app.models.subscription import Subscription
from app.models.account_invite import AccountInvite
from app.schemas.user import UserCreate, UserResponse, UserLogin
from app.schemas.token import Token
from app.schemas.auth_password import (
ChangePasswordRequest,
ForgotPasswordRequest,
VerifyResetTokenRequest,
VerifyResetTokenResponse,
ResetPasswordRequest,
)
from app.models.password_reset_token import PasswordResetToken
from app.core.email import EmailService
from app.api.deps import get_current_active_user, get_refresh_token_payload
from app.core.audit import log_audit
router = APIRouter(prefix="/auth", tags=["authentication"])
@@ -241,7 +252,8 @@ async def login(
return Token(
access_token=access_token,
refresh_token=refresh_token_str,
token_type="bearer"
token_type="bearer",
must_change_password=user.must_change_password,
)
@@ -274,7 +286,8 @@ async def login_json(
return Token(
access_token=access_token,
refresh_token=refresh_token_str,
token_type="bearer"
token_type="bearer",
must_change_password=user.must_change_password,
)
@@ -356,3 +369,177 @@ async def logout(
await db.commit()
return {"message": "Successfully logged out"}
@router.post("/password/change")
@limiter.limit("5/minute")
async def change_password(
request: Request,
data: ChangePasswordRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Change the current user's password."""
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect"
)
if data.current_password == data.new_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="New password must be different from current password"
)
current_user.password_hash = get_password_hash(data.new_password)
current_user.must_change_password = False
# Revoke all refresh tokens for this user
result = await db.execute(
select(RefreshToken).where(
RefreshToken.user_id == current_user.id,
RefreshToken.revoked_at.is_(None)
)
)
active_tokens = result.scalars().all()
for token in active_tokens:
token.revoked_at = datetime.now(timezone.utc)
await log_audit(db, current_user.id, "auth.password_change", "user", current_user.id)
await db.commit()
return {"message": "Password changed successfully"}
@router.post("/password/forgot")
@limiter.limit("3/minute")
async def forgot_password(
request: Request,
data: ForgotPasswordRequest,
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Request a password reset email. Always returns success (anti-enumeration)."""
result = await db.execute(select(User).where(User.email == data.email))
user = result.scalar_one_or_none()
if user:
# Create reset token JWT
raw_token = create_password_reset_token(str(user.id))
payload = decode_token(raw_token)
if payload and payload.get("jti"):
token_record = PasswordResetToken(
token_hash=hash_token(payload["jti"]),
user_id=user.id,
expires_at=datetime.fromtimestamp(payload["exp"], tz=timezone.utc),
)
db.add(token_record)
await db.commit()
# Send email (best-effort)
reset_url = f"{settings.FRONTEND_URL}/reset-password?token={raw_token}"
await EmailService.send_password_reset_email(
to_email=user.email,
reset_url=reset_url,
)
await log_audit(db, user.id, "auth.password_reset.request", "user", user.id)
await db.commit()
return {"message": "If an account with that email exists, a reset link has been sent."}
@router.post("/password/verify-reset-token", response_model=VerifyResetTokenResponse)
async def verify_reset_token(
data: VerifyResetTokenRequest,
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Verify a password reset token is valid."""
payload = decode_token(data.token)
if not payload or payload.get("type") != "password_reset":
return VerifyResetTokenResponse(valid=False)
jti = payload.get("jti")
if not jti:
return VerifyResetTokenResponse(valid=False)
result = await db.execute(
select(PasswordResetToken).where(PasswordResetToken.token_hash == hash_token(jti))
)
token_record = result.scalar_one_or_none()
if not token_record or not token_record.is_valid:
return VerifyResetTokenResponse(valid=False)
# Get user email for display
user_result = await db.execute(select(User.email).where(User.id == token_record.user_id))
email = user_result.scalar_one_or_none()
return VerifyResetTokenResponse(valid=True, email=email)
@router.post("/password/reset")
@limiter.limit("5/minute")
async def reset_password(
request: Request,
data: ResetPasswordRequest,
db: Annotated[AsyncSession, Depends(get_db)]
):
"""Reset password using a valid reset token."""
payload = decode_token(data.token)
if not payload or payload.get("type") != "password_reset":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired reset token"
)
jti = payload.get("jti")
user_id = payload.get("sub")
if not jti or not user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid reset token"
)
# Validate token in DB (single-use)
result = await db.execute(
select(PasswordResetToken).where(PasswordResetToken.token_hash == hash_token(jti))
)
token_record = result.scalar_one_or_none()
if not token_record or not token_record.is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Reset token has already been used or has expired"
)
# Get user
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid reset token"
)
# Update password
user.password_hash = get_password_hash(data.new_password)
user.must_change_password = False
# Mark token as used
token_record.used_at = datetime.now(timezone.utc)
# Revoke all refresh tokens
rt_result = await db.execute(
select(RefreshToken).where(
RefreshToken.user_id == user.id,
RefreshToken.revoked_at.is_(None)
)
)
for rt in rt_result.scalars().all():
rt.revoked_at = datetime.now(timezone.utc)
await log_audit(db, user.id, "auth.password_reset.complete", "user", user.id)
await db.commit()
return {"message": "Password has been reset successfully"}

View File

@@ -51,6 +51,76 @@ class EmailService:
return False
@staticmethod
async def send_password_reset_email(
to_email: str,
reset_url: str,
) -> bool:
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
resend.api_key = settings.RESEND_API_KEY
subject = "Reset Your Password — ResolutionFlow"
html = _render_password_reset_html(reset_url=reset_url)
resend.Emails.send(
{
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
}
)
logger.info("Password reset email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send password reset email to %s", to_email)
return False
@staticmethod
async def send_welcome_email(
to_email: str,
temp_password: str,
login_url: str = "https://resolutionflow.com/login",
) -> bool:
if not settings.email_enabled:
logger.warning("Email not sent — RESEND_API_KEY not configured")
return False
try:
import resend
resend.api_key = settings.RESEND_API_KEY
subject = "Welcome to ResolutionFlow — Your Account Is Ready"
html = _render_welcome_html(
temp_password=temp_password,
login_url=login_url,
)
resend.Emails.send(
{
"from": settings.FROM_EMAIL,
"to": [to_email],
"subject": subject,
"html": html,
}
)
logger.info("Welcome email sent to %s", to_email)
return True
except Exception:
logger.exception("Failed to send welcome email to %s", to_email)
return False
@staticmethod
async def send_account_invite_email(
to_email: str,
@@ -189,3 +259,78 @@ def _render_account_invite_html(
</td></tr>
</table>
</body></html>"""
def _render_welcome_html(
temp_password: str,
login_url: str,
) -> str:
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow</h1>
<p style="margin:8px 0 0;color:#a0a0a0;font-size:14px;">Decision Tree Platform for MSP Professionals</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#e0e0e0;font-size:16px;line-height:1.6;">
Your account has been created. Use the temporary password below to sign in.
You will be asked to change it on first login.
</p>
</td></tr>
<tr><td style="padding:0 40px 24px;text-align:center;">
<div style="background:#000;border:1px solid rgba(255,255,255,0.1);border-radius:12px;padding:20px;">
<p style="margin:0 0 4px;color:#a0a0a0;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Temporary Password</p>
<p style="margin:0;color:#fff;font-size:20px;font-weight:700;letter-spacing:2px;font-family:monospace;">{temp_password}</p>
</div>
</td></tr>
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{login_url}" style="display:inline-block;background:#fff;color:#000;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:8px;">
Sign In
</a>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
For security, please change your password immediately after signing in.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
def _render_password_reset_html(reset_url: str) -> str:
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="margin:0;padding:0;background:#000;font-family:'Inter',Helvetica,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:40px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#111;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
<tr><td style="padding:40px 40px 24px;text-align:center;">
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:600;">ResolutionFlow</h1>
<p style="margin:8px 0 0;color:#a0a0a0;font-size:14px;">Decision Tree Platform for MSP Professionals</p>
</td></tr>
<tr><td style="padding:0 40px 24px;">
<p style="margin:0;color:#e0e0e0;font-size:16px;line-height:1.6;">
We received a request to reset your password. Click the button below to choose a new password.
This link expires in 30 minutes.
</p>
</td></tr>
<tr><td style="padding:0 40px 32px;text-align:center;">
<a href="{reset_url}" style="display:inline-block;background:#fff;color:#000;font-size:16px;font-weight:600;text-decoration:none;padding:14px 40px;border-radius:8px;">
Reset Your Password
</a>
</td></tr>
<tr><td style="padding:0 40px 32px;">
<p style="margin:0;color:#666;font-size:12px;text-align:center;">
If you didn't request this, you can safely ignore this email. Your password will not be changed.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""

View File

@@ -1,4 +1,6 @@
import hashlib
import secrets
import string
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
@@ -53,3 +55,49 @@ def decode_token(token: str) -> Optional[dict]:
return payload
except JWTError:
return None
def create_password_reset_token(user_id: str) -> str:
"""Create a JWT password reset token (30-minute expiry, unique JTI)."""
jti = str(uuid.uuid4())
expire = datetime.now(timezone.utc) + timedelta(minutes=30)
to_encode = {
"sub": user_id,
"type": "password_reset",
"jti": jti,
"exp": expire,
}
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def generate_temp_password(length: int = 16) -> str:
"""Generate a temporary password with guaranteed complexity.
Includes at least 1 uppercase, 1 lowercase, 1 digit, and 1 symbol.
Excludes ambiguous characters: 0, O, I, l, 1, |
"""
upper = "ABCDEFGHJKLMNPQRSTUVWXYZ" # no O, I
lower = "abcdefghjkmnopqrstuvwxyz" # no l
digits = "23456789" # no 0, 1
symbols = "!@#$%^&*-_+=?"
# Guarantee at least one of each category
required = [
secrets.choice(upper),
secrets.choice(lower),
secrets.choice(digits),
secrets.choice(symbols),
]
# Fill the rest from the combined pool
pool = upper + lower + digits + symbols
remaining = [secrets.choice(pool) for _ in range(length - len(required))]
# Combine and shuffle
all_chars = required + remaining
# Fisher-Yates shuffle using secrets for uniform randomness
for i in range(len(all_chars) - 1, 0, -1):
j = secrets.randbelow(i + 1)
all_chars[i], all_chars[j] = all_chars[j], all_chars[i]
return "".join(all_chars)

View File

@@ -15,6 +15,7 @@ from .step_category import StepCategory
from .step_library import StepLibrary, StepRating, StepUsageLog
from .refresh_token import RefreshToken
from .audit_log import AuditLog
from .password_reset_token import PasswordResetToken
from .session_share import SessionShare, SessionShareView
from .account_limit_override import AccountLimitOverride
from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
@@ -42,6 +43,7 @@ __all__ = [
"StepUsageLog",
"RefreshToken",
"AuditLog",
"PasswordResetToken",
"SessionShare",
"SessionShareView",
"AccountLimitOverride",

View File

@@ -0,0 +1,47 @@
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=False,
index=True
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_by_admin_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)
@property
def is_used(self) -> bool:
return self.used_at is not None
@property
def is_expired(self) -> bool:
return datetime.now(timezone.utc) > self.expires_at
@property
def is_valid(self) -> bool:
return not self.is_used and not self.is_expired

View File

@@ -39,6 +39,7 @@ class User(Base):
is_super_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_team_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# Account-based multi-tenancy (new)
account_id: Mapped[Optional[uuid.UUID]] = mapped_column(
@@ -66,6 +67,18 @@ class User(Base):
)
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Soft delete
deleted_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True
)
deleted_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True
)
# Relationships
account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys=[account_id], back_populates="users")
owned_account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys="[Account.owner_id]", back_populates="owner", uselist=False)

View File

@@ -1,8 +1,8 @@
"""Pydantic schemas for admin panel endpoints."""
from datetime import datetime
from typing import Optional
from typing import Literal, Optional
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, EmailStr, Field
# --- Dashboard ---
@@ -206,3 +206,39 @@ class GlobalCategoryResponse(BaseModel):
class MoveUserAccount(BaseModel):
display_code: str = Field(..., description="Target account display code")
# --- Admin User Creation ---
class AdminUserCreate(BaseModel):
email: EmailStr
name: str = Field(..., min_length=1, max_length=255)
account_mode: Literal["existing", "personal"]
account_display_code: Optional[str] = Field(None, description="Required when account_mode='existing'")
account_role: Optional[Literal["engineer", "viewer"]] = Field(None, description="Required when account_mode='existing'")
send_email: bool = True
class AdminUserCreateResponse(BaseModel):
user: dict # UserResponse fields
temporary_password: str
email_sent: bool
# --- Admin Password Reset ---
class AdminPasswordReset(BaseModel):
mode: Literal["email_link", "temp_password"]
class AdminPasswordResetResponse(BaseModel):
message: str
temporary_password: Optional[str] = None
email_sent: bool = False
# --- Hard Delete Precheck ---
class HardDeleteCheckResponse(BaseModel):
can_delete: bool
blockers: dict

View File

@@ -0,0 +1,46 @@
import re
from typing import Optional
from pydantic import BaseModel, EmailStr, Field, field_validator
def _validate_password_complexity(v: str) -> str:
if not re.search(r'[A-Z]', v):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r'[a-z]', v):
raise ValueError('Password must contain at least one lowercase letter')
if not re.search(r'[0-9]', v):
raise ValueError('Password must contain at least one digit')
return v
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(..., min_length=10, description="Password must be at least 10 characters")
@field_validator('new_password')
@classmethod
def password_complexity(cls, v: str) -> str:
return _validate_password_complexity(v)
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class VerifyResetTokenRequest(BaseModel):
token: str
class VerifyResetTokenResponse(BaseModel):
valid: bool
email: Optional[str] = None
class ResetPasswordRequest(BaseModel):
token: str
new_password: str = Field(..., min_length=10, description="Password must be at least 10 characters")
@field_validator('new_password')
@classmethod
def password_complexity(cls, v: str) -> str:
return _validate_password_complexity(v)

View File

@@ -6,6 +6,7 @@ class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
must_change_password: bool = False
class TokenPayload(BaseModel):

View File

@@ -44,8 +44,10 @@ class UserResponse(UserBase):
account_role: str
is_super_admin: bool = False
is_active: bool = True
must_change_password: bool = False
created_at: datetime
last_login: Optional[datetime] = None
deleted_at: Optional[datetime] = None
class Config:
from_attributes = True

View File

@@ -61,6 +61,7 @@ class UserDetailResponse(BaseModel):
is_super_admin: bool
is_team_admin: bool
created_at: datetime
deleted_at: Optional[datetime] = None
account: Optional[AccountSummary] = None
subscription: Optional[SubscriptionSummary] = None

View File

@@ -957,6 +957,50 @@ def get_site_to_site_vpn_tree() -> dict[str, Any]:
# SEEDING INFRASTRUCTURE
# =============================================================================
def _fix_node_fields(node: dict[str, Any]) -> None:
"""Recursively add required 'action'/'solution' fields from title/description.
The tree validator requires:
- action nodes to have a non-empty 'action' field
- solution nodes to have a non-empty 'solution' field
- decision nodes with children to have at least 2 children
Seed data uses 'title' and 'description' but not the required fields.
This patches them in-place before sending to the API.
"""
node_type = node.get("type")
if node_type == "action" and not node.get("action"):
node["action"] = node.get("title") or node.get("description") or "Action"
elif node_type == "solution" and not node.get("solution"):
node["solution"] = node.get("title") or node.get("description") or "Solution"
elif node_type == "decision":
children = node.get("children", [])
# If decision node has exactly 1 child, duplicate it with a fallback label
if len(children) == 1:
fallback = {
"id": children[0]["id"] + "_fallback",
"type": "solution",
"title": "Escalate: No Other Options",
"solution": "If the above path does not apply, escalate to senior support.",
}
children.append(fallback)
# Add a matching option if options exist
options = node.get("options", [])
if options and len(options) < 2:
options.append({
"id": "fallback",
"label": "None of the above / Escalate",
"next_node_id": fallback["id"],
})
for child in node.get("children", []):
_fix_node_fields(child)
async def get_admin_token(client: httpx.AsyncClient) -> str:
"""Authenticate with admin credentials."""
if not ADMIN_EMAIL or not ADMIN_PASSWORD:
@@ -980,6 +1024,10 @@ async def create_tree(client: httpx.AsyncClient, token: str, tree_data: dict) ->
tree_data["is_default"] = True
tree_data["is_public"] = True
# Fix missing action/solution fields in tree structure nodes
if "tree_structure" in tree_data:
_fix_node_fields(tree_data["tree_structure"])
list_response = await client.get(f"{API_BASE_URL}/trees", headers=headers)
if list_response.status_code == 200:
existing_trees = list_response.json()

View File

@@ -0,0 +1,450 @@
# 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)

View File

@@ -16,6 +16,8 @@ import type {
InviteCodeResponse,
InviteCodeCreateRequest,
UserDetailResponse,
AdminUserCreate,
AdminUserCreateResponse,
} from '@/types/admin'
export const adminApi = {
@@ -25,7 +27,9 @@ export const adminApi = {
getDashboardActivity: () =>
api.get<ActivityEntry[]>('/admin/dashboard/activity').then(r => r.data),
// Users (existing endpoints)
// Users
createUser: (data: AdminUserCreate) =>
api.post<AdminUserCreateResponse>('/admin/users', data).then(r => r.data),
listUsers: (params?: Record<string, unknown>) =>
api.get('/admin/users', { params }).then(r => r.data),
getUser: (id: string) =>
@@ -41,6 +45,24 @@ export const adminApi = {
moveUserAccount: (id: string, display_code: string) =>
api.put(`/admin/users/${id}/move-account`, { display_code }).then(r => r.data),
// Users - archive & delete
archiveUser: (id: string) =>
api.put(`/admin/users/${id}/archive`).then(r => r.data),
restoreUser: (id: string) =>
api.put(`/admin/users/${id}/restore`).then(r => r.data),
hardDeleteCheck: (id: string) =>
api.get<{ can_delete: boolean; blockers: Record<string, number> }>(`/admin/users/${id}/hard-delete-check`).then(r => r.data),
hardDeleteUser: (id: string) =>
api.delete(`/admin/users/${id}/hard-delete`),
// Users - quick invite
createInvite: (data: { email: string; account_display_code: string; role: string }) =>
api.post<{ id: string; email: string; code: string; role: string; account_display_code: string; email_sent: boolean }>('/admin/invites', data).then(r => r.data),
// Users - password reset
adminResetPassword: (id: string, mode: 'email_link' | 'temp_password') =>
api.post<{ message: string; temporary_password?: string; email_sent: boolean }>(`/admin/users/${id}/password-reset`, { mode }).then(r => r.data),
// Users - detail + subscription
getUserDetail: (id: string) =>
api.get<UserDetailResponse>(`/admin/users/${id}`).then(r => r.data),

View File

@@ -30,6 +30,29 @@ export const authApi = {
async logout(): Promise<void> {
await apiClient.post('/auth/logout')
},
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
await apiClient.post('/auth/password/change', {
current_password: currentPassword,
new_password: newPassword,
})
},
async forgotPassword(email: string): Promise<void> {
await apiClient.post('/auth/password/forgot', { email })
},
async verifyResetToken(token: string): Promise<{ valid: boolean; email: string | null }> {
const response = await apiClient.post<{ valid: boolean; email: string | null }>('/auth/password/verify-reset-token', { token })
return response.data
},
async resetPassword(token: string, newPassword: string): Promise<void> {
await apiClient.post('/auth/password/reset', {
token,
new_password: newPassword,
})
},
}
export default authApi

View File

@@ -8,7 +8,7 @@ interface ProtectedRouteProps {
}
export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuthStore()
const { isAuthenticated, isLoading, user } = useAuthStore()
const location = useLocation()
const { effectiveRole } = usePermissions()
@@ -24,6 +24,11 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps)
return <Navigate to="/login" state={{ from: location }} replace />
}
// Enforce must_change_password — redirect unless already on /change-password
if (user?.must_change_password && location.pathname !== '/change-password') {
return <Navigate to="/change-password" replace />
}
if (requiredRole) {
const ROLE_HIERARCHY: Record<EffectiveRole, number> = {
super_admin: 4,

View File

@@ -0,0 +1,170 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore'
import { authApi } from '@/api/auth'
import { toast } from '@/lib/toast'
import { BrandLogo } from '@/components/common/BrandLogo'
import { cn } from '@/lib/utils'
export function ChangePasswordPage() {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const isForced = user?.must_change_password ?? false
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!currentPassword || !newPassword || !confirmPassword) {
setError('Please fill in all fields')
return
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match')
return
}
if (newPassword.length < 10) {
setError('Password must be at least 10 characters')
return
}
setIsLoading(true)
try {
await authApi.changePassword(currentPassword, newPassword)
toast.success('Password changed successfully. Please sign in again.')
await logout()
navigate('/login', { replace: true })
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
setError(axiosErr.response?.data?.detail || 'Failed to change password')
} else {
setError('Failed to change password')
}
} finally {
setIsLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-black px-4">
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
<div className="relative w-full max-w-md space-y-8">
<div className="text-center">
<div className="mb-4 flex justify-center sm:mb-6">
<div className="w-16 h-16 rounded-2xl bg-white flex items-center justify-center sm:w-20 sm:h-20">
<BrandLogo size="lg" className="h-10 w-10 invert sm:h-12 sm:w-12" />
</div>
</div>
<h1 className="text-3xl font-bold text-white tracking-tight">
Change Password
</h1>
{isForced && (
<div className="mt-4 rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
You must change your password before continuing.
</div>
)}
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="glass-card rounded-2xl p-6 space-y-4">
{error && (
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
{error}
</div>
)}
<div>
<label htmlFor="current-password" className="mb-1 block text-sm font-medium text-white">
Current Password
</label>
<input
id="current-password"
type="password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className={cn(
'block w-full rounded-xl border border-white/10 bg-black/50 px-3 py-2',
'text-white placeholder:text-white/30',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
'transition-colors'
)}
/>
</div>
<div>
<label htmlFor="new-password" className="mb-1 block text-sm font-medium text-white">
New Password
</label>
<input
id="new-password"
type="password"
autoComplete="new-password"
required
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={cn(
'block w-full rounded-xl border border-white/10 bg-black/50 px-3 py-2',
'text-white placeholder:text-white/30',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
'transition-colors'
)}
placeholder="At least 10 characters"
/>
<p className="mt-1 text-xs text-white/40">
Must include uppercase, lowercase, and a digit.
</p>
</div>
<div>
<label htmlFor="confirm-password" className="mb-1 block text-sm font-medium text-white">
Confirm New Password
</label>
<input
id="confirm-password"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={cn(
'block w-full rounded-xl border border-white/10 bg-black/50 px-3 py-2',
'text-white placeholder:text-white/30',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
'transition-colors'
)}
/>
</div>
<button
type="submit"
disabled={isLoading}
className={cn(
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-white text-black hover:bg-white/90',
'focus:outline-none focus:ring-2 focus:ring-white/30 focus:ring-offset-2 focus:ring-offset-black',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-all'
)}
>
{isLoading ? 'Changing password...' : 'Change Password'}
</button>
</div>
</form>
</div>
</div>
)
}
export default ChangePasswordPage

View File

@@ -0,0 +1,115 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { authApi } from '@/api/auth'
import { BrandLogo } from '@/components/common/BrandLogo'
import { cn } from '@/lib/utils'
export function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [submitted, setSubmitted] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!email) return
setIsLoading(true)
try {
await authApi.forgotPassword(email)
} catch {
// Always show success (anti-enumeration)
} finally {
setIsLoading(false)
setSubmitted(true)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-black px-4">
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
<div className="relative w-full max-w-md space-y-8">
<div className="text-center">
<div className="mb-4 flex justify-center sm:mb-6">
<div className="w-16 h-16 rounded-2xl bg-white flex items-center justify-center sm:w-20 sm:h-20">
<BrandLogo size="lg" className="h-10 w-10 invert sm:h-12 sm:w-12" />
</div>
</div>
<h1 className="text-3xl font-bold text-white tracking-tight">
Reset Password
</h1>
<p className="mt-2 text-sm text-white/40">
Enter your email and we'll send you a link to reset your password.
</p>
</div>
{submitted ? (
<div className="glass-card rounded-2xl p-6 space-y-4">
<div className="rounded-xl border border-green-400/20 bg-green-400/10 p-4 text-sm text-green-400">
If an account with that email exists, we've sent a password reset link.
Check your inbox and follow the instructions.
</div>
<div className="text-center">
<Link
to="/login"
className="text-sm text-white/60 hover:text-white transition-colors"
>
Back to sign in
</Link>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="glass-card rounded-2xl p-6 space-y-4">
<div>
<label htmlFor="email" className="mb-1 block text-sm font-medium text-white">
Email Address
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className={cn(
'block w-full rounded-xl border border-white/10 bg-black/50 px-3 py-2',
'text-white placeholder:text-white/30',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
'transition-colors'
)}
placeholder="you@example.com"
/>
</div>
<button
type="submit"
disabled={isLoading || !email}
className={cn(
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-white text-black hover:bg-white/90',
'focus:outline-none focus:ring-2 focus:ring-white/30 focus:ring-offset-2 focus:ring-offset-black',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-all'
)}
>
{isLoading ? 'Sending...' : 'Send Reset Link'}
</button>
<div className="text-center">
<Link
to="/login"
className="text-sm text-white/60 hover:text-white transition-colors"
>
Back to sign in
</Link>
</div>
</div>
</form>
)}
</div>
</div>
)
}
export default ForgotPasswordPage

View File

@@ -27,7 +27,12 @@ export function LoginPage() {
try {
await login({ email, password })
navigate(from, { replace: true })
const user = useAuthStore.getState().user
if (user?.must_change_password) {
navigate('/change-password', { replace: true })
} else {
navigate(from, { replace: true })
}
} catch {
// Error is set in the store
}
@@ -108,6 +113,12 @@ export function LoginPage() {
/>
</div>
<div className="text-right">
<Link to="/forgot-password" className="text-xs text-white/40 hover:text-white/60 transition-colors">
Forgot password?
</Link>
</div>
<button
type="submit"
disabled={isLoading}

View File

@@ -0,0 +1,187 @@
import { useState, useEffect } from 'react'
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
import { authApi } from '@/api/auth'
import { toast } from '@/lib/toast'
import { BrandLogo } from '@/components/common/BrandLogo'
import { cn } from '@/lib/utils'
export function ResetPasswordPage() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const token = searchParams.get('token') || ''
const [verifying, setVerifying] = useState(true)
const [valid, setValid] = useState(false)
const [email, setEmail] = useState<string | null>(null)
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!token) {
setVerifying(false)
return
}
authApi.verifyResetToken(token).then((res) => {
setValid(res.valid)
setEmail(res.email || null)
}).catch(() => {
setValid(false)
}).finally(() => {
setVerifying(false)
})
}, [token])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!newPassword || !confirmPassword) {
setError('Please fill in all fields')
return
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match')
return
}
if (newPassword.length < 10) {
setError('Password must be at least 10 characters')
return
}
setIsLoading(true)
try {
await authApi.resetPassword(token, newPassword)
toast.success('Password reset successfully. Please sign in.')
navigate('/login', { replace: true })
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
setError(axiosErr.response?.data?.detail || 'Failed to reset password')
} else {
setError('Failed to reset password')
}
} finally {
setIsLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-black px-4">
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
<div className="relative w-full max-w-md space-y-8">
<div className="text-center">
<div className="mb-4 flex justify-center sm:mb-6">
<div className="w-16 h-16 rounded-2xl bg-white flex items-center justify-center sm:w-20 sm:h-20">
<BrandLogo size="lg" className="h-10 w-10 invert sm:h-12 sm:w-12" />
</div>
</div>
<h1 className="text-3xl font-bold text-white tracking-tight">
Reset Password
</h1>
</div>
{verifying ? (
<div className="glass-card rounded-2xl p-6 text-center">
<p className="text-white/60">Verifying reset link...</p>
</div>
) : !token || !valid ? (
<div className="glass-card rounded-2xl p-6 space-y-4">
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-4 text-sm text-red-400">
This reset link is invalid or has expired. Please request a new one.
</div>
<div className="text-center">
<Link
to="/forgot-password"
className="text-sm text-white/60 hover:text-white transition-colors"
>
Request new reset link
</Link>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="glass-card rounded-2xl p-6 space-y-4">
{email && (
<p className="text-sm text-white/60">
Resetting password for <span className="font-medium text-white">{email}</span>
</p>
)}
{error && (
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
{error}
</div>
)}
<div>
<label htmlFor="new-password" className="mb-1 block text-sm font-medium text-white">
New Password
</label>
<input
id="new-password"
type="password"
autoComplete="new-password"
required
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={cn(
'block w-full rounded-xl border border-white/10 bg-black/50 px-3 py-2',
'text-white placeholder:text-white/30',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
'transition-colors'
)}
placeholder="At least 10 characters"
/>
<p className="mt-1 text-xs text-white/40">
Must include uppercase, lowercase, and a digit.
</p>
</div>
<div>
<label htmlFor="confirm-password" className="mb-1 block text-sm font-medium text-white">
Confirm New Password
</label>
<input
id="confirm-password"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={cn(
'block w-full rounded-xl border border-white/10 bg-black/50 px-3 py-2',
'text-white placeholder:text-white/30',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
'transition-colors'
)}
/>
</div>
<button
type="submit"
disabled={isLoading}
className={cn(
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-white text-black hover:bg-white/90',
'focus:outline-none focus:ring-2 focus:ring-white/30 focus:ring-offset-2 focus:ring-offset-black',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-all'
)}
>
{isLoading ? 'Resetting...' : 'Reset Password'}
</button>
</div>
</form>
)}
</div>
</div>
)
}
export default ResetPasswordPage

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Shield, Crown, UserCheck, UserX, Clock, Ticket } from 'lucide-react'
import { ArrowLeft, Shield, Crown, UserCheck, UserX, Clock, Ticket, KeyRound, Copy, Check, Archive, ArchiveRestore, Trash2 } from 'lucide-react'
import { StatusBadge } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
import { adminApi } from '@/api/admin'
@@ -23,6 +23,18 @@ export function UserDetailPage() {
const [trialDays, setTrialDays] = useState('14')
const [activeTab, setActiveTab] = useState<'sessions' | 'audit'>('sessions')
// Password reset modal
const [resetModalOpen, setResetModalOpen] = useState(false)
const [resetMode, setResetMode] = useState<'email_link' | 'temp_password'>('email_link')
const [resetLoading, setResetLoading] = useState(false)
const [resetTempPassword, setResetTempPassword] = useState<string | null>(null)
const [resetCopied, setResetCopied] = useState(false)
// Hard delete
const [hardDeleteModalOpen, setHardDeleteModalOpen] = useState(false)
const [hardDeleteChecking, setHardDeleteChecking] = useState(false)
const [hardDeleteBlockers, setHardDeleteBlockers] = useState<Record<string, number> | null>(null)
const fetchUser = useCallback(async () => {
if (!userId) return
setLoading(true)
@@ -78,6 +90,85 @@ export function UserDetailPage() {
}
}
const handleResetPassword = async () => {
if (!userId) return
setResetLoading(true)
try {
const result = await adminApi.adminResetPassword(userId, resetMode)
if (resetMode === 'temp_password' && result.temporary_password) {
setResetTempPassword(result.temporary_password)
setResetCopied(false)
} else {
toast.success(result.email_sent ? 'Password reset email sent' : result.message)
setResetModalOpen(false)
}
fetchUser()
} catch {
toast.error('Failed to reset password')
} finally {
setResetLoading(false)
}
}
const handleCopyResetPassword = async () => {
if (!resetTempPassword) return
await navigator.clipboard.writeText(resetTempPassword)
setResetCopied(true)
setTimeout(() => setResetCopied(false), 2000)
}
const handleArchive = async () => {
if (!userId) return
try {
await adminApi.archiveUser(userId)
toast.success('User archived')
fetchUser()
} catch {
toast.error('Failed to archive user')
}
}
const handleRestore = async () => {
if (!userId) return
try {
await adminApi.restoreUser(userId)
toast.success('User restored')
fetchUser()
} catch {
toast.error('Failed to restore user')
}
}
const handleHardDeleteCheck = async () => {
if (!userId) return
setHardDeleteChecking(true)
try {
const result = await adminApi.hardDeleteCheck(userId)
setHardDeleteBlockers(result.blockers)
setHardDeleteModalOpen(true)
} catch {
toast.error('Failed to check delete eligibility')
} finally {
setHardDeleteChecking(false)
}
}
const handleHardDelete = async () => {
if (!userId) return
try {
await adminApi.hardDeleteUser(userId)
toast.success('User permanently deleted')
navigate('/admin/users')
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr.response?.data?.detail || 'Failed to delete user')
} else {
toast.error('Failed to delete user')
}
}
}
const inputClass = cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
@@ -126,6 +217,9 @@ export function UserDetailPage() {
{user.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
<StatusBadge variant="default">{user.role}</StatusBadge>
{user.deleted_at && (
<StatusBadge variant="warning">Archived</StatusBadge>
)}
</div>
</div>
@@ -189,22 +283,37 @@ export function UserDetailPage() {
Admin Actions
</h2>
<div className="space-y-3">
{user.account && (
<>
<button
onClick={() => {
setSelectedPlan(user.subscription?.plan || 'free')
setPlanModalOpen(true)
}}
className="flex w-full items-center gap-3 rounded-lg border border-white/10 px-4 py-3 text-left text-sm text-white/70 hover:bg-white/5 hover:text-white"
>
<Shield className="h-4 w-4 text-white/40" />
Change Plan
</button>
<button
onClick={() => setTrialModalOpen(true)}
className="flex w-full items-center gap-3 rounded-lg border border-white/10 px-4 py-3 text-left text-sm text-white/70 hover:bg-white/5 hover:text-white"
>
<Clock className="h-4 w-4 text-white/40" />
{user.subscription?.status === 'trialing' ? 'Extend Trial' : 'Start Trial'}
</button>
</>
)}
<button
onClick={() => {
setSelectedPlan(user.subscription?.plan || 'free')
setPlanModalOpen(true)
setResetMode('email_link')
setResetTempPassword(null)
setResetModalOpen(true)
}}
className="flex w-full items-center gap-3 rounded-lg border border-white/10 px-4 py-3 text-left text-sm text-white/70 hover:bg-white/5 hover:text-white"
>
<Shield className="h-4 w-4 text-white/40" />
Change Plan
</button>
<button
onClick={() => setTrialModalOpen(true)}
className="flex w-full items-center gap-3 rounded-lg border border-white/10 px-4 py-3 text-left text-sm text-white/70 hover:bg-white/5 hover:text-white"
>
<Clock className="h-4 w-4 text-white/40" />
{user.subscription?.status === 'trialing' ? 'Extend Trial' : 'Start Trial'}
<KeyRound className="h-4 w-4 text-white/40" />
Reset Password
</button>
<button
onClick={handleToggleActive}
@@ -221,6 +330,33 @@ export function UserDetailPage() {
<><UserCheck className="h-4 w-4" /> Activate User</>
)}
</button>
{/* Archive / Restore */}
{user.deleted_at ? (
<button
onClick={handleRestore}
className="flex w-full items-center gap-3 rounded-lg border border-emerald-500/20 px-4 py-3 text-left text-sm text-emerald-400 hover:bg-emerald-500/5"
>
<ArchiveRestore className="h-4 w-4" /> Restore User
</button>
) : (
<button
onClick={handleArchive}
className="flex w-full items-center gap-3 rounded-lg border border-yellow-500/20 px-4 py-3 text-left text-sm text-yellow-400 hover:bg-yellow-500/5"
>
<Archive className="h-4 w-4" /> Archive User
</button>
)}
{/* Hard Delete (only if archived) */}
{user.deleted_at && (
<button
onClick={handleHardDeleteCheck}
disabled={hardDeleteChecking}
className="flex w-full items-center gap-3 rounded-lg border border-red-500/20 px-4 py-3 text-left text-sm text-red-400 hover:bg-red-500/5 disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
{hardDeleteChecking ? 'Checking...' : 'Permanently Delete'}
</button>
)}
</div>
</div>
</div>
@@ -379,6 +515,106 @@ export function UserDetailPage() {
</div>
</Modal>
{/* Reset Password Modal */}
<Modal
isOpen={resetModalOpen && !resetTempPassword}
onClose={() => setResetModalOpen(false)}
title="Reset User Password"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button
onClick={() => setResetModalOpen(false)}
className="rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10"
>
Cancel
</button>
<button
onClick={handleResetPassword}
disabled={resetLoading}
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90 disabled:opacity-50"
>
{resetLoading ? 'Resetting...' : 'Reset Password'}
</button>
</div>
}
>
<div className="space-y-4">
<p className="text-sm text-white/70">
Choose how to reset the password for <span className="font-medium text-white">{user.full_name || user.email}</span>.
</p>
<div className="space-y-2">
<label className="flex items-start gap-3 rounded-lg border border-white/10 p-3 cursor-pointer hover:bg-white/5">
<input
type="radio"
name="reset-mode"
value="email_link"
checked={resetMode === 'email_link'}
onChange={() => setResetMode('email_link')}
className="mt-0.5"
/>
<div>
<div className="text-sm font-medium text-white">Send Reset Email</div>
<div className="text-xs text-white/40">User receives an email with a reset link (30 min expiry)</div>
</div>
</label>
<label className="flex items-start gap-3 rounded-lg border border-white/10 p-3 cursor-pointer hover:bg-white/5">
<input
type="radio"
name="reset-mode"
value="temp_password"
checked={resetMode === 'temp_password'}
onChange={() => setResetMode('temp_password')}
className="mt-0.5"
/>
<div>
<div className="text-sm font-medium text-white">Generate Temp Password</div>
<div className="text-xs text-white/40">A temporary password is generated. You share it manually.</div>
</div>
</label>
</div>
</div>
</Modal>
{/* Temp Password Result Modal */}
<Modal
isOpen={!!resetTempPassword}
onClose={() => { setResetTempPassword(null); setResetModalOpen(false) }}
title="Temporary Password"
size="sm"
footer={
<div className="flex justify-end">
<button
onClick={() => { setResetTempPassword(null); setResetModalOpen(false) }}
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90"
>
Done
</button>
</div>
}
>
<div className="space-y-4">
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
This password will not be shown again. Copy it now.
</div>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white font-mono">
{resetTempPassword}
</code>
<button
onClick={handleCopyResetPassword}
className="rounded-md border border-white/10 p-2 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
title="Copy password"
>
{resetCopied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
</button>
</div>
<p className="text-xs text-white/40">
The user will be required to change this password on next login.
</p>
</div>
</Modal>
{/* Extend Trial Modal */}
<Modal
isOpen={trialModalOpen}
@@ -415,6 +651,55 @@ export function UserDetailPage() {
<p className="mt-1 text-xs text-white/40">1-90 days. Will convert to trialing status if not already.</p>
</div>
</Modal>
{/* Hard Delete Modal */}
<Modal
isOpen={hardDeleteModalOpen}
onClose={() => setHardDeleteModalOpen(false)}
title="Permanently Delete User"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button
onClick={() => setHardDeleteModalOpen(false)}
className="rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10"
>
Cancel
</button>
{hardDeleteBlockers && Object.keys(hardDeleteBlockers).length === 0 && (
<button
onClick={handleHardDelete}
className="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
Delete Permanently
</button>
)}
</div>
}
>
<div className="space-y-4">
{hardDeleteBlockers && Object.keys(hardDeleteBlockers).length > 0 ? (
<>
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
This user cannot be deleted because they have dependencies:
</div>
<ul className="space-y-1 text-sm text-white/70">
{Object.entries(hardDeleteBlockers).map(([key, count]) => (
<li key={key} className="flex justify-between">
<span>{key.replace(/_/g, ' ')}</span>
<span className="font-mono text-white/40">{count}</span>
</li>
))}
</ul>
</>
) : (
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-4 text-sm text-red-400">
<p className="font-medium">This action is irreversible.</p>
<p className="mt-1">The user <strong>{user?.full_name || user?.email}</strong> and all their technical data (tokens, reset tokens) will be permanently removed.</p>
</div>
)}
</div>
</Modal>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { UserCheck, UserX, Shield, ArrowRightLeft, ExternalLink } from 'lucide-react'
import { UserCheck, UserX, Shield, ArrowRightLeft, ExternalLink, UserPlus, Copy, Check, Mail } from 'lucide-react'
import { DataTable, Pagination, SearchInput, PageHeader, StatusBadge, ActionMenu } from '@/components/admin'
import type { Column } from '@/components/admin'
import { Modal } from '@/components/common/Modal'
@@ -19,6 +19,7 @@ interface AdminUser {
account_role: string | null
created_at: string
last_login: string | null
deleted_at: string | null
}
export function UsersPage() {
@@ -29,6 +30,7 @@ export function UsersPage() {
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const pageSize = 20
const [showArchived, setShowArchived] = useState(false)
// Role change modal
const [roleModalUser, setRoleModalUser] = useState<AdminUser | null>(null)
@@ -38,10 +40,31 @@ export function UsersPage() {
const [moveModalUser, setMoveModalUser] = useState<AdminUser | null>(null)
const [displayCode, setDisplayCode] = useState('')
// Create user modal
const [showCreateModal, setShowCreateModal] = useState(false)
const [createForm, setCreateForm] = useState({
email: '',
name: '',
account_mode: 'personal' as 'existing' | 'personal',
account_display_code: '',
account_role: 'engineer' as 'engineer' | 'viewer',
send_email: true,
})
const [createLoading, setCreateLoading] = useState(false)
// Temp password display modal
const [tempPassword, setTempPassword] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
// Invite user modal
const [showInviteModal, setShowInviteModal] = useState(false)
const [inviteForm, setInviteForm] = useState({ email: '', account_display_code: '', role: 'engineer' as 'engineer' | 'viewer' })
const [inviteLoading, setInviteLoading] = useState(false)
const fetchUsers = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.listUsers({ page, size: pageSize, search: search || undefined })
const data = await adminApi.listUsers({ page, size: pageSize, search: search || undefined, include_archived: showArchived || undefined })
setUsers(data.items || data)
setTotal(data.total || (data.items ? data.items.length : data.length))
} catch {
@@ -49,7 +72,7 @@ export function UsersPage() {
} finally {
setLoading(false)
}
}, [page, search])
}, [page, search, showArchived])
useEffect(() => { fetchUsers() }, [fetchUsers])
@@ -93,6 +116,71 @@ export function UsersPage() {
}
}
const handleCreateUser = async () => {
if (!createForm.email || !createForm.name) return
if (createForm.account_mode === 'existing' && !createForm.account_display_code) {
toast.error('Account display code is required')
return
}
setCreateLoading(true)
try {
const result = await adminApi.createUser({
email: createForm.email,
name: createForm.name,
account_mode: createForm.account_mode,
account_display_code: createForm.account_mode === 'existing' ? createForm.account_display_code : undefined,
account_role: createForm.account_mode === 'existing' ? createForm.account_role : undefined,
send_email: createForm.send_email,
})
setShowCreateModal(false)
setTempPassword(result.temporary_password)
setCopied(false)
toast.success(result.email_sent ? 'User created and welcome email sent' : 'User created')
setCreateForm({ email: '', name: '', account_mode: 'personal', account_display_code: '', account_role: 'engineer', send_email: true })
fetchUsers()
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr.response?.data?.detail || 'Failed to create user')
} else {
toast.error('Failed to create user')
}
} finally {
setCreateLoading(false)
}
}
const handleCopyPassword = async () => {
if (!tempPassword) return
await navigator.clipboard.writeText(tempPassword)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleInviteUser = async () => {
if (!inviteForm.email || !inviteForm.account_display_code) return
setInviteLoading(true)
try {
const result = await adminApi.createInvite({
email: inviteForm.email,
account_display_code: inviteForm.account_display_code,
role: inviteForm.role,
})
setShowInviteModal(false)
setInviteForm({ email: '', account_display_code: '', role: 'engineer' })
toast.success(result.email_sent ? 'Invite sent' : 'Invite created (email not configured)')
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { data?: { detail?: string } } }
toast.error(axiosErr.response?.data?.detail || 'Failed to send invite')
} else {
toast.error('Failed to send invite')
}
} finally {
setInviteLoading(false)
}
}
const columns: Column<AdminUser>[] = [
{
key: 'name',
@@ -121,9 +209,14 @@ export function UsersPage() {
key: 'status',
header: 'Status',
render: (u) => (
<StatusBadge variant={u.is_active ? 'success' : 'destructive'}>
{u.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
<div className="flex items-center gap-1">
<StatusBadge variant={u.is_active ? 'success' : 'destructive'}>
{u.is_active ? 'Active' : 'Inactive'}
</StatusBadge>
{u.deleted_at && (
<StatusBadge variant="warning">Archived</StatusBadge>
)}
</div>
),
},
{
@@ -170,14 +263,43 @@ export function UsersPage() {
return (
<div className="space-y-6">
<PageHeader title="Users" description="Manage platform users and roles" />
<div className="flex items-center justify-between">
<PageHeader title="Users" description="Manage platform users and roles" />
<div className="flex items-center gap-3">
<button
onClick={() => setShowInviteModal(true)}
className="flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
>
<Mail className="h-4 w-4" />
Invite User
</button>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 rounded-lg bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90 transition-colors"
>
<UserPlus className="h-4 w-4" />
Create User
</button>
</div>
</div>
<SearchInput
value={search}
onSearch={(v) => { setSearch(v); setPage(1) }}
placeholder="Search by name or email..."
className="max-w-sm"
/>
<div className="flex items-center gap-4">
<SearchInput
value={search}
onSearch={(v) => { setSearch(v); setPage(1) }}
placeholder="Search by name or email..."
className="max-w-sm"
/>
<label className="flex items-center gap-2 text-sm text-white/60">
<input
type="checkbox"
checked={showArchived}
onChange={(e) => { setShowArchived(e.target.checked); setPage(1) }}
className="rounded border-white/20 bg-black/50"
/>
Show archived
</label>
</div>
<DataTable
columns={columns}
@@ -278,6 +400,227 @@ export function UsersPage() {
</div>
</div>
</Modal>
{/* Create User Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="Create User"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button
onClick={() => setShowCreateModal(false)}
className="rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10 hover:text-white"
>
Cancel
</button>
<button
onClick={handleCreateUser}
disabled={createLoading || !createForm.email || !createForm.name}
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90 disabled:opacity-50"
>
{createLoading ? 'Creating...' : 'Create User'}
</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-white">Name</label>
<input
type="text"
value={createForm.name}
onChange={(e) => setCreateForm(f => ({ ...f, name: e.target.value }))}
placeholder="Full name"
className={cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
)}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white">Email</label>
<input
type="email"
value={createForm.email}
onChange={(e) => setCreateForm(f => ({ ...f, email: e.target.value }))}
placeholder="user@example.com"
className={cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
)}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white">Account Mode</label>
<select
value={createForm.account_mode}
onChange={(e) => setCreateForm(f => ({ ...f, account_mode: e.target.value as 'existing' | 'personal' }))}
className={cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
)}
>
<option value="personal">Personal (new account)</option>
<option value="existing">Join existing account</option>
</select>
</div>
{createForm.account_mode === 'existing' && (
<>
<div>
<label className="mb-1 block text-sm font-medium text-white">Account Display Code</label>
<input
type="text"
value={createForm.account_display_code}
onChange={(e) => setCreateForm(f => ({ ...f, account_display_code: e.target.value }))}
placeholder="e.g. ABC12345"
className={cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
)}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white">Account Role</label>
<select
value={createForm.account_role}
onChange={(e) => setCreateForm(f => ({ ...f, account_role: e.target.value as 'engineer' | 'viewer' }))}
className={cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
</>
)}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="send-email"
checked={createForm.send_email}
onChange={(e) => setCreateForm(f => ({ ...f, send_email: e.target.checked }))}
className="rounded border-white/20 bg-black/50"
/>
<label htmlFor="send-email" className="text-sm text-white/70">
Send welcome email with temporary password
</label>
</div>
</div>
</Modal>
{/* Temporary Password Modal */}
<Modal
isOpen={!!tempPassword}
onClose={() => setTempPassword(null)}
title="User Created"
size="sm"
footer={
<div className="flex justify-end">
<button
onClick={() => setTempPassword(null)}
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90"
>
Done
</button>
</div>
}
>
<div className="space-y-4">
<div className="rounded-xl border border-yellow-400/20 bg-yellow-400/10 p-3 text-sm text-yellow-400">
This password will not be shown again. Copy it now.
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white">Temporary Password</label>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white font-mono">
{tempPassword}
</code>
<button
onClick={handleCopyPassword}
className="rounded-md border border-white/10 p-2 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
title="Copy password"
>
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
</button>
</div>
</div>
<p className="text-xs text-white/40">
The user will be required to change this password on first login.
</p>
</div>
</Modal>
{/* Invite User Modal */}
<Modal
isOpen={showInviteModal}
onClose={() => setShowInviteModal(false)}
title="Invite User"
size="sm"
footer={
<div className="flex justify-end gap-3">
<button
onClick={() => setShowInviteModal(false)}
className="rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60 hover:bg-white/10 hover:text-white"
>
Cancel
</button>
<button
onClick={handleInviteUser}
disabled={inviteLoading || !inviteForm.email || !inviteForm.account_display_code}
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90 disabled:opacity-50"
>
{inviteLoading ? 'Sending...' : 'Send Invite'}
</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-white">Email</label>
<input
type="email"
value={inviteForm.email}
onChange={(e) => setInviteForm(f => ({ ...f, email: e.target.value }))}
placeholder="user@example.com"
className={cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
)}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white">Account Display Code</label>
<input
type="text"
value={inviteForm.account_display_code}
onChange={(e) => setInviteForm(f => ({ ...f, account_display_code: e.target.value }))}
placeholder="e.g. ABC12345"
className={cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'placeholder:text-white/40 focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
)}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white">Role</label>
<select
value={inviteForm.role}
onChange={(e) => setInviteForm(f => ({ ...f, role: e.target.value as 'engineer' | 'viewer' }))}
className={cn(
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'focus:outline-none focus:border-white/30 focus:ring-2 focus:ring-white/20'
)}
>
<option value="engineer">Engineer</option>
<option value="viewer">Viewer</option>
</select>
</div>
</div>
</Modal>
</div>
)
}

View File

@@ -8,6 +8,11 @@ import {
RegisterPage,
} from '@/pages'
// Standalone auth pages
const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage'))
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'))
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'))
// Lazy load heavy pages for code splitting
const QuickStartPage = lazy(() => import('@/pages/QuickStartPage'))
const TreeLibraryPage = lazy(() => import('@/pages/TreeLibraryPage'))
@@ -44,6 +49,35 @@ export const router = createBrowserRouter([
element: <RegisterPage />,
errorElement: <RouteError />,
},
{
path: '/forgot-password',
element: (
<Suspense fallback={<PageLoader />}>
<ForgotPasswordPage />
</Suspense>
),
errorElement: <RouteError />,
},
{
path: '/reset-password',
element: (
<Suspense fallback={<PageLoader />}>
<ResetPasswordPage />
</Suspense>
),
errorElement: <RouteError />,
},
{
path: '/change-password',
element: (
<ProtectedRoute>
<Suspense fallback={<PageLoader />}>
<ChangePasswordPage />
</Suspense>
</ProtectedRoute>
),
errorElement: <RouteError />,
},
{
path: '/',
element: (

View File

@@ -158,6 +158,22 @@ export interface InviteCodeCreateRequest {
trial_duration_days?: number | null
}
// Admin user creation types
export interface AdminUserCreate {
email: string
name: string
account_mode: 'existing' | 'personal'
account_display_code?: string
account_role?: 'engineer' | 'viewer'
send_email: boolean
}
export interface AdminUserCreateResponse {
user: Record<string, unknown>
temporary_password: string
email_sent: boolean
}
// User detail types
export interface AccountSummary {
id: string
@@ -206,6 +222,7 @@ export interface UserDetailResponse {
is_super_admin: boolean
is_team_admin: boolean
created_at: string
deleted_at: string | null
account: AccountSummary | null
subscription: SubscriptionSummary | null
invite_code_used: InviteCodeUsedSummary | null

View File

@@ -2,6 +2,7 @@ export interface Token {
access_token: string
refresh_token: string
token_type: string
must_change_password?: boolean
}
export interface AuthState {

View File

@@ -6,6 +6,8 @@ export interface User {
name: string
role: UserRole
is_super_admin: boolean
is_active: boolean
must_change_password: boolean
account_id: string
account_role: 'owner' | 'engineer' | 'viewer'
created_at: string