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

@@ -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()