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:
@@ -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')
|
||||
37
backend/alembic/versions/032_add_password_reset_tokens.py
Normal file
37
backend/alembic/versions/032_add_password_reset_tokens.py
Normal 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')
|
||||
32
backend/alembic/versions/033_add_soft_delete_to_users.py
Normal file
32
backend/alembic/versions/033_add_soft_delete_to_users.py
Normal 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')
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
47
backend/app/models/password_reset_token.py
Normal file
47
backend/app/models/password_reset_token.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
46
backend/app/schemas/auth_password.py
Normal file
46
backend/app/schemas/auth_password.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user