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

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