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