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

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