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

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