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,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)