diff --git a/application/single_app/config.py b/application/single_app/config.py index 85f9d9a22..682bba36f 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.281" +VERSION = "0.261.285" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_control_center_groups.py b/application/single_app/functions_control_center_groups.py new file mode 100644 index 000000000..93c941ff7 --- /dev/null +++ b/application/single_app/functions_control_center_groups.py @@ -0,0 +1,248 @@ +# functions_control_center_groups.py +"""Server-side group inventory and validated selection for Control Center.""" + +import re +from datetime import datetime, timezone + + +GROUP_STATUSES = ("active", "locked", "upload_disabled", "inactive") +GROUP_SORTS = ("name", "owner", "members", "documents", "tokens", "last_activity") +GROUP_BULK_LIMIT = 500 +GROUP_SNAPSHOT_TTL = 90 +GROUP_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +GROUP_FILTER_KEYS = { + "search", "status", "status_filter", "owner", "members_min", "members_max", + "has_documents", "created_from", "created_to", "activity_from", "activity_to", +} + + +class GroupRequestError(ValueError): + """A reviewed, user-safe validation message, distinct from storage failures.""" + + +def validate_group_id(value): + if not isinstance(value, str) or not GROUP_ID_PATTERN.fullmatch(value): + raise GroupRequestError("Invalid group or member ID.") + return value + + +def parse_group_filters(values): + """Accept only documented filters; never interpolate caller values into SQL.""" + filters = {} + for key in ("search", "owner"): + value = values.get(key, "") + if not isinstance(value, str) or len(value) > 200: + raise GroupRequestError("Search and owner filters may not exceed 200 characters.") + filters[key] = value.strip().casefold() + filters["status"] = values.get("status", values.get("status_filter", "all")) + if filters["status"] not in ("all", *GROUP_STATUSES): + raise GroupRequestError("Invalid group status filter.") + filters["has_documents"] = values.get("has_documents", "all") + if filters["has_documents"] not in ("all", "yes", "no"): + raise GroupRequestError("Invalid documents filter.") + for key in ("members_min", "members_max"): + raw = values.get(key) + if raw in (None, ""): + filters[key] = None + elif isinstance(raw, bool) or not re.fullmatch(r"\d{1,7}", str(raw)): + raise GroupRequestError("Member bounds must be non-negative whole numbers.") + else: + filters[key] = int(raw) + if (filters["members_min"] is not None and filters["members_max"] is not None + and filters["members_min"] > filters["members_max"]): + raise GroupRequestError("Minimum members may not exceed maximum members.") + for prefix in ("created", "activity"): + for suffix in ("from", "to"): + key = f"{prefix}_{suffix}" + raw = values.get(key) + if not raw: + filters[key] = None + continue + if not isinstance(raw, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", raw): + raise GroupRequestError("Date filters must use YYYY-MM-DD.") + try: + datetime.strptime(raw, "%Y-%m-%d") + except ValueError as ex: + raise GroupRequestError("Invalid calendar date.") from ex + filters[key] = raw + if filters[f"{prefix}_from"] and filters[f"{prefix}_to"]: + if filters[f"{prefix}_from"] > filters[f"{prefix}_to"]: + raise GroupRequestError("Date range start may not follow its end.") + filters["sort"] = values.get("sort", "name") + filters["direction"] = values.get("direction", "asc") + if filters["sort"] not in GROUP_SORTS or filters["direction"] not in ("asc", "desc"): + raise GroupRequestError("Invalid group sort or direction.") + return filters + + +def group_members(group): + """Project actual membership roles, including an owner omitted from legacy users.""" + owner = group.get("owner") or {} + users = list(group.get("users") or []) + if owner.get("id") and not any(user.get("userId") == owner["id"] for user in users): + users.append({"userId": owner["id"], **{k: owner.get(k, "") for k in ("email", "displayName")}}) + members = {} + for user in users: + user_id = user.get("userId") + if not user_id: + continue + role = ("Owner" if user_id == owner.get("id") else + "Admin" if user_id in (group.get("admins") or []) else + "DocumentManager" if user_id in (group.get("documentManagers") or []) else "User") + members[user_id] = { + "id": user_id, "display_name": user.get("displayName", ""), + "email": user.get("email", ""), "role": role, + } + return list(members.values()) + + +def group_row(group, documents, tokens, last_activity): + owner = group.get("owner") or {} + metrics = group.get("metrics") or {} + return { + "id": group["id"], + "name": group.get("name") or "", + "description": group.get("description") or "", + "owner": { + "id": owner.get("id"), "email": owner.get("email") or "", + "display_name": owner.get("displayName") or owner.get("display_name") or "", + }, + "status": group.get("status") if group.get("status") in GROUP_STATUSES else "active", + "members": len(group_members(group)), + "documents": documents, + "tokens": tokens, + "created_at": group.get("createdDate"), + "last_activity": last_activity, + "metrics_calculated_at": metrics.get("calculated_at"), + } + + +def load_group_inventory(groups_container, documents_container, activity_container): + """Four batched queries, independent of row count. Fail rather than invent totals.""" + groups = list(groups_container.query_items( + query=("SELECT c.id, c.name, c.description, c.owner, c.users, c.admins, " + "c.documentManagers, c.status, c.createdDate, c.metrics FROM c"), + enable_cross_partition_query=True, + )) + documents = { + row["group_id"]: int(row.get("total") or 0) + for row in documents_container.query_items( + query=("SELECT c.group_id, COUNT(1) AS total FROM c " + "WHERE c.type = 'document_metadata' AND IS_DEFINED(c.group_id) GROUP BY c.group_id"), + enable_cross_partition_query=True, + ) + } + tokens = { + row["group_id"]: int(row.get("total") or 0) + for row in activity_container.query_items( + query=("SELECT c.workspace_context.group_id AS group_id, SUM(c.usage.total_tokens) AS total " + "FROM c WHERE c.activity_type = 'token_usage' " + "AND IS_DEFINED(c.workspace_context.group_id) AND IS_NUMBER(c.usage.total_tokens) " + "GROUP BY c.workspace_context.group_id"), + enable_cross_partition_query=True, + ) + } + # The writers use three group locations. One coalesced expression avoids duplicate + # records and includes admin CSV and approval events that use top-level group_id. + group_expression = ( + "IIF(IS_STRING(c.group_id) AND c.group_id != '', c.group_id, " + "IIF(IS_STRING(c.group.group_id) AND c.group.group_id != '', " + "c.group.group_id, c.workspace_context.group_id))" + ) + activity_times = { + row["group_id"]: row.get("last_activity") + for row in activity_container.query_items( + query=(f"SELECT {group_expression} AS group_id, MAX(c.timestamp) AS last_activity FROM c " + f"WHERE IS_STRING({group_expression}) AND {group_expression} != '' " + f"GROUP BY {group_expression}"), + enable_cross_partition_query=True, + ) + } + calculated_at = datetime.now(timezone.utc).isoformat() + return { + "rows": [group_row(group, documents.get(group["id"], 0), tokens.get(group["id"], 0), + activity_times.get(group["id"])) for group in groups], + "calculated_at": calculated_at, + } + + +def filter_group_inventory(rows, filters): + """Filter before sorting or paging; missing dates do not match a date window.""" + def matches(row): + if filters["search"] and filters["search"] not in f"{row['name']} {row['description']}".casefold(): + return False + if filters["status"] != "all" and row["status"] != filters["status"]: + return False + owner = row["owner"] + if filters["owner"] and filters["owner"] not in ( + f"{owner['id'] or ''} {owner['email']} {owner['display_name']}".casefold() + ): + return False + for key, comparator in (("members_min", lambda x, y: x < y), ("members_max", lambda x, y: x > y)): + if filters[key] is not None and comparator(row["members"], filters[key]): + return False + if filters["has_documents"] == "yes" and row["documents"] == 0: + return False + if filters["has_documents"] == "no" and row["documents"] != 0: + return False + for prefix, field in (("created", "created_at"), ("activity", "last_activity")): + date = str(row.get(field) or "")[:10] + if filters[f"{prefix}_from"] and (not date or date < filters[f"{prefix}_from"]): + return False + if filters[f"{prefix}_to"] and (not date or date > filters[f"{prefix}_to"]): + return False + return True + + selected = [row for row in rows if matches(row)] + field = filters["sort"] + def sort_value(row): + value = row["owner"]["display_name"] or row["owner"]["email"] if field == "owner" else row.get(field) + return value.casefold() if isinstance(value, str) else value + present = [row for row in selected if sort_value(row) is not None] + missing = [row for row in selected if sort_value(row) is None] + present.sort(key=lambda row: row["id"]) + present.sort(key=sort_value, reverse=filters["direction"] == "desc") + missing.sort(key=lambda row: row["id"]) + return present + missing + + +def validate_group_status_payload(data): + if not isinstance(data, dict): + raise GroupRequestError("A JSON object is required.") + status = data.get("status") + reason = data.get("reason", "") + if status not in GROUP_STATUSES: + raise GroupRequestError("Invalid group status.") + if not isinstance(reason, str) or len(reason) > 2000: + raise GroupRequestError("Reason must be text no longer than 2000 characters.") + reason = reason.strip() + if status in ("locked", "inactive") and not reason: + raise GroupRequestError("A reason is required for locked or inactive status.") + return status, reason + + +def select_group_bulk_ids(data, inventory_loader): + """Resolve exclusions and cap the complete population before any mutation.""" + if ("group_ids" in data) == ("filter" in data): + raise GroupRequestError("Provide either group_ids or a filter selection.") + if "group_ids" in data: + ids = data["group_ids"] + if not isinstance(ids, list) or len(ids) > GROUP_BULK_LIMIT: + raise GroupRequestError("Bulk actions are limited to 500 groups.") + ids = list(dict.fromkeys(validate_group_id(value) for value in ids)) + else: + values = data["filter"] + exclusions = data.get("exclude_ids", []) + if not isinstance(values, dict) or set(values) - GROUP_FILTER_KEYS: + raise GroupRequestError("Invalid group selection filters.") + if not isinstance(exclusions, list) or len(exclusions) > GROUP_BULK_LIMIT: + raise GroupRequestError("Invalid exclusion list.") + excluded = {validate_group_id(value) for value in exclusions} + filters = parse_group_filters(values) + ids = [row["id"] for row in filter_group_inventory(inventory_loader()["rows"], filters) + if row["id"] not in excluded] + if not ids: + raise GroupRequestError("Select at least one group.") + if len(ids) > GROUP_BULK_LIMIT: + raise GroupRequestError("Bulk actions are limited to 500 groups.") + return ids diff --git a/application/single_app/functions_control_center_public_workspaces.py b/application/single_app/functions_control_center_public_workspaces.py new file mode 100644 index 000000000..2f68e4dfd --- /dev/null +++ b/application/single_app/functions_control_center_public_workspaces.py @@ -0,0 +1,187 @@ +# functions_control_center_public_workspaces.py +"""Parameterized workspace queries; inventory rows use recorded metrics only.""" + +import re + +from functions_control_center_groups import GroupRequestError, validate_group_id + + +WORKSPACE_LIMIT = 500 +WORKSPACE_EXPORT_LIMIT = 10000 +WORKSPACE_STATUSES = ("active", "locked", "upload_disabled", "inactive") +WORKSPACE_SORTS = { + "name": "c.name", + "owner": "c.owner.displayName", + "documents": "c.metrics.document_metrics.total_documents", + "tokens": "c.metrics.token_metrics.total_tokens", + "last_activity": "c.metrics.last_activity", + "created_at": "c.createdDate", +} +WORKSPACE_FIELDS = ( + "c.id, c.name, c.description, c.owner, c.admins, c.documentManagers, " + "c.status, c.createdDate, c.metrics" +) + + +def parse_workspace_filters(values): + filters = {} + for key in ("search", "owner"): + value = values.get(key, "") + if not isinstance(value, str) or len(value) > 200: + raise GroupRequestError("Search and owner filters may not exceed 200 characters.") + filters[key] = value.strip().lower() + for key, default, allowed in ( + ("status", "all", ("all", *WORKSPACE_STATUSES)), + ("sort", "name", WORKSPACE_SORTS), + ("direction", "asc", ("asc", "desc")), + ): + value = values.get(key, default) + if value not in allowed: + raise GroupRequestError(f"Invalid workspace {key}.") + filters[key] = value + for key, default, maximum in (("page", 1, 100000), ("per_page", 25, 250)): + value = values.get(key, default) + if isinstance(value, bool) or not re.fullmatch(r"\d{1,6}", str(value)): + raise GroupRequestError("Page and page size must be positive whole numbers.") + value = int(value) + if not 1 <= value <= maximum: + raise GroupRequestError(f"{key} must be between 1 and {maximum}.") + filters[key] = value + return filters + + +def workspace_where(filters): + clauses, parameters = [], [] + if filters["search"]: + clauses.append("(CONTAINS(LOWER(c.name), @search) OR CONTAINS(LOWER(c.description), @search))") + parameters.append({"name": "@search", "value": filters["search"]}) + if filters["owner"]: + clauses.append( + "(CONTAINS(LOWER(c.owner.userId), @owner) OR CONTAINS(LOWER(c.owner.displayName), @owner) " + "OR CONTAINS(LOWER(c.owner.email), @owner) OR (IS_STRING(c.owner) AND CONTAINS(LOWER(c.owner), @owner)))" + ) + parameters.append({"name": "@owner", "value": filters["owner"]}) + if filters["status"] != "all": + clause = "c.status = @status" + if filters["status"] == "active": + clause = f"(NOT IS_DEFINED(c.status) OR IS_NULL(c.status) OR c.status = '' OR {clause})" + elif filters["status"] == "inactive": + clause = "(IS_DEFINED(c.status) AND NOT IS_NULL(c.status) AND c.status != '' AND NOT ARRAY_CONTAINS(@known_statuses, c.status))" + parameters.append({"name": "@known_statuses", "value": ["active", "locked", "upload_disabled"]}) + clauses.append(clause) + if filters["status"] != "inactive": + parameters.append({"name": "@status", "value": filters["status"]}) + return " AND ".join(clauses) or "1=1", parameters + + +def workspace_members(workspace): + """Do not perform directory/user lookups for legacy string identities.""" + members = {} + for role, entries in ( + ("Owner", [workspace.get("owner")]), + ("Admin", workspace.get("admins") or []), + ("DocumentManager", workspace.get("documentManagers") or []), + ): + for entry in entries: + identity = entry if isinstance(entry, dict) else {"userId": entry} + member_id = identity.get("userId") + if member_id and member_id not in members: + members[member_id] = { + "id": member_id, "display_name": identity.get("displayName") or "", + "email": identity.get("email") or "", "role": role, + } + return list(members.values()) + + +def workspace_row(workspace): + members = workspace_members(workspace) + owner = next((member for member in members if member["role"] == "Owner"), None) + metrics = workspace.get("metrics") or {} + status = workspace.get("status") or "active" + return { + "id": workspace["id"], "name": workspace.get("name") or "", + "description": workspace.get("description") or "", + "owner": owner or {"id": None, "display_name": "", "email": ""}, + "status": status if status in WORKSPACE_STATUSES else "inactive", + "members": len(members), + "documents": (metrics.get("document_metrics") or {}).get("total_documents"), + "tokens": (metrics.get("token_metrics") or {}).get("total_tokens"), + "created_at": workspace.get("createdDate"), + "last_activity": metrics.get("last_activity"), + "metrics_calculated_at": metrics.get("calculated_at"), + } + + +def query_workspaces(container, filters): + """Page in Cosmos, including missing sort values last without a composite index. + + Two populations avoid Cosmos ORDER BY dropping undefined properties. Each + ordered query uses one indexed property, so existing automatic indexes suffice. + """ + where, parameters = workspace_where(filters) + field = WORKSPACE_SORTS[filters["sort"]] + present = f"IS_DEFINED({field}) AND NOT IS_NULL({field})" + def count(condition): + return list(container.query_items( + query=f"SELECT VALUE COUNT(1) FROM c WHERE ({where}) AND ({condition})", + parameters=parameters, enable_cross_partition_query=True, + ))[0] + total = count("1=1") + recorded = count(present) + size = filters["per_page"] + pages = max(1, (total + size - 1) // size) + page = min(filters["page"], pages) + offset = (page - 1) * size + rows = [] + if offset < recorded: + take = min(size, recorded - offset) + rows.extend(container.query_items( + query=(f"SELECT {WORKSPACE_FIELDS} FROM c WHERE ({where}) AND ({present}) " + f"ORDER BY {field} {filters['direction'].upper()} OFFSET @offset LIMIT @limit"), + parameters=parameters + [{"name": "@offset", "value": offset}, {"name": "@limit", "value": take}], + enable_cross_partition_query=True, + )) + if len(rows) < size and total > recorded: + rows.extend(container.query_items( + query=(f"SELECT {WORKSPACE_FIELDS} FROM c WHERE ({where}) AND NOT ({present}) " + "ORDER BY c.id ASC OFFSET @offset LIMIT @limit"), + parameters=parameters + [{"name": "@offset", "value": max(0, offset - recorded)}, + {"name": "@limit", "value": size - len(rows)}], + enable_cross_partition_query=True, + )) + return { + "workspaces": [workspace_row(row) for row in rows], + "pagination": {"total_items": total, "page": page, "per_page": size, "total_pages": pages}, + "metrics_freshness": {"source": "recorded workspace metrics; missing values are unavailable"}, + } + + +def select_workspace_ids(container, data): + """Resolve a bounded population before writing anything, never load all rows.""" + if ("workspace_ids" in data) == ("filter" in data): + raise GroupRequestError("Provide either workspace_ids or a filter selection.") + if "workspace_ids" in data: + ids = data["workspace_ids"] + if not isinstance(ids, list) or len(ids) > WORKSPACE_LIMIT: + raise GroupRequestError("Select at most 500 workspaces.") + ids = list(dict.fromkeys(validate_group_id(value) for value in ids)) + else: + values = data["filter"] + if not isinstance(values, dict) or set(values) - {"search", "status", "owner"}: + raise GroupRequestError("Invalid workspace selection filters.") + filters = parse_workspace_filters(values) + where, parameters = workspace_where(filters) + excluded = data.get("exclude_ids", []) + if not isinstance(excluded, list) or len(excluded) > WORKSPACE_LIMIT: + raise GroupRequestError("Provide at most 500 exclusions.") + excluded = list(dict.fromkeys(validate_group_id(value) for value in excluded)) + parameters.append({"name": "@excluded", "value": excluded}) + ids = [row["id"] for row in container.query_items( + query=f"SELECT TOP 501 c.id FROM c WHERE ({where}) AND NOT ARRAY_CONTAINS(@excluded, c.id)", + parameters=parameters, enable_cross_partition_query=True, + )] + if len(ids) > WORKSPACE_LIMIT: + raise GroupRequestError("Select at most 500 workspaces.") + if not ids: + raise GroupRequestError("Select at least one workspace.") + return ids diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py index 02cd312fd..0a4e86441 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -3,10 +3,12 @@ import csv import logging import math +import re import time from io import StringIO -from flask import make_response +from flask import Response, make_response, stream_with_context +from azure.cosmos.exceptions import CosmosResourceNotFoundError from config import * from functions_authentication import * @@ -30,8 +32,25 @@ GROUP_WRITE_CONFLICT_MESSAGE, GroupDocumentWriteConflict, delete_group, + get_user_role_in_group, update_group_document_with_etag_guard, ) +from functions_control_center_groups import ( + GROUP_SNAPSHOT_TTL, + GroupRequestError, + filter_group_inventory, + group_members, + group_row, + load_group_inventory, + parse_group_filters, + select_group_bulk_ids, + validate_group_id, + validate_group_status_payload, +) +from functions_control_center_public_workspaces import ( + WORKSPACE_EXPORT_LIMIT, parse_workspace_filters, query_workspaces, + select_workspace_ids, workspace_members, workspace_row, +) from functions_safety_remediation import ( execute_safety_violation_action, get_safety_log_item, @@ -42,6 +61,7 @@ PUBLIC_WORKSPACE_WRITE_CONFLICT_MESSAGE, PublicWorkspaceDocumentWriteConflict, update_public_workspace_document_with_etag_guard, + get_user_role_in_public_workspace, ) from utils_cache import invalidate_group_search_cache from swagger_wrapper import swagger_route, get_auth_security @@ -61,6 +81,21 @@ "Invalid dashboard date range. Use 7, 30, or 90 days or a valid custom range of up to 366 days." ) _control_center_dashboard_cache = {} +_control_center_group_snapshot_cache = {} + + +def _control_center_group_inventory(force_refresh=False): + now = time.monotonic() + cached = _control_center_group_snapshot_cache.get("inventory") + if cached and not force_refresh and now < cached["expires_at"]: + return cached["payload"] + payload = load_group_inventory( + cosmos_groups_container, cosmos_group_documents_container, cosmos_activity_logs_container, + ) + _control_center_group_snapshot_cache["inventory"] = { + "expires_at": now + GROUP_SNAPSHOT_TTL, "payload": payload, + } + return payload # The answers an approved ownership change gives when the group's current copy no # longer matches the request. Each is stored on the approval as its failure reason. @@ -1009,7 +1044,8 @@ def enhance_user_with_activity(user, force_refresh=False): 'total_documents': 0, 'ai_search_size': 0, # pages × 80KB 'storage_account_size': 0 # Actual file sizes from storage - } + }, + 'token_metrics': {'total_tokens': 0} }, 'access_status': 'allow', # default 'file_upload_status': 'allow' # default @@ -1066,6 +1102,8 @@ def enhance_user_with_activity(user, force_refresh=False): cached_doc_metrics['personal_workspace_enabled'] = user.get('settings', {}).get('enable_personal_workspace', False) # Do NOT include enhanced_citation_enabled in user data - frontend gets it from app settings enhanced['activity']['document_metrics'] = cached_doc_metrics + if 'token_metrics' in cached_metrics: + enhanced['activity']['token_metrics'] = cached_metrics['token_metrics'] return enhanced except Exception as cache_e: debug_print(f"Error using cached metrics for user {user.get('id')}: {cache_e}") @@ -1360,6 +1398,25 @@ def enhance_user_with_activity(user, force_refresh=False): except Exception as e: debug_print(f"Could not get document metrics for user {user.get('id')}: {e}") + + try: + token_total_query = """ + SELECT VALUE SUM(c.usage.total_tokens) FROM c + WHERE c.user_id = @user_id + AND c.activity_type = 'token_usage' + AND IS_DEFINED(c.usage.total_tokens) + AND IS_NUMBER(c.usage.total_tokens) + """ + token_totals = list(cosmos_activity_logs_container.query_items( + query=token_total_query, + parameters=[{"name": "@user_id", "value": user_id}], + partition_key=user_id + )) + enhanced['activity']['token_metrics']['total_tokens'] = int( + token_totals[0] or 0 + ) if token_totals else 0 + except Exception as token_error: + debug_print(f"Could not get token metrics for user {user_id}: {token_error}") # Save calculated metrics to user settings for caching (only if we calculated fresh data) if force_refresh or not user.get('settings', {}).get('metrics', {}).get('calculated_at'): @@ -1371,6 +1428,7 @@ def enhance_user_with_activity(user, force_refresh=False): 'calculated_at': datetime.now(timezone.utc).isoformat(), 'login_metrics': enhanced['activity']['login_metrics'], 'chat_metrics': enhanced['activity']['chat_metrics'], + 'token_metrics': enhanced['activity']['token_metrics'], 'document_metrics': { 'total_documents': enhanced['activity']['document_metrics']['total_documents'], 'ai_search_size': enhanced['activity']['document_metrics']['ai_search_size'], @@ -1397,6 +1455,202 @@ def enhance_user_with_activity(user, force_refresh=False): debug_print(f"Error enhancing user data: {e}") return user # Return original user data if enhancement fails + +CONTROL_CENTER_USERS_MAX_BULK = 500 +CONTROL_CENTER_USER_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +CONTROL_CENTER_USER_SORTS = { + "name": "c.display_name", + "email": "c.email", + "last_login": "c.settings.metrics.login_metrics.last_login", + "conversations": "c.settings.metrics.chat_metrics.total_conversations", + "documents": "c.settings.metrics.document_metrics.total_documents", + "tokens": "c.settings.metrics.token_metrics.total_tokens", +} + + +def _control_center_validate_user_id(user_id): + """Reject malformed route/body identifiers before using them in lookups or links.""" + normalized = str(user_id or "").strip() + if not CONTROL_CENTER_USER_ID_PATTERN.fullmatch(normalized): + raise ValueError("Invalid user ID.") + return normalized + + +def _control_center_parse_user_filters(values, now=None): + """Normalize the shared Users list, export, and filter-selection query contract.""" + current_time = now or datetime.now(timezone.utc) + search = str(values.get("search", "") or "").strip() + if len(search) > 200: + raise ValueError("Search text is too long.") + + legacy_filter = str(values.get("filter", "") or "").strip().lower() + status = str(values.get("status", "") or "").strip().lower() + access_status = str(values.get("access_status", "all") or "all").strip().lower() + if status == "blocked": + access_status = "deny" + elif status in {"allow", "deny"} and not access_status: + access_status = status + if legacy_filter == "active" and not values.get("last_login"): + last_login = "30" + else: + last_login = str(values.get("last_login", "all") or "all").strip().lower() + + upload_status = str(values.get("upload_status", "all") or "all").strip().lower() + has_documents = str(values.get("has_documents", "all") or "all").strip().lower() + sort = str(values.get("sort", "name") or "name").strip().lower() + direction = str(values.get("direction", "asc") or "asc").strip().lower() + + if access_status not in {"all", "allow", "deny"}: + raise ValueError("Invalid access status filter.") + if upload_status not in {"all", "allow", "deny"}: + raise ValueError("Invalid file-upload status filter.") + if last_login not in {"all", "never", "7", "30", "90", "90_plus"}: + raise ValueError("Invalid last-login filter.") + if has_documents not in {"all", "yes", "no"}: + raise ValueError("Invalid document filter.") + if sort not in CONTROL_CENTER_USER_SORTS: + raise ValueError("Invalid user sort field.") + if direction not in {"asc", "desc"}: + raise ValueError("Invalid sort direction.") + + return { + "search": search, + "access_status": access_status, + "upload_status": upload_status, + "last_login": last_login, + "has_documents": has_documents, + "sort": sort, + "direction": direction, + "now": current_time, + } + + +def _control_center_user_where(filters): + """Build a parameterized Cosmos WHERE clause from normalized Users filters.""" + clauses = [] + parameters = [] + + def add_clause(clause, name, value): + clauses.append(clause) + parameters.append({"name": name, "value": value}) + + if filters["search"]: + add_clause( + "(CONTAINS(LOWER(c.email), @search) OR CONTAINS(LOWER(c.display_name), @search))", + "@search", + filters["search"].lower(), + ) + + now_iso = filters["now"].astimezone(timezone.utc).isoformat() + for field, value, parameter_prefix in ( + ("access", filters["access_status"], "access"), + ("file_uploads", filters["upload_status"], "upload"), + ): + if value == "deny": + add_clause( + f"(c.settings.{field}.status = @{parameter_prefix}_status " + f"AND (NOT IS_DEFINED(c.settings.{field}.datetime_to_allow) " + f"OR IS_NULL(c.settings.{field}.datetime_to_allow) " + f"OR c.settings.{field}.datetime_to_allow > @{parameter_prefix}_now))", + f"@{parameter_prefix}_status", + "deny", + ) + parameters.append({"name": f"@{parameter_prefix}_now", "value": now_iso}) + elif value == "allow": + add_clause( + f"(NOT IS_DEFINED(c.settings.{field}.status) " + f"OR c.settings.{field}.status != 'deny' " + f"OR (IS_DEFINED(c.settings.{field}.datetime_to_allow) " + f"AND NOT IS_NULL(c.settings.{field}.datetime_to_allow) " + f"AND c.settings.{field}.datetime_to_allow <= @{parameter_prefix}_now))", + f"@{parameter_prefix}_now", + now_iso, + ) + + login_path = "c.settings.metrics.login_metrics.last_login" + if filters["last_login"] == "never": + clauses.append( + f"(NOT IS_DEFINED({login_path}) OR IS_NULL({login_path}) OR {login_path} = 'Never' OR {login_path} = '')" + ) + elif filters["last_login"] in {"7", "30", "90", "90_plus"}: + days = 90 if filters["last_login"] == "90_plus" else int(filters["last_login"]) + cutoff = (filters["now"] - timedelta(days=days)).astimezone(timezone.utc).isoformat() + if filters["last_login"] == "90_plus": + clauses.append( + f"IS_DEFINED({login_path}) AND NOT IS_NULL({login_path}) " + f"AND {login_path} != 'Never' AND {login_path} < @last_login_cutoff" + ) + else: + clauses.append( + f"IS_DEFINED({login_path}) AND {login_path} >= @last_login_cutoff" + ) + parameters.append({"name": "@last_login_cutoff", "value": cutoff}) + + document_count_path = "c.settings.metrics.document_metrics.total_documents" + if filters["has_documents"] == "yes": + clauses.append(f"IS_DEFINED({document_count_path}) AND {document_count_path} > 0") + elif filters["has_documents"] == "no": + clauses.append(f"IS_DEFINED({document_count_path}) AND {document_count_path} = 0") + + return (" AND ".join(clauses) if clauses else "1=1"), parameters + + +def _control_center_effective_restriction(settings, setting_key, now=None): + """Return the effective allow/deny state and any stored expiry for one user setting.""" + value = settings.get(setting_key, {}) + if not isinstance(value, dict): + value = {} + expires_at = value.get("datetime_to_allow") + status = "deny" if value.get("status") == "deny" else "allow" + if status == "deny" and expires_at: + try: + expiry = datetime.fromisoformat( + str(expires_at).replace("Z", "+00:00") + ) + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=timezone.utc) + if expiry <= (now or datetime.now(timezone.utc)): + status = "allow" + except (TypeError, ValueError): + # A malformed stored expiry must not lift an active deny. + pass + return {"status": status, "expires_at": expires_at} + + +def _control_center_user_row(user, now=None): + """Project a user document onto the non-sensitive Users table response shape.""" + settings = user.get("settings", {}) + if not isinstance(settings, dict): + settings = {} + metrics = settings.get("metrics", {}) + if not isinstance(metrics, dict): + metrics = {} + login_metrics = metrics.get("login_metrics", {}) + chat_metrics = metrics.get("chat_metrics", {}) + document_metrics = metrics.get("document_metrics", {}) + token_metrics = metrics.get("token_metrics", {}) + return { + "id": user.get("id", ""), + "email": user.get("email", ""), + "display_name": user.get("display_name", ""), + "access": _control_center_effective_restriction(settings, "access", now), + "file_uploads": _control_center_effective_restriction(settings, "file_uploads", now), + "last_login": login_metrics.get("last_login"), + "total_logins": login_metrics.get("total_logins"), + "conversations": chat_metrics.get("total_conversations"), + "documents": document_metrics.get("total_documents"), + "tokens": token_metrics.get("total_tokens"), + "metrics_calculated_at": metrics.get("calculated_at"), + } + + +def _control_center_csv_safe_cell(value): + """Prevent formula execution in spreadsheet applications for every CSV cell type.""" + serialized = "" if value is None else str(value) + if serialized.lstrip().startswith(("=", "+", "-", "@")): + return f"'{serialized}" + return serialized + def enhance_public_workspace_with_activity(workspace, force_refresh=False): """ Enhance public workspace data with activity information and computed fields. @@ -3427,7 +3681,573 @@ def api_bulk_user_action(): debug_print(f"Error performing bulk user action: {e}") return jsonify({'error': 'Failed to perform bulk action'}), 500 + # V2 User Management APIs + @bp.route('/api/v2/control-center/users', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_v2_control_center_users(): + """Return filtered user rows with cached activity metrics and explicit freshness.""" + try: + filters = _control_center_parse_user_filters(request.args) + page, per_page = parse_control_center_management_pagination(request.args) + where_clause, parameters = _control_center_user_where(filters) + + count_result = list(cosmos_user_settings_container.query_items( + query=f"SELECT VALUE COUNT(1) FROM c WHERE {where_clause}", + parameters=parameters, + enable_cross_partition_query=True, + )) + total = int(count_result[0] or 0) if count_result else 0 + total_pages = get_control_center_total_pages(total, per_page) + page = clamp_control_center_page(page, total_pages) + sort_expression = CONTROL_CENTER_USER_SORTS[filters["sort"]] + query = ( + "SELECT c.id, c.email, c.display_name, c.settings FROM c " + f"WHERE {where_clause} ORDER BY {sort_expression} {filters['direction'].upper()}, c.id ASC " + "OFFSET @offset LIMIT @limit" + ) + paged_parameters = parameters + [ + {"name": "@offset", "value": (page - 1) * per_page}, + {"name": "@limit", "value": per_page}, + ] + user_docs = list(cosmos_user_settings_container.query_items( + query=query, + parameters=paged_parameters, + enable_cross_partition_query=True, + )) + users = [_control_center_user_row(user, filters["now"]) for user in user_docs] + metric_times = sorted( + user["metrics_calculated_at"] for user in users + if isinstance(user.get("metrics_calculated_at"), str) + ) + return jsonify({ + "users": users, + "pagination": { + "page": page, + "per_page": per_page, + "total_items": total, + "total_pages": total_pages, + "has_prev": page > 1, + "has_next": page < total_pages, + }, + "metrics_freshness": { + "oldest_calculated_at": metric_times[0] if metric_times else None, + "newest_calculated_at": metric_times[-1] if metric_times else None, + "missing_count": sum(not user.get("metrics_calculated_at") for user in users), + "source": "user metrics refresh cache", + }, + }), 200 + except ValueError: + return jsonify({"error": "Invalid user filters or pagination value."}), 400 + except Exception as ex: + log_event( + "[CONTROL_CENTER] V2 user list query failed.", + extra={"error_type": type(ex).__name__}, + level=logging.ERROR, + ) + return jsonify({"error": "Unable to retrieve users."}), 500 + + @bp.route('/api/v2/control-center/users/', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_v2_control_center_user_detail(user_id): + """Return a user's profile, cached usage, recent activity and workspace memberships.""" + try: + normalized_user_id = _control_center_validate_user_id(user_id) + exists = list(cosmos_user_settings_container.query_items( + query="SELECT VALUE COUNT(1) FROM c WHERE c.id = @user_id", + parameters=[{"name": "@user_id", "value": normalized_user_id}], + enable_cross_partition_query=True, + )) + if not exists or not exists[0]: + return jsonify({"error": "User not found."}), 404 + + user_doc = read_user_settings_snapshot( + normalized_user_id, + allow_cross_user=True, + ) + settings = user_doc.get("settings", {}) + metrics = settings.get("metrics", {}) if isinstance(settings, dict) else {} + login_metrics = metrics.get("login_metrics", {}) + chat_metrics = metrics.get("chat_metrics", {}) + document_metrics = metrics.get("document_metrics", {}) + token_metrics = metrics.get("token_metrics", {}) + + activity_items = list(cosmos_activity_logs_container.query_items( + query=( + "SELECT TOP 20 c.id, c.timestamp, c.created_at, c.activity_type, " + "c.resource_name, c.workspace_type, c.token_type, c.status, c.usage " + "FROM c WHERE c.user_id = @user_id " + "ORDER BY c.timestamp DESC" + ), + parameters=[{"name": "@user_id", "value": normalized_user_id}], + partition_key=normalized_user_id, + )) + + group_docs = list(cosmos_groups_container.query_items( + query=( + "SELECT c.id, c.name, c.owner, c.admins, c.documentManagers, c.users " + "FROM c WHERE c.owner.id = @user_id " + "OR ARRAY_CONTAINS(c.admins, @user_id) " + "OR ARRAY_CONTAINS(c.documentManagers, @user_id) " + "OR EXISTS (SELECT VALUE member FROM member IN c.users WHERE member.userId = @user_id)" + ), + parameters=[{"name": "@user_id", "value": normalized_user_id}], + enable_cross_partition_query=True, + )) + group_memberships = [] + for group in group_docs: + if (group.get("owner") or {}).get("id") == normalized_user_id: + role = "Owner" + elif normalized_user_id in (group.get("admins") or []): + role = "Admin" + elif normalized_user_id in (group.get("documentManagers") or []): + role = "DocumentManager" + else: + role = "User" + group_memberships.append({ + "id": group.get("id", ""), + "name": group.get("name", ""), + "role": role, + "owned": role == "Owner", + }) + + public_docs = list(cosmos_public_workspaces_container.query_items( + query=( + "SELECT c.id, c.name, c.owner, c.admins, c.documentManagers " + "FROM c WHERE c.owner.userId = @user_id " + "OR ARRAY_CONTAINS(c.admins, @user_id) " + "OR EXISTS (SELECT VALUE manager FROM manager IN c.documentManagers " + "WHERE manager.userId = @user_id OR manager = @user_id)" + ), + parameters=[{"name": "@user_id", "value": normalized_user_id}], + enable_cross_partition_query=True, + )) + public_memberships = [] + for workspace in public_docs: + owner_id = (workspace.get("owner") or {}).get("userId") + is_admin = normalized_user_id in (workspace.get("admins") or []) + is_manager = any( + manager == normalized_user_id + or isinstance(manager, dict) and manager.get("userId") == normalized_user_id + for manager in (workspace.get("documentManagers") or []) + ) + role = "Owner" if owner_id == normalized_user_id else "Admin" if is_admin else "DocumentManager" if is_manager else "Member" + public_memberships.append({ + "id": workspace.get("id", ""), + "name": workspace.get("name", ""), + "role": role, + "owned": role == "Owner", + }) + + activity_tokens = list(cosmos_activity_logs_container.query_items( + query=( + "SELECT VALUE SUM(c.usage.total_tokens) FROM c " + "WHERE c.user_id = @user_id AND c.activity_type = 'token_usage' " + "AND IS_DEFINED(c.usage.total_tokens) AND IS_NUMBER(c.usage.total_tokens)" + ), + parameters=[{"name": "@user_id", "value": normalized_user_id}], + partition_key=normalized_user_id, + )) + total_tokens = ( + activity_tokens[0] + if activity_tokens and activity_tokens[0] is not None + else token_metrics.get("total_tokens") + ) + + return jsonify({ + "user": { + "id": normalized_user_id, + "email": user_doc.get("email", ""), + "display_name": user_doc.get("display_name", ""), + "access": _control_center_effective_restriction(settings, "access"), + "file_uploads": _control_center_effective_restriction(settings, "file_uploads"), + }, + "usage": { + "last_login": login_metrics.get("last_login"), + "total_logins": login_metrics.get("total_logins"), + "conversations": chat_metrics.get("total_conversations"), + "documents": document_metrics.get("total_documents"), + "tokens": total_tokens, + "metrics_calculated_at": metrics.get("calculated_at"), + }, + "activity": [ + { + "id": item.get("id", ""), + "activity_type": item.get("activity_type", ""), + "timestamp": item.get("timestamp") or item.get("created_at"), + "resource_name": item.get("resource_name"), + "workspace_type": item.get("workspace_type"), + "token_type": item.get("token_type"), + "status": item.get("status"), + "usage": item.get("usage"), + } + for item in activity_items + ], + "memberships": { + "groups": group_memberships, + "public_workspaces": public_memberships, + }, + }), 200 + except ValueError: + return jsonify({"error": "Invalid user ID."}), 400 + except Exception as ex: + log_event( + "[CONTROL_CENTER] V2 user detail query failed.", + extra={"user_id": str(user_id), "error_type": type(ex).__name__}, + level=logging.ERROR, + ) + return jsonify({"error": "Unable to retrieve user details."}), 500 + + @bp.route('/api/v2/control-center/users/bulk-action', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_v2_control_center_users_bulk_action(): + """Apply one access or upload setting to at most 500 explicitly or filter-selected users.""" + try: + data = request.get_json(silent=True) or {} + action_type = data.get("action_type") + action_settings = data.get("settings") + if action_type not in {"access", "file_uploads"} or not isinstance(action_settings, dict): + return jsonify({"error": "Choose an access or file-upload action."}), 400 + status = action_settings.get("status") + if status not in {"allow", "deny"}: + return jsonify({"error": "Status must be allow or deny."}), 400 + expires_at = action_settings.get("datetime_to_allow") + if expires_at: + try: + parsed_expiry = datetime.fromisoformat( + str(expires_at).replace("Z", "+00:00") + ) + except (TypeError, ValueError): + return jsonify({"error": "Expiry must be an ISO 8601 datetime."}), 400 + if parsed_expiry.tzinfo is None: + parsed_expiry = parsed_expiry.replace(tzinfo=timezone.utc) + expires_at = parsed_expiry.astimezone(timezone.utc).isoformat() + + if isinstance(data.get("user_ids"), list): + user_ids = [ + _control_center_validate_user_id(value) + for value in data["user_ids"] + ] + user_ids = list(dict.fromkeys(user_ids)) + elif isinstance(data.get("filter"), dict): + filters = _control_center_parse_user_filters(data["filter"]) + where_clause, parameters = _control_center_user_where(filters) + excluded = data.get("exclude_ids", []) + if not isinstance(excluded, list) or len(excluded) > CONTROL_CENTER_USERS_MAX_BULK: + return jsonify({"error": "The exclusion list is too large."}), 400 + excluded_ids = list(dict.fromkeys( + _control_center_validate_user_id(value) for value in excluded + )) + if excluded_ids: + exclusion_params = [] + placeholders = [] + for index, excluded_id in enumerate(excluded_ids): + name = f"@excluded_{index}" + placeholders.append(name) + exclusion_params.append({"name": name, "value": excluded_id}) + where_clause += f" AND c.id NOT IN ({', '.join(placeholders)})" + parameters += exclusion_params + bounded_query = ( + "SELECT c.id FROM c WHERE " + f"{where_clause} OFFSET 0 LIMIT @selection_limit" + ) + selected_rows = list(cosmos_user_settings_container.query_items( + query=bounded_query, + parameters=parameters + [{ + "name": "@selection_limit", + "value": CONTROL_CENTER_USERS_MAX_BULK + 1, + }], + enable_cross_partition_query=True, + )) + if len(selected_rows) > CONTROL_CENTER_USERS_MAX_BULK: + return jsonify({ + "error": f"Bulk actions are limited to {CONTROL_CENTER_USERS_MAX_BULK} users." + }), 400 + user_ids = [ + _control_center_validate_user_id(item.get("id")) + for item in selected_rows + ] + else: + return jsonify({"error": "Provide user_ids or a filter with optional exclude_ids."}), 400 + + if not user_ids: + return jsonify({"error": "Select at least one user."}), 400 + if len(user_ids) > CONTROL_CENTER_USERS_MAX_BULK: + return jsonify({ + "error": f"Bulk actions are limited to {CONTROL_CENTER_USERS_MAX_BULK} users." + }), 400 + + setting_value = { + "status": status, + "datetime_to_allow": expires_at, + } + successful_ids = [] + failed_ids = [] + for target_user_id in user_ids: + try: + updated = update_user_settings( + target_user_id, + {action_type: setting_value}, + allow_cross_user=True, + ) + (successful_ids if updated else failed_ids).append(target_user_id) + except Exception as update_error: + debug_print( + f"[CONTROL_CENTER] Bulk user update failed for {target_user_id}: " + f"{type(update_error).__name__}" + ) + failed_ids.append(target_user_id) + + admin_user = session.get("user", {}) + log_event("[CONTROL_CENTER] Bulk User Action", { + "admin_user": admin_user.get("preferred_username", "unknown"), + "action_type": action_type, + "user_count": len(user_ids), + "success_count": len(successful_ids), + "failed_count": len(failed_ids), + "settings": setting_value, + }) + return jsonify({ + "success_count": len(successful_ids), + "failed_count": len(failed_ids), + "failed_user_ids": failed_ids, + }), 200 + except ValueError: + return jsonify({"error": "Invalid bulk user action request."}), 400 + except Exception as ex: + log_event( + "[CONTROL_CENTER] V2 bulk user action failed.", + extra={"error_type": type(ex).__name__}, + level=logging.ERROR, + ) + return jsonify({"error": "Unable to complete the bulk user action."}), 500 + + @bp.route('/api/v2/control-center/users/export.csv', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_v2_control_center_users_export(): + """Stream a CSV export of users matching the same filters as the Users list.""" + try: + filters = _control_center_parse_user_filters(request.args) + where_clause, parameters = _control_center_user_where(filters) + sort_expression = CONTROL_CENTER_USER_SORTS[filters["sort"]] + query = ( + "SELECT c.id, c.email, c.display_name, c.settings FROM c " + f"WHERE {where_clause} ORDER BY {sort_expression} {filters['direction'].upper()}, c.id ASC" + ) + user_docs = cosmos_user_settings_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True, + ) + + def stream_csv(): + buffer = StringIO() + writer = csv.writer(buffer) + headers = ( + "id", "display_name", "email", "access_status", "access_expires_at", + "file_upload_status", "file_upload_expires_at", "last_login", + "conversations", "documents", "tokens", "metrics_calculated_at", + ) + writer.writerow(headers) + yield buffer.getvalue() + buffer.seek(0) + buffer.truncate(0) + for user_doc in user_docs: + row = _control_center_user_row(user_doc, filters["now"]) + values = ( + row["id"], row["display_name"], row["email"], + row["access"]["status"], row["access"]["expires_at"], + row["file_uploads"]["status"], row["file_uploads"]["expires_at"], + row["last_login"], row["conversations"], row["documents"], + row["tokens"], row["metrics_calculated_at"], + ) + writer.writerow(_control_center_csv_safe_cell(value) for value in values) + yield buffer.getvalue() + buffer.seek(0) + buffer.truncate(0) + + response = Response( + stream_with_context(stream_csv()), + mimetype="text/csv", + headers={"Content-Disposition": 'attachment; filename="control-center-users.csv"'}, + ) + response.headers["Cache-Control"] = "private, no-store" + return response + except ValueError: + return jsonify({"error": "Invalid user export filters."}), 400 + except Exception as ex: + log_event( + "[CONTROL_CENTER] V2 user export query failed.", + extra={"error_type": type(ex).__name__}, + level=logging.ERROR, + ) + return jsonify({"error": "Unable to export users."}), 500 + # Group Management APIs + @bp.route('/api/v2/control-center/groups', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_v2_control_center_groups(): + """Filter and sort the batched server inventory before paging.""" + try: + filters = parse_group_filters(request.args) + page, per_page = parse_control_center_management_pagination(request.args) + inventory = _control_center_group_inventory(request.args.get("force_refresh") == "1") + rows = filter_group_inventory(inventory["rows"], filters) + total_pages = get_control_center_total_pages(len(rows), per_page) + page = clamp_control_center_page(page, total_pages) + return jsonify({ + "groups": rows[(page - 1) * per_page:page * per_page], + "pagination": { + "page": page, "per_page": per_page, "total_items": len(rows), + "total_pages": total_pages, "has_prev": page > 1, "has_next": page < total_pages, + }, + "metrics_freshness": { + "calculated_at": inventory["calculated_at"], + "ttl_seconds": GROUP_SNAPSHOT_TTL, + "source": "batched group inventory, document metadata and activity logs", + }, + }), 200 + except GroupRequestError: + return jsonify({"error": "Invalid group filters."}), 400 + except Exception as ex: + log_event("[CONTROL_CENTER] V2 group list failed.", + extra={"error_type": type(ex).__name__}, level=logging.ERROR) + return jsonify({"error": "Unable to retrieve groups."}), 500 + + @bp.route('/api/v2/control-center/groups/', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_v2_control_center_group_detail(group_id): + """Admin-authorized overview with a fresh membership/status read and bounded activity.""" + try: + validate_group_id(group_id) + group = cosmos_groups_container.read_item(item=group_id, partition_key=group_id) + inventory = _control_center_group_inventory() + cached = next((row for row in inventory["rows"] if row["id"] == group_id), None) + if cached is None: + inventory = _control_center_group_inventory(force_refresh=True) + cached = next((row for row in inventory["rows"] if row["id"] == group_id), None) + if cached is None: + return jsonify({"error": "Group not found."}), 404 + row = group_row(group, cached["documents"], cached["tokens"], cached["last_activity"]) + activity = list(cosmos_activity_logs_container.query_items( + query=( + "SELECT TOP 20 c.id, c.activity_type, c.timestamp, c.description, " + "c.user_id, c.admin_user_id, c.admin_email, c.group, c.workspace_context, " + "c.status_change, c.added_member, c.removed_member, c.member_email, " + "c.member_name, c.member_role, c.document, c.usage, c.token_type " + "FROM c WHERE c.group_id = @group_id OR c.group.group_id = @group_id " + "OR c.workspace_context.group_id = @group_id ORDER BY c.timestamp DESC" + ), + parameters=[{"name": "@group_id", "value": group_id}], + enable_cross_partition_query=True, + )) + user = session.get("user") or {} + role = get_user_role_in_group(group, user.get("oid") or user.get("sub")) + settings = get_settings() + can_edit_members = role in ("Owner", "Admin") and bool(settings.get("enable_group_workspaces")) + return jsonify({ + "group": row, + "members": group_members(group), + "status_history": group.get("statusHistory") or [], + "retention": { + "conversation_retention_days": (group.get("retention_policy") or {}).get("conversation_retention_days", "default"), + "document_retention_days": (group.get("retention_policy") or {}).get("document_retention_days", "default"), + "enabled": bool(settings.get("enable_retention_policy_group")), + "can_edit": can_edit_members and bool(settings.get("enable_retention_policy_group")), + }, + "permissions": {"can_edit_members": can_edit_members, "current_role": role}, + "documents_summary": { + "count": row["documents"], + "cached_metrics": (group.get("metrics") or {}).get("document_metrics") or {}, + "metrics_calculated_at": (group.get("metrics") or {}).get("calculated_at"), + }, + "tokens": row["tokens"], + "activity": activity, + "activity_limit": 20, + "metrics_calculated_at": inventory["calculated_at"], + }), 200 + except GroupRequestError: + return jsonify({"error": "Invalid group ID."}), 400 + except CosmosResourceNotFoundError: + return jsonify({"error": "Group not found."}), 404 + except Exception as ex: + log_event("[CONTROL_CENTER] V2 group detail failed.", + extra={"group_id": group_id, "error_type": type(ex).__name__}, level=logging.ERROR) + return jsonify({"error": "Unable to retrieve group details."}), 500 + + @bp.route('/api/v2/control-center/groups/bulk-status', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_v2_control_center_groups_bulk_status(): + """Apply the same guarded status writer to a validated, capped population.""" + try: + data = request.get_json(silent=True) + status, reason = validate_group_status_payload(data) + ids = select_group_bulk_ids(data, lambda: _control_center_group_inventory(force_refresh=True)) + except GroupRequestError: + return jsonify({"error": "Invalid bulk group status request."}), 400 + except Exception as ex: + log_event("[CONTROL_CENTER] V2 group selection failed.", + extra={"error_type": type(ex).__name__}, level=logging.ERROR) + return jsonify({"error": "Unable to select groups."}), 500 + failed = [] + for group_id in ids: + response, code = _update_group_status(group_id, {"status": status, "reason": reason}) + if code != 200: + failed.append({"id": group_id, "status": code, "error": response.get_json()["error"]}) + return jsonify({ + "success_count": len(ids) - len(failed), "failed_count": len(failed), "failed_groups": failed, + }), 200 + + @bp.route('/api/v2/control-center/groups//status', methods=['PUT']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_v2_control_center_group_status(group_id): + try: + validate_group_id(group_id) + status, reason = validate_group_status_payload(request.get_json(silent=True)) + except GroupRequestError: + return jsonify({"error": "Invalid group status request."}), 400 + return _update_group_status(group_id, {"status": status, "reason": reason}) + + @bp.route('/api/v2/control-center/groups/export.csv', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_v2_control_center_groups_export(): + try: + filters = parse_group_filters(request.args) + rows = filter_group_inventory(_control_center_group_inventory()["rows"], filters) + buffer = StringIO() + writer = csv.writer(buffer) + writer.writerow(["id", "name", "owner", "status", "members", "documents", "tokens", "last_activity"]) + for row in rows: + writer.writerow(_control_center_csv_safe_cell(value) for value in ( + row["id"], row["name"], row["owner"]["email"], row["status"], + row["members"], row["documents"], row["tokens"], row["last_activity"], + )) + return Response(buffer.getvalue(), mimetype="text/csv", + headers={"Content-Disposition": 'attachment; filename="control-center-groups.csv"'}), 200 + except GroupRequestError: + return jsonify({"error": "Invalid group export filters."}), 400 + except Exception as ex: + log_event("[CONTROL_CENTER] V2 group export failed.", + extra={"error_type": type(ex).__name__}, level=logging.ERROR) + return jsonify({"error": "Unable to export groups."}), 500 + @bp.route('/api/admin/control-center/groups', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required @@ -3546,12 +4366,14 @@ def api_get_all_groups(): @login_required @control_center_required('admin') def api_update_group_status(group_id): + return _update_group_status(group_id, request.get_json(silent=True)) + + def _update_group_status(group_id, data): """ Update group status (active, locked, upload_disabled, inactive) Tracks who made the change and when, logs to activity_logs """ try: - data = request.get_json() if not data: return jsonify({'error': 'No data provided'}), 400 @@ -3620,6 +4442,7 @@ def apply_status(fresh): return jsonify({'error': GROUP_WRITE_CONFLICT_MESSAGE, 'error_code': GROUP_WRITE_CONFLICT_CODE}), 409 if group is None: return jsonify({'error': 'Group not found'}), 404 + _control_center_group_snapshot_cache.clear() old_status = transition['old_status'] # Log to activity_logs container for audit trail @@ -3743,7 +4566,7 @@ def api_delete_group_admin(group_id): except Exception as e: debug_print(f"Error creating group deletion request: {e}") - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'Unable to request group deletion.'}), 500 @bp.route('/api/admin/control-center/groups//delete-documents', methods=['POST']) @swagger_route(security=get_auth_security()) @@ -3806,7 +4629,7 @@ def api_delete_group_documents_admin(group_id): except Exception as e: debug_print(f"Error creating document deletion request: {e}") - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'Unable to request document deletion.'}), 500 @bp.route('/api/admin/control-center/groups//members', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -3908,7 +4731,7 @@ def api_admin_take_group_ownership(group_id): except Exception as e: debug_print(f"Error creating take ownership request: {e}") - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'Unable to request group ownership.'}), 500 @bp.route('/api/admin/control-center/groups//transfer-ownership', methods=['POST']) @swagger_route(security=get_auth_security()) @@ -3991,7 +4814,7 @@ def api_admin_transfer_group_ownership(group_id): except Exception as e: debug_print(f"Error creating transfer ownership request: {e}") - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'Unable to request ownership transfer.'}), 500 @bp.route('/api/admin/control-center/groups//add-member', methods=['POST']) @swagger_route(security=get_auth_security()) @@ -4069,6 +4892,7 @@ def apply_member(fresh): return jsonify({'error': 'Group not found'}), 404 # Determine the action source (single add vs bulk CSV) + _control_center_group_snapshot_cache.clear() source = data.get('source', 'csv') # Default to 'csv' for backward compatibility action_type = 'add_member_directly' if source == 'single' else 'admin_add_member_csv' @@ -4332,6 +5156,148 @@ def api_admin_get_group_activity(group_id): traceback.print_exc() return jsonify({'error': f'Failed to fetch group activity: {str(e)}'}), 500 + @bp.route('/api/v2/control-center/public-workspaces', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_v2_control_center_public_workspaces(): + try: + filters = parse_workspace_filters(request.args) + return jsonify(query_workspaces(cosmos_public_workspaces_container, filters)), 200 + except GroupRequestError: + return jsonify({"error": "Invalid public workspace filters."}), 400 + except Exception as ex: + log_event("[CONTROL_CENTER] Workspace query failed.", + extra={"error_type": type(ex).__name__}, level=logging.ERROR) + return jsonify({"error": "Unable to retrieve public workspaces."}), 500 + + @bp.route('/api/v2/control-center/public-workspaces/', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_v2_control_center_public_workspace_detail(workspace_id): + try: + validate_group_id(workspace_id) + workspace = cosmos_public_workspaces_container.read_item(item=workspace_id, partition_key=workspace_id) + parameters = [{"name": "@id", "value": workspace_id}] + documents = list(cosmos_public_documents_container.query_items( + query="SELECT VALUE COUNT(1) FROM c WHERE c.type = 'document_metadata' AND c.public_workspace_id = @id", + parameters=parameters, enable_cross_partition_query=True, + ))[0] + tokens = list(cosmos_activity_logs_container.query_items( + query=("SELECT VALUE SUM(c.usage.total_tokens) FROM c WHERE c.activity_type = 'token_usage' " + "AND c.workspace_context.public_workspace_id = @id AND IS_NUMBER(c.usage.total_tokens)"), + parameters=parameters, enable_cross_partition_query=True, + )) + activity = list(cosmos_activity_logs_container.query_items( + query=("SELECT TOP 20 c.id, c.activity_type, c.timestamp, c.description, c.user_id, " + "c.admin_user_id, c.admin_email, c.workspace_context, c.public_workspace, c.changed_by, c.status_change, " + "c.document, c.usage, c.token_type, c.member_email, c.member_name, c.member_role " + "FROM c WHERE c.workspace_context.public_workspace_id = @id OR c.workspace_id = @id " + "OR c.public_workspace.workspace_id = @id ORDER BY c.timestamp DESC"), + parameters=parameters, enable_cross_partition_query=True, + )) + user = session.get("user") or {} + # Normalize legacy bare-string owner for the existing role helper. + role_workspace = dict(workspace) + if isinstance(role_workspace.get("owner"), str): + role_workspace["owner"] = {"userId": role_workspace["owner"]} + role = get_user_role_in_public_workspace(role_workspace, user.get("oid") or user.get("sub")) + settings = get_settings() + can_edit = role in ("Owner", "Admin") and bool(settings.get("enable_public_workspaces")) + retention = workspace.get("retention_policy") or {} + row = workspace_row(workspace) + return jsonify({ + "workspace": row, "members": workspace_members(workspace), + "status_history": workspace.get("statusHistory") or [], + "permissions": {"can_edit_members": can_edit, "current_user_id": user.get("oid") or user.get("sub")}, + "retention": { + "conversation_retention_days": retention.get("conversation_retention_days", "default"), + "document_retention_days": retention.get("document_retention_days", "default"), + "enabled": bool(settings.get("enable_retention_policy_public")), + "can_edit": can_edit and bool(settings.get("enable_retention_policy_public")), + }, + "documents_summary": {"count": documents, + "cached_metrics": (workspace.get("metrics") or {}).get("document_metrics") or {}, + "metrics_calculated_at": row["metrics_calculated_at"]}, + "tokens": tokens[0] if tokens and tokens[0] is not None else 0, + "activity": activity, "metrics_calculated_at": datetime.now(timezone.utc).isoformat(), + }), 200 + except GroupRequestError: + return jsonify({"error": "Invalid public workspace ID."}), 400 + except CosmosResourceNotFoundError: + return jsonify({"error": "Public workspace not found."}), 404 + except Exception as ex: + log_event("[CONTROL_CENTER] Workspace detail failed.", + extra={"workspace_id": workspace_id, "error_type": type(ex).__name__}, level=logging.ERROR) + return jsonify({"error": "Unable to retrieve workspace details."}), 500 + + @bp.route('/api/v2/control-center/public-workspaces/bulk-status', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_v2_control_center_public_workspaces_bulk_status(): + try: + data = request.get_json(silent=True) + validate_group_status_payload(data) + ids = select_workspace_ids(cosmos_public_workspaces_container, data) + except GroupRequestError: + return jsonify({"error": "Invalid bulk public workspace status request."}), 400 + except Exception as ex: + log_event("[CONTROL_CENTER] Workspace selection failed.", + extra={"error_type": type(ex).__name__}, level=logging.ERROR) + return jsonify({"error": "Unable to select public workspaces."}), 500 + failed = [] + for workspace_id in ids: + response, code = api_update_public_workspace_status(workspace_id) + if code != 200: + failed.append({"id": workspace_id, "status": code, "error": response.get_json()["error"]}) + return jsonify({"success_count": len(ids) - len(failed), "failed_count": len(failed), + "failed_workspaces": failed}), 200 + + @bp.route('/api/v2/control-center/public-workspaces//status', methods=['PUT']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_v2_control_center_public_workspace_status(workspace_id): + try: + validate_group_id(workspace_id) + validate_group_status_payload(request.get_json(silent=True)) + except GroupRequestError: + return jsonify({"error": "Invalid public workspace status request."}), 400 + return api_update_public_workspace_status(workspace_id) + + @bp.route('/api/v2/control-center/public-workspaces/export.csv', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @control_center_required('admin') + def api_v2_control_center_public_workspaces_export(): + try: + filters = parse_workspace_filters(request.args) + filters.update(page=1, per_page=250) + payload = query_workspaces(cosmos_public_workspaces_container, filters) + if payload["pagination"]["total_items"] > WORKSPACE_EXPORT_LIMIT: + return jsonify({"error": "Export is limited to 10000 workspaces. Narrow the filters."}), 400 + rows = payload["workspaces"] + for page in range(2, payload["pagination"]["total_pages"] + 1): + rows.extend(query_workspaces(cosmos_public_workspaces_container, {**filters, "page": page})["workspaces"]) + buffer = StringIO() + writer = csv.writer(buffer) + writer.writerow(["id", "name", "owner", "status", "members", "documents", "tokens", "last_activity", "metrics_calculated_at"]) + for row in rows: + writer.writerow(_control_center_csv_safe_cell(value) for value in ( + row["id"], row["name"], row["owner"]["email"], row["status"], row["members"], + row["documents"], row["tokens"], row["last_activity"], row["metrics_calculated_at"], + )) + return Response(buffer.getvalue(), mimetype="text/csv", + headers={"Content-Disposition": 'attachment; filename="control-center-public-workspaces.csv"'}), 200 + except GroupRequestError: + return jsonify({"error": "Invalid public workspace export filters."}), 400 + except Exception as ex: + log_event("[CONTROL_CENTER] Workspace export failed.", + extra={"error_type": type(ex).__name__}, level=logging.ERROR) + return jsonify({"error": "Unable to export public workspaces."}), 500 + # Public Workspaces API @bp.route('/api/admin/control-center/public-workspaces', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -5381,7 +6347,7 @@ def api_admin_take_workspace_ownership(workspace_id): debug_print(f"Error creating take workspace ownership request: {e}") import traceback traceback.print_exc() - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'Unable to request workspace ownership.'}), 500 @bp.route('/api/admin/control-center/public-workspaces//ownership', methods=['PUT']) @swagger_route(security=get_auth_security()) @@ -5569,7 +6535,7 @@ def api_delete_public_workspace_documents_admin(workspace_id): except Exception as e: debug_print(f"Error creating document deletion request: {e}") - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'Unable to request workspace document deletion.'}), 500 @bp.route('/api/admin/control-center/public-workspaces/', methods=['DELETE']) @@ -5637,7 +6603,7 @@ def api_delete_public_workspace_admin(workspace_id): except Exception as e: debug_print(f"Error creating workspace deletion request: {e}") - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'Unable to request workspace deletion.'}), 500 @bp.route('/api/v2/control-center/dashboard/summary', methods=['GET']) @swagger_route(security=get_auth_security()) diff --git a/application/v2_ui/src/components/controlCenter/ControlCenterPrimitives.tsx b/application/v2_ui/src/components/controlCenter/ControlCenterPrimitives.tsx index c0bb2ae0c..a39625503 100644 --- a/application/v2_ui/src/components/controlCenter/ControlCenterPrimitives.tsx +++ b/application/v2_ui/src/components/controlCenter/ControlCenterPrimitives.tsx @@ -4,7 +4,9 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { clsx } from 'clsx'; import { Download, Search } from 'lucide-react'; +import { Link } from 'react-router-dom'; import { ConfirmDialog } from '../ui/ConfirmDialog'; +import { Modal } from '../ui/Modal'; import { GlassButton, GlassPanel } from '../ui/primitives'; export interface DataColumn { @@ -22,6 +24,7 @@ export function useCrossPageSelection() { }>(() => ({ allMatchingSelected: false, includedIds: new Set(), excludedIds: new Set() })); return { selectedIds: selection.includedIds, + excludedIds: selection.excludedIds, allMatchingSelected: selection.allMatchingSelected, isSelected: (id: string) => selection.allMatchingSelected ? !selection.excludedIds.has(id) @@ -225,25 +228,22 @@ export function useUrlQueryParams() { export function DetailDrawer({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) { return ( -
{ - if (event.target === event.currentTarget) onClose(); - }}> - -
+ }> + {children} + ); } export function StatusBadge({ status }: { status: string }) { - const tone = /active|approved|complete|enabled/i.test(status) + const tone = /^(active|approved|complete|enabled)$/i.test(status) ? 'bg-ok-soft text-ok' : /pending|review|processing/i.test(status) - ? 'bg-warn-soft text-warn' : /blocked|denied|failed|disabled/i.test(status) + ? 'bg-warn-soft text-warn' : /blocked|denied|failed|disabled|locked|inactive/i.test(status) ? 'bg-danger-soft text-danger' : 'bg-surface-2 text-text-2'; return {status}; } @@ -274,16 +274,45 @@ export function ReasonConfirmDialog({ export const APPROVALS_URL = '/approvals'; -export function ApprovalSubmittedNotice({ children }: { children: ReactNode }) { +export function ApprovalSubmittedNotice({ children, approvalId, groupId }: { + children: ReactNode; approvalId?: string; groupId?: string; +}) { + const path = approvalId ? `${APPROVALS_URL}/all/${encodeURIComponent(approvalId)}` : APPROVALS_URL; + const query = groupId ? `?${new URLSearchParams({ group_id: groupId })}` : ''; return -

{children}

View approval requests +

{children}

View approval requests
; } -export function ExportButton({ filename, rows }: { filename: string; rows: Record[] }) { +export function ExportButton({ + filename, + rows, + serverQuery, + entity = 'users', +}: { + filename: string; + rows?: Record[]; + serverQuery?: string; + entity?: 'users' | 'groups' | 'public-workspaces'; +}) { + if (serverQuery !== undefined) { + return ( + + + ); + } + const exportCsv = () => { + if (!rows) return; const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]; - const escape = (value: unknown) => `"${String(value ?? '').replaceAll('"', '""')}"`; + const escape = (value: unknown) => { + const text = typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value ?? ''); + const safe = /^[\s]*[=+\-@]/.test(text) ? `'${text}` : text; + return `"${safe.replaceAll('"', '""')}"`; + }; const csv = [headers.map(escape).join(','), ...rows.map((row) => headers.map((key) => escape(row[key])).join(','))].join('\r\n'); const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); const anchor = document.createElement('a'); diff --git a/application/v2_ui/src/components/controlCenter/EntityDetailSections.tsx b/application/v2_ui/src/components/controlCenter/EntityDetailSections.tsx new file mode 100644 index 000000000..1886f2194 --- /dev/null +++ b/application/v2_ui/src/components/controlCenter/EntityDetailSections.tsx @@ -0,0 +1,73 @@ +// EntityDetailSections.tsx +// Shared detail navigation and activity presentation for administrative workspace drawers. + +import { useState, type ReactNode } from 'react'; +import { ExportButton } from './ControlCenterPrimitives'; + +export function entityDate(value: string | null | undefined) { + if (!value) return 'Not recorded'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +export function EntityDetailTabs({ + tabs, selected, onSelect, children, +}: { + tabs: readonly T[]; + selected: T; + onSelect: (tab: T) => void; + children: ReactNode; +}) { + return <> +
+ {tabs.map((tab, index) => )} +
+
+ {children} +
+ ; +} + +export interface EntityActivityItem extends Record { + id: string; + timestamp?: string; + activity_type: string; + description?: string; +} + +export function EntityActivity({ items, entityLabel = 'group' }: { items: EntityActivityItem[]; entityLabel?: string }) { + const [expanded, setExpanded] = useState(null); + return
+
+

Most recent {items.length} records. Export includes these records only.

+ +
+ {!items.length ?

No {entityLabel} activity recorded.

: null} +
    + {items.map((item) =>
  1. +

    {item.activity_type.replaceAll('_', ' ')}

    +

    {entityDate(item.timestamp)}

    + {item.description ?

    {item.description}

    : null} + + {expanded === item.id ?
    {JSON.stringify(item, null, 2)}
    : null} +
  2. )} +
+
; +} diff --git a/application/v2_ui/src/components/controlCenter/GroupDetailDrawer.tsx b/application/v2_ui/src/components/controlCenter/GroupDetailDrawer.tsx new file mode 100644 index 000000000..aa1c1919a --- /dev/null +++ b/application/v2_ui/src/components/controlCenter/GroupDetailDrawer.tsx @@ -0,0 +1,349 @@ +// GroupDetailDrawer.tsx +// Group administration reuses classic governed actions and native membership permissions. + +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api, ApiError } from '../../lib/apiClient'; +import { ASSIGNABLE_MEMBER_ROLES, ASSIGNABLE_ROLE_OPTIONS, type AssignableMemberRole, type DirectoryUser, type MemberCsvRow } from '../../lib/groupMembership'; +import { toast } from '../../stores/toastStore'; +import { GlassButton } from '../ui/primitives'; +import { AddMemberDialog } from '../membership/AddMemberDialog'; +import { ImportMembersDialog, type ImportRowOutcome } from '../membership/ImportMembersDialog'; +import { ApprovalSubmittedNotice, DetailDrawer, KpiCard, ReasonConfirmDialog, StatusBadge } from './ControlCenterPrimitives'; +import { EntityActivity, EntityDetailTabs, entityDate, type EntityActivityItem } from './EntityDetailSections'; + +export interface GroupRow { + id: string; + name: string; + description: string; + owner: { id: string | null; email: string; display_name: string }; + status: string; + members: number; + documents: number | null; + tokens: number | null; + created_at: string | null; + last_activity: string | null; + metrics_calculated_at: string | null; +} + +interface GroupMember { + id: string; + display_name: string; + email: string; + role: 'Owner' | AssignableMemberRole; +} + +interface GroupDetail { + group: GroupRow; + members: GroupMember[]; + status_history: { old_status: string; new_status: string; changed_by_email: string; changed_at: string; reason: string | null }[]; + retention: { + enabled: boolean; + can_edit: boolean; + conversation_retention_days: string | number; + document_retention_days: string | number; + }; + permissions: { can_edit_members: boolean; current_role: string | null; current_user_id?: string }; + documents_summary: { + count: number; + cached_metrics: Record; + metrics_calculated_at: string | null; + }; + tokens: number; + activity: EntityActivityItem[]; + metrics_calculated_at: string; +} + +type WorkspaceDetail = Omit & { workspace: GroupRow }; + +const TABS = ['overview', 'members', 'ownership', 'status', 'retention', 'activity', 'documents'] as const; +const STATUSES = ['active', 'locked', 'upload_disabled', 'inactive'] as const; +export const GROUP_INPUT = 'rounded-lg border border-edge bg-surface-1 px-2 py-2 text-sm text-text-1 focus-visible:outline-2 focus-visible:outline-accent disabled:opacity-60'; +type Action = { kind: 'status'; status: string } | { kind: 'remove'; member: GroupMember } + | { kind: 'delete-group' | 'delete-documents' | 'take-ownership' } + | { kind: 'transfer-ownership'; memberId: string }; + +function roleForAdminApi(role: AssignableMemberRole) { + return role === 'DocumentManager' ? 'document_manager' : role.toLowerCase(); +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : 'The request could not be completed.'; +} + +export function GroupDetailDrawer({ id, onClose, onChanged, entity = 'group' }: { + id: string; onClose: () => void; onChanged: () => void; entity?: 'group' | 'public'; +}) { + const isPublic = entity === 'public'; + const label = isPublic ? 'Public workspace' : 'Group'; + const noun = isPublic ? 'workspace' : 'group'; + const collection = isPublic ? 'public-workspaces' : 'groups'; + const memberRoles: readonly AssignableMemberRole[] = isPublic ? ASSIGNABLE_MEMBER_ROLES.filter((role) => role !== 'User') : ASSIGNABLE_MEMBER_ROLES; + const roleOptions = ASSIGNABLE_ROLE_OPTIONS.filter((role) => memberRoles.includes(role.value)); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [tab, setTab] = useState('overview'); + const [revision, setRevision] = useState(0); + const [busy, setBusy] = useState(false); + const [action, setAction] = useState(null); + const [approval, setApproval] = useState(null); + const [newOwner, setNewOwner] = useState(''); + const [status, setStatus] = useState('active'); + const [conversationDays, setConversationDays] = useState('default'); + const [documentDays, setDocumentDays] = useState('default'); + const [addOpen, setAddOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [addError, setAddError] = useState(''); + const adminBase = `/api/admin/control-center/${collection}/${encodeURIComponent(id)}`; + const nativeBase = `/api/${isPublic ? 'public_workspaces' : 'groups'}/${encodeURIComponent(id)}/members`; + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(''); + api.get(`/api/v2/control-center/${collection}/${encodeURIComponent(id)}`, controller.signal) + .then((data) => { + if (controller.signal.aborted) return; + const normalized = 'workspace' in data ? { ...data, group: data.workspace } : data; + setDetail(normalized); + setStatus(normalized.group.status); + setConversationDays(String(normalized.retention.conversation_retention_days)); + setDocumentDays(String(normalized.retention.document_retention_days)); + }) + .catch((cause: unknown) => { + if (!controller.signal.aborted) { + setDetail(null); + setError(errorMessage(cause)); + } + }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [id, revision, collection, isPublic]); + + const changed = () => { onChanged(); setRevision((value) => value + 1); }; + const confirmAction = async (reason: string) => { + if (!action || busy) return; + const target = action; + setAction(null); + setBusy(true); + setError(''); + try { + if (target.kind === 'status') { + await api.put(`/api/v2/control-center/${collection}/${encodeURIComponent(id)}/status`, { status: target.status, reason }); + toast.success(`${label} status saved.`); + changed(); + } else if (target.kind === 'remove') { + await api.delete(`${nativeBase}/${encodeURIComponent(target.member.id)}`); + toast.success('Member removed.'); + changed(); + } else { + const path = target.kind === 'delete-group' ? adminBase : `${adminBase}/${isPublic && target.kind === 'delete-documents' ? 'documents' : isPublic && target.kind === 'transfer-ownership' ? 'ownership' : target.kind}`; + const payload = target.kind === 'transfer-ownership' ? { reason, newOwnerId: target.memberId } : { reason }; + const result = target.kind === 'delete-group' || isPublic && target.kind === 'delete-documents' + ? await api.delete<{ approval_id?: string; success?: boolean; message?: string }>(path, payload) + : isPublic && target.kind === 'transfer-ownership' + ? await api.put<{ approval_id?: string }>(path, payload) + : await api.post<{ approval_id?: string }>(path, payload); + if (!result.approval_id) throw new Error('The server did not return an approval request. Refresh before trying again.'); + setApproval(result.approval_id); + } + } catch (cause) { + setError(errorMessage(cause)); + } finally { + setBusy(false); + } + }; + + const addMember = async (user: DirectoryUser, role: AssignableMemberRole, source = 'single') => { + return api.post<{ skipped: boolean }>(`${adminBase}/add-member`, { + userId: user.id, displayName: user.displayName, email: user.email, + role: roleForAdminApi(role), source, + }); + }; + const addSingle = async (user: DirectoryUser, role: AssignableMemberRole) => { + setBusy(true); + setAddError(''); + try { + const result = await addMember(user, role); + toast.success(result.skipped ? 'This person is already a member.' : 'Member added.'); + setAddOpen(false); + changed(); + } catch (cause) { + setAddError(errorMessage(cause)); + } finally { setBusy(false); } + }; + const addCsvRow = async (row: MemberCsvRow): Promise => { + try { + const result = await addMember({ id: row.userId, displayName: row.displayName, email: row.email }, row.role, 'csv'); + return result.skipped ? { status: 'already_member', message: 'Already a member' } : { status: 'added', name: row.displayName }; + } catch (cause) { + return { status: 'failed', message: errorMessage(cause), + stop: cause instanceof ApiError && [401, 403, 404].includes(cause.status) }; + } + }; + const changeRole = async (memberId: string, role: string) => { + setBusy(true); + setError(''); + try { + await api.patch(`${nativeBase}/${encodeURIComponent(memberId)}`, { role }); + toast.success('Member role saved.'); + changed(); + } catch (cause) { setError(errorMessage(cause)); } + finally { setBusy(false); } + }; + const saveRetention = async () => { + setBusy(true); + setError(''); + try { + const payload = { + conversation_retention_days: conversationDays, document_retention_days: documentDays, + }; + // The existing public save path does not accept "default". Leave inherited fields unchanged. + await api.post(`/api/retention-policy/${entity}/${encodeURIComponent(id)}`, isPublic + ? Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== 'default')) + : payload); + toast.success('Retention policy saved.'); + changed(); + } catch (cause) { setError(errorMessage(cause)); } + finally { setBusy(false); } + }; + const ownerLink = detail?.group.owner.id + ? + {detail.group.owner.display_name || detail.group.owner.email || detail.group.owner.id} + : 'No owner recorded'; + + return { if (!busy) onClose(); }}> +
+ {loading ?

Loading {noun} details...

: null} + {error ?

{error}

: null} + {approval ? Request {approval} submitted for approval. No {noun} or documents have been deleted, and ownership has not changed. : null} + {detail && !loading ? <> +
+ + {label} ID: {id} +
+ + View in Activity Logs + + + {tab === 'overview' ? <> +

{detail.group.description || 'No description recorded.'}

+
+
Owner
{ownerLink}
+
Created
{entityDate(detail.group.created_at)}
+
Last activity
{entityDate(detail.group.last_activity)}
+
+
+ + + +
+

Totals calculated: {entityDate(detail.metrics_calculated_at)}

+ setAction({ kind: 'delete-group' })}>Request {noun} deletion + : null} + {tab === 'members' ? <> +
+ { setAddError(''); setAddOpen(true); }}>Add member + setImportOpen(true)}>Import CSV +
+ {isPublic ?

Public readers are implicit, not stored members. Only Admin and Document Manager roles can be added or imported.

: null} + {!detail.permissions.can_edit_members ?

Removing members and changing roles require {noun} Owner or Admin membership. Use Ownership to request a change.

: null} +
    + {detail.members.map((member) =>
  • + + {member.display_name || member.email || member.id} + +

    {member.email}

    +
    + + {member.role !== 'Owner' && detail.permissions.can_edit_members && (!isPublic || member.id !== detail.permissions.current_user_id) ? <> + + setAction({ kind: 'remove', member })}>Remove member + : null} +
    +
  • )} +
+ : null} + {tab === 'ownership' ? <> +

Current owner: {ownerLink}

+

Ownership changes require approval from the owner or another administrator.

+ setAction({ kind: 'take-ownership' })}>Request to take ownership + + setAction({ kind: 'transfer-ownership', memberId: newOwner })}>Request ownership transfer + : null} + {tab === 'status' ? <> +

Locked makes documents read-only while viewing and chat remain available; upload disabled blocks uploads; inactive makes the {noun} unavailable. Locking and inactivation require a reason.

+ + setAction({ kind: 'status', status })}>Change status +

Status history

+ {!detail.status_history.length ?

No status changes recorded.

: null} +
    {[...detail.status_history].reverse().map((entry, index) =>
  1. +

    {entry.old_status} to {entry.new_status}

    +

    {entityDate(entry.changed_at)} by {entry.changed_by_email}

    + {entry.reason ?

    {entry.reason}

    : null} +
  2. )}
+ : null} + {tab === 'retention' ? <> +

Retention automatically deletes aged content. Enter none to keep content or a whole number of days.{isPublic ? ' Inherited (default) fields can be left unchanged; the existing public API cannot reset a custom value to default.' : ' Enter default to inherit organization policy.'}

+ {!detail.retention.enabled ?

{label} retention is not enabled.

: null} + {!detail.retention.can_edit ?

Editing requires {noun} Owner or Admin membership and enabled retention.

: null} + + + /^(default|none|[1-9]\d*)$/.test(value)) + || isPublic && ((conversationDays === 'default' && String(detail.retention.conversation_retention_days) !== 'default') + || (documentDays === 'default' && String(detail.retention.document_retention_days) !== 'default') + || (conversationDays === 'default' && documentDays === 'default'))} + onClick={() => void saveRetention()}>Save retention + : null} + {tab === 'activity' ? : null} + {tab === 'documents' ? <> +

{detail.documents_summary.count.toLocaleString()} document metadata records in this {noun}.

+
+ {Object.entries(detail.documents_summary.cached_metrics).map(([key, value]) =>
+
{key.replaceAll('_', ' ')}
{Number(value).toLocaleString()}
+
)} +
+

Storage estimates refreshed: {entityDate(detail.documents_summary.metrics_calculated_at)}. These estimates can be older than the document count.

+ setAction({ kind: 'delete-documents' })}>Request deletion of all documents + : null} +
+ : null} +
+ {action ? setAction(null)} onConfirm={(reason) => void confirmAction(reason)} /> : null} + {addOpen ? setAddOpen(false)} + roleOptions={roleOptions} defaultRole={isPublic ? 'DocumentManager' : 'User'} + onSubmit={(user, role) => void addSingle(user, role)} /> : null} + {importOpen ? setImportOpen(false)} /> : null} +
; +} diff --git a/application/v2_ui/src/components/controlCenter/GroupsSection.tsx b/application/v2_ui/src/components/controlCenter/GroupsSection.tsx new file mode 100644 index 000000000..fe6dd06d5 --- /dev/null +++ b/application/v2_ui/src/components/controlCenter/GroupsSection.tsx @@ -0,0 +1,187 @@ +// GroupsSection.tsx +// Server-filtered group inventory with URL state, cross-page selection and audited bulk updates. + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../lib/apiClient'; +import { toast } from '../../stores/toastStore'; +import { GlassButton } from '../ui/primitives'; +import { DataTable, ExportButton, FilterBar, ReasonConfirmDialog, StatusBadge, useCrossPageSelection, useUrlQueryParams, type DataColumn } from './ControlCenterPrimitives'; +import { GROUP_INPUT, GroupDetailDrawer, type GroupRow } from './GroupDetailDrawer'; +import { entityDate } from './EntityDetailSections'; + +interface GroupsResponse { + groups: GroupRow[]; + pagination: { total_items: number; page: number }; + metrics_freshness: { calculated_at: string; ttl_seconds: number }; +} + +const FILTER_KEYS = ['search', 'status', 'owner', 'members_min', 'members_max', 'has_documents', 'created_from', 'created_to', 'activity_from', 'activity_to']; +const STATUSES = ['active', 'locked', 'upload_disabled', 'inactive']; + +function groupQuery(params: URLSearchParams) { + const query = new URLSearchParams(params); + query.delete('id'); + return query; +} + +export function GroupsSection() { + const { params, setParam } = useUrlQueryParams(); + const selection = useCrossPageSelection(); + const [response, setResponse] = useState(null); + const [error, setError] = useState(''); + const [actionError, setActionError] = useState(''); + const [loading, setLoading] = useState(true); + const [revision, setRevision] = useState(0); + const refreshedRevision = useRef(0); + const [bulkStatus, setBulkStatus] = useState('locked'); + const [confirmBulk, setConfirmBulk] = useState(false); + const [busy, setBusy] = useState(false); + const requestQuery = useMemo(() => { + const query = groupQuery(params); + query.set('per_page', '25'); + return query.toString(); + }, [params]); + const filterKey = FILTER_KEYS.map((key) => params.get(key)).join('|'); + + useEffect(() => { + selection.clear(); + // Paging and sorting preserve the selected population; filters do not. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterKey]); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(''); + const query = new URLSearchParams(requestQuery); + if (revision > refreshedRevision.current) query.set('force_refresh', '1'); + api.get(`/api/v2/control-center/groups?${query}`, controller.signal) + .then((data) => { + if (!controller.signal.aborted) { + refreshedRevision.current = revision; + setResponse(data); + } + }) + .catch((cause: unknown) => { + if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : 'Unable to load groups.'); + }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [requestQuery, revision]); + + const updateFilter = (key: string, value: string) => { + setParam('page', null); + setParam(key, value === 'all' || !value ? null : value); + }; + const columns: DataColumn[] = [ + { id: 'name', label: 'Group', sortable: true, render: (row) => + }, + { id: 'status', label: 'Status', render: (row) => }, + { id: 'owner', label: 'Owner', sortable: true, render: (row) => row.owner.id + ? {row.owner.display_name || row.owner.email || row.owner.id} + : 'No owner recorded' }, + { id: 'members', label: 'Members', sortable: true, render: (row) => row.members.toLocaleString() }, + { id: 'documents', label: 'Documents', sortable: true, render: (row) => row.documents?.toLocaleString() ?? 'Not recorded' }, + { id: 'tokens', label: 'Tokens', sortable: true, render: (row) => row.tokens?.toLocaleString() ?? 'Not recorded' }, + { id: 'last_activity', label: 'Last activity', sortable: true, render: (row) => entityDate(row.last_activity) }, + ]; + const total = response?.pagination.total_items ?? 0; + const count = selection.allMatchingSelected ? Math.max(0, total - selection.excludedIds.size) : selection.selectedIds.size; + const applyBulk = async (reason: string) => { + if (busy) return; + setConfirmBulk(false); + setBusy(true); + setActionError(''); + const payload = selection.allMatchingSelected + ? { filter: Object.fromEntries(FILTER_KEYS.flatMap((key) => params.has(key) ? [[key, params.get(key)]] : [])), + exclude_ids: Array.from(selection.excludedIds) } + : { group_ids: Array.from(selection.selectedIds) }; + try { + const result = await api.post<{ success_count: number; failed_count: number; failed_groups: { id: string; error: string }[] }>( + '/api/v2/control-center/groups/bulk-status', { ...payload, status: bulkStatus, reason }, + ); + selection.clear(); + setRevision((value) => value + 1); + if (result.failed_count) { + setActionError(`${result.success_count} succeeded; ${result.failed_count} failed. ${result.failed_groups.map((group) => `${group.id}: ${group.error}`).join('; ')}`); + toast.error('Some groups could not be updated. Review the failure details.'); + } else { toast.success(`${result.success_count} groups updated.`); } + } catch (cause) { + toast.error(cause instanceof Error ? cause.message : 'Unable to update groups.'); + } finally { setBusy(false); } + }; + const exportQuery = groupQuery(params); + exportQuery.delete('page'); + exportQuery.delete('per_page'); + exportQuery.delete('force_refresh'); + + return
+
+
+

Groups

+

Review shared workspaces, membership, usage and governed actions.

+
+
+ setRevision((value) => value + 1)}>Refresh groups + +
+
+ updateFilter('search', value)}> + + + + +
+ Member and date filters +
+ {[ + ['members_min', 'Minimum members', 'number'], ['members_max', 'Maximum members', 'number'], + ['created_from', 'Created from', 'date'], ['created_to', 'Created through', 'date'], + ['activity_from', 'Activity from', 'date'], ['activity_to', 'Activity through', 'date'], + ].map(([key, label, type]) => )} +
+
+ {count ?
+ {selection.allMatchingSelected ? `All ${count} matching groups selected` : `${count} selected`} (limit 500) + + 500} onClick={() => setConfirmBulk(true)}>Apply bulk status + Clear selection +
: null} + {actionError ?

{actionError}

: null} + { setParam('sort', field); setParam('direction', direction); }} + onPageChange={(page) => setParam('page', String(page))} /> + {response ?

+ Totals calculated {entityDate(response.metrics_freshness.calculated_at)}. Server snapshot cached for {response.metrics_freshness.ttl_seconds} seconds. +

: null} + {params.get('id') ? setParam('id', null)} onChanged={() => setRevision((value) => value + 1)} /> : null} + {confirmBulk ? setConfirmBulk(false)} onConfirm={(reason) => void applyBulk(reason)} /> : null} +
; +} diff --git a/application/v2_ui/src/components/controlCenter/PublicWorkspacesSection.tsx b/application/v2_ui/src/components/controlCenter/PublicWorkspacesSection.tsx new file mode 100644 index 000000000..4a8d4efd3 --- /dev/null +++ b/application/v2_ui/src/components/controlCenter/PublicWorkspacesSection.tsx @@ -0,0 +1,152 @@ +// PublicWorkspacesSection.tsx +// Operate: query-backed inventory with explicit recorded-metric freshness and governed details. + +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../lib/apiClient'; +import { toast } from '../../stores/toastStore'; +import { GlassButton } from '../ui/primitives'; +import { DataTable, ExportButton, FilterBar, ReasonConfirmDialog, StatusBadge, useCrossPageSelection, useUrlQueryParams, type DataColumn } from './ControlCenterPrimitives'; +import { GROUP_INPUT, GroupDetailDrawer, type GroupRow } from './GroupDetailDrawer'; +import { entityDate } from './EntityDetailSections'; + +interface WorkspaceResponse { + workspaces: GroupRow[]; + pagination: { total_items: number; page: number }; +} + +const FILTER_KEYS = ['search', 'status', 'owner']; +const STATUSES = ['active', 'locked', 'upload_disabled', 'inactive']; + +export function PublicWorkspacesSection() { + const { params, setParam } = useUrlQueryParams(); + const selection = useCrossPageSelection(); + const [response, setResponse] = useState(null); + const [error, setError] = useState(''); + const [actionError, setActionError] = useState(''); + const [loading, setLoading] = useState(true); + const [revision, setRevision] = useState(0); + const [busy, setBusy] = useState(false); + const [bulkStatus, setBulkStatus] = useState('locked'); + const [confirm, setConfirm] = useState(false); + const query = useMemo(() => { + const value = new URLSearchParams(); + for (const key of [...FILTER_KEYS, 'sort', 'direction', 'page']) { + if (params.has(key)) value.set(key, params.get(key)!); + } + value.set('per_page', '25'); + return value.toString(); + }, [params]); + const filterKey = FILTER_KEYS.map((key) => params.get(key)).join('|'); + useEffect(() => { + selection.clear(); + // Selection survives page/sort changes, not filter changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterKey]); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(''); + api.get(`/api/v2/control-center/public-workspaces?${query}`, controller.signal) + .then((data) => { if (!controller.signal.aborted) setResponse(data); }) + .catch((cause: unknown) => { + if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : 'Unable to load public workspaces.'); + }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [query, revision]); + const updateFilter = (key: string, value: string) => { + setParam('page', null); + setParam(key, !value || value === 'all' ? null : value); + }; + const total = response?.pagination.total_items ?? 0; + const count = selection.allMatchingSelected ? Math.max(0, total - selection.excludedIds.size) : selection.selectedIds.size; + const columns: DataColumn[] = [ + { id: 'name', label: 'Public workspace', sortable: true, render: (row) => + }, + { id: 'status', label: 'Status', render: (row) => }, + { id: 'owner', label: 'Owner', sortable: true, render: (row) => row.owner.id + ? {row.owner.display_name || row.owner.email || row.owner.id} + : 'No owner recorded' }, + { id: 'members', label: 'Managers', render: (row) => row.members.toLocaleString() }, + { id: 'documents', label: 'Recorded documents', sortable: true, render: (row) => row.documents?.toLocaleString() ?? 'Not recorded' }, + { id: 'tokens', label: 'Recorded tokens', sortable: true, render: (row) => row.tokens?.toLocaleString() ?? 'Not recorded' }, + { id: 'last_activity', label: 'Recorded last activity', sortable: true, render: (row) => entityDate(row.last_activity) }, + { id: 'created_at', label: 'Created', sortable: true, render: (row) => entityDate(row.created_at) }, + { id: 'metrics_calculated_at', label: 'Metrics refreshed', render: (row) => entityDate(row.metrics_calculated_at) }, + ]; + const applyBulk = async (reason: string) => { + if (busy) return; + setConfirm(false); + setBusy(true); + setActionError(''); + const payload = selection.allMatchingSelected + ? { filter: Object.fromEntries(FILTER_KEYS.flatMap((key) => params.has(key) ? [[key, params.get(key)]] : [])), exclude_ids: Array.from(selection.excludedIds) } + : { workspace_ids: Array.from(selection.selectedIds) }; + try { + const result = await api.post<{ success_count: number; failed_count: number; failed_workspaces: { id: string; error: string }[] }>( + '/api/v2/control-center/public-workspaces/bulk-status', { ...payload, status: bulkStatus, reason }, + ); + selection.clear(); + setRevision((value) => value + 1); + if (result.failed_count) { + setActionError(`${result.success_count} succeeded; ${result.failed_count} failed. ${result.failed_workspaces.map((item) => `${item.id}: ${item.error}`).join('; ')}`); + toast.error('Some workspaces could not be updated. Review the failure details.'); + } else toast.success(`${result.success_count} public workspaces updated.`); + } catch (cause) { + setActionError(cause instanceof Error ? cause.message : 'Unable to update public workspaces.'); + } finally { setBusy(false); } + }; + const exportQuery = new URLSearchParams(query); + exportQuery.delete('page'); + exportQuery.delete('per_page'); + return
+
+
+

Public Workspaces

+

Review public knowledge spaces, their managers and recorded usage.

+
+
+ setRevision((value) => value + 1)}>Refresh workspaces + +
+
+ updateFilter('search', value)}> + + + +

List metrics are stored snapshots, not live counts. Unrecorded values are unavailable, not zero. Refresh reloads stored data; open details for live document and token totals. Managers include the owner, not implicit public readers. Export limit: 10,000 workspaces.

+ {count ?
+ {count} selected (limit 500) + + 500} onClick={() => setConfirm(true)}>Apply bulk status + Clear selection +
: null} + {actionError ?

{actionError}

: null} + { setParam('page', null); setParam('sort', field); setParam('direction', direction); }} + onPageChange={(page) => setParam('page', String(page))} /> + {params.get('id') ? setParam('id', null)} onChanged={() => setRevision((value) => value + 1)} /> : null} + {confirm ? setConfirm(false)} onConfirm={(reason) => void applyBulk(reason)} /> : null} +
; +} diff --git a/application/v2_ui/src/components/controlCenter/UsersSection.tsx b/application/v2_ui/src/components/controlCenter/UsersSection.tsx new file mode 100644 index 000000000..6a437bad6 --- /dev/null +++ b/application/v2_ui/src/components/controlCenter/UsersSection.tsx @@ -0,0 +1,647 @@ +// UsersSection.tsx +// Server-driven user administration for the V2 Control Center. + +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { ExternalLink, Search, Shield, Users } from 'lucide-react'; +import { api } from '../../lib/apiClient'; +import { + ApprovalSubmittedNotice, + DataTable, + DetailDrawer, + ExportButton, + FilterBar, + KpiCard, + ReasonConfirmDialog, + StatusBadge, + useCrossPageSelection, + useUrlQueryParams, + type DataColumn, +} from './ControlCenterPrimitives'; +import { EmptyState, GlassButton, GlassPanel } from '../ui/primitives'; +import { toast } from '../../stores/toastStore'; + +interface Restriction { + status: 'allow' | 'deny'; + expires_at: string | null; +} + +interface UserRow { + id: string; + email: string; + display_name: string; + access: Restriction; + file_uploads: Restriction; + last_login: string | null; + total_logins: number | null; + conversations: number | null; + documents: number | null; + tokens: number | null; + metrics_calculated_at: string | null; +} + +interface UserProfile { + id: string; + email: string; + display_name: string; + access: Restriction; + file_uploads: Restriction; +} + +interface UsersResponse { + users: UserRow[]; + pagination: { + page: number; + per_page: number; + total_items: number; + total_pages: number; + }; + metrics_freshness: { + oldest_calculated_at: string | null; + newest_calculated_at: string | null; + missing_count: number; + source: string; + }; +} + +interface ActivityItem { + id: string; + activity_type: string; + timestamp: string | null; + resource_name?: string | null; + workspace_type?: string | null; + token_type?: string | null; + status?: string | null; + usage?: { total_tokens?: number } | null; +} + +interface Membership { + id: string; + name: string; + role: string; + owned: boolean; +} + +interface UserDetail { + user: UserProfile; + usage: { + last_login: string | null; + total_logins: number | null; + conversations: number | null; + documents: number | null; + tokens: number | null; + metrics_calculated_at: string | null; + }; + activity: ActivityItem[]; + memberships: { + groups: Membership[]; + public_workspaces: Membership[]; + }; +} + +type DetailTab = 'overview' | 'activity' | 'memberships'; + +const PAGE_SIZE = '25'; + +function userFilterQuery(params: URLSearchParams, includePaging = true) { + const query = new URLSearchParams(params); + query.delete('user_id'); + if (!includePaging) { + query.delete('page'); + query.delete('per_page'); + query.delete('sort'); + query.delete('direction'); + } + return query.toString(); +} + +function displayDate(value: string | null | undefined) { + if (!value || value === 'Never') return 'Never'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +function displayMetric(value: number | null | undefined) { + return value === null || value === undefined ? 'Not refreshed' : Number(value).toLocaleString(); +} + +export function UsersSection() { + const { params, setParam } = useUrlQueryParams(); + const selection = useCrossPageSelection(); + const [search, setSearch] = useState(() => new URLSearchParams(window.location.search).get('search') ?? ''); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [freshness, setFreshness] = useState(null); + const [reload, setReload] = useState(0); + const [selectedTab, setSelectedTab] = useState('overview'); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + const [expiryInput, setExpiryInput] = useState(''); + const [bulkAccess, setBulkAccess] = useState<'allow' | 'deny'>('deny'); + const [bulkUploads, setBulkUploads] = useState<'allow' | 'deny'>('deny'); + const [busyAction, setBusyAction] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [approvalId, setApprovalId] = useState(null); + + const selectedUserId = params.get('user_id'); + const currentPage = Math.max(1, Number.parseInt(params.get('page') ?? '1', 10) || 1); + const accessStatus = params.get('access_status') + ?? (params.get('status') === 'blocked' ? 'deny' : 'all'); + const uploadStatus = params.get('upload_status') ?? 'all'; + const lastLogin = params.get('last_login') + ?? (params.get('filter') === 'active' ? '30' : 'all'); + const hasDocuments = params.get('has_documents') ?? 'all'; + const sort = params.get('sort') ?? 'name'; + const direction = params.get('direction') === 'desc' ? 'desc' : 'asc'; + const requestQuery = useMemo(() => { + const query = new URLSearchParams(params); + query.set('page', String(currentPage)); + query.set('per_page', PAGE_SIZE); + return query.toString(); + }, [currentPage, params]); + const selectionFilterKey = [ + params.get('search'), params.get('filter'), params.get('status'), + params.get('access_status'), params.get('upload_status'), + params.get('last_login'), params.get('has_documents'), + ].join('|'); + + useEffect(() => { + setSearch(params.get('search') ?? ''); + }, [params]); + + useEffect(() => { + const timer = window.setTimeout(() => { + if (search !== (params.get('search') ?? '')) { + setParam('page', null); + setParam('search', search.trim() || null); + } + }, 250); + return () => window.clearTimeout(timer); + }, [params, search, setParam]); + + useEffect(() => { + selection.clear(); + // A changed filter creates a different all-matching population; page and sort changes do not. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectionFilterKey]); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + api.get(`/api/v2/control-center/users?${requestQuery}`, controller.signal) + .then((response) => { + setRows(response.users); + setTotal(response.pagination.total_items); + setPage(response.pagination.page); + setFreshness(response.metrics_freshness); + }) + .catch((requestError: unknown) => { + if (controller.signal.aborted) return; + setError(requestError instanceof Error ? requestError.message : 'Unable to load users.'); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [requestQuery, reload]); + + useEffect(() => { + if (!selectedUserId) { + setDetail(null); + setDetailError(null); + setApprovalId(null); + return; + } + const controller = new AbortController(); + setDetailLoading(true); + setDetailError(null); + api.get( + `/api/v2/control-center/users/${encodeURIComponent(selectedUserId)}`, + controller.signal, + ) + .then((response) => { + setDetail(response); + setApprovalId(null); + }) + .catch((requestError: unknown) => { + if (controller.signal.aborted) return; + setDetailError(requestError instanceof Error ? requestError.message : 'Unable to load user details.'); + }) + .finally(() => { + if (!controller.signal.aborted) setDetailLoading(false); + }); + return () => controller.abort(); + }, [selectedUserId]); + + const updateParam = useCallback((key: string, value: string) => { + setParam('page', null); + if (['access_status', 'last_login', 'upload_status', 'has_documents'].includes(key)) { + setParam('filter', null); + setParam('status', null); + } + setParam(key, value === 'all' ? null : value); + }, [setParam]); + + const openUser = useCallback((id: string) => { + setSelectedTab('overview'); + setExpiryInput(''); + setApprovalId(null); + setParam('user_id', id); + }, [setParam]); + + const closeDrawer = useCallback(() => { + setParam('user_id', null); + setApprovalId(null); + }, [setParam]); + + const columns = useMemo[]>(() => [ + { + id: 'name', + label: 'User', + sortable: true, + render: (row) => ( +
+ +

{row.email || 'No email recorded'}

+
+ ), + }, + { + id: 'access', + label: 'Access', + render: (row) => , + }, + { + id: 'file_uploads', + label: 'Uploads', + render: (row) => , + }, + { id: 'last_login', label: 'Last login', sortable: true, render: (row) => displayDate(row.last_login) }, + { id: 'conversations', label: 'Conversations', sortable: true, render: (row) => displayMetric(row.conversations) }, + { id: 'documents', label: 'Documents', sortable: true, render: (row) => displayMetric(row.documents) }, + { id: 'tokens', label: 'Tokens', sortable: true, render: (row) => displayMetric(row.tokens) }, + ], [openUser]); + + const setCurrentFilter = (key: string, value: string) => updateParam(key, value); + + const applyBulkAction = async (actionType: 'access' | 'file_uploads', status: 'allow' | 'deny') => { + if (!selection.allMatchingSelected && selection.selectedIds.size === 0) return; + const datetimeToAllow = status === 'deny' && expiryInput + ? new Date(expiryInput).toISOString() + : null; + const payload: Record = { + action_type: actionType, + settings: { status, datetime_to_allow: datetimeToAllow }, + }; + if (selection.allMatchingSelected) { + const filter = new URLSearchParams(params); + filter.delete('user_id'); + filter.delete('page'); + filter.delete('per_page'); + filter.delete('sort'); + filter.delete('direction'); + payload.filter = Object.fromEntries(filter.entries()); + payload.exclude_ids = Array.from(selection.excludedIds); + } else { + payload.user_ids = Array.from(selection.selectedIds); + } + + const previousRows = rows; + setRows((current) => current.map((row) => selection.isSelected(row.id) + ? { ...row, [actionType]: { status, expires_at: datetimeToAllow } } + : row)); + setBusyAction(`bulk-${actionType}`); + try { + const result = await api.post<{ success_count: number; failed_count: number }>( + '/api/v2/control-center/users/bulk-action', + payload, + ); + selection.clear(); + setReload((value) => value + 1); + if (result.failed_count) { + toast.error(`${result.success_count} updated; ${result.failed_count} users could not be updated.`); + } else { + toast.success(`${result.success_count.toLocaleString()} users updated.`); + } + } catch (requestError) { + setRows(previousRows); + toast.error(requestError instanceof Error ? requestError.message : 'Bulk update failed.'); + } finally { + setBusyAction(null); + } + }; + + const updateRestriction = async (actionType: 'access' | 'file_uploads', status: 'allow' | 'deny') => { + if (!selectedUserId || !detail) return; + const expiry = status === 'deny' && expiryInput + ? new Date(expiryInput).toISOString() + : null; + const priorDetail = detail; + const priorRows = rows; + setDetail((current) => current ? { + ...current, + user: { ...current.user, [actionType]: { status, expires_at: expiry } }, + } : current); + setRows((current) => current.map((row) => row.id === selectedUserId + ? { ...row, [actionType]: { status, expires_at: expiry } } + : row)); + setBusyAction(actionType); + try { + await api.patch( + `/api/admin/control-center/users/${encodeURIComponent(selectedUserId)}/${actionType === 'access' ? 'access' : 'file-uploads'}`, + { status, datetime_to_allow: expiry }, + ); + const reconciled = await api.get( + `/api/v2/control-center/users/${encodeURIComponent(selectedUserId)}`, + ); + setDetail(reconciled); + setReload((value) => value + 1); + toast.success(`${actionType === 'access' ? 'Access' : 'File uploads'} ${status === 'deny' ? 'denied' : 'allowed'}.`); + } catch (requestError) { + setDetail(priorDetail); + setRows(priorRows); + toast.error(requestError instanceof Error ? requestError.message : 'User setting could not be saved.'); + } finally { + setBusyAction(null); + } + }; + + const requestDocumentDeletion = async (reason: string) => { + if (!selectedUserId) return; + setConfirmDelete(false); + setBusyAction('delete-documents'); + try { + const result = await api.post<{ approval_id: string }>( + `/api/admin/control-center/users/${encodeURIComponent(selectedUserId)}/delete-documents`, + { reason }, + ); + setApprovalId(result.approval_id); + toast.success('Document deletion submitted for approval.'); + } catch (requestError) { + toast.error(requestError instanceof Error ? requestError.message : 'Approval request could not be created.'); + } finally { + setBusyAction(null); + } + }; + + const exportQuery = userFilterQuery(params, false); + const selectedCount = selection.allMatchingSelected + ? Math.max(0, total - selection.excludedIds.size) + : selection.selectedIds.size; + + return ( +
+
+
+

Users

+

+ Review account access, upload permissions, and cached usage. Changes are recorded against the selected account. +

+
+ +
+ + + + + + + + + {selectedCount > 0 ? ( + +

+ {selection.allMatchingSelected ? `All ${selectedCount.toLocaleString()} matching users selected` : `${selectedCount.toLocaleString()} selected`} +

+ + + void applyBulkAction('access', bulkAccess)}> + Apply access + + + void applyBulkAction('file_uploads', bulkUploads)}> + Apply uploads + + Clear selection +
+ ) : null} + + { + setParam('sort', field); + setParam('direction', nextDirection); + }} + onPageChange={(nextPage) => setParam('page', String(nextPage))} + emptyMessage="No users match these filters." /> + +
+ + Usage metrics come from the scheduled Control Center refresh. + {freshness?.oldest_calculated_at + ? ` Oldest value on this page: ${displayDate(freshness.oldest_calculated_at)}.` + : ' No usage metrics have been refreshed for this page yet.'} + + {freshness?.missing_count ? {freshness.missing_count} rows on this page have no cached metrics. : null} +
+ + {selectedUserId ? ( + + {detailLoading && !detail ?

Loading user details…

: null} + {detailError ?

{detailError}

: null} + {detail ? ( +
+
+

{detail.user.email || 'No email recorded'}

+

Account ID: {detail.user.id}

+
+
+ {(['overview', 'activity', 'memberships'] as const).map((tab) => ( + + ))} +
+
+ {selectedTab === 'overview' ? ( + <> +
+ {displayDate(detail.usage.last_login)}} /> + + + +
+

+ Metrics refreshed: {displayDate(detail.usage.metrics_calculated_at)} +

+ +

Account controls

+
+
+

Access

+

{detail.user.access.expires_at ? `Expires ${displayDate(detail.user.access.expires_at)}` : 'No expiry scheduled'}

+
+ void updateRestriction('access', 'allow')}>Allow + void updateRestriction('access', 'deny')}>Deny +
+
+
+

File uploads

+

{detail.user.file_uploads.expires_at ? `Expires ${displayDate(detail.user.file_uploads.expires_at)}` : 'No expiry scheduled'}

+
+ void updateRestriction('file_uploads', 'allow')}>Allow + void updateRestriction('file_uploads', 'deny')}>Deny +
+
+
+ +
+ {approvalId ? ( + + Document deletion approval {approvalId} was submitted for another administrator to review. + + ) : null} + setConfirmDelete(true)}> + Request deletion of all documents + + +
+
+ ) : null} +
+ ) : null} + {confirmDelete ? ( + setConfirmDelete(false)} + onConfirm={(reason) => void requestDocumentDeletion(reason)} /> + ) : null} +
+ ); +} + +function MembershipList({ title, items, icon }: { title: string; items: Membership[]; icon: ReactNode }) { + return ( +
+

{icon}{title}

+ {items.length ? ( +
    + {items.map((item) => ( +
  • + {item.name || item.id} + {item.owned ? 'Owner' : item.role} +
  • + ))} +
+ ) :

No {title.toLowerCase()} memberships.

} +
+ ); +} diff --git a/application/v2_ui/src/components/membership/ImportMembersDialog.tsx b/application/v2_ui/src/components/membership/ImportMembersDialog.tsx index f36e5ca61..b3ed9db08 100644 --- a/application/v2_ui/src/components/membership/ImportMembersDialog.tsx +++ b/application/v2_ui/src/components/membership/ImportMembersDialog.tsx @@ -45,12 +45,14 @@ function outcomeText(outcome: ImportRowOutcome | null): string { export function ImportMembersDialog({ roles, onAddRow, onRunningChange, onFinished, onClose, + identityDescription = "Each person is looked up in the directory by their user ID. The name and email in the file are used only when the directory can't be reached.", }: { roles: readonly AssignableMemberRole[]; onAddRow: (row: MemberCsvRow) => Promise; onRunningChange: (running: boolean) => void; onFinished: () => void; onClose: () => void; + identityDescription?: string; }) { const [stage, setStage] = useState('choose'); const [fileName, setFileName] = useState(''); @@ -139,7 +141,7 @@ export function ImportMembersDialog({ onChange={(event) => void chooseFile(event.target.files?.[0])} />

- Each person is looked up in the directory by their user ID. The name and email in the file are used only when the directory can't be reached. + {identityDescription}

{errors.length ? (
diff --git a/application/v2_ui/src/components/ui/Modal.tsx b/application/v2_ui/src/components/ui/Modal.tsx index e0eec6c22..f31933c46 100644 --- a/application/v2_ui/src/components/ui/Modal.tsx +++ b/application/v2_ui/src/components/ui/Modal.tsx @@ -94,6 +94,7 @@ export function Modal({ tall = false, banner, panelRef: externalPanelRef, + placement = 'center', }: { title: string; description?: string; @@ -118,6 +119,7 @@ export function Modal({ banner?: ReactNode; /** The dialog's panel, for a caller that animates it into place. */ panelRef?: Ref; + placement?: 'center' | 'drawer'; }) { // Read while rendering: by the time an effect runs, a field's autoFocus has already moved // focus into the dialog, and it is the control that opened it that focus must return to. @@ -157,11 +159,12 @@ export function Modal({ useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { + const panel = panelRef.current; + if (!panel || openPanels[openPanels.length - 1] !== panel) return; if (event.key === 'Escape') { onClose(); return; } - const panel = panelRef.current; const dialog = panel?.parentElement; if (event.key !== 'Tab' || event.defaultPrevented || !panel || !dialog || openPanels[openPanels.length - 1] !== panel) return; @@ -186,7 +189,8 @@ export function Modal({ return createPortal(
event.stopPropagation()} className={clsx( - 'glass-modal flex w-full flex-col rounded-2xl outline-none', - size === 'full' ? 'max-h-[calc(100vh_-_2rem)]' : 'max-h-[85vh]', - SIZE_CLASS[size], - tall && 'h-[85vh]', + 'glass-modal flex w-full flex-col outline-none', + placement === 'drawer' ? 'h-full max-w-xl border-l border-edge' + : ['rounded-2xl', size === 'full' ? 'max-h-[calc(100vh_-_2rem)]' : 'max-h-[85vh]', SIZE_CLASS[size]], + tall && placement !== 'drawer' && 'h-[85vh]', )} > {banner ?? ( diff --git a/application/v2_ui/src/pages/ControlCenterPage.tsx b/application/v2_ui/src/pages/ControlCenterPage.tsx index 54d73e567..82bf8687f 100644 --- a/application/v2_ui/src/pages/ControlCenterPage.tsx +++ b/application/v2_ui/src/pages/ControlCenterPage.tsx @@ -17,6 +17,9 @@ import { DashboardSection } from '../components/controlCenter/DashboardSection'; import type { ControlCenterCapabilities } from '../lib/types'; import { useBootstrapStore } from '../stores/bootstrapStore'; import { useUserSettingsStore } from '../stores/userSettingsStore'; +import { UsersSection } from '../components/controlCenter/UsersSection'; +import { GroupsSection } from '../components/controlCenter/GroupsSection'; +import { PublicWorkspacesSection } from '../components/controlCenter/PublicWorkspacesSection'; type SectionId = 'dashboard' | 'users' | 'groups' | 'public-workspaces' | 'activity-logs' | 'data-health'; @@ -225,6 +228,9 @@ export function ControlCenterPage() {

Your Control Center permissions do not include this section.

) : section === 'dashboard' ? + : section === 'users' ? + : section === 'groups' ? + : section === 'public-workspaces' ? : section === 'data-health' ? : } diff --git a/docs/explanation/features/V2_CONTROL_CENTER.md b/docs/explanation/features/V2_CONTROL_CENTER.md index 22d2a2451..b97a815c5 100644 --- a/docs/explanation/features/V2_CONTROL_CENTER.md +++ b/docs/explanation/features/V2_CONTROL_CENTER.md @@ -4,6 +4,10 @@ The V2 Control Center is a permission-aware administration pane for managing Sim **Dashboard implemented in version:** 0.261.279 **Foundation implemented in version:** 0.261.278 +**Users implemented in version:** 0.261.280 +**Groups implemented in version:** 0.261.282 +**Public Workspaces implemented in version:** 0.261.283 +**Current version:** 0.261.285 (including public workspace validation-safety fixes) **Dependencies:** React 18, TypeScript, Vite, Flask session authentication, and the existing Control Center APIs. @@ -15,7 +19,7 @@ The Control Center is a distinct React route (`/control-center` and `/control-ce ## Dashboard -Users, Groups, Public Workspaces, and Activity Logs remain permission-gated placeholders until their V2 implementations are delivered. They link to the classic Control Center at `/admin/control-center`. +Users, Groups and Public Workspaces management are available with `can_manage_users`, `can_manage_groups` and `can_manage_workspaces`, respectively. The Dashboard is available to users with `can_view_dashboard`, including users assigned the configured ControlCenterDashboardReader role. `GET /api/v2/control-center/dashboard/summary` returns counts and period comparisons; `GET /api/v2/control-center/dashboard/insights` returns grouped chart data. Both use a 90-second in-process cache keyed by the date range and token filters. Pass `force_refresh=1` to bypass it. @@ -34,14 +38,107 @@ Dashboard drill-through links set these parameters for the management sections b | Users | `user_id`, `filter`, `status` | | Groups | `id`, `status` | | Public Workspaces | `id`, `status` | -| Activity Logs | `activity_type`, `date`, `start_date`, `end_date`, `workspace_type`, `token_type`, `model`, `status` | +| Activity Logs | `activity_type`, `date`, `start_date`, `end_date`, `workspace_type`, `workspace_id`, `group_id`, `user_id`, `token_type`, `model`, `status` | Section paths are `/control-center/users`, `/control-center/groups`, `/control-center/public-workspaces`, and `/control-center/activity-logs`. IDs and parameter values are URL-encoded. `date` is a UTC calendar date; `start_date` and `end_date` are inclusive UTC dates. +## Users + +The Users section supports server-side search by email or display name, access and file-upload status filters, last-login windows, document-ownership filtering, sortable usage columns, and paging. It accepts the Dashboard drill-through contract: `user_id` opens that user's detail drawer, `filter=active` selects users active within 30 days, and `status=blocked` selects denied accounts. Direct filters are `access_status`, `upload_status`, `last_login`, and `has_documents`; `search`, `page`, `per_page`, `sort`, and `direction` control search and result ordering. The API validates filter and sort values and binds query values as parameters. + +`GET /api/v2/control-center/users` returns cached login, conversation, document, and token metrics with their calculation timestamps. Its response includes the oldest and newest metric timestamps and the count of users on the current page without a cached metric snapshot so administrators can judge freshness. `GET /api/v2/control-center/users/` returns the profile and current access/upload restrictions, usage summary, the most recent activity records, and group/public-workspace memberships and ownership. + +Administrators can change access or upload restrictions for one user, or select explicit users and users matching the current filters across pages. Filter-based bulk selection supports exclusions and is capped at 500 accounts. Bulk changes use the existing user settings update path so established activity and audit behavior remains in effect. Deleting a user's documents creates an approval request; it does not directly delete the documents. + +`GET /api/v2/control-center/users/export.csv` exports all users matching the current filters. CSV cells beginning with `=`, `+`, `-`, or `@` are prefixed to prevent spreadsheet formula execution. The Activity tab's link carries `user_id` into the Activity Logs section. + ## Data health Data health is available to users with `can_run_maintenance`. Its Check button calls `GET /api/admin/control-center/migrate/status` only when requested. Run backfill requires confirmation and calls `POST /api/admin/control-center/migrate/all`. The legacy-flag counts do not prove that activity logs are missing: normal application writers already record activity, so the backfill is normally unnecessary. The backfill checks for a matching resource creation record in the user's activity-log partition and uses stable per-resource IDs to avoid duplicate writes on repeated or concurrent runs. +## Groups + +Implemented in version: **0.261.282**, tracked in `application/single_app/config.py`. + +The Groups section helps administrators find shared workspaces that need attention, inspect their membership and usage, and perform audited status changes without losing the classic approval boundaries. Dashboard links with `id` open the drawer; `status` filters the list. Every new endpoint uses the existing Control Center Blueprint login policy, Swagger security decorator, and `control_center_required('admin')`. + +### List and selection contract + +`GET /api/v2/control-center/groups` accepts: + +| Parameter | Meaning | +|---|---| +| `search` | Case-insensitive substring of group name or description, up to 200 characters | +| `status` | `active`, `locked`, `upload_disabled`, `inactive`, or `all`; missing/null/unknown stored status is displayed as active | +| `owner` | Case-insensitive substring of owner ID, name, or email, up to 200 characters | +| `members_min`, `members_max` | Inclusive whole-number bounds, including the owner | +| `has_documents` | `yes`, `no`, or `all`, based on document metadata records, not chunks | +| `created_from`, `created_to` | Inclusive creation dates in `YYYY-MM-DD` | +| `activity_from`, `activity_to` | Inclusive last-recorded-activity dates in `YYYY-MM-DD`; missing activity does not match a date window | +| `sort`, `direction` | Name, owner, members, documents, tokens, or last activity; `asc`/`desc`, default name ascending | +| `page`, `per_page` | Server paging, default 25 and maximum 250 rows | +| `force_refresh=1` | Rebuild the server inventory instead of using its 90-second cache | + +`functions_control_center_groups.py` builds an inventory with four batched Cosmos queries: projected groups, document metadata counts, all-time token totals, and latest activity timestamps. Activity includes the top-level `group_id`, nested `group.group_id`, and `workspace_context.group_id` writer shapes. Filtering, sorting and paging occur on the server after aggregation; neither the browser nor per-row enrichment performs filtering or counting. This deliberately avoids N+1 queries and Cosmos ordering on undefined/computed fields. Tied sort values use stable ID ordering and missing activity sorts last in either direction. Failed aggregates fail the request rather than silently reporting zero. + +The response includes `groups`, `pagination`, and `metrics_freshness` with the snapshot's `calculated_at`, source and TTL. List/CSV totals can lag external writes by up to 90 seconds; **Refresh groups** bypasses the cache. Memory and rebuild cost scale with the group inventory and activity aggregates; this is not continuation-token pagination. Legacy storage-size estimates retain their own older refresh timestamp and are not represented as current counts. + +`POST /api/v2/control-center/groups/bulk-status` accepts either `group_ids` or a `filter` object, plus `status` and `reason`. Filter selection accepts the same filter fields, supports `exclude_ids`, and resolves against a fresh server inventory. The full population is capped at 500 before any writes occur. Locked/inactive status requires a nonblank reason (maximum 2,000 characters). `PUT /api/v2/control-center/groups//status` has the same reason rule. Both call the classic single-status writer, preserving etag conflict handling, status history, activity logging and App Insights audit events. Partial results report `success_count`, `failed_count`, and individual `failed_groups`; unchanged groups succeed without duplicate audit records. The classic status route keeps its existing optional-reason behavior. + +`GET /api/v2/control-center/groups/export.csv` exports the entire filtered/sorted inventory, not just the visible page, with spreadsheet-formula-safe cells. + +### Drawer and existing actions + +`GET /api/v2/control-center/groups/` reads current ownership, members, roles and status history, then returns the overview, retention policy, document summary, token total and the 20 most recent projected activity records. It does not expose model endpoints, credentials, logos, or the raw group document. The activity view shows the returned record as JSON and exports that recent subset as CSV. **View in Activity Logs** carries `workspace_type=group`, `workspace_id`, and `group_id` to the Activity Logs section; that section remains a placeholder until Phase 6. + +The drawer has Overview, Members, Ownership, Status, Retention, Activity and Documents tabs. Owner and member links open the Users drawer. `EntityDetailSections.tsx` provides reusable keyboard-operable detail tabs, timeline/JSON presentation and date formatting for later workspace management. `DetailDrawer` reuses the application's modal focus trap, focus restoration and innermost-dialog Escape handling. + +Member addition reuses `/api/userSearch` and the existing admin `/groups//add-member` endpoint. The shared directory-search and CSV-import dialogs retain validation, a 1,000-row import limit, per-row outcomes and retry support. CSV columns are `userId,displayName,email,role`; the admin add flow trusts the supplied member identity fields, so verify CSV data before importing. Supported roles are Admin, DocumentManager and User, mapped to the existing endpoint's `admin`, `document_manager`, and `user` tokens. + +Removing members and changing roles reuse the existing `/api/groups//members/` DELETE/PATCH routes. Those operations still require current group Owner/Admin membership; a Control Center role alone does not confer it. The owner cannot be removed or assigned a member role. Retention uses the existing `/api/retention-policy/group/` POST route, requires group Owner/Admin membership and enabled group retention, and accepts `default`, `none`, or organization-bounded whole-number days. Nonmember administrators can inspect these settings and request ownership rather than bypassing the existing membership rules. + +Delete group, delete all documents, take ownership and transfer ownership reuse the existing admin approval APIs. Each requires a reason and returns an approval ID, not a performed deletion or ownership change. Transfer recipients must already be members; the UI excludes the current owner. The shared submitted notice links to `/v2/approvals/all/?group_id=` using a basename-aware router link, matching the V2 Approvals contract (PR #1687), instead of adding an approvals queue to Control Center. User requests use the same detail destination without a group filter. + +### Validation + +`functional_tests/test_v2_control_center_groups.py` exercises filters/sorts/paging, cache reuse/expiry, detail projection, roles, bulk cap/exclusions/reasons, real status-writer audit parity, denied access, formula-safe export, and approval creation without direct mutation. `ui_tests/test_v2_control_center_groups.py` covers desktop/mobile drawers, URL filters, cross-page bulk selection, partial failures, status history, escaped text/raw JSON, member search/CSV/roles/removal, retention, ownership approvals and permission-limited controls. Route policy tests include all five new routes; prior Control Center layers and `test_v2_api_security.py` are included in regression validation. + +## Public Workspaces + +Implemented in version: **0.261.283**, tracked in `application/single_app/config.py`. + +The Public Workspaces section helps administrators review public knowledge collections, their responsible managers, restrictions and usage. It reuses the Groups detail drawer, membership dialogs, keyboard-operated tabs, reason confirmation, activity presentation and shared approval notice. Dashboard `id` and `status` deep links retain their meaning. + +### Query-backed list and selection + +`GET /api/v2/control-center/public-workspaces` accepts `search` (name/description substring), `owner` (stored owner name/email/ID substring), `status`, `page`, `per_page`, `sort` and `direction`. Search/owner text is limited to 200 characters, pages to 100,000, and page size to 250 (default 25). Invalid inputs fail before storage access. Status values are active, locked, upload_disabled and inactive; missing, null and empty status is active, and unrecognized stored strings are inactive. + +`functions_control_center_public_workspaces.py` applies parameterized search, owner and status predicates to Cosmos counts and paginated queries, **not an in-memory workspace inventory**. Sort choices are name, stored owner display name, created date, recorded documents, recorded tokens and recorded last activity. A separate missing-value population places unavailable sort fields last without dropping those workspaces. Each query orders one property and uses the existing automatic indexes, not a new composite index. Equal recorded values have no guaranteed secondary ordering, and concurrent writes can shift OFFSET pages; this is live offset pagination, not a stable snapshot or continuation-token cursor. + +The list reads recorded `metrics.document_metrics`, `metrics.token_metrics` and `metrics.last_activity` fields from the returned page. There are no per-row metric or directory queries. Unrecorded statistics are unavailable rather than zero, and every row shows the stored metric refresh timestamp. **Refresh workspaces** rereads stored records; it does not recalculate metrics. In particular, the legacy public-workspace refresh does not currently record token totals, so those list cells can remain unavailable. Detail views calculate live metadata-document and activity-log token totals separately. + +`POST /api/v2/control-center/public-workspaces/bulk-status` accepts either `workspace_ids` or `filter` with optional `exclude_ids`, plus `status` and `reason`. Filter selection queries at most 501 IDs after exclusions and rejects populations above 500 **before writing anything**. Locked/inactive changes require a reason of at most 2,000 characters. `PUT /api/v2/control-center/public-workspaces//status` applies the same validation. Both reuse the classic guarded status writer with its etag conflict handling, status history and audit logging. Partial failures return `failed_workspaces` alongside success/failure counts. + +Bulk controls are status-only. Setting active unlocks and enables uploads, as in classic lock/unlock and upload-enable actions. V2 does not expose the classic bulk `delete_documents` action, which deletes directly and differs from individual document-deletion approval requests. + +`GET /api/v2/control-center/public-workspaces/export.csv` honors the same filters and server sorting across pages, exports at most 10,000 workspaces, and protects every cell against spreadsheet formula injection. Narrow filters when the limit is exceeded. Export shares live offset pagination's concurrent-write limitation. + +### Details and existing behavior + +`GET /api/v2/control-center/public-workspaces/` projects current owner, managers, history and retention, computes document metadata count and all-time recorded token usage, and returns the most recent 20 projected activity records. Activity failures fail the detail request instead of producing an empty success. Raw JSON and local CSV export apply to this returned, allowlisted subset, not the full storage document. Links carry `workspace_type=public`, `workspace_id` and `public_workspace_id` to Activity Logs (Phase 6), and owner/manager links open Users. + +Unlike Groups, public readers are implicit; only Owner, Admin and DocumentManager are stored. Legacy string identities are shown by ID without uncached directory lookup. Addition/CSV uses classic `/add-member` with `admin` or `document_manager`; the unsupported `user` role is never offered. CSV identities are trusted by that existing admin path and should be verified before import. Native `/api/public_workspaces//members/` PATCH/DELETE still require workspace Owner/Admin membership and enabled public workspaces. Owner and self-removal controls are excluded. Control Center access does not bypass those membership checks. + +Retention uses `/api/retention-policy/public/` with the existing Owner/Admin rules. It accepts `none` or organization-bounded numeric days. The public save API does **not** accept `default`: inherited fields are omitted from writes, and resetting a custom policy to inherited defaults is unavailable in this drawer. Organization retention defaults remain managed through existing admin settings. + +Take ownership POST, ownership PUT with `newOwnerId`, documents DELETE and workspace DELETE reuse existing classic admin routes with a required reason. On this branch both deletion routes already create approvals; this release does not introduce a new deletion authorization policy. Submission displays the returned approval ID and links to `/v2/approvals/all/?group_id=`; it never claims deletion or ownership execution. The approval's `metadata.entity_type=workspace` dispatches to public-workspace executors, using `group_id` as the existing approval partition/scope key, not the Groups container. + +An existing executor limitation remains: document deletion catches individual document errors and reports the successful deletion count. The workspace executor can consequently delete the workspace after partial document cleanup. This release does not change those destructive semantics; inspect the approval execution result and logs for cleanup failures. + +### Validation + +`functional_tests/test_v2_control_center_public_workspaces.py` exercises query-level filters/paging, missing-status normalization, sorts with unavailable metrics, bounded inputs, admin-versus-reader access, bulk cap/exclusions/partial failures, real guarded audit writes, safe filtered CSV, detail projection and actual public-scope approval dispatch. `ui_tests/test_v2_control_center_public_workspaces.py` exercises the built local bundle at desktop/mobile sizes: filters/sorts, cross-page selection, partial errors, details/history/raw JSON, supported member roles/CSV, public retention payloads, ownership and deletion approval requests. Existing Groups, Users, foundation, dashboard, API security, public writers and route-policy checks remain regression coverage. + ## Usage Open **Account → Control Center**, then select a section in its internal rail. A bookmarked section URL opens that section directly. On the Dashboard, select a date range and optional token filters; charts include accessible data tables, and chart selections link to the corresponding filtered activity view. Export downloads the trend data as CSV. “Chat with these trends” creates a conversation containing the selected trend data. The Data health page is intended for explicit diagnosis or a known recovery scenario; check first, and run the backfill only when the result and operational context justify it. @@ -52,4 +149,4 @@ Functional checks cover dashboard status normalization, period deltas, cache exp ## Version tracking -The application version is defined by `VERSION` in `application/single_app/config.py`. The foundation was added in **0.261.278** and the dashboard in **0.261.279**. +The application version is defined by `VERSION` in `application/single_app/config.py`. The foundation was added in **0.261.278**, the dashboard in **0.261.279**, user management in **0.261.280**, group management in **0.261.282**, and public workspace management in **0.261.283**. diff --git a/docs/explanation/fixes/V2_CONTROL_CENTER_VALIDATION_ERRORS_FIX.md b/docs/explanation/fixes/V2_CONTROL_CENTER_VALIDATION_ERRORS_FIX.md new file mode 100644 index 000000000..e3c7f17ed --- /dev/null +++ b/docs/explanation/fixes/V2_CONTROL_CENTER_VALIDATION_ERRORS_FIX.md @@ -0,0 +1,40 @@ +# V2 Control Center Validation Error Safety + +Fixed in version: **0.261.283**, tracked in `application/single_app/config.py`. + +Public workspace handlers fixed in version: **0.261.285**. + +## Issue and root cause + +Users and Groups API validation handlers returned exception text to the browser. +Even when a custom validation exception normally contains a reviewed message, +returning exception text creates an unnecessary client-visible exception boundary. +CodeQL identified nine such handlers in the V2 Control Center routes. + +The same boundary affected all five public workspace handlers: list, detail, +bulk status, individual status, and CSV export. These now return fixed messages +while preserving HTTP 400 and existing input bounds. + +## Technical details + +`application/single_app/route_backend_control_center.py` uses stable validation +responses instead of serializing caught exceptions. Existing authorization, +validation status codes, status-change audit behavior, and approval gating are +preserved. Related review cleanup clarifies the intentional legacy token fallback +and removes unused Flask imports from the Groups functional test. + +## Validation + +Focused Users and Groups regression tests cover validation failures and safe +storage-error responses. The prior Control Center tests and route-policy tests +check that the merged integration retains the same authorization boundaries. +The built local V2 bundle is exercised by Groups and Users browser workflows. + +Before the fix, client responses could contain exception text. After the fix, +validation failures use stable messages and do not expose exception details. + +`functional_tests/test_control_center_safe_exception_responses.py` injects +sensitive exception text into all fourteen Users, Groups, and Public Workspaces +validation handlers and checks their exact safe response and status. + +See [V2 Control Center](../features/V2_CONTROL_CENTER.md) for the API contracts. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 236cf5570..4d78b779c 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,10 +2,51 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.285)** + +#### Bug Fixes + +* **Public Workspace Validation Error Safety** + * List, detail, bulk status, individual status, and export validation failures return stable messages instead of exception text, preserving HTTP 400 and input bounds. + * (Ref: `route_backend_control_center.py`, `test_control_center_safe_exception_responses.py`, [Control Center Validation Error Safety](fixes/V2_CONTROL_CENTER_VALIDATION_ERRORS_FIX.md)) + +### **(v0.261.284)** + +#### New Features + +* **V2 Control Center Public Workspace Management** + * Adds query-level search/status/owner filters, server sorting and pagination, bounded cross-page bulk status changes, and injection-safe filtered CSV export. + * Reuses detail tabs for ownership, supported manager roles/CSV, status history, retention, recent activity and document totals. Missing recorded metrics remain unavailable, not zero. + * Preserves existing approval semantics and native membership permissions; public retention cannot reset custom values to defaults through its existing save API. + * (Ref: `PublicWorkspacesSection.tsx`, `GroupDetailDrawer.tsx`, `functions_control_center_public_workspaces.py`, [V2 Control Center](features/V2_CONTROL_CENTER.md)) + +### **(v0.261.283)** + +#### Bug Fixes + +* **V2 Control Center Validation Error Safety** + * Users and Groups APIs return stable validation messages instead of exception text, preventing internal failure details from reaching the browser. + * (Ref: `route_backend_control_center.py`, [Control Center Validation Error Safety](fixes/V2_CONTROL_CENTER_VALIDATION_ERRORS_FIX.md)) + +### **(v0.261.282)** + +#### New Features + +* **V2 Control Center Group Management** + * Adds server-filtered group inventory, sortable usage and activity, cross-page bulk status updates with required reasons for locking/inactivation, and filtered CSV export. + * Group drawers include membership search/CSV import, supported member controls, ownership requests, status history, retention, recent activity with JSON/export, and document summaries. + * Deletion and ownership changes remain approval-gated. Existing group membership requirements for role changes, removal and retention are preserved. + * (Ref: `GroupsSection.tsx`, `GroupDetailDrawer.tsx`, `functions_control_center_groups.py`, `route_backend_control_center.py`, [V2 Control Center](features/V2_CONTROL_CENTER.md)) + ### **(v0.261.280)** #### New Features +* **V2 Control Center User Management** + * Adds filtered, sortable user administration with cached usage metrics, account detail and activity, cross-page bulk access/upload updates, and server-side CSV export. + * Document deletion requests require a reason and are submitted for approval; dashboard drill-through filters and selected-user links are preserved. + * (Ref: `UsersSection.tsx`, `route_backend_control_center.py`, [V2 Control Center](features/V2_CONTROL_CENTER.md)) + * **Guided Tours and Latest Features in V2** * Chat and Personal Workspace have step-by-step guided tours, started from a help button in the page header or with **Start now** in User Settings. Tours skip controls that are not on screen, support the keyboard, and respect reduced motion. * A new **Help and guidance** group in Preferences turns tours off one by one, or all at once with **Show tour buttons**. That switch is shared with the classic tutorial buttons. diff --git a/docs/guides/v2-control-center.md b/docs/guides/v2-control-center.md index 5e43e6d14..1fdabf527 100644 --- a/docs/guides/v2-control-center.md +++ b/docs/guides/v2-control-center.md @@ -10,7 +10,15 @@ audience: admin The V2 Control Center is a separate administration pane for users assigned Control Center access. It is kept out of the primary workspace navigation and appears in **Account → Control Center** when at least one Control Center capability is available to you. -The section rail is filtered to your permissions. The Dashboard is available to dashboard readers and Control Center administrators. User and group management, public workspaces, and activity logs are being delivered in phases; open the classic Control Center from those sections' placeholders. +The section rail is filtered to your permissions. The Dashboard is available to dashboard readers and Control Center administrators. Users, Groups and Public Workspaces have native V2 management. + +## Manage users + +Open **Users** to find accounts by email or display name, filter by access, upload permission, recent login, or document ownership, and sort usage columns. Filters and sorting are reflected in the URL, so a filtered view can be bookmarked or opened from a Dashboard drill-through. The list reports when its cached usage metrics were calculated and how many accounts on the current page have not yet received a metrics refresh. + +Select rows to allow or deny access or uploads. Selection can include all users matching the current filters across pages, with an option to exclude individual accounts. Restrictions may have an optional expiry. Open a user to review the account, recent activity, group and public-workspace memberships, and ownership. Changes reconcile against the server and report failures rather than leaving the list in an optimistic-only state. + +Deleting all documents for a user requires a reason and submits an approval request; it does not perform deletion immediately. **Export CSV** downloads the full filtered result set rather than only the current page. The Activity tab links to Activity Logs with the selected `user_id`. ## Review dashboard activity @@ -20,6 +28,34 @@ The charts use recorded logins, conversation creation, document creation by work The login heatmap reports UTC hours with Monday as weekday zero. Charts include data tables for screen-reader and text-based access. Dashboard summaries are cached for 90 seconds; choose **Refresh** to bypass the cache. +## Manage groups + +Group management was implemented in **0.261.282**. Open **Groups** to locate shared workspaces by name, description, owner, status, member-count range, document presence, creation date or last activity. Sort by name, owner, members, documents, tokens or activity to prioritize a review. Counts and all-time tokens use a server snapshot, timestamped in the list and cached for 90 seconds. **Refresh groups** rebuilds that snapshot. Export downloads all matching groups, not just the visible page. + +Select a group to inspect its Overview, Members, Ownership, Status, Retention, Activity and Documents tabs. Owner/member links open the corresponding user details. Activity shows the most recent 20 records, offers their raw JSON and a CSV of that subset, and links to the future Activity Logs view with the group scope. + +Select rows, then optionally select all matches across pages, to apply a bulk status. Bulk updates are limited to 500 groups. Locked and inactive changes require a reason and record each actual transition in the status history. Locked groups keep document viewing and chat but disallow document changes; upload-disabled groups prohibit uploads; inactive groups are unavailable. Individual failures remain visible after the list refreshes. + +Use **Add member** to search the directory, or **Import CSV** to add up to 1,000 people using `userId,displayName,email,role`. Confirm the CSV identities before importing: the Control Center admin flow uses the supplied ID, name and email. The import reports added, already-member and failed rows, and lets you retry failures. + +Changing member roles, removing members and saving retention still require group Owner/Admin membership; full Control Center access alone is not enough. The UI explains unavailable controls. Retention additionally requires enabled group retention and accepts organization defaults, no automatic deletion, or a permitted day count. + +Requesting group deletion, deleting all group documents, taking ownership, or transferring ownership to a member requires a reason and creates an approval request. Nothing is deleted and ownership remains unchanged at submission. Follow **View approval requests** in the result notice to the approvals page. + +## Manage public workspaces + +Public workspace management was implemented in **0.261.283**. Open **Public Workspaces** to find knowledge spaces by name/description, responsible owner and status. Sort and page on the server, bookmark the filtered URL or export matching records (up to 10,000). The list displays recorded metric refresh times. Unavailable metrics are not zero; refresh reloads stored snapshots, while opening details computes live document and token totals. + +Managers include the owner, administrators and document managers, not everyone who can read the public collection. Add or import only Admin and DocumentManager roles. Public readers are implicit. Verify CSV identities before import, and review each import outcome. + +Select up to 500 workspaces across pages to change status. Locked and inactive changes require a reason. Active both unlocks the workspace and enables uploads; upload-disabled blocks uploads, while inactive makes the workspace unavailable. Failed items stay visible after the list refresh. + +The detail drawer shares the Groups tabs and supports status history, recent activity/raw JSON/export, ownership requests and document summaries. Member removal/role changes and retention edits still require workspace Owner/Admin membership. The existing public retention API supports `none` or a permitted numeric day count: inherited fields are left unchanged, and resetting a custom value to organization defaults is not available here. + +Individual document deletion, workspace deletion, take-ownership and transfer-to-member workflows request approval on the existing server routes. A submission notice means **requested**, not executed. Its link opens the particular approval with workspace scope. The existing document-deletion executor can report successful deletions while other documents fail; workspace deletion can then proceed after partial cleanup. Review execution logs/results rather than treating the submitted request as completed cleanup. + +Activity exports contain only the 20 recent projected records displayed in the drawer. Activity Logs links retain public workspace scope for Phase 6. + ## Check activity-log data health The **Data health** section is available to users with maintenance access. Its backfill is a legacy repair operation for conversation and document creation events; ordinary application workflows already write activity logs, so the backfill is normally unnecessary. diff --git a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py index 66ad81166..4cc49d43c 100644 --- a/functional_tests/route_tests/test_route_blueprint_policy_inventory.py +++ b/functional_tests/route_tests/test_route_blueprint_policy_inventory.py @@ -154,6 +154,15 @@ SENSITIVE_ROUTE_POLICIES = { ("route_backend_control_center.py", "api_v2_control_center_dashboard_summary"): ("login_required", "control_center_required"), ("route_backend_control_center.py", "api_v2_control_center_dashboard_insights"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_users"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_user_detail"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_users_bulk_action"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_users_export"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_groups"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_group_detail"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_groups_bulk_status"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_group_status"): ("login_required", "control_center_required"), + ("route_backend_control_center.py", "api_v2_control_center_groups_export"): ("login_required", "control_center_required"), ("route_backend_group_documents.py", "api_scoped_group_document_upload"): ("login_required", "user_required", "enabled_required"), ("route_backend_group_documents.py", "api_scoped_group_document_metadata"): ("login_required", "user_required", "enabled_required"), ("route_backend_group_documents.py", "api_scoped_group_document_delete"): ("login_required", "user_required", "enabled_required"), diff --git a/functional_tests/route_tests/test_route_policy_test_coverage.py b/functional_tests/route_tests/test_route_policy_test_coverage.py index f765df587..4c3230aac 100644 --- a/functional_tests/route_tests/test_route_policy_test_coverage.py +++ b/functional_tests/route_tests/test_route_policy_test_coverage.py @@ -10,6 +10,7 @@ contracts to stay in sync. """ +import ast import importlib.util import sys from pathlib import Path @@ -67,10 +68,28 @@ def test_route_policy_test_files_are_documented_in_repo_instructions() -> None: assert expected_reference in instruction_source, f"Missing route test instruction reference: {expected_reference}" +def test_v2_public_workspace_management_routes_require_control_center_admin(): + """All five Phase 5 routes remain under the login Blueprint and strict role guard.""" + source = ROOT_DIR / "application" / "single_app" / "route_backend_control_center.py" + tree = ast.parse(source.read_text(encoding="utf-8")) + routes = [ + node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) + and node.name.startswith("api_v2_control_center_public_workspace") + ] + assert len(routes) == 5 + for node in routes: + decorators = [ast.unparse(decorator) for decorator in node.decorator_list] + assert decorators[0].startswith("bp.route(") + assert decorators[1] == "swagger_route(security=get_auth_security())" + assert "login_required" in decorators + assert "control_center_required('admin')" in decorators + + if __name__ == "__main__": tests = [ test_route_policy_tests_cover_identical_route_sets, test_route_policy_test_files_are_documented_in_repo_instructions, + test_v2_public_workspace_management_routes_require_control_center_admin, ] results = [] for test in tests: diff --git a/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py b/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py index 67c49a375..8b4a5d297 100644 --- a/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py +++ b/functional_tests/route_tests/test_route_unauthenticated_policy_contract.py @@ -56,6 +56,7 @@ ADMIN_PATH_PREFIXES = ( "/admin/", "/api/admin/", + "/api/v2/control-center/", "/api/semantic-kernel/plugins", ) diff --git a/functional_tests/test_control_center_safe_exception_responses.py b/functional_tests/test_control_center_safe_exception_responses.py new file mode 100644 index 000000000..5ab086e86 --- /dev/null +++ b/functional_tests/test_control_center_safe_exception_responses.py @@ -0,0 +1,128 @@ +# test_control_center_safe_exception_responses.py +""" +Functional test for safe Control Center exception responses. +Version: 0.261.285 +Implemented in: 0.261.283 + +This test ensures validation exceptions in the V2 Control Center routes do not +expose provider or stack-trace details, and malformed stored expiry values keep +access restrictions fail-closed. +""" + +import ast +import copy +import sys +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from flask import Flask, jsonify, request + +ROOT = Path(__file__).resolve().parents[1] +ROUTE = ROOT / "application" / "single_app" / "route_backend_control_center.py" +sys.path.insert(0, str(ROOT / "functional_tests")) + +from test_support.versioning import assert_app_version_at_least + + +SENSITIVE_ERROR = "provider-secret connection details" + + +class GroupRequestError(ValueError): + """Test exception type caught by the group request handlers.""" + + +def _load_route(name, failing_helper, error_type): + tree = ast.parse(ROUTE.read_text(encoding="utf-8")) + route_node = next( + node for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == name + ) + route_node = copy.deepcopy(route_node) + route_node.decorator_list = [] + + def fail(*_args, **_kwargs): + raise error_type(SENSITIVE_ERROR) + + namespace = { + "jsonify": jsonify, + "request": request, + "GroupRequestError": GroupRequestError, + failing_helper: fail, + } + module = ast.fix_missing_locations(ast.Module(body=[route_node], type_ignores=[])) + exec(compile(module, str(ROUTE), "exec"), namespace) + return namespace[name] + + +@pytest.mark.parametrize( + ("route_name", "helper", "error_type", "method", "json_body", "args", "safe_message"), + [ + ("api_v2_control_center_users", "_control_center_parse_user_filters", ValueError, + "GET", None, (), "Invalid user filters or pagination value."), + ("api_v2_control_center_user_detail", "_control_center_validate_user_id", ValueError, + "GET", None, ("user-1",), "Invalid user ID."), + ("api_v2_control_center_users_bulk_action", "_control_center_validate_user_id", ValueError, + "POST", {"action_type": "access", "settings": {"status": "allow"}, "user_ids": ["user-1"]}, + (), "Invalid bulk user action request."), + ("api_v2_control_center_users_export", "_control_center_parse_user_filters", ValueError, + "GET", None, (), "Invalid user export filters."), + ("api_v2_control_center_groups", "parse_group_filters", GroupRequestError, + "GET", None, (), "Invalid group filters."), + ("api_v2_control_center_group_detail", "validate_group_id", GroupRequestError, + "GET", None, ("group-1",), "Invalid group ID."), + ("api_v2_control_center_groups_bulk_status", "validate_group_status_payload", GroupRequestError, + "POST", {}, (), "Invalid bulk group status request."), + ("api_v2_control_center_group_status", "validate_group_id", GroupRequestError, + "PUT", None, ("group-1",), "Invalid group status request."), + ("api_v2_control_center_groups_export", "parse_group_filters", GroupRequestError, + "GET", None, (), "Invalid group export filters."), + ("api_v2_control_center_public_workspaces", "parse_workspace_filters", GroupRequestError, + "GET", None, (), "Invalid public workspace filters."), + ("api_v2_control_center_public_workspace_detail", "validate_group_id", GroupRequestError, + "GET", None, ("workspace-1",), "Invalid public workspace ID."), + ("api_v2_control_center_public_workspaces_bulk_status", "validate_group_status_payload", GroupRequestError, + "POST", {}, (), "Invalid bulk public workspace status request."), + ("api_v2_control_center_public_workspace_status", "validate_group_id", GroupRequestError, + "PUT", None, ("workspace-1",), "Invalid public workspace status request."), + ("api_v2_control_center_public_workspaces_export", "parse_workspace_filters", GroupRequestError, + "GET", None, (), "Invalid public workspace export filters."), + ], +) +def test_validation_exceptions_return_stable_safe_messages( + route_name, helper, error_type, method, json_body, args, safe_message +): + route = _load_route(route_name, helper, error_type) + app = Flask(__name__) + with app.test_request_context("/", method=method, json=json_body): + response, status_code = route(*args) + + assert status_code == 400 + assert response.get_json() == {"error": safe_message} + assert SENSITIVE_ERROR not in response.get_data(as_text=True) + + +def test_malformed_stored_expiry_keeps_restriction_fail_closed(): + tree = ast.parse(ROUTE.read_text(encoding="utf-8")) + helper_node = next( + node for node in tree.body + if isinstance(node, ast.FunctionDef) + and node.name == "_control_center_effective_restriction" + ) + module = ast.fix_missing_locations(ast.Module( + body=[copy.deepcopy(helper_node)], + type_ignores=[], + )) + namespace = {"datetime": datetime, "timezone": timezone} + exec(compile(module, str(ROUTE), "exec"), namespace) + + restriction = namespace["_control_center_effective_restriction"]( + {"access": {"status": "deny", "datetime_to_allow": "invalid timestamp"}}, + "access", + ) + + assert restriction == {"status": "deny", "expires_at": "invalid timestamp"} + + +def test_implementation_version_is_present(): + assert_app_version_at_least("0.261.283") diff --git a/functional_tests/test_support/control_center_group_harness.py b/functional_tests/test_support/control_center_group_harness.py index 9ed7d2ff3..2ed966b26 100644 --- a/functional_tests/test_support/control_center_group_harness.py +++ b/functional_tests/test_support/control_center_group_harness.py @@ -47,7 +47,7 @@ MODULE_LEVEL = {"enhance_group_with_activity", "_GroupChangeAnswer", "GROUP_OWNERSHIP_CHANGED_MESSAGE", "GROUP_NO_LONGER_EXISTS_MESSAGE", "GROUP_APPROVAL_CONFLICT_MESSAGE"} -NESTED = {"api_update_group_status", "api_admin_add_group_member", "_execute_approved_action", +NESTED = {"api_update_group_status", "_update_group_status", "api_admin_add_group_member", "_execute_approved_action", "_execute_take_ownership", "_execute_transfer_ownership"} CC_ADMIN = {"oid": "cc-admin", "roles": ["Admin"], "name": "Casey Control", "preferred_username": "cc.admin@example.test"} @@ -135,7 +135,9 @@ def control_center_group_environment(): "wraps": wraps, "session": session, "request": request, "jsonify": jsonify, "get_settings": env.get_settings, "debug_print": lambda *args, **kwargs: None, } - execute_functions("functions_authentication.py", {"login_required", "control_center_required"}, auth_namespace) + execute_functions("functions_authentication.py", { + "login_required", "control_center_required", "get_control_center_capabilities", + }, auth_namespace) blueprint = Blueprint("backend_control_center_groups", __name__) namespace = { @@ -166,6 +168,7 @@ def control_center_group_environment(): "TYPE_SUSPEND_USER": approvals_namespace["TYPE_SUSPEND_USER"], "TYPE_BLOCK_USER": approvals_namespace["TYPE_BLOCK_USER"], "CLIENTS": {}, + "_control_center_group_snapshot_cache": {}, "storage_account_group_documents_container_name": "group-documents", } tree = ast.parse((APP_ROOT / SOURCE).read_text(encoding="utf-8")) diff --git a/functional_tests/test_v2_control_center_groups.py b/functional_tests/test_v2_control_center_groups.py new file mode 100644 index 000000000..67d6d143c --- /dev/null +++ b/functional_tests/test_v2_control_center_groups.py @@ -0,0 +1,338 @@ +# test_v2_control_center_groups.py +""" +Functional tests for V2 Control Center Groups. +Version: 0.261.283 +Implemented in: 0.261.282 + +Run real filters and routes over isolated Cosmos services and the real guarded +group writer. Cover selection caps before writes, audit parity, detail projections, +admin-only access, snapshot expiry, safe exports and approval-only actions. +""" + +import ast +import copy +import importlib.util +import time +from contextlib import contextmanager +from io import StringIO +from pathlib import Path + +import pytest +from azure.cosmos.exceptions import CosmosResourceNotFoundError +from flask import Blueprint, Flask, Response + +from test_support.control_center_group_harness import control_center_group_environment +from test_support.versioning import assert_app_version_at_least + + +APP = Path(__file__).resolve().parents[1] / "application" / "single_app" +spec = importlib.util.spec_from_file_location("v2_group_inventory", APP / "functions_control_center_groups.py") +inventory_module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(inventory_module) +ROUTES = { + "api_v2_control_center_groups", "api_v2_control_center_group_detail", + "api_v2_control_center_groups_bulk_status", "api_v2_control_center_group_status", + "api_v2_control_center_groups_export", "api_delete_group_admin", + "api_delete_group_documents_admin", "api_admin_take_group_ownership", + "api_admin_transfer_group_ownership", +} + + +class InventoryGroups: + def __init__(self, env): + self.env = env + self.queries = [] + + def query_items(self, query, **kwargs): + self.queries.append(query) + assert "SELECT c.id, c.name" in query and "FROM c" in query + fields = ("id", "name", "description", "owner", "users", "admins", "documentManagers", + "status", "createdDate", "metrics") + return [{key: copy.deepcopy(value) for key, value in row.items() if key in fields} + for row in self.env.groups.records.values()] + + def read_item(self, **kwargs): + return self.env.groups.read_item(**kwargs) + + +class InventoryDocuments: + def __init__(self): + self.queries = [] + + def query_items(self, query, **kwargs): + self.queries.append(query) + assert "c.type = 'document_metadata'" in query + assert "COUNT(1)" in query and "GROUP BY c.group_id" in query + return [{"group_id": "group-1", "total": 2}] + + +class InventoryActivity: + def __init__(self): + self.queries = [] + + def query_items(self, query, **kwargs): + self.queries.append(query) + if "SUM(c.usage.total_tokens)" in query: + assert "GROUP BY c.workspace_context.group_id" in query + return [{"group_id": "group-1", "total": 120}] + if "MAX(c.timestamp)" in query: + assert "IS_STRING(c.group_id) AND c.group_id != ''" in query + assert "IS_STRING(c.group.group_id) AND c.group.group_id != ''" in query + return [{"group_id": "group-1", "last_activity": "2026-10-06T00:00:00Z"}] + if "TOP 20" in query: + assert "c.group_id = @group_id" in query and "c.workspace_context.group_id = @group_id" in query + return [{"id": "event-1", "activity_type": "group_status_change", "timestamp": "2026-10-06T00:00:00Z"}] + raise AssertionError(f"Unexpected activity query: {query}") + + +@contextmanager +def group_routes(): + with control_center_group_environment() as env: + env.seed_group("group-1", name="Research") + env.seed_group("group-2", name="Archive", status="locked") + blueprint = Blueprint("v2_cc_groups_test", __name__) + ns = dict(env.cc.namespace) + ns.update({name: getattr(inventory_module, name) for name in ( + "GROUP_SNAPSHOT_TTL", "GroupRequestError", "filter_group_inventory", "group_members", "group_row", + "load_group_inventory", "parse_group_filters", "select_group_bulk_ids", + "validate_group_id", "validate_group_status_payload", + )}) + groups, documents, activity = InventoryGroups(env), InventoryDocuments(), InventoryActivity() + approvals = [] + def create_approval_request(**kwargs): + approvals.append(kwargs) + return {"id": f"approval-{len(approvals)}"} + ns.update({ + "bp": blueprint, "Response": Response, "StringIO": StringIO, "time": time, + "csv": __import__("csv"), "CosmosResourceNotFoundError": CosmosResourceNotFoundError, + "cosmos_groups_container": groups, "cosmos_group_documents_container": documents, + "cosmos_activity_logs_container": activity, "get_settings": env.get_settings, + "get_user_role_in_group": env.modules.group.get_user_role_in_group, + "create_approval_request": create_approval_request, + "CONTROL_CENTER_MANAGEMENT_DEFAULT_PER_PAGE": 25, "CONTROL_CENTER_MANAGEMENT_MAX_PER_PAGE": 250, + }) + source = APP / "route_backend_control_center.py" + tree = ast.parse(source.read_text(encoding="utf-8")) + helpers = {"_control_center_group_inventory", "parse_control_center_management_pagination", + "get_control_center_total_pages", "clamp_control_center_page", "_control_center_csv_safe_cell"} + nodes = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in helpers] + nodes += [node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name in ROUTES] + assert {node.name for node in nodes} == helpers | ROUTES + exec(compile(ast.Module(body=nodes, type_ignores=[]), str(source), "exec"), ns) + app = Flask("v2_control_center_groups_test") + app.config.update(TESTING=True, SECRET_KEY="isolated-test-key") + app.register_blueprint(blueprint) + client = app.test_client() + def sign_in(user=None): + with client.session_transaction() as state: + state.clear() + if user is not None: + state["user"] = user + sign_in(env.cc.admin) + yield env, client, ns, (groups, documents, activity), approvals, sign_in + + +@pytest.fixture +def routes(): + with group_routes() as result: + yield result + + +def test_list_filters_sort_paging_and_batched_cache(routes): + _, client, ns, services, _, _ = routes + response = client.get("/api/v2/control-center/groups?sort=tokens&direction=desc&per_page=1") + assert response.status_code == 200 + payload = response.get_json() + assert payload["groups"][0]["name"] == "Research" + assert payload["pagination"]["total_items"] == 2 + assert payload["metrics_freshness"]["ttl_seconds"] == 90 + assert sum(len(service.queries) for service in services) == 4 + cached_response = client.get("/api/v2/control-center/groups?status=active&owner=olive&has_documents=yes&members_min=4&created_from=2026-09-01&activity_to=2026-10-07") + assert [row["id"] for row in cached_response.get_json()["groups"]] == ["group-1"] + assert sum(len(service.queries) for service in services) == 4 + next_page = client.get("/api/v2/control-center/groups?sort=name&per_page=1&page=2") + assert next_page.get_json()["groups"][0]["id"] == "group-1" + refreshed = client.get("/api/v2/control-center/groups?force_refresh=1") + assert refreshed.status_code == 200 + assert sum(len(service.queries) for service in services) == 8 + ns["_control_center_group_snapshot_cache"]["inventory"]["expires_at"] = 0 + expired = client.get("/api/v2/control-center/groups") + assert expired.status_code == 200 + assert sum(len(service.queries) for service in services) == 12 + + +@pytest.mark.parametrize("query", [ + "status=bogus", "sort=settings", "direction=sideways", "has_documents=maybe", + "members_min=-1", "members_min=8&members_max=1", "created_from=2026-02-30", + "activity_from=2026-10-08&activity_to=2026-10-01", "owner=" + "x" * 201, +]) +def test_invalid_filters_do_not_query_storage(routes, query): + _, client, _, services, _, _ = routes + response = client.get(f"/api/v2/control-center/groups?{query}") + assert response.status_code == 400 + assert not any(service.queries for service in services) + + +@pytest.mark.parametrize("path", ["/api/v2/control-center/groups", + "/api/v2/control-center/groups/group-1", + "/api/v2/control-center/groups/export.csv"]) +def test_inventory_failures_are_safe_not_validation_messages(routes, monkeypatch, path): + _, client, _, services, _, _ = routes + def fail_query(**kwargs): + raise ValueError("secret-storage-credential") + monkeypatch.setattr(services[1], "query_items", fail_query) + response = client.get(path) + assert response.status_code == 500 + assert "secret-storage-credential" not in response.get_data(as_text=True) + + +def test_missing_status_and_dates_and_all_sorts_are_consistent(): + first = inventory_module.group_row({"id": "a", "name": "Zulu", "owner": {"id": "person"}, + "users": [], "status": None}, 0, 0, None) + second = inventory_module.group_row({"id": "b", "name": "Alpha", "owner": {"displayName": "B"}}, + 2, 10, "2026-10-06T00:00:00Z") + assert first["status"] == "active" and first["members"] == 1 + for field in inventory_module.GROUP_SORTS: + result = inventory_module.filter_group_inventory([first, second], inventory_module.parse_group_filters({"sort": field})) + assert len(result) == 2 + result = inventory_module.filter_group_inventory([first, second], inventory_module.parse_group_filters({"activity_to": "2026-10-07"})) + assert [row["id"] for row in result] == ["b"] + + +def test_detail_shape_is_allowlisted_and_fresh_membership_has_roles(routes): + env, client, _, _, _, _ = routes + response = client.get("/api/v2/control-center/groups/group-1") + data = response.get_json() + assert response.status_code == 200 + assert {member["role"] for member in data["members"]} == {"Owner", "Admin", "DocumentManager", "User"} + assert data["documents_summary"]["count"] == 2 and data["tokens"] == 120 + assert data["activity"][0]["id"] == "event-1" + assert data["status_history"] == [] + assert not data["permissions"]["can_edit_members"] + assert "sk-secret" not in response.get_data(as_text=True) + assert "model_endpoints" not in response.get_data(as_text=True) + assert data["retention"]["conversation_retention_days"] == "default" + env.groups.records.clear() + missing = client.get("/api/v2/control-center/groups/group-1") + assert missing.status_code == 404 + + +def test_bulk_reuses_legacy_writer_and_audits_each_transition_once(routes): + env, client, _, _, _, _ = routes + response = client.post("/api/v2/control-center/groups/bulk-status", json={ + "filter": {"status": "active"}, "exclude_ids": [], "status": "locked", "reason": "Review", + }) + assert response.status_code == 200 and response.get_json()["success_count"] == 1 + stored = env.stored_group("group-1") + assert stored["statusHistory"][-1]["reason"] == "Review" + logs = [kwargs for name, _, kwargs in env.activity.calls if name == "log_group_status_change"] + assert len(logs) == 1 and logs[0]["new_status"] == "locked" + unchanged = client.put("/api/v2/control-center/groups/group-1/status", json={"status": "locked", "reason": "Review"}) + assert unchanged.status_code == 200 + assert len([entry for entry in env.activity.calls if entry[0] == "log_group_status_change"]) == 1 + + +@pytest.mark.parametrize("data", [ + {"group_ids": ["group-1"], "status": "locked"}, + {"group_ids": ["group-1"], "status": "inactive", "reason": " "}, + {"group_ids": ["../admin"], "status": "active"}, + {"group_ids": ["group-1"] * 501, "status": "active"}, + {"group_ids": ["group-1"], "filter": {}, "status": "active"}, + {"filter": {"arbitrary": "field"}, "status": "active"}, + {"filter": {}, "exclude_ids": "group-1", "status": "active"}, + {"group_ids": [], "status": "active"}, +]) +def test_bulk_validation_precedes_all_writes(routes, data): + env, client, _, _, _, _ = routes + response = client.post("/api/v2/control-center/groups/bulk-status", json=data) + assert response.status_code == 400 + assert not env.write_calls() + + +def test_filter_selection_cap_and_exclusions(): + rows = [inventory_module.group_row({"id": f"g-{index}"}, 0, 0, None) for index in range(501)] + with pytest.raises(ValueError, match="500"): + inventory_module.select_group_bulk_ids({"filter": {}}, lambda: {"rows": rows}) + selected = inventory_module.select_group_bulk_ids({"filter": {}, "exclude_ids": ["g-0"]}, lambda: {"rows": rows}) + assert len(selected) == 500 and "g-0" not in selected + + +def test_filter_selection_cap_is_enforced_by_route_before_writes(routes): + env, client, _, _, _, _ = routes + for index in range(499): + env.seed_group(f"extra-{index}") + response = client.post("/api/v2/control-center/groups/bulk-status", json={ + "filter": {}, "status": "locked", "reason": "Review", + }) + assert response.status_code == 400 + assert not env.write_calls() + + +def test_bulk_partial_failures_are_explicit(routes): + _, client, _, _, _, _ = routes + response = client.post("/api/v2/control-center/groups/bulk-status", json={ + "group_ids": ["group-1", "missing"], "status": "inactive", "reason": "Archived", + }) + data = response.get_json() + assert data["success_count"] == 1 and data["failed_count"] == 1 + assert data["failed_groups"][0]["id"] == "missing" + + +@pytest.mark.parametrize("name", ["=1+1", " \t=1+1", "\r\n@SUM(A1:A2)"]) +def test_exports_are_server_filtered_and_formula_safe(routes, name): + env, client, _, _, _, _ = routes + doc = env.stored_group("group-1") + doc["name"] = name + env.groups.seed(doc) + response = client.get("/api/v2/control-center/groups/export.csv?has_documents=yes") + assert response.status_code == 200 + text = response.get_data(as_text=True) + assert f"'{name}" in text and "Archive" not in text + + +@pytest.mark.parametrize("user", [None, {"oid": "member-1", "roles": ["User"]}, + {"oid": "reader", "roles": ["ControlCenterDashboardReader"]}]) +def test_readers_and_unauthenticated_callers_cannot_manage_groups(routes, user): + env, client, _, services, _, sign_in = routes + env.settings.update({"require_member_of_control_center_admin": True, "require_member_of_control_center_dashboard_reader": True}) + sign_in(user) + for method, path, data in ( + ("GET", "/api/v2/control-center/groups", None), + ("GET", "/api/v2/control-center/groups/group-1", None), + ("GET", "/api/v2/control-center/groups/export.csv", None), + ("PUT", "/api/v2/control-center/groups/group-1/status", {"status": "active"}), + ("POST", "/api/v2/control-center/groups/bulk-status", {"group_ids": ["group-1"], "status": "active"}), + ): + response = client.open(path, method=method, json=data) + assert response.status_code in (401, 403) + assert not any(service.queries for service in services) + assert not env.write_calls() + + +def test_destructive_and_ownership_actions_create_approvals_not_mutations(routes): + env, client, _, _, approvals, _ = routes + for method, suffix, data in ( + ("DELETE", "", {"reason": "Cleanup"}), + ("POST", "/delete-documents", {"reason": "Cleanup"}), + ("POST", "/take-ownership", {"reason": "Cover owner absence"}), + ("POST", "/transfer-ownership", {"reason": "New owner", "newOwnerId": "member-1"}), + ): + response = client.open(f"/api/admin/control-center/groups/group-1{suffix}", method=method, json=data) + assert response.status_code == 200 and response.get_json()["approval_id"] + assert len(approvals) == 4 + assert not env.write_calls() + assert env.stored_group("group-1")["owner"]["id"] == "owner-1" + + +def test_routes_have_explicit_admin_and_swagger_decorators(): + tree = ast.parse((APP / "route_backend_control_center.py").read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name in ROUTES: + decorators = [ast.unparse(decorator) for decorator in node.decorator_list] + assert "login_required" in decorators and "control_center_required('admin')" in decorators + assert "swagger_route(security=get_auth_security())" in decorators + assert_app_version_at_least("0.261.282") + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/functional_tests/test_v2_control_center_public_workspaces.py b/functional_tests/test_v2_control_center_public_workspaces.py new file mode 100644 index 000000000..12123984b --- /dev/null +++ b/functional_tests/test_v2_control_center_public_workspaces.py @@ -0,0 +1,402 @@ +# test_v2_control_center_public_workspaces.py +""" +Functional tests for V2 Control Center public workspace management. +Version: 0.261.285 +Implemented in: 0.261.283 + +Execute real query builders, guarded status writes, authentication, approval +creation and dispatch against isolated Cosmos containers. No Azure calls. +""" + +import copy +import csv +import importlib.util +import sys +from functools import wraps +from io import StringIO +from pathlib import Path + +import pytest +from flask import Blueprint, Flask, Response, jsonify, request, session +from azure.cosmos.exceptions import CosmosResourceNotFoundError + +from test_control_center_public_writers import _build_env, seed_workspace +from test_support.app_source import definitions +from test_support.agent_delegation import module_stub + + +APP = Path(__file__).resolve().parents[1] / "application" / "single_app" +spec = importlib.util.spec_from_file_location("workspace_queries", APP / "functions_control_center_public_workspaces.py") +module = importlib.util.module_from_spec(spec) +# This pure module's sibling import is loaded without initializing the Azure config. +group_spec = importlib.util.spec_from_file_location("functions_control_center_groups", APP / "functions_control_center_groups.py") +group_module = importlib.util.module_from_spec(group_spec) +group_spec.loader.exec_module(group_module) + + +class WorkspaceQueries: + """Model emitted WHERE/ORDER/OFFSET queries, refusing unbounded row fetches.""" + + def __init__(self, container): + self.container = container + self.queries = [] + + def read_item(self, **kwargs): + return self.container.read_item(**kwargs) + + def query_items(self, query, parameters=None, **kwargs): + self.queries.append((query, parameters)) + values = {item["name"]: item["value"] for item in parameters or []} + rows = [copy.deepcopy(row) for row in self.container.records.values()] + # FakeContainer keys are opaque; values are raw workspace documents. + def matches(row): + if "@search" in values and values["@search"] not in f"{row.get('name', '')} {row.get('description', '')}".lower(): + return False + if "@owner" in values: + owner = row.get("owner") or {} + text = " ".join(str(owner.get(key, "")) for key in ("userId", "displayName", "email")) if isinstance(owner, dict) else owner + if values["@owner"] not in text.lower(): + return False + if "@status" in values and (row.get("status") or "active") != values["@status"]: + return False + if "@known_statuses" in values and (row.get("status") or "active") in ["active", "locked", "upload_disabled"]: + return False + return row["id"] not in values.get("@excluded", []) + rows = [row for row in rows if matches(row)] + field = next((path for path in module.WORKSPACE_SORTS.values() if f"IS_DEFINED({path})" in query), None) + def field_value(row, path): + value = row + for key in path.removeprefix("c.").split("."): + value = value.get(key) if isinstance(value, dict) else None + return value + if field: + recorded = "AND NOT (IS_DEFINED" not in query + rows = [row for row in rows if (field_value(row, field) is not None) == recorded] + if "COUNT(1)" in query: + return [len(rows)] + if "TOP 501" in query: + return [{"id": row["id"]} for row in rows[:501]] + assert "OFFSET @offset LIMIT @limit" in query, query + sort_field = query.split("ORDER BY ")[1].split(" ")[0] + rows.sort(key=lambda row: field_value(row, sort_field), reverse=f"{sort_field} DESC" in query) + rows = rows[values["@offset"]:values["@offset"] + values["@limit"]] + return rows + + +class Documents: + def __init__(self): + self.deleted = [] + + def query_items(self, query, parameters=None, **kwargs): + if "COUNT" in query: + return [2] + assert "c.public_workspace_id = @workspace_id" in query + return [{"id": "doc-1"}, {"id": "doc-2"}] + + +class Activity: + def __init__(self): + self.records = [] + + def query_items(self, query, **kwargs): + if "SUM" in query: + return [120] + assert "TOP 20" in query + return [{"id": "event-1", "activity_type": "public_workspace_status_change", "description": "=danger"}] + + def create_item(self, body): + self.records.append(body) + + +@pytest.fixture +def routes(monkeypatch): + monkeypatch.setitem(sys.modules, "functions_control_center_groups", group_module) + spec.loader.exec_module(module) + env = _build_env() + monkeypatch.setitem(sys.modules, "functions_activity_logging", env.activity_stub) + seed_workspace(env, workspace_id="public-1", status=None, metrics={ + "calculated_at": "2026-10-07T00:00:00Z", "document_metrics": {"total_documents": 3}, + }) + seed_workspace(env, workspace_id="public-2", status="locked") + settings = {"require_member_of_control_center_admin": False, "require_member_of_control_center_dashboard": True, + "enable_public_workspaces": True, "enable_retention_policy_public": True} + auth = {"wraps": wraps, "session": session, "request": request, "jsonify": jsonify, + "get_settings": lambda: settings, "debug_print": lambda *a, **k: None} + exec(compile(definitions("functions_authentication.py", { + "login_required", "control_center_required", "get_control_center_capabilities", + }), "functions_authentication.py", "exec"), auth) + blueprint = Blueprint("workspace_test", __name__) + queries, docs, activity = WorkspaceQueries(env.workspaces), Documents(), Activity() + approvals, executions = [], [] + def approval(**kwargs): + item = {"id": f"approval-{len(approvals)}", **kwargs} + approvals.append(item) + return item + ns = dict(env.route_ns) + ns.update({name: getattr(module, name) for name in ( + "WORKSPACE_EXPORT_LIMIT", "parse_workspace_filters", "query_workspaces", + "select_workspace_ids", "workspace_members", "workspace_row", + )}) + ns.update({ + "bp": blueprint, "request": request, "session": session, "jsonify": jsonify, "Response": Response, + "StringIO": StringIO, "csv": csv, "CosmosResourceNotFoundError": CosmosResourceNotFoundError, + "login_required": auth["login_required"], "control_center_required": auth["control_center_required"], + "GroupRequestError": group_module.GroupRequestError, "validate_group_id": group_module.validate_group_id, + "validate_group_status_payload": group_module.validate_group_status_payload, + "cosmos_public_workspaces_container": queries, "cosmos_public_documents_container": docs, + "cosmos_activity_logs_container": activity, "get_settings": lambda: settings, + "create_approval_request": approval, + "TYPE_TAKE_OWNERSHIP": "take_ownership", "TYPE_TRANSFER_OWNERSHIP": "transfer_ownership", + "TYPE_DELETE_DOCUMENTS": "delete_documents", "TYPE_DELETE_GROUP": "delete_group", + "TYPE_DELETE_USER_DOCUMENTS": "delete_user_documents", "TYPE_WARN_USER": "warn", + "TYPE_SUSPEND_USER": "suspend", "TYPE_BLOCK_USER": "block", "is_m365_approval": lambda _: False, + "mark_approval_executed": lambda **kw: executions.append(kw), + "delete_document_chunks": lambda **kw: docs.deleted.append(("chunks", kw)), + "delete_document": lambda **kw: docs.deleted.append(("metadata", kw)), + }) + exec(compile(definitions("functions_public_workspaces.py", {"get_user_role_in_public_workspace"}), + "roles", "exec"), ns) + routes_to_extract = { + "api_v2_control_center_public_workspaces", "api_v2_control_center_public_workspace_detail", + "api_v2_control_center_public_workspaces_bulk_status", "api_v2_control_center_public_workspace_status", + "api_v2_control_center_public_workspaces_export", "api_update_public_workspace_status", + "api_admin_add_workspace_member", "api_admin_take_workspace_ownership", "api_update_public_workspace_ownership", + "api_delete_public_workspace_documents_admin", "api_delete_public_workspace_admin", + "_execute_approved_action", "_execute_delete_public_workspace", "_execute_delete_public_workspace_documents", + } + source = definitions("route_backend_control_center.py", {"_control_center_csv_safe_cell"}, + register="register_route_backend_control_center", nested=routes_to_extract) + exec(compile(source, "route_backend_control_center.py", "exec"), ns) + monkeypatch.setitem(sys.modules, "functions_public_workspaces", module_stub( + "functions_public_workspaces", + PUBLIC_WORKSPACE_WRITE_CONFLICT_CODE=env.ws_ns["PUBLIC_WORKSPACE_WRITE_CONFLICT_CODE"], + PUBLIC_WORKSPACE_WRITE_CONFLICT_MESSAGE=env.ws_ns["PUBLIC_WORKSPACE_WRITE_CONFLICT_MESSAGE"], + PublicWorkspaceDocumentWriteConflict=env.ws_ns["PublicWorkspaceDocumentWriteConflict"], + find_public_workspace_by_id=lambda identifier: env.workspaces.get(identifier, identifier), + get_user_role_in_public_workspace=ns["get_user_role_in_public_workspace"], + update_public_workspace_document_with_etag_guard=env.ws_ns["update_public_workspace_document_with_etag_guard"], + )) + ns.update({"get_current_user_id": lambda: session["user"]["oid"], "user_required": lambda function: function}) + exec(compile(definitions("route_backend_retention_policy.py", set(), + register="register_route_backend_retention_policy", + nested={"update_public_workspace_retention_settings"}), "retention", "exec"), ns) + app = Flask("v2_workspace") + app.config.update(TESTING=True, SECRET_KEY="isolated-key") + app.register_blueprint(blueprint) + client = app.test_client() + with client.session_transaction() as state: + state["user"] = {"oid": "cc-admin", "roles": ["Admin"], "preferred_username": "cc@example.test"} + yield env, client, ns, queries, approvals, executions, docs + + +def test_query_filters_pagination_and_missing_status_active(routes): + _, client, _, queries, _, _, _ = routes + result = client.get("/api/v2/control-center/public-workspaces?status=active&search=public&owner=owner&per_page=1") + assert result.status_code == 200 + data = result.get_json() + assert data["pagination"]["total_items"] == 1 + assert data["workspaces"][0]["id"] == "public-1" + assert data["workspaces"][0]["tokens"] is None + assert len(queries.queries) == 3 + for query, params in queries.queries: + assert "NOT IS_DEFINED(c.status) OR IS_NULL(c.status)" in query + assert {"name": "@search", "value": "public"} in params + page = client.get("/api/v2/control-center/public-workspaces?per_page=1&page=2") + assert page.get_json()["pagination"]["page"] == 2 + assert any({"name": "@offset", "value": 1} in params for _, params in queries.queries) + + +@pytest.mark.parametrize("query", ["status=bad", "sort=password", "direction=bad", "page=0", + "page=-1", "page=100001", "per_page=251", "per_page=x", + "search=" + "a" * 201, "owner=" + "a" * 201]) +def test_invalid_requests_do_not_query(routes, query): + _, client, _, queries, _, _, _ = routes + result = client.get(f"/api/v2/control-center/public-workspaces?{query}") + assert result.status_code == 400 + assert not queries.queries + + +def test_sort_recorded_values_then_missing_without_dropping_workspaces(routes): + _, client, _, _, _, _, _ = routes + for direction in ("asc", "desc"): + result = client.get(f"/api/v2/control-center/public-workspaces?sort=documents&direction={direction}&per_page=1&page=2") + assert result.status_code == 200 + assert result.get_json()["workspaces"][0]["id"] == "public-2" + assert result.get_json()["pagination"]["total_items"] == 2 + owner = client.get("/api/v2/control-center/public-workspaces?sort=owner") + assert owner.status_code == 200 + + +def test_unknown_status_is_inactive_and_search_is_bound_not_interpolated(routes): + env, client, _, queries, _, _, _ = routes + workspace = env.workspaces.get("public-2", "public-2") + workspace["status"] = "legacy-unknown" + env.workspaces.seed(workspace) + result = client.get("/api/v2/control-center/public-workspaces?status=inactive") + assert result.status_code == 200 + assert result.get_json()["workspaces"][0]["status"] == "inactive" + malicious = "' OR 1=1 --" + searched = client.get("/api/v2/control-center/public-workspaces", query_string={"search": malicious}) + assert searched.status_code == 200 and searched.get_json()["pagination"]["total_items"] == 0 + assert all(malicious not in query for query, _ in queries.queries) + assert any({"name": "@search", "value": malicious.lower()} in params for _, params in queries.queries) + + +@pytest.mark.parametrize("path,method", [ + ("", "GET"), ("/public-1", "GET"), ("/export.csv", "GET"), + ("/public-1/status", "PUT"), ("/bulk-status", "POST"), +]) +@pytest.mark.parametrize("user,status", [(None, 401), ({"oid": "reader", "roles": ["ControlCenterDashboardReader"]}, 403)]) +def test_admin_only(routes, path, method, user, status): + _, client, _, queries, _, _, _ = routes + with client.session_transaction() as state: + state.clear() + if user: + state["user"] = user + result = client.open("/api/v2/control-center/public-workspaces" + path, method=method, json={}) + assert result.status_code == status + assert not queries.queries + + +def test_details_roles_and_live_totals_without_raw_settings(routes): + _, client, _, _, _, _, _ = routes + result = client.get("/api/v2/control-center/public-workspaces/public-1") + assert result.status_code == 200 + data = result.get_json() + assert data["documents_summary"]["count"] == 2 and data["tokens"] == 120 + assert {member["role"] for member in data["members"]} == {"Owner", "Admin", "DocumentManager"} + assert not data["permissions"]["can_edit_members"] + assert not data["retention"]["can_edit"] + assert "settings" not in data and "model_endpoints" not in data + + +def test_bulk_real_writer_partial_results_and_validation_before_writes(routes): + env, client, _, _, _, _, _ = routes + invalid = client.post("/api/v2/control-center/public-workspaces/bulk-status", json={"workspace_ids": ["public-1"], "status": "locked"}) + assert invalid.status_code == 400 + result = client.post("/api/v2/control-center/public-workspaces/bulk-status", json={ + "workspace_ids": ["public-1", "missing"], "status": "locked", "reason": "Review", + }) + assert result.status_code == 200 + assert result.get_json()["success_count"] == 1 and result.get_json()["failed_count"] == 1 + stored = env.workspaces.get("public-1", "public-1") + assert stored["statusHistory"][-1]["reason"] == "Review" + assert len(env.status_logs) == 1 + unchanged = client.put("/api/v2/control-center/public-workspaces/public-1/status", json={"status": "locked", "reason": "Review"}) + assert unchanged.status_code == 200 and len(env.status_logs) == 1 + + +def test_filter_selection_cap_and_exclusions_precede_writes(routes): + env, client, _, queries, _, _, _ = routes + excluded = module.select_workspace_ids(queries, {"filter": {}, "exclude_ids": ["public-1"]}) + assert excluded == ["public-2"] + for index in range(2, 502): + seed_workspace(env, workspace_id=f"w-{index}", status="active") + capped = client.post("/api/v2/control-center/public-workspaces/bulk-status", json={ + "filter": {}, "status": "active", + }) + assert capped.status_code == 400 + assert not env.status_logs + assert any("TOP 501" in query for query, _ in queries.queries) + selected = module.select_workspace_ids(queries, {"filter": {"status": "locked"}, "exclude_ids": []}) + assert selected == ["public-2"] + with pytest.raises(group_module.GroupRequestError): + module.select_workspace_ids(queries, {"filter": {"arbitrary": "field"}}) + with pytest.raises(group_module.GroupRequestError): + module.select_workspace_ids(queries, {"workspace_ids": ["public-1"] * 501}) + + +@pytest.mark.parametrize("payload", [ + {"workspace_ids": ["../admin"], "status": "active"}, + {"workspace_ids": [], "status": "active"}, + {"workspace_ids": ["public-1"], "filter": {}, "status": "active"}, + {"filter": {}, "exclude_ids": "public-1", "status": "active"}, + {"workspace_ids": ["public-1"], "status": "locked", "reason": "x" * 2001}, +]) +def test_invalid_bulk_payloads_never_write(routes, payload): + env, client, _, _, _, _, _ = routes + result = client.post("/api/v2/control-center/public-workspaces/bulk-status", json=payload) + assert result.status_code == 400 and not env.status_logs + + +def test_safe_filtered_csv_and_failures(routes, monkeypatch): + env, client, _, queries, _, _, _ = routes + row = env.workspaces.get("public-1", "public-1") + row["name"] = " =HYPERLINK(\"bad\")" + env.workspaces.seed(row) + result = client.get("/api/v2/control-center/public-workspaces/export.csv?status=active") + assert result.status_code == 200 + cells = list(csv.reader(StringIO(result.get_data(as_text=True)))) + assert len(cells) == 2 and cells[1][1].startswith("'") + assert cells[1][6] == "" + def fail(**kwargs): + raise RuntimeError("sensitive-credential") + monkeypatch.setattr(queries, "query_items", fail) + failed = client.get("/api/v2/control-center/public-workspaces") + assert failed.status_code == 500 and "sensitive-credential" not in failed.get_data(as_text=True) + + +@pytest.mark.parametrize("suffix,kind", [("", "delete_group"), ("/documents", "delete_documents"), ("/take-ownership", "take_ownership")]) +def test_existing_approval_creation_does_not_mutate_workspace(routes, suffix, kind): + env, client, _, _, approvals, _, _ = routes + method = "POST" if suffix == "/take-ownership" else "DELETE" + result = client.open(f"/api/admin/control-center/public-workspaces/public-1{suffix}", method=method, json={"reason": "Governance"}) + assert result.status_code in (200, 201) + assert result.get_json()["approval_id"] + assert approvals[-1]["request_type"] == kind + assert approvals[-1]["metadata"]["entity_type"] == "workspace" + assert approvals[-1]["group_id"] == "public-1" + assert env.workspaces.get("public-1", "public-1") is not None + + +def test_actual_approval_dispatch_deletes_public_scope_not_groups(routes): + env, client, ns, queries, approvals, executions, docs = routes + created = client.delete("/api/admin/control-center/public-workspaces/public-1", json={"reason": "Governance"}) + assert created.status_code == 200 + # Query wrapper forwards only the actual public-container deletion. + queries.delete_item = env.workspaces.delete_item + approval = {**approvals[-1], "requester_id": "cc-admin", "requester_email": "cc@example.test"} + result = ns["_execute_approved_action"](approval, "other-admin", "other@example.test", "Other") + assert result["success"] is True + assert executions[-1]["success"] is True and executions[-1]["group_id"] == "public-1" + assert all(item["public_workspace_id"] == "public-1" for _, item in docs.deleted) + assert len(docs.deleted) == 4 + with pytest.raises(CosmosResourceNotFoundError): + env.workspaces.read_item(item="public-1", partition_key="public-1") + assert env.workspaces.get("public-2", "public-2") is not None + + +def test_existing_member_add_and_transfer_validate_recorded_roles(routes): + env, client, _, _, approvals, _, _ = routes + added = client.post("/api/admin/control-center/public-workspaces/public-1/add-member", json={ + "userId": "new-manager", "displayName": "New", "email": "new@example.test", + "role": "document_manager", "source": "csv", + }) + assert added.status_code == 200 and not added.get_json()["skipped"] + workspace = env.workspaces.get("public-1", "public-1") + assert any(member["userId"] == "new-manager" for member in workspace["documentManagers"]) + env.user_settings.seed({"id": "new-manager", "email": "new@example.test", "display_name": "New"}) + transferred = client.put("/api/admin/control-center/public-workspaces/public-1/ownership", + json={"newOwnerId": "new-manager", "reason": "New steward"}) + assert transferred.status_code == 201 and transferred.get_json()["requires_approval"] + assert approvals[-1]["metadata"]["new_owner_id"] == "new-manager" + assert approvals[-1]["metadata"]["entity_type"] == "workspace" + workspace = env.workspaces.get("public-1", "public-1") + assert workspace["owner"]["userId"] == "owner" + + +def test_existing_public_retention_requires_membership_and_rejects_default(routes): + env, client, _, _, _, _, _ = routes + base = "/api/retention-policy/public/public-1" + denied = client.post(base, json={"document_retention_days": 90}) + assert denied.status_code == 403 + with client.session_transaction() as state: + state["user"] = {**state["user"], "oid": "owner"} + inherited = client.post(base, json={"document_retention_days": "default"}) + assert inherited.status_code == 400 + saved = client.post(base, json={"document_retention_days": "90"}) + assert saved.status_code == 200 + workspace = env.workspaces.get("public-1", "public-1") + assert workspace["retention_policy"]["document_retention_days"] == 90 + unlimited = client.post(base, json={"document_retention_days": "none"}) + assert unlimited.status_code == 200 diff --git a/functional_tests/test_v2_control_center_users.py b/functional_tests/test_v2_control_center_users.py new file mode 100644 index 000000000..9a4b8307a --- /dev/null +++ b/functional_tests/test_v2_control_center_users.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +# test_v2_control_center_users.py +""" +Functional test for V2 Control Center user management. +Version: 0.261.280 +Implemented in: 0.261.280 + +This test validates Users filtering, sorting, paging, detail data boundaries, +bulk-action limits, admin-only authorization, cached metric freshness and CSV safety. +""" + +import ast +import re +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +APP = ROOT / "application" / "single_app" +ROUTE = APP / "route_backend_control_center.py" +AUTH = APP / "functions_authentication.py" +sys.path.insert(0, str(ROOT / "functional_tests")) + +from test_support.versioning import assert_app_version_at_least + + +HELPERS = { + "_control_center_validate_user_id", + "_control_center_parse_user_filters", + "_control_center_user_where", + "_control_center_effective_restriction", + "_control_center_user_row", + "_control_center_csv_safe_cell", +} + + +def _route_helpers(): + tree = ast.parse(ROUTE.read_text(encoding="utf-8")) + assignments = [ + node for node in tree.body + if isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Name) + and target.id in {"CONTROL_CENTER_USER_ID_PATTERN", "CONTROL_CENTER_USER_SORTS"} + for target in node.targets + ) + ] + functions = [ + node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name in HELPERS + ] + assert {node.name for node in functions} == HELPERS + namespace = { + "re": re, + "datetime": datetime, + "timedelta": timedelta, + "timezone": timezone, + } + exec(compile(ast.Module(body=assignments + functions, type_ignores=[]), str(ROUTE), "exec"), namespace) + return namespace + + +def _route_functions(path): + tree = ast.parse(path.read_text(encoding="utf-8")) + return { + node.name: node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) + } + + +def test_filter_contract_is_parameterized_and_supports_dashboard_drillthrough(): + helpers = _route_helpers() + now = datetime(2026, 10, 7, tzinfo=timezone.utc) + filters = helpers["_control_center_parse_user_filters"]({ + "search": "x' OR 1=1 --", + "filter": "active", + "status": "blocked", + "sort": "tokens", + "direction": "desc", + "has_documents": "yes", + }, now=now) + where, parameters = helpers["_control_center_user_where"](filters) + parameter_values = {item["name"]: item["value"] for item in parameters} + assert filters["last_login"] == "30" + assert filters["access_status"] == "deny" + assert filters["sort"] == "tokens" + assert filters["direction"] == "desc" + assert parameter_values["@search"] == "x' or 1=1 --" + assert "@search" in where and "x' OR 1=1" not in where + assert "c.settings.metrics.token_metrics.total_tokens" in helpers["CONTROL_CENTER_USER_SORTS"]["tokens"] + assert "IS_DEFINED(c.settings.metrics.document_metrics.total_documents)" in where + assert {item["name"] for item in parameters} >= { + "@search", "@access_status", "@access_now", "@last_login_cutoff", + } + + route = ROUTE.read_text(encoding="utf-8") + assert "OFFSET @offset LIMIT @limit" in route + assert '"total_items": total' in route + assert '"metrics_freshness"' in route + assert "CONTROL_CENTER_USER_SORTS[filters[\"sort\"]]" in route + + +def test_filter_validation_rejects_unbounded_or_unknown_fields(): + parse_filters = _route_helpers()["_control_center_parse_user_filters"] + for query, message in ( + ({"sort": "settings"}, "sort"), + ({"direction": "sideways"}, "direction"), + ({"last_login": "365"}, "last-login"), + ({"search": "x" * 201}, "Search"), + ): + try: + parse_filters(query) + except ValueError as error: + assert message.lower() in str(error).lower() + else: + raise AssertionError(f"Invalid query was accepted: {query!r}") + + +def test_user_ids_expiry_and_cached_row_projection_are_validated(): + helpers = _route_helpers() + validate_id = helpers["_control_center_validate_user_id"] + assert validate_id("abc-123") == "abc-123" + for value in ("", "../admin", "a/b", "x" * 129): + try: + validate_id(value) + except ValueError: + pass + else: + raise AssertionError(f"Invalid user ID was accepted: {value!r}") + + now = datetime(2026, 10, 7, tzinfo=timezone.utc) + effective = helpers["_control_center_effective_restriction"] + assert effective({"access": {"status": "deny", "datetime_to_allow": "2026-10-06T00:00:00Z"}}, "access", now)["status"] == "allow" + assert effective({"access": {"status": "deny", "datetime_to_allow": "2026-10-08T00:00:00Z"}}, "access", now)["status"] == "deny" + + row = helpers["_control_center_user_row"]({ + "id": "user-1", + "email": "person@example.test", + "display_name": "Person", + "settings": { + "metrics": { + "calculated_at": "2026-10-07T00:00:00Z", + "login_metrics": {"last_login": "2026-10-06T00:00:00Z", "total_logins": 4}, + "chat_metrics": {"total_conversations": 9}, + "document_metrics": {"total_documents": 2}, + "token_metrics": {"total_tokens": 120}, + }, + }, + }, now) + assert row["access"]["status"] == "allow" + assert (row["last_login"], row["conversations"], row["documents"], row["tokens"]) == ( + "2026-10-06T00:00:00Z", 9, 2, 120, + ) + assert row["metrics_calculated_at"] == "2026-10-07T00:00:00Z" + + +def test_allow_filters_exclude_active_denials_with_null_or_future_expiry(): + parse_filters = _route_helpers()["_control_center_parse_user_filters"] + build_where = _route_helpers()["_control_center_user_where"] + filters = parse_filters({"access_status": "allow", "upload_status": "allow"}) + where, _ = build_where(filters) + for field, parameter in (("access", "@access_now"), ("file_uploads", "@upload_now")): + assert ( + f"IS_DEFINED(c.settings.{field}.datetime_to_allow) " + f"AND NOT IS_NULL(c.settings.{field}.datetime_to_allow) " + f"AND c.settings.{field}.datetime_to_allow <= {parameter}" + ) in where + assert f"OR IS_NULL(c.settings.{field}.datetime_to_allow)" not in where + + +def test_bulk_selection_is_filter_based_exclusion_aware_and_capped(): + source = ROUTE.read_text(encoding="utf-8") + route = _route_functions(ROUTE)["api_v2_control_center_users_bulk_action"] + route_source = ast.get_source_segment(source, route) + assert "CONTROL_CENTER_USERS_MAX_BULK = 500" in source + assert "data.get(\"user_ids\")" in route_source + assert "data.get(\"filter\")" in route_source + assert "data.get(\"exclude_ids\", [])" in route_source + assert "CONTROL_CENTER_USERS_MAX_BULK + 1" in route_source + assert "update_user_settings(" in route_source + assert '"failed_user_ids": failed_ids' in route_source + + +def test_detail_contract_uses_authorized_snapshot_and_batched_memberships(): + source = ROUTE.read_text(encoding="utf-8") + route = _route_functions(ROUTE)["api_v2_control_center_user_detail"] + route_source = ast.get_source_segment(source, route) + assert "read_user_settings_snapshot(" in route_source + assert "allow_cross_user=True" in route_source + assert "TOP 20" in route_source + assert "partition_key=normalized_user_id" in route_source + assert '"groups": group_memberships' in route_source + assert '"public_workspaces": public_memberships' in route_source + assert '"usage": {' in route_source + + +def test_csv_cells_neutralize_all_formula_prefixes_and_export_is_streamed(): + helpers = _route_helpers() + safe_cell = helpers["_control_center_csv_safe_cell"] + for prefix in ("=", "+", "-", "@"): + assert safe_cell(f"{prefix}1+1") == f"'{prefix}1+1" + assert safe_cell("ordinary text") == "ordinary text" + assert safe_cell(None) == "" + + source = ROUTE.read_text(encoding="utf-8") + route_source = ast.get_source_segment( + source, + _route_functions(ROUTE)["api_v2_control_center_users_export"], + ) + assert "stream_with_context(stream_csv())" in route_source + assert "_control_center_user_where(filters)" in route_source + assert "_control_center_csv_safe_cell(value)" in route_source + + +def test_routes_require_full_control_center_admin_not_dashboard_reader(): + route_functions = _route_functions(ROUTE) + for name in ( + "api_v2_control_center_users", + "api_v2_control_center_user_detail", + "api_v2_control_center_users_bulk_action", + "api_v2_control_center_users_export", + ): + route = route_functions[name] + decorators = [ast.unparse(decorator) for decorator in route.decorator_list] + assert any("swagger_route" in decorator and "get_auth_security" in decorator for decorator in decorators) + assert "login_required" in decorators + assert "control_center_required('admin')" in decorators + + auth_functions = _route_functions(AUTH) + capabilities = auth_functions["get_control_center_capabilities"] + namespace = {} + exec(compile(ast.Module(body=[capabilities], type_ignores=[]), str(AUTH), "exec"), namespace) + reader_capabilities = namespace["get_control_center_capabilities"]( + {"roles": ["ControlCenterDashboardReader"]}, + { + "require_member_of_control_center_admin": True, + "require_member_of_control_center_dashboard_reader": True, + }, + ) + assert reader_capabilities["can_view_dashboard"] + assert not reader_capabilities["can_manage_users"] + + +def test_version_is_at_least_the_implementation_version(): + assert_app_version_at_least("0.261.280") + + +TESTS = [ + test_filter_contract_is_parameterized_and_supports_dashboard_drillthrough, + test_filter_validation_rejects_unbounded_or_unknown_fields, + test_user_ids_expiry_and_cached_row_projection_are_validated, + test_allow_filters_exclude_active_denials_with_null_or_future_expiry, + test_bulk_selection_is_filter_based_exclusion_aware_and_capped, + test_detail_contract_uses_authorized_snapshot_and_batched_memberships, + test_csv_cells_neutralize_all_formula_prefixes_and_export_is_streamed, + test_routes_require_full_control_center_admin_not_dashboard_reader, + test_version_is_at_least_the_implementation_version, +] + + +if __name__ == "__main__": + for test in TESTS: + test() + print(f"PASS {test.__name__}") + print(f"{len(TESTS)}/{len(TESTS)} V2 user management checks passed") diff --git a/ui_tests/test_v2_control_center_groups.py b/ui_tests/test_v2_control_center_groups.py new file mode 100644 index 000000000..6e2bcdc49 --- /dev/null +++ b/ui_tests/test_v2_control_center_groups.py @@ -0,0 +1,288 @@ +# test_v2_control_center_groups.py +""" +Browser workflows for V2 Control Center Groups. +Version: 0.261.282 +Implemented in: 0.261.282 + +Uses the built local bundle with intercepted APIs. Shared Azure Playwright +connection support uses DefaultAzureCredential when a workspace is configured. +""" + +import json +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import pytest +from playwright.sync_api import expect + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent / "fixtures")) + +from playwright_connection import connect_options +from test_v2_control_center_users import ORIGIN, UsersFixture + + +GROUP_ID = "group-1" +GROUP_NAME = "Research " + + +def group_row(): + return { + "id": GROUP_ID, "name": GROUP_NAME, "description": "Research documents", + "owner": {"id": "owner-1", "display_name": "Olive Owner", "email": "owner@example.test"}, + "status": "active", "members": 2, "documents": 3, "tokens": 120, + "created_at": "2026-09-01T00:00:00Z", "last_activity": "2026-10-06T00:00:00Z", + "metrics_calculated_at": None, + } + + +class GroupsFixture(UsersFixture): + def __init__(self, page): + self.group = group_row() + self.member_manager = True + self.members = [ + {"id": "owner-1", "display_name": "Olive Owner", "email": "owner@example.test", "role": "Owner"}, + {"id": "member-1", "display_name": "Max Member", "email": "max@example.test", "role": "User"}, + ] + self.mutations = [] + self.status_history = [] + self.retention = {"conversation_retention_days": "default", "document_retention_days": 30} + self.fail_bulk = False + self.console_errors = [] + super().__init__(page) + page.on("pageerror", lambda error: self.console_errors.append(str(error))) + + def _route(self, route): + request = route.request + parsed = urlsplit(request.url) + path = parsed.path + method = request.method + if path == "/api/v2/control-center/groups" and method == "GET": + self.requests.append((method, path, parsed.query)) + route.fulfill(json={ + "groups": [self.group], + "pagination": {"page": 1, "total_items": 2}, + "metrics_freshness": {"calculated_at": "2026-10-07T00:00:00Z", "ttl_seconds": 90}, + }) + elif path == f"/api/v2/control-center/groups/{GROUP_ID}" and method == "GET": + route.fulfill(json={ + "group": self.group, "members": self.members, "status_history": self.status_history, + "retention": {**self.retention, "enabled": True, "can_edit": self.member_manager}, + "permissions": {"can_edit_members": self.member_manager, "current_role": "Admin" if self.member_manager else None}, + "documents_summary": {"count": 3, "cached_metrics": {"total_documents": 3}, "metrics_calculated_at": None}, + "tokens": 120, "metrics_calculated_at": "2026-10-07T00:00:00Z", + "activity": [{"id": "activity-1", "timestamp": "2026-10-06T00:00:00Z", + "activity_type": "group_status_change", "description": ""}], + }) + elif path == "/api/v2/control-center/groups/bulk-status" and method == "POST": + payload = json.loads(request.post_data) + self.bulk_payloads.append(payload) + if self.fail_bulk: + route.fulfill(json={"success_count": 1, "failed_count": 1, "failed_groups": [{"id": "missing", "error": "Group not found."}]}) + else: + self.group["status"] = payload["status"] + route.fulfill(json={"success_count": 2, "failed_count": 0, "failed_groups": []}) + elif path == f"/api/v2/control-center/groups/{GROUP_ID}/status" and method == "PUT": + payload = json.loads(request.post_data) + self.mutations.append((method, path, payload)) + self.status_history.append({ + "old_status": self.group["status"], "new_status": payload["status"], + "changed_by_email": "admin@example.test", "changed_at": "2026-10-07T00:00:00Z", "reason": payload["reason"], + }) + self.group["status"] = payload["status"] + route.fulfill(json={"new_status": payload["status"]}) + elif path.startswith(f"/api/admin/control-center/groups/{GROUP_ID}") and method in ("POST", "DELETE"): + payload = json.loads(request.post_data or "{}") + self.mutations.append((method, path, payload)) + if path.endswith("/add-member"): + self.members.append({"id": payload["userId"], "display_name": payload["displayName"], "email": payload["email"], "role": "User"}) + route.fulfill(json={"skipped": False}) + else: + route.fulfill(json={"approval_id": "approval-42", "status": "pending"}) + elif path.startswith(f"/api/groups/{GROUP_ID}/members/") and method in ("DELETE", "PATCH"): + member_id = path.rsplit("/", 1)[1] + payload = json.loads(request.post_data or "{}") + self.mutations.append((method, path, payload)) + if method == "DELETE": + self.members = [member for member in self.members if member["id"] != member_id] + else: + for member in self.members: + if member["id"] == member_id: + member["role"] = payload["role"] + route.fulfill(json={"success": True}) + elif path == f"/api/retention-policy/group/{GROUP_ID}" and method == "POST": + self.retention = json.loads(request.post_data) + self.mutations.append((method, path, self.retention)) + route.fulfill(json={"success": True}) + elif path == "/api/userSearch" and method == "GET": + route.fulfill(json=[{"id": "new-1", "displayName": "New Member", "mail": "new@example.test", "userPrincipalName": "new@example.test"}]) + elif path == "/api/v2/control-center/groups/export.csv": + route.fulfill(content_type="text/csv", body="id,name\ngroup-1,Research\n", + headers={"Content-Disposition": 'attachment; filename="groups.csv"'}) + else: + super()._route(route) + + def open(self, query="", mobile=False): + self.page.set_viewport_size({"width": 390, "height": 844} if mobile else {"width": 1440, "height": 900}) + self.page.goto(f"{ORIGIN}/v2/control-center/groups{query}", wait_until="networkidle") + + def drawer(self): + self.page.get_by_role("button", name=f"Open details for {GROUP_NAME}").click() + expect(self.page.get_by_role("dialog", name=GROUP_NAME)).to_be_visible() + + def assert_clean(self): + super().assert_clean() + assert not self.console_errors, self.console_errors + + +@pytest.fixture +def groups_ui(page): + fixture = GroupsFixture(page) + yield fixture + fixture.assert_clean() + + +pytestmark = pytest.mark.ui + + +def test_filters_cross_page_bulk_reason_and_export(groups_ui): + groups_ui.open() + page = groups_ui.page + page.get_by_label("Filter by group status").select_option("active") + expect(page).to_have_url(f"{ORIGIN}/v2/control-center/groups?status=active") + expect(page.get_by_role("link", name="Export CSV")).to_have_attribute( + "href", "/api/v2/control-center/groups/export.csv?status=active", + ) + page.get_by_label("Select all rows on this page").check() + page.get_by_role("button", name="Select all 2 matching records").click() + page.get_by_role("button", name="Apply bulk status").click() + expect(page.get_by_role("button", name="Apply bulk status").last).to_be_disabled() + page.get_by_label("Reason (required)").fill("Governance review") + page.get_by_role("button", name="Apply bulk status").last.click() + expect(page.get_by_text("2 groups updated.")).to_be_visible() + assert groups_ui.bulk_payloads[-1] == { + "filter": {"status": "active"}, "exclude_ids": [], "status": "locked", "reason": "Governance review", + } + page.get_by_role("button", name="Refresh groups").click() + expect(page.get_by_role("button", name="Refresh groups")).to_be_enabled() + assert "force_refresh=1" in groups_ui.requests[-1][2] + page.get_by_label("Filter by group status").select_option("locked") + expect(page.get_by_role("button", name="Refresh groups")).to_be_enabled() + assert "force_refresh" not in groups_ui.requests[-1][2] + + +def test_partial_bulk_failure_survives_reload(groups_ui): + groups_ui.fail_bulk = True + groups_ui.open() + page = groups_ui.page + page.get_by_label("Select all rows on this page").check() + page.get_by_role("button", name="Apply bulk status").click() + page.get_by_label("Reason (required)").fill("Review") + page.get_by_role("button", name="Apply bulk status").last.click() + expect(page.get_by_text("1 succeeded; 1 failed. missing: Group not found.")).to_be_visible() + + +@pytest.mark.parametrize("mobile", [False, True]) +def test_detail_tabs_status_history_raw_json_and_approval(groups_ui, mobile): + groups_ui.open(query="?id=group-1", mobile=mobile) + page = groups_ui.page + expect(page.get_by_role("dialog", name=GROUP_NAME)).to_be_visible() + expect(page.locator("img[src=x]")).to_have_count(0) + page.get_by_role("tab", name="Status", exact=True).click() + page.get_by_label("Group status", exact=True).select_option("locked") + page.get_by_role("button", name="Change status").click() + page.get_by_label("Reason (required)").fill("Review documents") + page.get_by_role("button", name="Apply status").click() + expect(page.get_by_text("Review documents", exact=True)).to_be_visible() + page.get_by_role("tab", name="Activity", exact=True).click() + expect(page.get_by_text("", exact=True)).to_be_visible() + page.get_by_role("button", name="Raw JSON").click() + expect(page.locator("pre")).to_contain_text("group_status_change") + expect(page.get_by_role("link", name="View in Activity Logs")).to_have_attribute( + "href", "/v2/control-center/activity-logs?workspace_type=group&workspace_id=group-1&group_id=group-1", + ) + page.get_by_role("tab", name="Documents", exact=True).click() + page.get_by_role("button", name="Request deletion of all documents").click() + page.get_by_label("Reason (required)").fill("Approved cleanup process") + page.get_by_role("button", name="Submit approval request").click() + expect(page.get_by_text("Request approval-42 submitted for approval.", exact=False)).to_be_visible() + assert groups_ui.mutations[-1][1].endswith("/delete-documents") + expect(page.get_by_role("link", name="View approval requests")).to_have_attribute( + "href", "/v2/approvals/all/approval-42?group_id=group-1", + ) + + +def test_members_add_csv_role_remove_and_retention(groups_ui): + groups_ui.open() + groups_ui.drawer() + page = groups_ui.page + page.get_by_role("tab", name="Members", exact=True).click() + page.get_by_role("button", name="Add member", exact=True).click() + page.get_by_label("Search the directory").fill("new") + page.get_by_role("button", name="Choose New Member").click() + page.get_by_role("button", name="Add member", exact=True).last.click() + expect(page.get_by_role("link", name="New Member", exact=True)).to_be_visible() + page.get_by_label("Role for Max Member").select_option("Admin") + expect(page.get_by_label("Role for Max Member")).to_have_value("Admin") + page.get_by_role("button", name="Import CSV").click() + page.get_by_label("CSV file").set_input_files({ + "name": "members.csv", "mimeType": "text/csv", + "buffer": b"userId,displayName,email,role\n00000000-0000-0000-0000-000000000001,CSV Member,csv@example.test,user\n", + }) + page.get_by_role("button", name="Add 1 member", exact=True).click() + expect(page.get_by_text("1 added, 0 already members, 0 failed.", exact=True)).to_be_visible() + page.get_by_role("button", name="Done", exact=True).click() + expect(page.get_by_role("link", name="CSV Member", exact=True)).to_be_visible() + assert any(payload.get("source") == "csv" for _, path, payload in groups_ui.mutations if path.endswith("/add-member")) + page.get_by_role("button", name="Remove member", exact=True).first.click() + page.get_by_role("button", name="Remove member", exact=True).last.click() + expect(page.get_by_role("link", name="Max Member", exact=True)).to_have_count(0) + page.get_by_role("tab", name="Retention", exact=True).click() + page.get_by_label("Document retention days", exact=True).fill("90") + page.get_by_role("button", name="Save retention").click() + expect(page.get_by_text("Retention policy saved.", exact=True)).to_be_visible() + assert groups_ui.retention["document_retention_days"] == "90" + + +def test_nonmember_admin_permissions_and_ownership_requests(groups_ui): + groups_ui.member_manager = False + groups_ui.open() + groups_ui.drawer() + page = groups_ui.page + page.get_by_role("tab", name="Members", exact=True).click() + expect(page.get_by_role("button", name="Remove member", exact=True)).to_have_count(0) + page.get_by_role("tab", name="Retention", exact=True).click() + expect(page.get_by_role("button", name="Save retention")).to_be_disabled() + page.get_by_role("tab", name="Ownership", exact=True).click() + page.get_by_label("Transfer to a member").select_option("member-1") + page.get_by_role("button", name="Request ownership transfer").click() + page.get_by_label("Reason (required)").fill("New project lead") + page.get_by_role("button", name="Submit approval request").click() + expect(page.get_by_text("Request approval-42 submitted for approval.", exact=False)).to_be_visible() + assert groups_ui.mutations[-1][2]["newOwnerId"] == "member-1" + assert groups_ui.group["owner"]["id"] == "owner-1" + + +def test_drawer_keyboard_tabs_and_nested_escape(groups_ui): + groups_ui.open() + opener = groups_ui.page.get_by_role("button", name=f"Open details for {GROUP_NAME}") + groups_ui.drawer() + page = groups_ui.page + overview = page.get_by_role("tab", name="Overview", exact=True) + overview.focus() + overview.press("ArrowRight") + expect(page.get_by_role("tab", name="Members", exact=True)).to_be_focused() + page.get_by_role("button", name="Add member", exact=True).click() + expect(page.get_by_role("dialog")).to_have_count(2) + page.keyboard.press("Escape") + expect(page.get_by_role("dialog")).to_have_count(1) + expect(page.get_by_role("dialog", name=GROUP_NAME)).to_be_visible() + expect(page.get_by_role("button", name="Add member", exact=True)).to_be_focused() + page.keyboard.press("Escape") + expect(page.get_by_role("dialog")).to_have_count(0) + expect(opener).to_be_focused() + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-m", "ui", "-q"])) diff --git a/ui_tests/test_v2_control_center_public_workspaces.py b/ui_tests/test_v2_control_center_public_workspaces.py new file mode 100644 index 000000000..daad18249 --- /dev/null +++ b/ui_tests/test_v2_control_center_public_workspaces.py @@ -0,0 +1,243 @@ +# test_v2_control_center_public_workspaces.py +""" +Browser tests for public workspace management using the built local V2 assets. +Version: 0.261.285 +Implemented in: 0.261.283 + +Shared fixture supports Azure Playwright with DefaultAzureCredential when configured. +""" + +import json +import sys +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +import pytest +from playwright.sync_api import expect + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent / "fixtures")) + +from playwright_connection import connect_options +from test_v2_control_center_users import ORIGIN, UsersFixture + + +NAME = "Public " +ID = "public-1" + + +class WorkspacesFixture(UsersFixture): + def __init__(self, page): + self.workspace = { + "id": ID, "name": NAME, "description": "Shared knowledge", "status": "active", + "owner": {"id": "owner", "display_name": "Owner", "email": "owner@example.test"}, + "members": 2, "documents": None, "tokens": None, "last_activity": None, + "created_at": "2026-10-01T00:00:00Z", "metrics_calculated_at": None, + } + self.members = [ + {"id": "owner", "display_name": "Owner", "email": "owner@example.test", "role": "Owner"}, + {"id": "manager", "display_name": "Manager", "email": "manager@example.test", "role": "DocumentManager"}, + ] + self.can_edit = True + self.mutations = [] + self.fail_bulk = False + self.fail_list = False + self.history = [] + self.retention = {"conversation_retention_days": "default", "document_retention_days": 30} + self.errors = [] + super().__init__(page) + page.on("pageerror", lambda error: self.errors.append(str(error))) + + def _route(self, route): + request = route.request + parsed = urlsplit(request.url) + path, method = parsed.path, request.method + base = "/api/v2/control-center/public-workspaces" + admin = f"/api/admin/control-center/public-workspaces/{ID}" + if path == base and method == "GET": + self.requests.append((method, path, parsed.query)) + route.fulfill(status=500 if self.fail_list else 200, json={"error": "Inventory unavailable."} if self.fail_list else { + "workspaces": [self.workspace], "pagination": {"page": int(parse_qs(parsed.query).get("page", ["1"])[0]), "total_items": 30}, + }) + elif path == f"{base}/{ID}" and method == "GET": + route.fulfill(json={ + "workspace": self.workspace, "members": self.members, "status_history": self.history, + "permissions": {"can_edit_members": self.can_edit, "current_user_id": "admin"}, + "retention": {**self.retention, "enabled": True, "can_edit": self.can_edit}, + "documents_summary": {"count": 3, "cached_metrics": {}, "metrics_calculated_at": None}, + "tokens": 120, "metrics_calculated_at": "2026-10-07T00:00:00Z", + "activity": [{"id": "event-1", "activity_type": "public_workspace_status_change", + "description": "", "timestamp": "2026-10-07T00:00:00Z"}], + }) + elif path == f"{base}/bulk-status": + payload = json.loads(request.post_data) + self.bulk_payloads.append(payload) + route.fulfill(json={"success_count": 1, "failed_count": 1 if self.fail_bulk else 0, + "failed_workspaces": [{"id": "missing", "error": "Not found."}] if self.fail_bulk else []}) + elif path == f"{base}/{ID}/status": + payload = json.loads(request.post_data) + self.mutations.append((method, path, payload)) + self.history.append({"old_status": self.workspace["status"], "new_status": payload["status"], + "changed_by_email": "admin@example.test", "changed_at": "2026-10-07T00:00:00Z", "reason": payload["reason"]}) + self.workspace["status"] = payload["status"] + route.fulfill(json={"new_status": payload["status"]}) + elif path.startswith(admin) and method in ("POST", "PUT", "DELETE"): + payload = json.loads(request.post_data or "{}") + self.mutations.append((method, path, payload)) + if path.endswith("/add-member"): + self.members.append({"id": payload["userId"], "display_name": payload["displayName"], + "email": payload["email"], "role": "Admin" if payload["role"] == "admin" else "DocumentManager"}) + route.fulfill(json={"skipped": False}) + else: + route.fulfill(json={"approval_id": "approval-42", "status": "pending"}) + elif path.startswith(f"/api/public_workspaces/{ID}/members/"): + payload = json.loads(request.post_data or "{}") + self.mutations.append((method, path, payload)) + member_id = path.rsplit("/", 1)[1] + if method == "DELETE": + self.members = [member for member in self.members if member["id"] != member_id] + else: + for member in self.members: + if member["id"] == member_id: + member["role"] = payload["role"] + route.fulfill(json={"success": True}) + elif path == f"/api/retention-policy/public/{ID}": + payload = json.loads(request.post_data) + self.mutations.append((method, path, payload)) + self.retention.update(payload) + route.fulfill(json={"success": True}) + elif path == "/api/userSearch": + route.fulfill(json=[{"id": "new-person", "displayName": "New Manager", "mail": "new@example.test", "userPrincipalName": "new@example.test"}]) + elif path == f"{base}/export.csv": + route.fulfill(content_type="text/csv", body="id,name\npublic-1,Public\n") + else: + super()._route(route) + + def open(self, query="", mobile=False): + self.page.set_viewport_size({"width": 390, "height": 844} if mobile else {"width": 1440, "height": 900}) + self.page.goto(f"{ORIGIN}/v2/control-center/public-workspaces{query}", wait_until="networkidle") + + def assert_clean(self): + super().assert_clean() + assert not self.errors + + +@pytest.fixture +def workspaces_ui(page): + fixture = WorkspacesFixture(page) + yield fixture + fixture.assert_clean() + + +pytestmark = pytest.mark.ui + + +def test_filters_sort_page_export_and_cross_page_bulk(workspaces_ui): + workspaces_ui.open() + page = workspaces_ui.page + expect(page.get_by_text("Not recorded", exact=True).first).to_be_visible() + page.get_by_label("Filter by workspace status").select_option("active") + expect(page).to_have_url(f"{ORIGIN}/v2/control-center/public-workspaces?status=active") + expect(page.get_by_role("link", name="Export CSV")).to_have_attribute("href", "/api/v2/control-center/public-workspaces/export.csv?status=active") + page.get_by_role("button", name="Sort by Recorded documents", exact=True).click() + expect(page).to_have_url(f"{ORIGIN}/v2/control-center/public-workspaces?status=active&sort=documents&direction=asc") + page.get_by_role("button", name="Next", exact=True).click() + expect(page).to_have_url(f"{ORIGIN}/v2/control-center/public-workspaces?status=active&sort=documents&direction=asc&page=2") + page.get_by_label("Select all rows on this page").check() + page.get_by_role("button", name="Select all 30 matching records").click() + page.get_by_role("button", name="Apply bulk status").click() + page.get_by_label("Reason (required)").fill("Review") + page.get_by_role("button", name="Apply bulk status").last.click() + expect(page.get_by_text("1 public workspaces updated.")).to_be_visible() + assert workspaces_ui.bulk_payloads[-1] == {"filter": {"status": "active"}, "exclude_ids": [], "status": "locked", "reason": "Review"} + + +def test_partial_failure_and_list_failure_are_visible(workspaces_ui): + workspaces_ui.fail_bulk = True + workspaces_ui.open() + page = workspaces_ui.page + page.get_by_label("Select all rows on this page").check() + page.get_by_role("button", name="Apply bulk status").click() + page.get_by_label("Reason (required)").fill("Review") + page.get_by_role("button", name="Apply bulk status").last.click() + expect(page.get_by_text("1 succeeded; 1 failed. missing: Not found.")).to_be_visible() + workspaces_ui.fail_list = True + page.get_by_role("button", name="Refresh workspaces").click() + expect(page.get_by_text("Inventory unavailable.", exact=True)).to_be_visible() + + +@pytest.mark.parametrize("mobile", [False, True]) +def test_detail_status_activity_and_scoped_approval(workspaces_ui, mobile): + workspaces_ui.open("?id=public-1", mobile=mobile) + page = workspaces_ui.page + expect(page.get_by_role("dialog", name=NAME)).to_be_visible() + expect(page.locator("img[src=x]")).to_have_count(0) + page.get_by_role("tab", name="Status", exact=True).click() + page.get_by_label("Public workspace status", exact=True).select_option("locked") + page.get_by_role("button", name="Change status").click() + page.get_by_label("Reason (required)").fill("Hold uploads") + page.get_by_role("button", name="Apply status").click() + expect(page.get_by_text("Hold uploads", exact=True)).to_be_visible() + page.get_by_role("tab", name="Activity", exact=True).click() + expect(page.get_by_text("", exact=True)).to_be_visible() + page.get_by_role("button", name="Raw JSON").click() + expect(page.locator("pre")).to_contain_text("public_workspace_status_change") + expect(page.get_by_role("link", name="View in Activity Logs")).to_have_attribute( + "href", "/v2/control-center/activity-logs?workspace_type=public&workspace_id=public-1&public_workspace_id=public-1") + page.get_by_role("tab", name="Documents", exact=True).click() + page.get_by_role("button", name="Request deletion of all documents").click() + page.get_by_label("Reason (required)").fill("Cleanup") + page.get_by_role("button", name="Submit approval request").click() + expect(page.get_by_text("Request approval-42 submitted for approval.", exact=False)).to_be_visible() + expect(page.get_by_role("link", name="View approval requests")).to_have_attribute("href", "/v2/approvals/all/approval-42?group_id=public-1") + assert workspaces_ui.mutations[-1] == ("DELETE", "/api/admin/control-center/public-workspaces/public-1/documents", {"reason": "Cleanup"}) + + +def test_members_supported_roles_and_existing_retention_payload(workspaces_ui): + workspaces_ui.open("?id=public-1") + page = workspaces_ui.page + page.get_by_role("tab", name="Members", exact=True).click() + page.get_by_role("button", name="Add member", exact=True).click() + expect(page.get_by_label("Role for the new member").locator("option")).to_have_count(2) + page.get_by_label("Search the directory").fill("new") + page.get_by_role("button", name="Choose New Manager").click() + page.get_by_role("button", name="Add member", exact=True).last.click() + expect(page.get_by_role("link", name="New Manager", exact=True)).to_be_visible() + page.get_by_label("Role for Manager", exact=True).select_option("Admin") + expect(page.get_by_label("Role for Manager", exact=True)).to_have_value("Admin") + page.get_by_role("button", name="Import CSV").click() + page.get_by_label("CSV file").set_input_files({"name": "members.csv", "mimeType": "text/csv", + "buffer": b"userId,displayName,email,role\n00000000-0000-0000-0000-000000000001,CSV Manager,csv@example.test,document_manager\n"}) + page.get_by_role("button", name="Add 1 member", exact=True).click() + expect(page.get_by_text("1 added, 0 already members, 0 failed.", exact=True)).to_be_visible() + page.get_by_role("button", name="Done", exact=True).click() + page.get_by_role("tab", name="Retention", exact=True).click() + page.get_by_label("Document retention days", exact=True).fill("90") + page.get_by_role("button", name="Save retention").click() + expect(page.get_by_text("Retention policy saved.", exact=True)).to_be_visible() + assert workspaces_ui.mutations[-1][2] == {"document_retention_days": "90"} + + +@pytest.mark.parametrize("action,path,method", [ + ("Request workspace deletion", "", "DELETE"), + ("Request to take ownership", "/take-ownership", "POST"), + ("Request ownership transfer", "/ownership", "PUT"), +]) +def test_existing_destructive_and_ownership_request_shapes(workspaces_ui, action, path, method): + workspaces_ui.can_edit = False + workspaces_ui.open("?id=public-1") + page = workspaces_ui.page + if path: + page.get_by_role("tab", name="Ownership", exact=True).click() + if path == "/ownership": + page.get_by_label("Transfer to a member").select_option("manager") + page.get_by_role("button", name=action, exact=True).click() + page.get_by_label("Reason (required)").fill("Governance") + page.get_by_role("button", name="Submit approval request").click() + expect(page.get_by_text("Request approval-42 submitted for approval.", exact=False)).to_be_visible() + expected = {"reason": "Governance", **({"newOwnerId": "manager"} if path == "/ownership" else {})} + assert workspaces_ui.mutations[-1] == (method, f"/api/admin/control-center/public-workspaces/public-1{path}", expected) + page.get_by_role("tab", name="Members", exact=True).click() + expect(page.get_by_role("button", name="Remove member", exact=True)).to_have_count(0) + page.get_by_role("tab", name="Retention", exact=True).click() + expect(page.get_by_role("button", name="Save retention")).to_be_disabled() diff --git a/ui_tests/test_v2_control_center_users.py b/ui_tests/test_v2_control_center_users.py new file mode 100644 index 000000000..ea0b8fa42 --- /dev/null +++ b/ui_tests/test_v2_control_center_users.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +# test_v2_control_center_users.py +""" +Browser coverage for the V2 Control Center Users section. +Version: 0.261.282 +Implemented in: 0.261.280 + +Validates filtered users, cross-page selection, detail tabs, reconciled access +updates, approval-gated document deletion and server-side CSV export. +""" + +import json +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import pytest +from playwright.sync_api import Page, Route, expect + +sys.path.insert(0, str(Path(__file__).resolve().parent / "fixtures")) + +from v2_notification_stubs import is_notification_count, notification_count_payload + + +ROOT = Path(__file__).resolve().parents[1] +STATIC = ROOT / "application" / "single_app" / "static" +SPA_INDEX = STATIC / "v2" / "index.html" +ORIGIN = "http://simplechat.test" +USER_ID = "user-123" + + +def user_row(): + return { + "id": USER_ID, + "email": "ada@example.test", + "display_name": "Ada Admin", + "access": {"status": "deny", "expires_at": None}, + "file_uploads": {"status": "allow", "expires_at": None}, + "last_login": "2026-10-06T10:00:00Z", + "total_logins": 4, + "conversations": 9, + "documents": 2, + "tokens": 120, + "metrics_calculated_at": "2026-10-07T00:00:00Z", + } + + +def user_detail(): + row = user_row() + return { + "user": {key: row[key] for key in ("id", "email", "display_name", "access", "file_uploads")}, + "usage": { + "last_login": row["last_login"], + "total_logins": 4, + "conversations": 9, + "documents": 2, + "tokens": 120, + "metrics_calculated_at": row["metrics_calculated_at"], + }, + "activity": [{ + "id": "activity-1", + "activity_type": "user_login", + "timestamp": "2026-10-06T10:00:00Z", + "resource_name": None, + "workspace_type": None, + "token_type": None, + "status": None, + "usage": None, + }], + "memberships": { + "groups": [{"id": "group-1", "name": "Research", "role": "Owner", "owned": True}], + "public_workspaces": [], + }, + } + + +class UsersFixture: + def __init__(self, page: Page): + self.page = page + self.requests = [] + self.bulk_payloads = [] + self.patch_requests = [] + self.unexpected = [] + page.route("**/*", self._route) + + def _route(self, route: Route): + request = route.request + parsed = urlsplit(request.url) + path = parsed.path + if f"{parsed.scheme}://{parsed.netloc}" != ORIGIN: + self.unexpected.append(request.url) + route.abort() + return + if request.method == "GET" and path.startswith("/v2"): + route.fulfill(path=str(SPA_INDEX), content_type="text/html") + elif request.method == "GET" and path.startswith("/static/"): + asset = (STATIC / path.removeprefix("/static/")).resolve() + if asset.is_relative_to(STATIC.resolve()) and asset.is_file(): + route.fulfill(path=str(asset)) + else: + self.unexpected.append(path) + route.fulfill(status=404, body="Fixture asset not found") + elif path == "/api/v2/bootstrap" and request.method == "GET": + route.fulfill(json={ + "version": "0.261.280", + "user": {"id": "admin-1", "display_name": "Admin", "is_admin": True, "roles": ["ControlCenterAdmin"]}, + "branding": {"app_title": "SimpleChat", "show_logo": False, "hide_app_title": False}, + "features": {}, + "control_center": { + "can_view_dashboard": True, + "can_manage_users": True, + "can_manage_groups": True, + "can_manage_workspaces": True, + "can_view_activity_logs": True, + "can_run_maintenance": True, + }, + "catalogs": {"models": [], "agents": [], "prompts": [], "initial_model_selection": None}, + "scope": {"groups": [], "public_workspaces": []}, + "navigation": { + "custom_pages": {"enabled": False, "items": []}, + "external_links": {"enabled": False, "items": []}, + }, + "workspace": {"sections": {}}, + "admin_nav": [], + "notices": {"ai": {}, "web_search": {}}, + "settings": {}, + }) + elif path == "/api/user/settings" and request.method == "GET": + route.fulfill(json={"settings": {}}) + elif is_notification_count(request.method, path): + route.fulfill(json=notification_count_payload()) + elif path == "/api/v2/control-center/users" and request.method == "GET": + self.requests.append((request.method, path, parsed.query)) + route.fulfill(json={ + "users": [user_row()], + "pagination": {"page": 1, "per_page": 25, "total_items": 2, "total_pages": 1}, + "metrics_freshness": { + "oldest_calculated_at": "2026-10-07T00:00:00Z", + "newest_calculated_at": "2026-10-07T00:00:00Z", + "missing_count": 0, + "source": "user metrics refresh cache", + }, + }) + elif path == f"/api/v2/control-center/users/{USER_ID}" and request.method == "GET": + self.requests.append((request.method, path, parsed.query)) + route.fulfill(json=user_detail()) + elif path == "/api/v2/control-center/users/bulk-action" and request.method == "POST": + payload = json.loads(request.post_data or "{}") + self.bulk_payloads.append(payload) + route.fulfill(json={"success_count": 2, "failed_count": 0, "failed_user_ids": []}) + elif path.startswith("/api/admin/control-center/users/") and request.method == "PATCH": + self.patch_requests.append((path, json.loads(request.post_data or "{}"))) + route.fulfill(json={"message": "Updated."}) + elif path.startswith("/api/admin/control-center/users/") and request.method == "POST": + route.fulfill(json={"success": True, "approval_id": "approval-42"}) + elif path == "/api/v2/control-center/users/export.csv" and request.method == "GET": + self.requests.append((request.method, path, parsed.query)) + route.fulfill(status=200, content_type="text/csv", + headers={"Content-Disposition": 'attachment; filename="control-center-users.csv"'}, + body="id,email\nuser-123,ada@example.test\n") + else: + self.unexpected.append(f"{request.method} {path}") + route.fulfill(status=404, json={"error": "Unexpected fixture request."}) + + def open(self): + if not SPA_INDEX.is_file(): + pytest.fail("Build the V2 SPA first: npm --prefix application/v2_ui run build") + self.page.set_viewport_size({"width": 1440, "height": 900}) + self.page.goto(f"{ORIGIN}/v2/control-center/users", wait_until="networkidle") + + def assert_clean(self): + assert not self.unexpected, self.unexpected + + +@pytest.fixture +def users_ui(page): + fixture = UsersFixture(page) + yield fixture + fixture.assert_clean() + + +pytestmark = pytest.mark.ui + + +def test_users_filters_cross_page_selection_and_bulk_action(users_ui): + users_ui.open() + page = users_ui.page + + expect(page.get_by_role("heading", name="Users")).to_be_visible() + expect(page.get_by_text("Ada Admin", exact=True)).to_be_visible() + page.get_by_label("Filter by access status").select_option("deny") + expect(page).to_have_url(f"{ORIGIN}/v2/control-center/users?access_status=deny") + assert any("access_status=deny" in query for _, path, query in users_ui.requests if path.endswith("/users")) + + page.get_by_label("Select all rows on this page").check() + page.get_by_role("button", name="Select all 2 matching records").click() + expect(page.get_by_text("All 2 matching users selected")).to_be_visible() + page.get_by_role("button", name="Apply access").click() + expect(page.get_by_text("2 users updated.")).to_be_visible() + assert users_ui.bulk_payloads[-1]["filter"]["access_status"] == "deny" + assert users_ui.bulk_payloads[-1]["exclude_ids"] == [] + + +def test_user_detail_activity_reconciled_update_and_approval(users_ui): + users_ui.open() + page = users_ui.page + + page.get_by_role("button", name="Open details for Ada Admin").click() + expect(page.get_by_role("dialog", name="Ada Admin")).to_be_visible() + page.get_by_role("tab", name="Activity").click() + expect(page.get_by_text("user login", exact=True)).to_be_visible() + expect(page.get_by_role("link", name="View all in Activity Logs")).to_have_attribute( + "href", "/v2/control-center/activity-logs?user_id=user-123", + ) + + page.get_by_role("tab", name="Overview").click() + page.get_by_role("button", name="Deny").first.click() + assert any(path.endswith("/access") for path, _ in users_ui.patch_requests) + + page.get_by_role("button", name="Request deletion of all documents").click() + page.get_by_label("Reason (required)").fill("Requested account cleanup") + page.get_by_role("button", name="Submit approval request").click() + expect(page.get_by_text("Document deletion approval approval-42 was submitted")).to_be_visible() + expect(page.get_by_role("link", name="View approval requests")).to_have_attribute( + "href", "/v2/approvals/all/approval-42", + ) + + +def test_users_export_uses_server_filter_endpoint(users_ui): + users_ui.open() + page = users_ui.page + page.get_by_label("Filter by file-upload status").select_option("deny") + expect(page.get_by_role("link", name="Export CSV")).to_have_attribute( + "href", "/api/v2/control-center/users/export.csv?upload_status=deny", + ) + with page.expect_download(): + page.get_by_role("link", name="Export CSV").click() + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-m", "ui"]))