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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user