diff --git a/application/single_app/app.py b/application/single_app/app.py index b577689b8..1f72fd2ce 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -62,6 +62,7 @@ from route_frontend_notifications import * from route_frontend_terms_of_use import register_route_frontend_terms_of_use from route_frontend_v2 import register_route_frontend_v2 +from route_access_restriction import register_route_access_restriction from route_custom_pages import register_route_custom_pages from route_backend_chats import * @@ -927,6 +928,11 @@ def _is_idle_timeout_exempt(path): '/api/v2/terms-of-use', '/api/v2/terms-of-use/accept', '/api/v2/terms-of-use/decline', + # The Access restricted screen. The Terms of Use pages require an unrestricted account, + # so gating this screen on the terms would bounce a restricted user between the two. + '/access-restricted', + '/v2/access-restricted', + '/api/v2/access-restriction', '/robots933456.txt', '/favicon.ico', '/acceptable_use_policy.html', @@ -1384,6 +1390,11 @@ def list_semantic_kernel_plugins(): # ------------------- Terms of Use Routes -- register_route_blueprint('frontend_terms_of_use', register_route_frontend_terms_of_use) +# ------------------- Access Restricted Routes ----------- +# Login-only on purpose: user_required sends a suspended or blocked user here, so these +# pages must not require an unrestricted account themselves. +register_route_blueprint('access_restriction', register_route_access_restriction, login_required_blueprint) + # ------------------- User Profile Routes ---------------- register_route_blueprint('frontend_profile', register_route_frontend_profile, login_required_blueprint) diff --git a/application/single_app/config.py b/application/single_app/config.py index 031f58018..c19ed149c 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.296" +VERSION = "0.261.298" 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_access_restriction.py b/application/single_app/functions_access_restriction.py new file mode 100644 index 000000000..501e1ed7f --- /dev/null +++ b/application/single_app/functions_access_restriction.py @@ -0,0 +1,213 @@ +# functions_access_restriction.py +"""Describe a user's access restriction for the access gate and the Access restricted screen. + +Control Center and safety remediation restrict a user by writing +``settings.access = {'status': 'deny', 'datetime_to_allow': }``. A +suspension or block applied for a safety violation also stores a ``notice`` beside them: +the title and message the user was sent, so the screen a restricted user lands on can say +why. Control Center writes replace the whole ``access`` value, so restoring access there +clears the notice too. + +This module only reads and builds those values. It imports nothing from ``config`` or +``functions_settings``: reading the signed-in user's settings, and restoring an expired +suspension, happen in ``functions_authentication.get_user_access_restriction``. +""" + +from datetime import datetime, timezone + + +ACCESS_RESTRICTION_KIND_SUSPENDED = 'suspended' +ACCESS_RESTRICTION_KIND_BLOCKED = 'blocked' +ACCESS_RESTRICTION_KINDS = ( + ACCESS_RESTRICTION_KIND_SUSPENDED, + ACCESS_RESTRICTION_KIND_BLOCKED, +) +ACCESS_RESTRICTION_SOURCE_SAFETY_VIOLATION = 'safety_violation' + +# What the gate answers an API call from a restricted user with, and where it sends a page. +ACCESS_RESTRICTED_ERROR = 'access_restricted' +V2_ACCESS_RESTRICTED_PATH = '/v2/access-restricted' +CLASSIC_ACCESS_RESTRICTED_PATH = '/access-restricted' +V2_ACCESS_RESTRICTION_API_PATH = '/api/v2/access-restriction' +ACCESS_RESTRICTION_PATHS = frozenset({ + V2_ACCESS_RESTRICTED_PATH, + CLASSIC_ACCESS_RESTRICTED_PATH, + V2_ACCESS_RESTRICTION_API_PATH, +}) + +ACCESS_STATE_ALLOW = 'allow' +ACCESS_STATE_EXPIRED = 'expired' +ACCESS_STATE_RESTRICTED = 'restricted' + +NOTICE_TITLE_MAX_LENGTH = 200 +NOTICE_MESSAGE_MAX_LENGTH = 4000 +NOTICE_REFERENCE_MAX_LENGTH = 200 + +LEGACY_PERMANENT_DENY_REASON = 'Access denied by administrator' +LEGACY_TIMED_DENY_PREFIX = 'Access denied until ' + +# Shown when a restriction carries no notice, such as one applied from Control Center. +_DEFAULT_COPY = { + ACCESS_RESTRICTION_KIND_SUSPENDED: ( + 'Your access is temporarily suspended', + 'An administrator has temporarily suspended your access to this application. ' + 'Your access is restored automatically at the time shown.', + ), + ACCESS_RESTRICTION_KIND_BLOCKED: ( + 'Your access has been blocked', + 'An administrator has blocked your access to this application. ' + 'Contact your administrator if you have questions about this decision.', + ), +} + +_API_SENTENCES = { + ACCESS_RESTRICTION_KIND_SUSPENDED: 'Your access to this application is temporarily suspended.', + ACCESS_RESTRICTION_KIND_BLOCKED: 'Your access to this application has been blocked by an administrator.', +} + +PUBLIC_RESTRICTION_FIELDS = ('kind', 'until', 'title', 'message', 'reference_id') + + +def _clean_text(value, max_length): + """Return trimmed text no longer than ``max_length``, or '' for anything that isn't text.""" + if not isinstance(value, str): + return '' + return value.strip()[:max_length] + + +def parse_access_restore_time(value): + """Return a stored restore time as an aware UTC datetime, or None when it can't be read. + + A value without a UTC offset is read as UTC, as Control Center reads it. + """ + if not isinstance(value, str) or not value.strip(): + return None + try: + parsed = datetime.fromisoformat(value.strip().replace('Z', '+00:00')) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _restriction(kind, until=None, title='', message='', reference_id=None): + default_title, default_message = _DEFAULT_COPY[kind] + return { + 'kind': kind, + 'until': until if kind == ACCESS_RESTRICTION_KIND_SUSPENDED else None, + 'title': title or default_title, + 'message': message or default_message, + 'reference_id': reference_id or None, + } + + +def _notice_copy(notice, kind): + """Return ``(title, message, reference_id)`` from a stored notice that matches ``kind``. + + A notice written for a different kind of restriction describes something that no longer + applies, so it is ignored and the generic copy is used instead. + """ + if not isinstance(notice, dict) or notice.get('kind') != kind: + return '', '', None + return ( + _clean_text(notice.get('title'), NOTICE_TITLE_MAX_LENGTH), + _clean_text(notice.get('message'), NOTICE_MESSAGE_MAX_LENGTH), + _clean_text(notice.get('reference_id'), NOTICE_REFERENCE_MAX_LENGTH) or None, + ) + + +def describe_access_restriction(access_settings, now=None): + """Return ``(state, restriction)`` for a stored ``settings.access`` value. + + ``state`` is ``'allow'``, ``'expired'`` -- a suspension whose restore time has passed, + which the caller restores -- or ``'restricted'``, the only state that comes with a + restriction. A deny whose restore time can't be read stays in force with no end date, + as it always has, and is described as a block. + + The restriction holds ``kind``, ``until`` (ISO 8601 UTC, suspensions only), ``title``, + ``message`` and ``reference_id``. + """ + if not isinstance(access_settings, dict) or access_settings.get('status') != 'deny': + return ACCESS_STATE_ALLOW, None + + kind = ACCESS_RESTRICTION_KIND_BLOCKED + until = None + if access_settings.get('datetime_to_allow'): + restore_at = parse_access_restore_time(access_settings.get('datetime_to_allow')) + if restore_at is not None: + if (now or datetime.now(timezone.utc)) >= restore_at: + return ACCESS_STATE_EXPIRED, None + kind = ACCESS_RESTRICTION_KIND_SUSPENDED + until = restore_at.isoformat() + + title, message, reference_id = _notice_copy(access_settings.get('notice'), kind) + return ACCESS_STATE_RESTRICTED, _restriction(kind, until, title, message, reference_id) + + +def legacy_access_denied_reason(access_settings, restriction): + """Return the reason ``check_user_access_status`` has always given for a restriction.""" + if restriction.get('kind') == ACCESS_RESTRICTION_KIND_SUSPENDED: + return f"{LEGACY_TIMED_DENY_PREFIX}{access_settings.get('datetime_to_allow')}" + return LEGACY_PERMANENT_DENY_REASON + + +def fallback_access_restriction(reason=None): + """Describe a restriction from its legacy reason alone, with the generic copy. + + Used when the access check refused a user but the stored details could not be read + again, so the response still says whether access returns on its own. + """ + text = reason if isinstance(reason, str) else '' + if text.startswith(LEGACY_TIMED_DENY_PREFIX): + restore_at = parse_access_restore_time(text[len(LEGACY_TIMED_DENY_PREFIX):]) + if restore_at is not None: + return _restriction(ACCESS_RESTRICTION_KIND_SUSPENDED, restore_at.isoformat()) + return _restriction(ACCESS_RESTRICTION_KIND_BLOCKED) + + +def public_access_restriction(restriction): + """Return only the fields of a restriction that are shown to the restricted user.""" + restriction = restriction if isinstance(restriction, dict) else {} + return {field: restriction.get(field) for field in PUBLIC_RESTRICTION_FIELDS} + + +def access_restricted_sentence(restriction): + """Return the plain sentence an API response gives a restricted user.""" + kind = (restriction or {}).get('kind') + return _API_SENTENCES.get(kind, _API_SENTENCES[ACCESS_RESTRICTION_KIND_BLOCKED]) + + +def build_access_restriction_notice( + kind, + title, + message, + until=None, + reference_id=None, + source=ACCESS_RESTRICTION_SOURCE_SAFETY_VIOLATION, + applied_at=None, +): + """Return the ``notice`` stored beside a restriction so the restricted user can see why.""" + if kind not in ACCESS_RESTRICTION_KINDS: + raise ValueError(f'Unsupported access restriction kind: {kind}') + return { + 'kind': kind, + 'title': _clean_text(title, NOTICE_TITLE_MAX_LENGTH), + 'message': _clean_text(message, NOTICE_MESSAGE_MAX_LENGTH), + 'until': until if kind == ACCESS_RESTRICTION_KIND_SUSPENDED and until else None, + 'source': source, + 'reference_id': _clean_text(reference_id, NOTICE_REFERENCE_MAX_LENGTH) or None, + 'applied_at': applied_at or datetime.now(timezone.utc).isoformat(), + } + + +def is_v2_request_path(path): + """True for V2 pages and V2 API calls. Mirrors ``_is_v2_request_path`` in app.py.""" + return path in ('/v2', '/api/v2') or path.startswith('/v2/') or path.startswith('/api/v2/') + + +def access_restricted_page_path(request_path): + """Return the Access restricted page for the interface ``request_path`` belongs to.""" + if is_v2_request_path(str(request_path or '')): + return V2_ACCESS_RESTRICTED_PATH + return CLASSIC_ACCESS_RESTRICTED_PATH diff --git a/application/single_app/functions_approvals.py b/application/single_app/functions_approvals.py index 22fe17adf..01ed35a7a 100644 --- a/application/single_app/functions_approvals.py +++ b/application/single_app/functions_approvals.py @@ -11,6 +11,7 @@ from datetime import datetime, timedelta, timezone from typing import Optional, List, Dict, Any from urllib.parse import quote +from azure.core import MatchConditions from content_screening.contracts import ( ScreeningConflictError, ScreeningError, @@ -35,6 +36,11 @@ is_m365_approval_subject, sanitize_m365_approval, ) +from functions_safety_remediation import ( + SAFETY_REQUEST_DENIED, + SAFETY_REQUEST_EXPIRED, + release_safety_log_after_approval_decision, +) # Approval request statuses STATUS_PENDING = "pending" @@ -715,6 +721,9 @@ def deny_request( debug_print(f"Request denied: {approval_id}") _clear_pending_admin_notifications(approval_id) + + if approval.get('request_type') in SAFETY_USER_APPROVAL_TYPES: + _release_denied_safety_request(approval, auto_denied) # Create notification for requester (only if not auto-denied) if not auto_denied: @@ -755,6 +764,83 @@ def deny_request( raise +def _release_denied_safety_request(approval: Dict[str, Any], auto_denied: bool) -> None: + """Unlock the violation a denied or expired warn, suspend or block request was for. + + The denial itself has already been saved. A failure here is logged rather than raised: + the violation is settled again the next time a reviewer lists or opens it. + """ + try: + release_safety_log_after_approval_decision( + approval, + SAFETY_REQUEST_EXPIRED if auto_denied else SAFETY_REQUEST_DENIED, + ) + except Exception as exc: + log_event("[APPROVALS] The safety violation could not be released after its request was denied.", { + 'approval_id': approval.get('id'), + 'request_type': approval.get('request_type'), + 'auto_denied': auto_denied, + 'error_type': type(exc).__name__, + }, level=logging.WARNING) + + +def withdraw_approval_request( + approval_id: str, + group_id: str, + withdrawn_by_id: str, + withdrawn_by_email: str, + withdrawn_by_name: str, + comment: str, +) -> Optional[Dict[str, Any]]: + """Deny a pending request its own creator could not record, and remove its notices. + + Used when the record a request was raised from changed while the request was being + created, so nothing links the two and the request must never be approved. The denial is + written only while the request is still pending, conditionally on the version read, so it + never overwrites a decision made meanwhile. The notices the request sent -- that it waits + for reviewers, and that it was submitted -- are removed: the requester was already told + nothing was requested. Returns the denied request, or None when it was already decided. + """ + current = cosmos_approvals_container.read_item(item=approval_id, partition_key=group_id) + if current.get('status') != STATUS_PENDING: + log_event("[APPROVALS] A request to withdraw was already decided, so it was left as it is.", { + 'approval_id': approval_id, + 'request_type': current.get('request_type'), + 'status': current.get('status'), + }, level=logging.WARNING) + return None + + current.update({ + 'status': STATUS_DENIED, + 'approved_by_id': withdrawn_by_id, + 'approved_by_email': withdrawn_by_email, + 'approved_by_name': withdrawn_by_name, + 'approved_at': datetime.utcnow().isoformat(), + 'approval_comment': comment, + 'ttl': -1, + }) + etag = current.get('_etag') + if etag: + stored = cosmos_approvals_container.replace_item( + item=approval_id, + body=current, + etag=etag, + match_condition=MatchConditions.IfNotModified, + ) + else: + stored = cosmos_approvals_container.upsert_item(current) + + _clear_pending_admin_notifications(approval_id) + _clear_requester_pending_notification(approval_id) + log_event("[APPROVALS] Request withdrawn", { + 'approval_id': approval_id, + 'request_type': current.get('request_type'), + 'group_id': group_id, + 'withdrawn_by': withdrawn_by_email, + }) + return stored if isinstance(stored, dict) else current + + def mark_approval_executed( approval_id: str, group_id: str, @@ -1104,6 +1190,104 @@ def _can_user_deny( return _can_user_approve(approval, user_id, user_roles) +APPROVAL_STATS_OLDEST_LIMIT = 5 +APPROVAL_STATS_SCAN_LIMIT = 10000 +APPROVAL_STATS_OUTCOMES = { + STATUS_APPROVED: 'approved', + STATUS_DENIED: 'denied', + STATUS_EXECUTED: 'executed', + STATUS_FAILED: 'failed', + STATUS_AUTO_DENIED: 'expired', + 'expired': 'expired', +} + + +def _parse_approval_time(value: Any) -> Optional[datetime]: + """Return a stored approval time as naive UTC, as the approval records write it.""" + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + try: + parsed = datetime.fromisoformat(value.strip().replace('Z', '+00:00')) + except ValueError: + return None + else: + return None + if parsed.tzinfo is not None: + parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None) + return parsed + + +def summarize_visible_approvals( + approvals: List[Dict[str, Any]], + user_id: str, + user_roles: List[str], + days: int, + now: Optional[datetime] = None, +) -> Dict[str, Any]: + """Summarize approval requests for the Approvals dashboard. + + ``approvals`` must be what ``get_pending_approvals`` returned for this user, which has + already applied the visibility rules; nothing here widens them, so a request the caller + cannot see is never counted. "Waiting on me" counts pending requests the caller may + approve; decided requests are counted when their decision falls in the last ``days``. + """ + current = now or datetime.utcnow() + window_start = current - timedelta(days=days) + soon = current + timedelta(hours=24) + safe_roles = _normalize_user_roles(user_roles) + + pending = [approval for approval in approvals if approval.get('status') == STATUS_PENDING] + actionable = [approval for approval in pending if _can_user_approve(approval, user_id, safe_roles)] + + decided = {'approved': 0, 'denied': 0, 'executed': 0, 'failed': 0, 'expired': 0} + for approval in approvals: + outcome = APPROVAL_STATS_OUTCOMES.get(str(approval.get('status') or '').lower()) + if not outcome: + continue + decided_at = None + for field in ('executed_at', 'approved_at', 'decided_at', 'updated_at'): + decided_at = _parse_approval_time(approval.get(field)) + if decided_at: + break + if decided_at and decided_at >= window_start: + decided[outcome] += 1 + + pending_by_type: Dict[str, int] = {} + for approval in pending: + request_type = str(approval.get('request_type') or 'unknown') + pending_by_type[request_type] = pending_by_type.get(request_type, 0) + 1 + + def expires_soon(approval): + expires_at = _parse_approval_time(approval.get('expires_at')) + return bool(expires_at and expires_at <= soon) + + oldest = sorted(actionable, key=_get_approval_sort_value)[:APPROVAL_STATS_OLDEST_LIMIT] + return { + 'window': {'days': days}, + 'waiting_on_me': len(actionable), + 'my_pending_requests': sum(1 for approval in pending if approval.get('requester_id') == user_id), + 'expiring_within_24h': sum(1 for approval in actionable if expires_soon(approval)), + 'pending_visible': len(pending), + 'decided_in_window': decided, + 'pending_by_type': [ + {'request_type': request_type, 'count': count} + for request_type, count in sorted(pending_by_type.items(), key=lambda pair: (-pair[1], pair[0])) + ], + 'oldest_actionable': [ + { + 'id': approval.get('id'), + 'group_id': approval.get('group_id'), + 'request_type': approval.get('request_type'), + 'group_name': approval.get('group_name'), + 'created_at': approval.get('created_at'), + 'expires_at': approval.get('expires_at'), + } + for approval in oldest + ], + } + + def _create_approval_notifications( approval: Dict[str, Any], group: Optional[Dict[str, Any]] @@ -1283,6 +1467,20 @@ def _create_requester_pending_notification(approval: Dict[str, Any]) -> None: debug_print(f"Error notifying requester of pending approval {approval['id']}: {e}") +def _clear_requester_pending_notification(approval_id: str) -> None: + """Remove the requester's "request submitted" notice for a request that was withdrawn.""" + try: + delete_notifications_by_metadata( + metadata_filters={'approval_id': approval_id}, + notification_types=['approval_request_pending_submitter'], + ) + except Exception as e: + log_event("[APPROVALS] Error clearing the requester's pending notification", { + 'error_type': type(e).__name__, + 'approval_id': approval_id, + }, level=logging.WARNING) + + def _clear_pending_admin_notifications(approval_id: str, *, safe_errors=False) -> None: """Remove stale pending-review notifications once an approval is resolved.""" try: diff --git a/application/single_app/functions_authentication.py b/application/single_app/functions_authentication.py index 1d466e4a4..203b64024 100644 --- a/application/single_app/functions_authentication.py +++ b/application/single_app/functions_authentication.py @@ -6,6 +6,17 @@ from flask import has_request_context from config import * +from functions_access_restriction import ( + ACCESS_RESTRICTED_ERROR, + ACCESS_STATE_EXPIRED, + ACCESS_STATE_RESTRICTED, + access_restricted_page_path, + access_restricted_sentence, + describe_access_restriction, + fallback_access_restriction, + legacy_access_denied_reason, + public_access_restriction, +) from functions_appinsights import log_event from functions_settings import * from functions_debug import debug_print @@ -743,53 +754,98 @@ def decorated_function(*args, **kwargs): return f(*args, **kwargs) return decorated_function +def _read_user_access_restriction(user_id): + """Return ``(restriction, reason)`` for the user's stored access setting. + + ``restriction`` is None when access is allowed. A suspension whose restore time has + passed is restored here, which also clears its notice. Raises when the settings can't + be read; callers allow access then, so a storage fault never locks a user out. + """ + # Imported at call time, as the access check always has been, so the check uses the + # settings functions functions_settings holds when it runs. + from functions_settings import get_user_settings, update_user_settings + + user_settings = get_user_settings(user_id) or {} + stored_settings = user_settings.get('settings') if isinstance(user_settings, dict) else None + access_settings = stored_settings.get('access') if isinstance(stored_settings, dict) else None + + state, restriction = describe_access_restriction(access_settings) + if state == ACCESS_STATE_EXPIRED: + update_user_settings(user_id, { + 'access': { + 'status': 'allow', + 'datetime_to_allow': None + } + }) + return None, None + if state != ACCESS_STATE_RESTRICTED: + return None, None + return restriction, legacy_access_denied_reason(access_settings, restriction) + + +def get_user_access_restriction(user_id): + """Return the user's current access restriction, or None when access is allowed. + + The restriction holds ``kind`` ('suspended' or 'blocked'), ``until`` (ISO 8601 UTC, + suspensions only), ``title``, ``message`` and ``reference_id`` -- the user's own data + only. Like ``check_user_access_status``, an expired suspension is restored and a failure + to read the settings allows access. + """ + if not user_id: + return None + try: + restriction, _reason = _read_user_access_restriction(user_id) + except Exception as e: + log_event( + "[ACCESS_RESTRICTION] Access restriction could not be read; access allowed.", + extra={"user_id": user_id, "error_type": type(e).__name__}, + level=logging.WARNING, + ) + return None + return restriction + + def check_user_access_status(user_id): """ Check if user access is currently allowed based on Control Center settings. Returns (is_allowed: bool, reason: str) """ try: - from functions_settings import get_user_settings - user_settings = get_user_settings(user_id) - - access_settings = user_settings.get('settings', {}).get('access', {}) - status = access_settings.get('status', 'allow') - - if status == 'allow': - return True, None - - if status == 'deny': - datetime_to_allow = access_settings.get('datetime_to_allow') - if datetime_to_allow: - try: - # Check if time-based restriction has expired - allow_time = datetime.fromisoformat(datetime_to_allow.replace('Z', '+00:00')) - current_time = datetime.now(timezone.utc) - - if current_time >= allow_time: - # Time-based restriction has expired, automatically restore access - from functions_settings import update_user_settings - update_user_settings(user_id, { - 'access': { - 'status': 'allow', - 'datetime_to_allow': None - } - }) - return True, None - else: - return False, f"Access denied until {datetime_to_allow}" - except ValueError: - # Invalid datetime format, treat as permanent deny - return False, "Access denied by administrator" - else: - return False, "Access denied by administrator" - - return True, None # Default to allow if status is unknown - + restriction, reason = _read_user_access_restriction(user_id) except Exception as e: debug_print(f"Error checking user access status: {e}") return True, None # Default to allow on error to prevent lockouts + if restriction is None: + return True, None + return False, reason + + +def _is_api_request(): + return ( + request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html + ) or request.path.startswith('/api/') + + +def access_restricted_response(user_id, reason=None): + """Answer a request from a user whose access is restricted. + + API calls get a 403 that says why, with the caller's own restriction. Page requests + are sent to the Access restricted page of the interface they came from, which is + served to restricted users so they can read the notice and sign out. + """ + restriction = get_user_access_restriction(user_id) or fallback_access_restriction(reason) + restricted_url = access_restricted_page_path(request.path) + if _is_api_request(): + return jsonify({ + "error": ACCESS_RESTRICTED_ERROR, + "message": access_restricted_sentence(restriction), + "restriction": public_access_restriction(restriction), + "restricted_url": restricted_url, + }), 403 + return redirect(restricted_url) + + def user_required(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -806,10 +862,7 @@ def decorated_function(*args, **kwargs): if user_id: is_allowed, reason = check_user_access_status(user_id) if not is_allowed: - if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html or request.path.startswith('/api/'): - return jsonify({"error": "Access Denied", "message": reason}), 403 - else: - return f"Access Denied: {reason}", 403 + return access_restricted_response(user_id, reason) return f(*args, **kwargs) return decorated_function diff --git a/application/single_app/functions_chat_content_review.py b/application/single_app/functions_chat_content_review.py index f12d8ed26..fb79effca 100644 --- a/application/single_app/functions_chat_content_review.py +++ b/application/single_app/functions_chat_content_review.py @@ -28,6 +28,15 @@ ) +# The messages the unchecked queue holds: allowed through while a required check could not +# finish. Shared by the queue's list and the Review center dashboard's count. +UNCHECKED_CHAT_CONTENT_WHERE = ( + "c.metadata.chat_content_checks.status = 'not_checked' " + "AND c.metadata.chat_content_checks.decision = 'allow_unchecked' " + "AND c.role IN ('user', 'assistant')" +) + + class ChatContentReviewError(ScreeningError): code = "chat_content_check_unavailable" public_message = "The chat content check could not be completed. Reload and try again." @@ -226,11 +235,7 @@ def list_unchecked_chat_content( next_cursor = None while index < len(sources) and len(items) < page_size: current_source = sources[index] - query = ( - "SELECT * FROM c WHERE c.metadata.chat_content_checks.status = 'not_checked' " - "AND c.metadata.chat_content_checks.decision = 'allow_unchecked' " - "AND c.role IN ('user', 'assistant')" - ) + query = f"SELECT * FROM c WHERE {UNCHECKED_CHAT_CONTENT_WHERE}" parameters = [] if current_source == "shared": query += " AND NOT IS_DEFINED(c.metadata.source_message_id)" @@ -262,6 +267,28 @@ def list_unchecked_chat_content( return {"items": items, "continuation": _encode_cursor(next_cursor, signature)} +def count_unchecked_chat_content(*, stores=None): + """How many messages the unchecked queue holds across chat and shared conversations. + + Counts what ``list_unchecked_chat_content`` lists with no filters, with one count query + per conversation source, for the Review center dashboard. + """ + stores = stores or get_chat_review_stores() + total = 0 + for current_source in ("chat", "shared"): + query = f"SELECT VALUE COUNT(1) FROM c WHERE {UNCHECKED_CHAT_CONTENT_WHERE}" + if current_source == "shared": + query += " AND NOT IS_DEFINED(c.metadata.source_message_id)" + values = stores.message_container(current_source).query_items( + query=query, parameters=[], enable_cross_partition_query=True, + ) + total += sum( + int(value) for value in values + if isinstance(value, (int, float)) and not isinstance(value, bool) + ) + return total + + def _recheck_text(message, conversation, summary): source = summary.get("source") or {} if source.get("kind") == "orchestration_turn": diff --git a/application/single_app/functions_notifications.py b/application/single_app/functions_notifications.py index cd13df387..97c819f65 100644 --- a/application/single_app/functions_notifications.py +++ b/application/single_app/functions_notifications.py @@ -214,6 +214,11 @@ 'icon': 'bi-shield-lock', 'color': 'danger' }, + # A reviewer's response to feedback the user sent, sent only when the reviewer chooses to. + 'feedback_response': { + 'icon': 'bi-chat-heart', + 'color': 'info' + }, 'agent_template_pending_admin': { 'icon': 'bi-layers', 'color': 'warning' diff --git a/application/single_app/functions_review_center.py b/application/single_app/functions_review_center.py new file mode 100644 index 000000000..de48d3d51 --- /dev/null +++ b/application/single_app/functions_review_center.py @@ -0,0 +1,406 @@ +# functions_review_center.py + +"""Shared helpers for the admin Review center's feedback and safety APIs. + +The Feedback and Safety review APIs both serve the V2 Review center. This module keeps the +parts they share in one place: the dashboard window an analytics request may ask for, the +daily series a chart reads, the batched lookup that turns the owners of records into +display names, and the bulk operation envelope with its per-call cap. + +Every user id looked up here comes from a review record the caller is already authorized +to read through the route's role decorator; nothing here accepts a user id from a client +as a reason to read a profile. +""" + +import copy +import logging +import re +from datetime import datetime, timedelta, timezone + +from azure.core import MatchConditions +from azure.cosmos import exceptions as cosmos_exceptions + +from config import cosmos_user_settings_container +from functions_access_restriction import ACCESS_STATE_RESTRICTED, describe_access_restriction +from functions_appinsights import log_event + + +REVIEW_WINDOW_DAYS = (7, 30, 90) +REVIEW_BULK_MAX_OPERATIONS = 100 +REVIEW_IDS_CAP = 500 +REVIEW_SEARCH_MAX_LENGTH = 200 +REVIEW_NAME_BATCH = 100 +REVIEW_EXCERPT_LENGTH = 160 +REVIEW_WRITE_ATTEMPTS = 3 +REVIEW_BULK_OPERATIONS = ('update', 'archive', 'delete') +# Every key an operation may carry. Unknown keys are refused, so a later field, such as an +# attribution to the suggestion that proposed the change, is added here on purpose. +REVIEW_BULK_OPERATION_KEYS = frozenset({'id', 'op', 'etag', 'changes', 'archived'}) +REVIEW_RECORD_ID_MAX_LENGTH = 200 + +REVIEW_RECORD_CHANGED_CODE = 'record_changed' +REVIEW_NOT_FOUND_CODE = 'not_found' +REVIEW_INVALID_OPERATION_CODE = 'invalid_operation' +REVIEW_DUPLICATE_OPERATION_CODE = 'duplicate_operation' +REVIEW_OPERATION_FAILED_CODE = 'operation_failed' + +_DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$') + + +class ReviewRequestError(ValueError): + """A request the Review center APIs refuse as a whole, with a stable code.""" + + def __init__(self, message, code='invalid_request', status=400): + super().__init__(message) + self.message = message + self.code = code + self.status = status + + +class ReviewRecordConflict(Exception): + """A record kept changing while it was being written.""" + + +def replace_review_record(container, item_id, mutate, *, base_item=None, attempts=REVIEW_WRITE_ATTEMPTS): + """Apply ``mutate`` to a record and replace it on the condition that it has not changed. + + ``mutate(record)`` changes the record in place and returns False to stop without + writing. The first attempt applies it to ``base_item`` when given; after a conflict, to + a fresh read, so only what ``mutate`` sets is ever written over another writer's change. + Returns the stored record, or None when ``mutate`` stopped. Raises + ``ReviewRecordConflict`` when every attempt conflicts, and lets a missing record's + ``CosmosResourceNotFoundError`` through. The record's id must be its partition key. + """ + current = base_item + for _attempt in range(max(1, attempts)): + if current is None: + current = container.read_item(item=item_id, partition_key=item_id) + record = copy.deepcopy(current) + if mutate(record) is False: + return None + etag = current.get('_etag') + try: + if etag: + stored = container.replace_item( + item=item_id, + body=record, + etag=etag, + match_condition=MatchConditions.IfNotModified, + ) + else: + stored = container.upsert_item(record) + except cosmos_exceptions.CosmosAccessConditionFailedError: + current = None + continue + return stored if isinstance(stored, dict) else record + raise ReviewRecordConflict(f'Record {item_id} changed while it was being written.') + + +def parse_review_window(value): + """Return the requested dashboard window in days, or None when none was asked for. + + Only the windows the dashboards offer are accepted, so a request cannot ask the server + to scan an arbitrary history. + """ + if value is None or str(value).strip() == '': + return None + text = str(value).strip() + if not text.isdigit() or int(text) not in REVIEW_WINDOW_DAYS: + raise ReviewRequestError('The window must be 7, 30 or 90 days.', code='invalid_window') + return int(text) + + +def parse_review_date(value): + """Return a ``YYYY-MM-DD`` day filter, or None. Anything else is refused.""" + if value is None or str(value).strip() == '': + return None + text = str(value).strip() + if not _DATE_RE.match(text): + raise ReviewRequestError('The date must be YYYY-MM-DD.', code='invalid_date') + try: + datetime.strptime(text, '%Y-%m-%d') + except ValueError as exc: + raise ReviewRequestError('The date must be YYYY-MM-DD.', code='invalid_date') from exc + return text + + +def parse_review_timestamp(value): + """Return a stored timestamp as an aware UTC datetime, or None when it can't be read. + + Records written with ``datetime.utcnow().isoformat()`` carry no offset; they are UTC. + """ + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + try: + parsed = datetime.fromisoformat(value.strip().replace('Z', '+00:00')) + except ValueError: + return None + else: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def review_day(value): + """The UTC day a timestamp falls on, as ``YYYY-MM-DD``, or None.""" + parsed = parse_review_timestamp(value) + return parsed.date().isoformat() if parsed else None + + +def review_window(days, now=None): + """Describe a window of ``days`` UTC days ending today, oldest day first.""" + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + end = current.date() + dates = [(end - timedelta(days=offset)).isoformat() for offset in range(days - 1, -1, -1)] + start = datetime.combine(end - timedelta(days=days - 1), datetime.min.time(), tzinfo=timezone.utc) + return { + 'days': days, + 'start_date': dates[0], + 'end_date': dates[-1], + 'dates': dates, + 'start': start, + } + + +def in_review_window(value, window): + """True when a timestamp falls inside the window.""" + parsed = parse_review_timestamp(value) + return bool(parsed and parsed >= window['start']) + + +def normalize_review_search(value): + """Return search text as the lists match it: trimmed, lowercased and bounded.""" + if not isinstance(value, str): + return '' + return value.strip().lower()[:REVIEW_SEARCH_MAX_LENGTH] + + +def review_text_matches(needle, *values): + """True when the search text appears in any of the values.""" + if not needle: + return True + for value in values: + if isinstance(value, str) and needle in value.lower(): + return True + return False + + +def review_excerpt(value, limit=REVIEW_EXCERPT_LENGTH): + """The first ``limit`` characters of text on one line, for a list row.""" + if not isinstance(value, str): + return '' + flattened = ' '.join(value.split()) + return flattened if len(flattened) <= limit else f"{flattened[:limit - 1].rstrip()}…" + + +def _normalize_user_ids(user_ids): + return [ + user_id for user_id in dict.fromkeys(user_ids or []) + if isinstance(user_id, str) and user_id.strip() + ] + + +def resolve_review_users(user_ids, include_access=False, now=None): + """Return ``{user_id: {display_name, email[, access]}}`` with one query per batch. + + Names are a convenience for the reviewer, so a failed lookup is logged and leaves the + affected ids out rather than failing the list. ``access`` describes whether the user is + restricted now: ``{'restricted': bool, 'kind': ..., 'until': ...}``. + """ + pending = _normalize_user_ids(user_ids) + found = {} + projection = 'c.id, c.display_name, c.email' + if include_access: + projection += ', c.settings.access AS access' + query = f"SELECT {projection} FROM c WHERE ARRAY_CONTAINS(@ids, c.id)" + for start in range(0, len(pending), REVIEW_NAME_BATCH): + batch = pending[start:start + REVIEW_NAME_BATCH] + try: + rows = list(cosmos_user_settings_container.query_items( + query=query, + parameters=[{'name': '@ids', 'value': batch}], + enable_cross_partition_query=True, + )) + except Exception as exc: + log_event( + '[REVIEW_CENTER] User names could not be resolved for a review list.', + extra={'user_count': len(batch), 'error_type': type(exc).__name__}, + level=logging.WARNING, + ) + continue + for row in rows: + if not isinstance(row, dict) or row.get('id') not in batch: + continue + entry = { + 'display_name': str(row.get('display_name') or '').strip(), + 'email': str(row.get('email') or '').strip(), + } + if include_access: + state, restriction = describe_access_restriction(row.get('access'), now=now) + entry['access'] = { + 'restricted': state == ACCESS_STATE_RESTRICTED, + 'kind': (restriction or {}).get('kind'), + 'until': (restriction or {}).get('until'), + } + found[row['id']] = entry + return found + + +def review_user_label(user_id, users): + """The name a reviewer reads for a user: display name, then email, then id.""" + entry = (users or {}).get(user_id) or {} + return entry.get('display_name') or entry.get('email') or user_id or '' + + +def cap_review_ids(ids): + """The ids a "select all matching" may act on, capped, and whether the cap applied.""" + unique = [item for item in dict.fromkeys(ids or []) if isinstance(item, str) and item] + return { + 'ids': unique[:REVIEW_IDS_CAP], + 'total': len(unique), + 'capped': len(unique) > REVIEW_IDS_CAP, + 'cap': REVIEW_IDS_CAP, + } + + +def _operation_error(index, record_id, op, message): + return { + 'index': index, + 'id': record_id if isinstance(record_id, str) else None, + 'op': op if isinstance(op, str) else None, + 'error': { + 'status': 400, + 'body': {'error': message, 'code': REVIEW_INVALID_OPERATION_CODE}, + }, + } + + +def parse_review_bulk_operations(payload): + """Validate a bulk request and return its operations, in the order they were sent. + + A request is ``{"operations": [...]}`` with 1 to 100 operations. Each operation is + ``{"id", "op", "etag"?, ...}``: ``update`` carries ``changes`` (the same fields the + single-record PATCH accepts), ``archive`` carries ``archived`` (a boolean) and + ``delete`` carries nothing more. ``etag``, when present, must match the stored record. + + A malformed request as a whole raises ``ReviewRequestError``. A malformed operation is + returned with an ``error`` so the rest of the request still runs and the caller can + report it per item. A second operation on an id already in the request is refused. + """ + if not isinstance(payload, dict) or set(payload) - {'operations'}: + raise ReviewRequestError('Send {"operations": [...]}.', code='invalid_request') + operations = payload.get('operations') + if not isinstance(operations, list) or not operations: + raise ReviewRequestError('Send at least one operation.', code='invalid_request') + if len(operations) > REVIEW_BULK_MAX_OPERATIONS: + raise ReviewRequestError( + f'Send at most {REVIEW_BULK_MAX_OPERATIONS} operations at a time.', + code='too_many_operations', + ) + + parsed = [] + seen = set() + for index, raw in enumerate(operations): + if not isinstance(raw, dict): + parsed.append(_operation_error(index, None, None, 'Each operation must be an object.')) + continue + record_id = raw.get('id') + op = raw.get('op') + if set(raw) - REVIEW_BULK_OPERATION_KEYS: + parsed.append(_operation_error(index, record_id, op, 'The operation has fields this API does not accept.')) + continue + if not isinstance(record_id, str) or not record_id.strip() or len(record_id) > REVIEW_RECORD_ID_MAX_LENGTH: + parsed.append(_operation_error(index, record_id, op, 'Each operation needs the id of a record.')) + continue + if op not in REVIEW_BULK_OPERATIONS: + parsed.append(_operation_error(index, record_id, op, 'The operation must be update, archive or delete.')) + continue + etag = raw.get('etag') + if etag is not None and (not isinstance(etag, str) or not etag): + parsed.append(_operation_error(index, record_id, op, 'The etag must be text.')) + continue + if op == 'update' and not isinstance(raw.get('changes'), dict): + parsed.append(_operation_error(index, record_id, op, 'An update needs its changes.')) + continue + if op == 'archive' and not isinstance(raw.get('archived'), bool): + parsed.append(_operation_error(index, record_id, op, 'The archived field must be a boolean.')) + continue + if (op != 'update' and 'changes' in raw) or (op != 'archive' and 'archived' in raw): + parsed.append(_operation_error(index, record_id, op, 'The operation has fields this API does not accept.')) + continue + if record_id in seen: + duplicate = _operation_error(index, record_id, op, 'This record already has an operation in this request.') + duplicate['error']['body']['code'] = REVIEW_DUPLICATE_OPERATION_CODE + parsed.append(duplicate) + continue + seen.add(record_id) + parsed.append({ + 'index': index, + 'id': record_id, + 'op': op, + 'etag': etag, + 'changes': raw.get('changes') if op == 'update' else None, + 'archived': raw.get('archived') if op == 'archive' else None, + }) + return parsed + + +def review_bulk_result(operation, body, status): + """One operation's result: the single-record route's response body, with its outcome.""" + body = body if isinstance(body, dict) else {} + result = { + 'index': operation.get('index'), + 'id': operation.get('id'), + 'op': operation.get('op'), + 'ok': 200 <= int(status) < 300, + 'status': int(status), + } + for key, value in body.items(): + if key not in result: + result[key] = value + if not result['ok'] and 'code' not in result: + result['code'] = { + 404: REVIEW_NOT_FOUND_CODE, + 409: REVIEW_RECORD_CHANGED_CODE, + }.get(int(status), REVIEW_OPERATION_FAILED_CODE) + return result + + +def summarize_review_bulk_results(results): + """The bulk response: every result in request order, and how many succeeded.""" + ordered = sorted(results, key=lambda item: item.get('index') or 0) + succeeded = sum(1 for item in ordered if item.get('ok')) + return { + 'results': ordered, + 'succeeded': succeeded, + 'failed': len(ordered) - succeeded, + } + + +def daily_counts(records, window, timestamp_of, group_of): + """Count records per day of the window, split by group. + + Returns ``{'dates': [...], 'series': [{'key': group, 'counts': [...]}, ...]}`` with the + groups ordered by their total, largest first. ``group_of`` may return several groups + for one record, which is then counted once in each. + """ + dates = window['dates'] + index = {date: position for position, date in enumerate(dates)} + counts = {} + for record in records: + day = review_day(timestamp_of(record)) + if day not in index: + continue + groups = group_of(record) + if isinstance(groups, str) or groups is None: + groups = [groups or 'Unknown'] + for group in dict.fromkeys(groups): + series = counts.setdefault(group, [0] * len(dates)) + series[index[day]] += 1 + ordered = sorted(counts.items(), key=lambda item: (-sum(item[1]), str(item[0]))) + return { + 'dates': list(dates), + 'series': [{'key': key, 'counts': values} for key, values in ordered], + } diff --git a/application/single_app/functions_review_lifecycle.py b/application/single_app/functions_review_lifecycle.py index 418b772dd..36ca6ab7b 100644 --- a/application/single_app/functions_review_lifecycle.py +++ b/application/single_app/functions_review_lifecycle.py @@ -7,9 +7,12 @@ ARCHIVE_STATE_ACTIVE = 'active' ARCHIVE_STATE_ARCHIVED = 'archived' +# Both: the Review center's dashboard counts and its links to what they counted. +ARCHIVE_STATE_ALL = 'all' ALLOWED_ARCHIVE_STATES = { ARCHIVE_STATE_ACTIVE, ARCHIVE_STATE_ARCHIVED, + ARCHIVE_STATE_ALL, } @@ -17,13 +20,15 @@ def normalize_archive_state(value): """Normalize an archive-state query value, defaulting to active records.""" normalized_value = str(value or ARCHIVE_STATE_ACTIVE).strip().lower() if normalized_value not in ALLOWED_ARCHIVE_STATES: - raise ValueError("Archive state must be 'active' or 'archived'.") + raise ValueError("Archive state must be 'active', 'archived' or 'all'.") return normalized_value def append_archive_query_filter(where_clauses, archive_state): """Add a backward-compatible archive predicate to a Cosmos SQL query.""" normalized_state = normalize_archive_state(archive_state) + if normalized_state == ARCHIVE_STATE_ALL: + return if normalized_state == ARCHIVE_STATE_ARCHIVED: where_clauses.append("IS_DEFINED(c.is_archived) AND c.is_archived = true") else: diff --git a/application/single_app/functions_safety_remediation.py b/application/single_app/functions_safety_remediation.py index 806fea96b..45aae17f5 100644 --- a/application/single_app/functions_safety_remediation.py +++ b/application/single_app/functions_safety_remediation.py @@ -2,14 +2,26 @@ """Helpers for safety violation remediation actions and notifications.""" +import copy import logging -from datetime import datetime -from typing import Any, Dict, Optional - -from config import cosmos_safety_container +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple + +from azure.core import MatchConditions +from azure.cosmos import exceptions as cosmos_exceptions + +from config import cosmos_approvals_container, cosmos_safety_container +from functions_access_restriction import ( + ACCESS_RESTRICTION_KIND_BLOCKED, + ACCESS_RESTRICTION_KIND_SUSPENDED, + ACCESS_RESTRICTION_SOURCE_SAFETY_VIOLATION, + build_access_restriction_notice, + parse_access_restore_time, +) from functions_appinsights import log_event from functions_debug import debug_print -from functions_notifications import create_notification +from functions_notifications import create_notification, mark_notification_read from functions_settings import get_user_settings, update_user_settings @@ -17,19 +29,353 @@ SAFETY_REMEDIATION_SUSPEND = 'SuspendUser' SAFETY_REMEDIATION_BLOCK = 'BlockUser' +# Warnings the signed-in user still has to acknowledge, read at most this many at a time. +SAFETY_WARNING_PENDING_LIMIT = 20 +SAFETY_WARNING_WRITE_ATTEMPTS = 3 +SAFETY_WARNING_FALLBACK_MESSAGE = ( + 'An administrator reviewed content you submitted and sent you this warning. ' + 'Please review the acceptable use requirements before continuing.' +) + +WARNING_ACKNOWLEDGMENT_PENDING = 'pending' +WARNING_ACKNOWLEDGMENT_ACKNOWLEDGED = 'acknowledged' +WARNING_ACKNOWLEDGMENT_NOT_TRACKED = 'not_tracked' + +# A reviewer can warn about the same violation again, which replaces the warning on it. An +# acknowledgment of the earlier one is refused with this code rather than recorded against +# a warning the user has not read. +SAFETY_WARNING_REPLACED_CODE = 'safety_warning_replaced' +SAFETY_WARNING_REPLACED_MESSAGE = ( + 'A newer warning replaced this one. Read the newer warning, then acknowledge it.' +) + +# A save claims the violation before it sends a warning, so of two overlapping saves -- a +# double-click, or two reviewers -- only one sends. While the claim is held the violation is +# 'sending', which never counts as a warning to acknowledge. A claim older than the time to +# live is from a save that stopped before it finished: it no longer blocks, and it reads as +# a failed send, which saving the warning again retries. +SAFETY_WARNING_SENDING_STATUS = 'sending' +SAFETY_WARNING_SEND_CLAIM_TTL = timedelta(minutes=5) +SAFETY_WARNING_SEND_CLAIM_FIELDS = ('warning_send_claim_id', 'warning_send_claimed_at') +SAFETY_WARNING_SEND_INTERRUPTED_MESSAGE = ( + 'The save that was sending this warning did not finish, so it is not known whether the ' + 'warning reached the user. Saving the warning again sends it again.' +) + +# What a remediation request leaves on its violation. Pending locks the violation until the +# request is decided; every other state is settled and leaves the violation editable. +SAFETY_REQUEST_PENDING = 'pending' +SAFETY_REQUEST_EXECUTED = 'executed' +SAFETY_REQUEST_FAILED = 'failed' +SAFETY_REQUEST_DENIED = 'denied' +SAFETY_REQUEST_EXPIRED = 'expired' +SAFETY_REQUEST_STATUSES = ( + SAFETY_REQUEST_PENDING, + SAFETY_REQUEST_EXECUTED, + SAFETY_REQUEST_FAILED, + SAFETY_REQUEST_DENIED, + SAFETY_REQUEST_EXPIRED, +) +# A pending approval request is removed by Cosmos TTL this long after it was created +# (functions_approvals.TTL_AUTO_DENY_SECONDS, which cannot be imported here without a +# cycle). A violation whose request can no longer be found is settled as expired only once +# the request could no longer exist, so a request that is slow to appear in a query never +# unlocks its violation. +SAFETY_APPROVAL_LIFETIME = timedelta(days=3) +SAFETY_LOG_WRITE_ATTEMPTS = 3 +SAFETY_REQUEST_LOOKUP_BATCH = 100 +SAFETY_REQUEST_FAILED_MESSAGE = ( + 'The approved action could not be completed. Open the approval request for details.' +) +SAFETY_REQUEST_NOT_CURRENT_MESSAGE = ( + 'The safety violation is no longer waiting on this request, so nothing was changed. ' + 'Open the violation to decide what to do now.' +) +# What a remediation decision rests on: the request the violation waits on, a warning being +# sent, and the warning last recorded. A reviewer's save is written only while these are as +# the save read them, so it never lands on another save's warning or request. +SAFETY_REMEDIATION_STATE_FIELDS = ( + 'action_request_status', + 'action_request_id', + 'warning_send_claim_id', + 'warning_notification_id', + 'warning_issued_at', +) + + +class SafetyLogConflict(Exception): + """A violation kept changing while it was being written.""" + def get_safety_log_item(log_id: str) -> Dict[str, Any]: """Return a safety log item by its document id.""" return cosmos_safety_container.read_item(item=log_id, partition_key=log_id) +def write_safety_log_updates( + log_id: str, + updates: Dict[str, Any], + *, + base_item: Optional[Dict[str, Any]] = None, + guard: Optional[Callable[[Dict[str, Any]], bool]] = None, + attempts: int = SAFETY_LOG_WRITE_ATTEMPTS, +) -> Optional[Dict[str, Any]]: + """Merge ``updates`` onto the stored violation with an ETag-conditional replace. + + The first attempt applies them to ``base_item``, the copy the caller already read, when + there is one; after a conflict, to a fresh read. Only the named fields are written, so a + concurrent write to any other field, such as the user acknowledging a warning, survives. + ``guard(current)`` can refuse a fresh read, for example one that has since moved on to + another request; nothing is written then and None is returned. Raises + ``SafetyLogConflict`` when every attempt conflicts, and lets a missing record's + ``CosmosResourceNotFoundError`` through. + """ + current = base_item + for _attempt in range(max(1, attempts)): + if current is None: + current = cosmos_safety_container.read_item(item=log_id, partition_key=log_id) + if guard is not None and not guard(current): + return None + merged = copy.deepcopy(current) + merged.update(copy.deepcopy(updates or {})) + etag = current.get('_etag') + try: + if etag: + stored = cosmos_safety_container.replace_item( + item=log_id, + body=merged, + etag=etag, + match_condition=MatchConditions.IfNotModified, + ) + else: + stored = cosmos_safety_container.upsert_item(merged) + except cosmos_exceptions.CosmosAccessConditionFailedError: + current = None + continue + return stored if isinstance(stored, dict) else merged + raise SafetyLogConflict(f'Safety violation {log_id} changed while it was being written.') + + def update_safety_log_action_state(log_id: str, updates: Dict[str, Any]) -> Dict[str, Any]: - """Persist remediation state changes onto a safety log item.""" - item = get_safety_log_item(log_id) - item.update(updates or {}) - item['last_updated'] = datetime.utcnow().isoformat() - cosmos_safety_container.upsert_item(item) - return item + """Persist remediation state changes onto a safety log item. + + Only the named fields are written, conditionally on the stored version and again on a + fresh copy after a conflict, so a concurrent write is never overwritten. + """ + fields = dict(updates or {}) + fields['last_updated'] = datetime.utcnow().isoformat() + return write_safety_log_updates(log_id, fields) + + +def _safety_request_status(log_item: Dict[str, Any]) -> str: + return str((log_item or {}).get('action_request_status') or '').strip().lower() + + +def _safety_request_still_pending(log_item: Dict[str, Any], approval_id: Optional[str]) -> bool: + """True while a violation is still waiting on this very remediation request.""" + return ( + bool(approval_id) + and _safety_request_status(log_item) == SAFETY_REQUEST_PENDING + and log_item.get('action_request_id') == approval_id + ) + + +def safety_log_awaits_request(log_item: Optional[Dict[str, Any]], approval_id: Optional[str]) -> bool: + """True while a violation is waiting on exactly this remediation request. + + An approved request is carried out only then. One its violation has moved on from -- + withdrawn, replaced by a newer request, or settled -- must not change the user's access. + """ + return _safety_request_still_pending(log_item or {}, approval_id) + + +def safety_remediation_state(log_item: Optional[Dict[str, Any]]) -> Tuple[Any, ...]: + """The parts of a violation a remediation decision rests on, to compare a later read with.""" + log_item = log_item or {} + return tuple(log_item.get(field) for field in SAFETY_REMEDIATION_STATE_FIELDS) + + +def release_safety_log_after_approval_decision( + approval: Dict[str, Any], + outcome: str, +) -> Optional[Dict[str, Any]]: + """Record that a violation's remediation request was denied or expired, unlocking it. + + Called whenever a warn, suspend or block request is denied by a reviewer or by the + expiry sweep. Only a violation still waiting on this very request changes: one that + has since moved on to a newer request, or was already settled, is left as it is. + Returns the stored violation, or None when nothing changed. + """ + if outcome not in (SAFETY_REQUEST_DENIED, SAFETY_REQUEST_EXPIRED): + raise ValueError(f'Unsupported remediation request outcome: {outcome}') + approval = approval if isinstance(approval, dict) else {} + metadata = approval.get('metadata') if isinstance(approval.get('metadata'), dict) else {} + log_id = str(metadata.get('safety_log_id') or '').strip() + approval_id = approval.get('id') + if not log_id or not approval_id: + return None + + now_text = datetime.utcnow().isoformat() + updates = { + 'action_request_status': outcome, + 'action_request_decided_at': approval.get('approved_at') or now_text, + 'action_execution_error': None, + 'last_updated': now_text, + } + try: + stored = write_safety_log_updates( + log_id, + updates, + guard=lambda current: _safety_request_still_pending(current, approval_id), + ) + except cosmos_exceptions.CosmosResourceNotFoundError: + return None + if stored is not None: + log_event( + '[SAFETY_REMEDIATION] A violation was released after its remediation request was decided.', + extra={ + 'safety_log_id': log_id, + 'approval_id': approval_id, + 'request_type': approval.get('request_type'), + 'outcome': outcome, + }, + ) + return stored + + +def _settled_request_outcome( + log_item: Dict[str, Any], + approval: Optional[Dict[str, Any]], + now: datetime, +) -> Optional[str]: + """What a pending violation's request has become, or None while it may still be decided.""" + if approval is None: + requested_at = parse_access_restore_time(log_item.get('action_requested_at')) + if requested_at is None or now - requested_at >= SAFETY_APPROVAL_LIFETIME: + return SAFETY_REQUEST_EXPIRED + return None + return { + 'denied': SAFETY_REQUEST_DENIED, + 'auto_denied': SAFETY_REQUEST_EXPIRED, + 'expired': SAFETY_REQUEST_EXPIRED, + 'executed': SAFETY_REQUEST_EXECUTED, + 'failed': SAFETY_REQUEST_FAILED, + }.get(str(approval.get('status') or '').strip().lower()) + + +def _settled_request_updates(outcome: str, approval: Optional[Dict[str, Any]]) -> Dict[str, Any]: + approval = approval or {} + now_text = datetime.utcnow().isoformat() + updates = {'action_request_status': outcome, 'last_updated': now_text} + if outcome in (SAFETY_REQUEST_DENIED, SAFETY_REQUEST_EXPIRED): + updates['action_request_decided_at'] = approval.get('approved_at') or now_text + updates['action_execution_error'] = None + elif outcome == SAFETY_REQUEST_EXECUTED: + # Recorded as executed only: whatever the request sent was sent by the approval + # path, so no warning is raised for acknowledgment here. + updates['action_approved_at'] = approval.get('approved_at') + updates['action_executed_at'] = approval.get('executed_at') or now_text + updates['action_execution_error'] = None + elif outcome == SAFETY_REQUEST_FAILED: + updates['action_approved_at'] = approval.get('approved_at') + updates['action_execution_error'] = SAFETY_REQUEST_FAILED_MESSAGE + return updates + + +def _lookup_remediation_requests(approval_ids: Iterable[str]) -> Dict[str, Dict[str, Any]]: + """Return ``{approval_id: request}`` for the remediation requests that still exist.""" + ids = [value for value in dict.fromkeys(approval_ids or []) if isinstance(value, str) and value] + found: Dict[str, Dict[str, Any]] = {} + for start in range(0, len(ids), SAFETY_REQUEST_LOOKUP_BATCH): + batch = ids[start:start + SAFETY_REQUEST_LOOKUP_BATCH] + rows = cosmos_approvals_container.query_items( + query=( + "SELECT c.id, c.status, c.request_type, c.approved_at, c.executed_at, " + "c.metadata.safety_log_id AS safety_log_id FROM c WHERE ARRAY_CONTAINS(@ids, c.id)" + ), + parameters=[{'name': '@ids', 'value': batch}], + enable_cross_partition_query=True, + ) + for row in rows: + if isinstance(row, dict) and row.get('id') in batch: + found[row['id']] = row + return found + + +def reconcile_pending_safety_logs( + logs: Iterable[Dict[str, Any]], + now: Optional[datetime] = None, +) -> List[Dict[str, Any]]: + """Settle violations still marked pending whose remediation request was decided or is gone. + + One lookup covers every pending request in ``logs``. A request that was denied or has + expired unlocks its violation; one that executed or failed is recorded as such. A request + that is still pending, or approved and still running, keeps its violation locked, and so + does a lookup that fails. Returns the list with each settled violation replaced by its + stored copy. + """ + logs = list(logs or []) + pending = [ + (index, log_item) for index, log_item in enumerate(logs) + if isinstance(log_item, dict) + and _safety_request_status(log_item) == SAFETY_REQUEST_PENDING + and log_item.get('action_request_id') + ] + if not pending: + return logs + try: + requests = _lookup_remediation_requests(log_item['action_request_id'] for _index, log_item in pending) + except Exception as exc: + log_event( + '[SAFETY_REMEDIATION] Pending remediation requests could not be looked up.', + extra={'request_count': len(pending), 'error_type': type(exc).__name__}, + level=logging.WARNING, + ) + return logs + + current_time = now or datetime.now(timezone.utc) + for index, log_item in pending: + approval_id = log_item['action_request_id'] + approval = requests.get(approval_id) + if approval is not None and approval.get('safety_log_id') not in (None, log_item.get('id')): + # A request that names another violation is never used to settle this one. + continue + outcome = _settled_request_outcome(log_item, approval, current_time) + if outcome is None: + continue + try: + stored = write_safety_log_updates( + log_item['id'], + _settled_request_updates(outcome, approval), + base_item=log_item, + guard=lambda current, request_id=approval_id: _safety_request_still_pending(current, request_id), + ) + except (SafetyLogConflict, cosmos_exceptions.CosmosResourceNotFoundError): + continue + except Exception as exc: + log_event( + '[SAFETY_REMEDIATION] A pending violation could not be settled.', + extra={'safety_log_id': log_item.get('id'), 'error_type': type(exc).__name__}, + level=logging.WARNING, + ) + continue + if stored is not None: + logs[index] = stored + log_event( + '[SAFETY_REMEDIATION] A pending violation was settled from its remediation request.', + extra={ + 'safety_log_id': log_item.get('id'), + 'approval_id': approval_id, + 'outcome': outcome, + 'request_found': approval is not None, + }, + ) + return logs + + +def reconcile_pending_safety_log(log_item: Dict[str, Any], now: Optional[datetime] = None) -> Dict[str, Any]: + """Settle one violation, as ``reconcile_pending_safety_logs`` does for a list.""" + return reconcile_pending_safety_logs([log_item], now=now)[0] def resolve_safety_target_user(user_id: str) -> Dict[str, str]: @@ -132,6 +478,15 @@ def execute_safety_violation_action( 'access': { 'status': 'deny', 'datetime_to_allow': datetime_to_allow, + # What the user was told, so the Access restricted screen can show it. + 'notice': build_access_restriction_notice( + ACCESS_RESTRICTION_KIND_SUSPENDED, + normalized_title, + normalized_message, + until=datetime_to_allow, + reference_id=safety_log.get('id'), + source=ACCESS_RESTRICTION_SOURCE_SAFETY_VIOLATION, + ), } }, allow_cross_user=True, @@ -147,6 +502,13 @@ def execute_safety_violation_action( 'access': { 'status': 'deny', 'datetime_to_allow': None, + 'notice': build_access_restriction_notice( + ACCESS_RESTRICTION_KIND_BLOCKED, + normalized_title, + normalized_message, + reference_id=safety_log.get('id'), + source=ACCESS_RESTRICTION_SOURCE_SAFETY_VIOLATION, + ), } }, allow_cross_user=True, @@ -204,5 +566,366 @@ def execute_safety_violation_action( 'success': True, 'message': result_message, 'notification_id': notification.get('id'), + 'notification_title': normalized_title, + 'notification_message': normalized_message, 'target_user': target_user, } + + +def build_safety_action_execution_updates( + action: str, + execution_result: Dict[str, Any], + executed_at: Optional[str] = None, +) -> Dict[str, Any]: + """Return the safety log fields that record an executed remediation action. + + An executed warning is also marked as needing the user's acknowledgment, with the title + and message the user was sent, so it can be shown to them until they acknowledge it. + Warnings executed before this was recorded carry none of these fields and are never + shown again. + """ + executed_at = executed_at or datetime.now(timezone.utc).isoformat() + updates = { + 'action_request_status': 'executed', + 'action_executed_at': executed_at, + 'action_execution_error': None, + } + if action == SAFETY_REMEDIATION_WARNING: + result = execution_result or {} + updates.update({ + 'warning_requires_acknowledgment': True, + 'warning_notification_id': result.get('notification_id'), + 'warning_title': result.get('notification_title'), + 'warning_message': result.get('notification_message'), + 'warning_issued_at': executed_at, + 'warning_acknowledged_at': None, + }) + return updates + + +def _request_status(log_item: Dict[str, Any]) -> str: + return str(log_item.get('action_request_status') or '').strip().lower() + + +def safety_warning_send_in_progress(log_item: Dict[str, Any], now: Optional[datetime] = None) -> bool: + """True while a save is sending a warning on this violation, so other writes must wait. + + A claim older than ``SAFETY_WARNING_SEND_CLAIM_TTL`` either way, or one whose time can't + be read, is from a save that stopped before it finished, and does not block. + """ + if _request_status(log_item) != SAFETY_WARNING_SENDING_STATUS: + return False + # The claim time is stored as an ISO UTC time, read the same way as a restore time. + claimed_at = parse_access_restore_time(log_item.get('warning_send_claimed_at')) + if claimed_at is None: + return False + now = now or datetime.now(timezone.utc) + return abs(now - claimed_at) < SAFETY_WARNING_SEND_CLAIM_TTL + + +def is_interrupted_safety_warning_send(log_item: Dict[str, Any], now: Optional[datetime] = None) -> bool: + """True for a violation left 'sending' by a save that stopped before it finished.""" + return ( + _request_status(log_item) == SAFETY_WARNING_SENDING_STATUS + and not safety_warning_send_in_progress(log_item, now) + ) + + +def mark_interrupted_safety_warning_send(log_item: Dict[str, Any]) -> None: + """Treat an unfinished send as a failed one, in place, so saving the warning retries it.""" + log_item['action_request_status'] = 'failed' + log_item['action_execution_error'] = SAFETY_WARNING_SEND_INTERRUPTED_MESSAGE + for field in SAFETY_WARNING_SEND_CLAIM_FIELDS: + log_item.pop(field, None) + + +def present_safety_warning_send_state(log_item: Dict[str, Any], now: Optional[datetime] = None) -> None: + """Prepare a listed violation's send state for display, in place. + + The claim's id and time are internal. An unfinished send reads as failed, as the next + save records it, rather than as sending forever. + """ + if is_interrupted_safety_warning_send(log_item, now): + mark_interrupted_safety_warning_send(log_item) + for field in SAFETY_WARNING_SEND_CLAIM_FIELDS: + log_item.pop(field, None) + + +def _replace_safety_log_if_unchanged(body: Dict[str, Any]) -> Dict[str, Any]: + """Write a safety log only if it is still the version that was read.""" + etag = body.get('_etag') + if not etag: + # Cosmos DB always returns an ETag; only a store without them lands here. + stored = cosmos_safety_container.upsert_item(body) + else: + stored = cosmos_safety_container.replace_item( + item=body['id'], + body=body, + etag=etag, + match_condition=MatchConditions.IfNotModified, + ) + return stored if isinstance(stored, dict) else body + + +def claim_safety_warning_send(log_item: Dict[str, Any]) -> Tuple[Dict[str, Any], str]: + """Mark a violation as sending a warning, unless it changed since this save read it. + + The write is conditional on the ETag the save read, so of two saves that read the same + version only one claims it. The other gets ``CosmosAccessConditionFailedError`` and must + send nothing. The claim also stores the rest of the save's changes. Returns the stored + record and the claim's id. + """ + claim_id = str(uuid.uuid4()) + body = dict(log_item) + body.update({ + 'action_request_status': SAFETY_WARNING_SENDING_STATUS, + 'warning_send_claim_id': claim_id, + 'warning_send_claimed_at': datetime.now(timezone.utc).isoformat(), + }) + return _replace_safety_log_if_unchanged(body), claim_id + + +def record_safety_warning_send( + claimed: Dict[str, Any], + claim_id: str, + updates: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """Store what a claimed send did on its violation, and release the claim. + + The write is conditional on the claimed version. When another writer changed the record + meanwhile but kept the claim -- archiving it, for example -- the outcome is written on + the latest version instead. Returns the stored record, or None when the claim was lost: + the record was deleted, or replaced without the claim, so nothing could be recorded. + """ + log_id = claimed.get('id') + current = claimed + for attempt in range(SAFETY_WARNING_WRITE_ATTEMPTS): + if attempt: + try: + current = cosmos_safety_container.read_item(item=log_id, partition_key=log_id) + except cosmos_exceptions.CosmosResourceNotFoundError: + return None + if current.get('warning_send_claim_id') != claim_id: + return None + body = dict(current) + body.update(updates) + for field in SAFETY_WARNING_SEND_CLAIM_FIELDS: + body.pop(field, None) + body['last_updated'] = datetime.utcnow().isoformat() + try: + return _replace_safety_log_if_unchanged(body) + except cosmos_exceptions.CosmosAccessConditionFailedError: + continue + except cosmos_exceptions.CosmosResourceNotFoundError: + return None + return None + + +def serialize_safety_warning_state(log_item: Dict[str, Any]) -> Dict[str, Any]: + """Return a record's warning acknowledgment state for reviewers and the warned user. + + ``warning_acknowledgment_status`` is None unless the record holds an executed warning; + then it is ``pending``, ``acknowledged`` or ``not_tracked`` for a warning sent before + acknowledgment was recorded. + """ + executed_warning = ( + log_item.get('action') == SAFETY_REMEDIATION_WARNING + and str(log_item.get('action_request_status') or '').strip().lower() == 'executed' + ) + requires_acknowledgment = log_item.get('warning_requires_acknowledgment') is True + status = None + if executed_warning: + if not requires_acknowledgment: + status = WARNING_ACKNOWLEDGMENT_NOT_TRACKED + elif log_item.get('warning_acknowledged_at'): + status = WARNING_ACKNOWLEDGMENT_ACKNOWLEDGED + else: + status = WARNING_ACKNOWLEDGMENT_PENDING + return { + 'warning_requires_acknowledgment': requires_acknowledgment, + 'warning_issued_at': log_item.get('warning_issued_at'), + 'warning_acknowledged_at': log_item.get('warning_acknowledged_at'), + 'warning_acknowledgment_status': status, + } + + +def _user_safe_categories(log_item: Dict[str, Any]) -> List[Dict[str, Any]]: + categories = [] + for entry in log_item.get('triggered_categories') or []: + if not isinstance(entry, dict): + continue + name = str(entry.get('category') or '').strip() + if not name: + continue + severity = entry.get('severity') + if isinstance(severity, bool) or not isinstance(severity, (int, float)): + severity = None + categories.append({'category': name, 'severity': severity}) + return categories + + +def _warning_issued_at(log_item: Dict[str, Any]) -> Optional[str]: + """When the warning now on a violation was sent. Each warning sent on it has its own.""" + return log_item.get('warning_issued_at') or log_item.get('action_executed_at') + + +def serialize_safety_warning_for_user(log_item: Dict[str, Any]) -> Dict[str, Any]: + """Return only what the warned user may see of a warning: what they were sent, and when.""" + return { + 'id': log_item.get('id'), + 'violation_id': log_item.get('id'), + 'title': str(log_item.get('warning_title') or '').strip() + or _default_notification_title(SAFETY_REMEDIATION_WARNING), + 'message': str(log_item.get('warning_message') or '').strip() or SAFETY_WARNING_FALLBACK_MESSAGE, + 'issued_at': _warning_issued_at(log_item), + 'acknowledged_at': log_item.get('warning_acknowledged_at'), + 'triggered_categories': _user_safe_categories(log_item), + } + + +def _is_acknowledgeable_warning(log_item: Dict[str, Any], user_id: str) -> bool: + """True for the caller's own executed warning that asks for acknowledgment.""" + return ( + bool(user_id) + and log_item.get('user_id') == user_id + and log_item.get('content_origin', 'user') == 'user' + and log_item.get('action') == SAFETY_REMEDIATION_WARNING + and str(log_item.get('action_request_status') or '').strip().lower() == 'executed' + and log_item.get('warning_requires_acknowledgment') is True + ) + + +_PENDING_WARNING_CONDITIONS = ( + "c.user_id = @user_id " + "AND c.action = @action " + "AND c.action_request_status = 'executed' " + "AND c.warning_requires_acknowledgment = true " + "AND (NOT IS_DEFINED(c.warning_acknowledged_at) OR IS_NULL(c.warning_acknowledged_at)) " + "AND (NOT IS_DEFINED(c.content_origin) OR c.content_origin = 'user')" +) + + +def _pending_warning_parameters(user_id: str) -> List[Dict[str, Any]]: + return [ + {'name': '@user_id', 'value': user_id}, + {'name': '@action', 'value': SAFETY_REMEDIATION_WARNING}, + ] + + +def count_pending_safety_warnings(user_id: str) -> int: + """Return how many warnings the user still has to acknowledge. + + V2 bootstrap carries this so the interface only asks for the warnings themselves when + there are some, rather than on every page load. + """ + if not user_id: + return 0 + values = cosmos_safety_container.query_items( + query=f"SELECT VALUE COUNT(1) FROM c WHERE {_PENDING_WARNING_CONDITIONS}", + parameters=_pending_warning_parameters(user_id), + enable_cross_partition_query=True, + ) + return sum(int(value) for value in values if isinstance(value, (int, float)) and not isinstance(value, bool)) + + +def list_pending_safety_warnings(user_id: str) -> List[Dict[str, Any]]: + """Return the caller's warnings that still need acknowledgment, oldest first.""" + if not user_id: + return [] + query = ( + "SELECT c.id, c.user_id, c.content_origin, c.action, c.action_request_status, " + "c.action_executed_at, c.warning_requires_acknowledgment, c.warning_acknowledged_at, " + "c.warning_title, c.warning_message, c.warning_issued_at, c.triggered_categories " + f"FROM c WHERE {_PENDING_WARNING_CONDITIONS}" + ) + items = list(cosmos_safety_container.query_items( + query=query, + parameters=_pending_warning_parameters(user_id), + enable_cross_partition_query=True, + )) + pending = [ + item for item in items + if _is_acknowledgeable_warning(item, user_id) and not item.get('warning_acknowledged_at') + ] + pending.sort(key=lambda item: str(item.get('warning_issued_at') or item.get('action_executed_at') or '')) + return [serialize_safety_warning_for_user(item) for item in pending[:SAFETY_WARNING_PENDING_LIMIT]] + + +def _mark_warning_notification_read(log_item: Dict[str, Any], user_id: str) -> None: + """Mark the bell notification that delivered the warning as read for its recipient.""" + notification_id = str(log_item.get('warning_notification_id') or '').strip() + if not notification_id: + return + if not mark_notification_read(notification_id, user_id): + log_event( + '[SAFETY_WARNINGS] The warning notification could not be marked read.', + extra={'safety_log_id': log_item.get('id'), 'user_id': user_id}, + level=logging.WARNING, + ) + + +def acknowledge_safety_warning( + log_id: str, + user_id: str, + issued_at: Optional[str] = None, +) -> Tuple[str, Optional[Dict[str, Any]]]: + """Record that the signed-in user acknowledged their own warning. + + Returns ``(status, warning)``. ``status`` is ``acknowledged``, ``already_acknowledged``, + ``replaced`` or ``not_found``. A record that doesn't exist, belongs to someone else, or + isn't a warning that asks for acknowledgment is ``not_found``, so the answer never + confirms another user's record. ``issued_at`` is when the warning the user read was + sent: when the violation now holds a warning sent at another time, the one they read was + replaced, and nothing is recorded (``replaced``). Without it, the warning now on the + violation is acknowledged. The write is conditional on the stored ETag and retried, so a + reviewer saving the record at the same moment is never overwritten. + """ + normalized_id = str(log_id or '').strip() + if not normalized_id or not user_id: + return 'not_found', None + expected_issued_at = str(issued_at or '').strip() + + for _attempt in range(SAFETY_WARNING_WRITE_ATTEMPTS): + try: + item = cosmos_safety_container.read_item(item=normalized_id, partition_key=normalized_id) + except cosmos_exceptions.CosmosResourceNotFoundError: + return 'not_found', None + if not _is_acknowledgeable_warning(item, user_id): + return 'not_found', None + if expected_issued_at and expected_issued_at != str(_warning_issued_at(item) or '').strip(): + return 'replaced', None + if item.get('warning_acknowledged_at'): + _mark_warning_notification_read(item, user_id) + return 'already_acknowledged', serialize_safety_warning_for_user(item) + + item['warning_acknowledged_at'] = datetime.now(timezone.utc).isoformat() + etag = item.get('_etag') + try: + if etag: + stored = cosmos_safety_container.replace_item( + item=normalized_id, + body=item, + etag=etag, + match_condition=MatchConditions.IfNotModified, + ) + else: + stored = cosmos_safety_container.upsert_item(item) + except cosmos_exceptions.CosmosAccessConditionFailedError: + continue + + stored = stored if isinstance(stored, dict) else item + _mark_warning_notification_read(stored, user_id) + log_event( + '[SAFETY_WARNINGS] Safety warning acknowledged by its recipient.', + extra={ + 'safety_log_id': normalized_id, + 'user_id': user_id, + 'warning_issued_at': stored.get('warning_issued_at'), + }, + ) + return 'acknowledged', serialize_safety_warning_for_user(stored) + + raise cosmos_exceptions.CosmosAccessConditionFailedError( + status_code=412, + message='The safety warning changed while it was being acknowledged.', + ) diff --git a/application/single_app/route_access_restriction.py b/application/single_app/route_access_restriction.py new file mode 100644 index 000000000..08f161b46 --- /dev/null +++ b/application/single_app/route_access_restriction.py @@ -0,0 +1,127 @@ +# route_access_restriction.py +"""The Access restricted screen, in both interfaces, and the call behind the V2 page. + +A user whose access an administrator suspended or blocked can still sign in, but +``functions_authentication.user_required`` refuses every app surface and sends them here. +These routes are therefore login-only on purpose: they must stay reachable by exactly the +users ``user_required`` refuses. They only ever describe the signed-in user's own +restriction, read from their own settings, and never accept a user id from the request. + +The Admin role is not subject to access restrictions, so an Admin is always told they are +not restricted, matching what ``user_required`` enforces. +""" + +from flask import jsonify, make_response, render_template, session + +from functions_access_restriction import ( + ACCESS_RESTRICTION_KIND_SUSPENDED, + parse_access_restore_time, + public_access_restriction, +) +from functions_authentication import get_user_access_restriction, login_required +from functions_branding_urls import build_custom_logo_urls +from functions_settings import get_settings, sanitize_settings_for_user +from route_frontend_v2 import _serve_v2_shell +from swagger_wrapper import get_auth_security, swagger_route + + +NO_STORE_HEADERS = {"Cache-Control": "no-store"} + + +def _session_user(): + user = session.get("user") + return user if isinstance(user, dict) else {} + + +def _current_restriction(): + """Return the signed-in user's own restriction, as ``user_required`` would enforce it.""" + user = _session_user() + if "Admin" in (user.get("roles") or []): + return None + user_id = user.get("oid") or user.get("sub") + if not user_id: + return None + return get_user_access_restriction(user_id) + + +def _restore_display(restriction): + """Return a UTC rendering of a suspension's restore time for the server-rendered page.""" + if not restriction or restriction.get("kind") != ACCESS_RESTRICTION_KIND_SUSPENDED: + return "" + restore_at = parse_access_restore_time(restriction.get("until")) + return restore_at.strftime("%Y-%m-%d %H:%M UTC") if restore_at else "" + + +def _restricted_page_branding(raw_settings, public_settings): + """Return the branding the V2 page draws: the fields the V2 Terms of Use page receives. + + Read from the same settings as ``route_backend_v2._build_branding``. Only the logo URLs + are returned, never the stored image data. + """ + show_logo = bool(raw_settings.get("show_logo", False)) + logo_url, logo_dark_url = build_custom_logo_urls(raw_settings) + classification_banner = None + if raw_settings.get("classification_banner_enabled") and raw_settings.get("classification_banner_text"): + classification_banner = { + "enabled": True, + "text": raw_settings.get("classification_banner_text"), + "color": raw_settings.get("classification_banner_color") or "#ffc107", + "text_color": raw_settings.get("classification_banner_text_color") or "#ffffff", + } + return { + "app_title": public_settings.get("app_title") or "SimpleChat", + "hide_app_title": bool(raw_settings.get("hide_app_title", False)), + "show_logo": show_logo, + "logo_url": logo_url if show_logo else None, + "logo_dark_url": logo_dark_url if show_logo else None, + "classification_banner": classification_banner, + } + + +def register_route_access_restriction(bp): + @bp.route("/v2/access-restricted", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + def v2_access_restricted(): + """Serve the V2 SPA shell for the Access restricted page. + + This static rule is matched ahead of the ``/v2/`` catch-all, which + requires the User role and an unrestricted account. The page itself loads nothing + but ``/api/v2/access-restriction``. + """ + return _serve_v2_shell() + + @bp.route("/api/v2/access-restriction", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + def v2_access_restriction(): + """Return the signed-in user's own access restriction for the V2 page. + + ``restricted`` is False once a suspension has ended -- the read restores it -- or + an administrator has restored access, and the page then offers to continue. + """ + restriction = _current_restriction() + settings = get_settings() or {} + payload = { + "restricted": restriction is not None, + "branding": _restricted_page_branding(settings, sanitize_settings_for_user(settings)), + } + if restriction is not None: + payload["restriction"] = public_access_restriction(restriction) + return jsonify(payload), 200, NO_STORE_HEADERS + + @bp.route("/access-restricted", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + def access_restricted(): + """Render the classic Access restricted page for the signed-in user.""" + restriction = _current_restriction() + public_settings = sanitize_settings_for_user(get_settings() or {}) + response = make_response(render_template( + "access_restricted.html", + app_settings=public_settings, + restriction=public_access_restriction(restriction) if restriction else None, + restore_display=_restore_display(restriction), + )) + response.headers["Cache-Control"] = "no-store" + return response diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py index 5762d5c13..841238ec3 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -45,7 +45,13 @@ from functions_logging import * from functions_activity_logging import * from functions_approvals import * -from functions_approvals import _can_user_approve, _can_user_deny +from functions_approvals import ( + APPROVAL_STATS_SCAN_LIMIT, + _can_user_approve, + _can_user_deny, + summarize_visible_approvals, +) +from functions_review_center import parse_review_window from functions_m365_approvals import is_m365_approval from functions_m365_pending_delivery import cancel_m365_conversation_deliveries from route_backend_m365 import m365_approval_decision_response @@ -75,8 +81,11 @@ select_workspace_ids, workspace_members, workspace_row, ) from functions_safety_remediation import ( + SAFETY_REQUEST_NOT_CURRENT_MESSAGE, + build_safety_action_execution_updates, execute_safety_violation_action, get_safety_log_item, + safety_log_awaits_request, update_safety_log_action_state, ) from functions_public_workspaces import ( @@ -8499,6 +8508,46 @@ def api_get_approvals(): log_event("[APPROVALS] Failed to fetch approvals", extra={'exception_type': type(e).__name__}, level=logging.ERROR) return jsonify({'error': 'Failed to fetch approvals'}), 500 + @bp.route('/api/approvals/stats', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + def api_get_approval_stats(): + """ + Summarize the approval requests the current user can see, for the Approvals dashboard. + + Query Parameters: + days (int): 7, 30 or 90 (default 30), the window decided requests are counted in. + + Requests are read through the same visibility rules as GET /api/approvals, so a + request the caller cannot see is never counted. + """ + try: + days = parse_review_window(request.args.get('days')) or 30 + except ValueError: + return jsonify({'error': 'The window must be 7, 30 or 90 days.'}), 400 + try: + user = session.get('user', {}) + user_id = user.get('oid') or user.get('sub') + user_roles = user.get('roles', []) + result = get_pending_approvals( + user_id=user_id, + user_roles=user_roles, + page=1, + per_page=APPROVAL_STATS_SCAN_LIMIT, + include_completed=True, + status_filter='all', + tenant_id=user.get('tid'), + ) + return jsonify(summarize_visible_approvals( + result.get('approvals', []), + user_id, + user_roles, + days, + )), 200 + except Exception as e: + log_event("[APPROVALS] Failed to summarize approvals", extra={'exception_type': type(e).__name__}, level=logging.ERROR) + return jsonify({'error': 'Failed to summarize approvals'}), 500 + @bp.route('/api/approvals/', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required @@ -8745,6 +8794,10 @@ def _execute_safety_violation_request(approval, executor_id, executor_email, exe return {'success': False, 'message': 'Approval metadata is missing the safety log reference.'} safety_log = get_safety_log_item(safety_log_id) + # Only the request its violation is waiting on is carried out. One the violation has + # moved on from -- withdrawn, replaced by a newer request, or settled -- changes nothing. + if not safety_log_awaits_request(safety_log, approval.get('id')): + return {'success': False, 'message': SAFETY_REQUEST_NOT_CURRENT_MESSAGE} action = metadata.get('violation_action') if not action: return {'success': False, 'message': 'Approval metadata is missing the violation action.'} @@ -8762,15 +8815,16 @@ def _execute_safety_violation_request(approval, executor_id, executor_email, exe }, ) - update_safety_log_action_state(safety_log_id, { - 'action_request_status': 'executed', + # Warnings are sent without approval now; this path still finishes warn_user + # requests created before that, and marks them for acknowledgment the same way. + execution_updates = build_safety_action_execution_updates(action, result) + execution_updates.update({ 'action_request_id': approval.get('id'), 'action_request_type': approval.get('request_type'), 'action_requested_at': approval.get('created_at'), 'action_approved_at': approval.get('approved_at'), - 'action_executed_at': datetime.utcnow().isoformat(), - 'action_execution_error': None, }) + update_safety_log_action_state(safety_log_id, execution_updates) return result diff --git a/application/single_app/route_backend_feedback.py b/application/single_app/route_backend_feedback.py index 98c3a6bcb..e54cdc488 100644 --- a/application/single_app/route_backend_feedback.py +++ b/application/single_app/route_backend_feedback.py @@ -4,12 +4,36 @@ import io import logging +from azure.core import MatchConditions from flask import make_response from config import * from functions_appinsights import log_event from functions_authentication import * +from functions_notifications import create_notification +from functions_review_center import ( + REVIEW_RECORD_CHANGED_CODE, + REVIEW_WRITE_ATTEMPTS, + ReviewRecordConflict, + ReviewRequestError, + cap_review_ids, + daily_counts, + in_review_window, + normalize_review_search, + parse_review_bulk_operations, + parse_review_date, + parse_review_window, + replace_review_record, + resolve_review_users, + review_bulk_result, + review_day, + review_excerpt, + review_text_matches, + review_window, + summarize_review_bulk_results, +) from functions_review_lifecycle import ( + ARCHIVE_STATE_ALL, apply_archive_state, append_archive_query_filter, log_review_lifecycle_action, @@ -27,6 +51,19 @@ "negative": "Negative", "neutral": "Neutral", } +FEEDBACK_REVIEW_TEXT_FIELDS = ('analysisNotes', 'responseToUser', 'actionTaken') +FEEDBACK_REVIEW_TEXT_MAX_LENGTH = 8000 +FEEDBACK_OLDEST_AWAITING_LIMIT = 5 +FEEDBACK_RECORD_CHANGED_MESSAGE = ( + 'This feedback changed after you opened it. Reload it to see the latest version, then try again.' +) +# The notice a user receives when a reviewer chooses to tell them about the review. The link +# opens the user's own feedback list: classic Profile, and V2 Settings, which reads it too. +FEEDBACK_RESPONSE_NOTIFICATION_TYPE = 'feedback_response' +FEEDBACK_RESPONSE_NOTIFICATION_TITLE = 'An administrator responded to your feedback' +FEEDBACK_RESPONSE_NOTIFICATION_FALLBACK = 'An administrator reviewed the feedback you sent about an AI response.' +FEEDBACK_RESPONSE_NOTIFICATION_LINK = '/profile?tab=feedback' +FEEDBACK_RESPONSE_NOTIFICATION_MAX_LENGTH = 1000 def _authorize_feedback_conversation(user_id, conversation_id): @@ -129,6 +166,14 @@ def _query_feedback_items( )) serialized_items = [_serialize_feedback_item(item) for item in items] + if user_id: + # Who reviewed the feedback is for reviewers; the user sees the review itself. + for serialized_item in serialized_items: + review = serialized_item.get("adminReview") + if isinstance(review, dict) and "analyzedBy" in review: + serialized_item["adminReview"] = { + key: value for key, value in review.items() if key != "analyzedBy" + } if filter_type: serialized_items = [ @@ -246,10 +291,11 @@ def _get_feedback_admin_actor(): return { 'id': actor_id, 'email': user.get('preferred_username') or user.get('email') or '', + 'name': user.get('name') or user.get('preferred_username') or '', } -def _feedback_lifecycle_response(message, audit_logged): +def _feedback_lifecycle_body(message, audit_logged): response = { 'success': True, 'message': message, @@ -259,7 +305,11 @@ def _feedback_lifecycle_response(message, audit_logged): response['audit_warning'] = ( 'The record was updated, but the audit activity could not be recorded.' ) - return jsonify(response) + return response + + +def _feedback_lifecycle_response(message, audit_logged): + return jsonify(_feedback_lifecycle_body(message, audit_logged)) def _log_feedback_audit_failure(feedback_id, lifecycle_action): @@ -272,6 +322,344 @@ def _log_feedback_audit_failure(feedback_id, lifecycle_action): level=logging.ERROR, ) + +def _feedback_record_changed_body(): + return {'error': FEEDBACK_RECORD_CHANGED_MESSAGE, 'code': REVIEW_RECORD_CHANGED_CODE} + + +def _feedback_write_attempts(expected_etag): + """How often a save may write: once when it names the version it read, so a conflict is + refused rather than merged onto a newer version; otherwise it is merged field by field.""" + return 1 if expected_etag else REVIEW_WRITE_ATTEMPTS + + +def _feedback_acknowledged(item): + return bool((item.get('adminReview') or {}).get('acknowledged')) + + +def _parse_feedback_list_filters(): + """Read the Review center list filters. Raises ValueError for a value that can't be used.""" + filter_type, filter_ack_bool, archive_state = _parse_feedback_filters(include_archive_state=True) + return { + 'type': filter_type, + 'ack': filter_ack_bool, + 'archive': archive_state, + 'search': normalize_review_search(request.args.get('search')), + 'user_id': (request.args.get('user_id') or '').strip() or None, + 'date': parse_review_date(request.args.get('date')), + 'days': parse_review_window(request.args.get('days')), + } + + +def _feedback_matches(item, filters, users, window): + if filters['user_id'] and item.get('userId') != filters['user_id']: + return False + if filters['date'] and review_day(item.get('timestamp')) != filters['date']: + return False + if window and not in_review_window(item.get('timestamp'), window): + return False + if filters['search']: + review = item.get('adminReview') or {} + user = users.get(item.get('userId')) or {} + return review_text_matches( + filters['search'], + item.get('id'), + item.get('prompt'), + item.get('aiResponse'), + item.get('reason'), + item.get('userId'), + review.get('analysisNotes'), + review.get('responseToUser'), + review.get('actionTaken'), + user.get('display_name'), + user.get('email'), + ) + return True + + +def _load_admin_feedback(filters): + """The feedback a Review center list or "select all matching" covers, newest first.""" + items = _query_feedback_items( + filter_type=filters['type'], + filter_ack_bool=filters['ack'], + archive_state=filters['archive'], + ) + users = resolve_review_users([item.get('userId') for item in items]) if filters['search'] else {} + window = review_window(filters['days']) if filters['days'] else None + return [item for item in items if _feedback_matches(item, filters, users, window)], users + + +def _with_feedback_user_names(items, users=None): + """Add each record's user display name and email, looked up in one batch.""" + names = dict(users or {}) + missing = [item.get('userId') for item in items if item.get('userId') not in names] + if missing: + names.update(resolve_review_users(missing)) + for item in items: + entry = names.get(item.get('userId')) or {} + item['userDisplayName'] = entry.get('display_name') or None + item['userEmail'] = entry.get('email') or None + return items + + +def _build_feedback_window_stats(days): + """The Feedback dashboard for the last ``days`` days, read across active and archived records.""" + window = review_window(days) + items = _query_feedback_items(archive_state=ARCHIVE_STATE_ALL) + in_window = [item for item in items if in_review_window(item.get('timestamp'), window)] + awaiting = [item for item in items if not item.get('isArchived') and not _feedback_acknowledged(item)] + oldest = sorted(awaiting, key=lambda item: str(item.get('timestamp') or ''))[:FEEDBACK_OLDEST_AWAITING_LIMIT] + names = resolve_review_users([item.get('userId') for item in oldest]) + acknowledged_in_window = sum(1 for item in in_window if _feedback_acknowledged(item)) + return { + 'window': { + 'days': window['days'], + 'start_date': window['start_date'], + 'end_date': window['end_date'], + }, + 'received_count': len(in_window), + 'awaiting_review_count': len(awaiting), + 'negative_count_in_window': sum(1 for item in in_window if item.get('feedbackType') == 'Negative'), + 'acknowledged_count_in_window': acknowledged_in_window, + 'acknowledgement_rate': round(acknowledged_in_window / len(in_window), 4) if in_window else None, + 'archived_count': sum(1 for item in items if item.get('isArchived')), + 'daily_by_rating': daily_counts( + in_window, + window, + lambda item: item.get('timestamp'), + lambda item: item.get('feedbackType') or 'Unknown', + ), + 'oldest_awaiting': [ + { + 'id': item.get('id'), + 'feedbackType': item.get('feedbackType'), + 'timestamp': item.get('timestamp'), + 'userId': item.get('userId'), + 'userDisplayName': (names.get(item.get('userId')) or {}).get('display_name') or None, + 'promptExcerpt': review_excerpt(item.get('prompt')), + } + for item in oldest + ], + } + + +def _validate_feedback_review_changes(data): + """Return an error message for review changes the API refuses, or None.""" + if 'acknowledged' in data and not isinstance(data.get('acknowledged'), bool): + return 'Acknowledged must be true or false.' + for field in FEEDBACK_REVIEW_TEXT_FIELDS: + value = data.get(field) + if value is not None and not isinstance(value, str): + return f'{field} must be text.' + if isinstance(value, str) and len(value) > FEEDBACK_REVIEW_TEXT_MAX_LENGTH: + return f'{field} is too long.' + if 'notify_user' in data and not isinstance(data.get('notify_user'), bool): + return 'notify_user must be true or false.' + return None + + +def _notify_feedback_user(feedback_doc, actor): + """Tell the user who sent the feedback that a reviewer responded. Returns a warning or None.""" + user_id = feedback_doc.get('userId') + if not user_id: + return 'The user could not be notified because the feedback has no user.' + response_text = str(((feedback_doc.get('adminReview') or {}).get('responseToUser')) or '').strip() + message = response_text[:FEEDBACK_RESPONSE_NOTIFICATION_MAX_LENGTH] or FEEDBACK_RESPONSE_NOTIFICATION_FALLBACK + notification = create_notification( + user_id=user_id, + notification_type=FEEDBACK_RESPONSE_NOTIFICATION_TYPE, + title=FEEDBACK_RESPONSE_NOTIFICATION_TITLE, + message=message, + link_url=FEEDBACK_RESPONSE_NOTIFICATION_LINK, + link_context={'tab': 'feedback', 'feedback_id': feedback_doc.get('id')}, + metadata={'feedback_id': feedback_doc.get('id')}, + ) + if notification is None: + log_event('[FEEDBACK_REVIEW] The feedback response notification could not be created.', { + 'feedback_id': feedback_doc.get('id'), + 'actor_id': actor.get('id'), + }, level=logging.WARNING) + return 'The review was saved, but the user could not be notified.' + log_event('[FEEDBACK_REVIEW] The user was notified about a feedback review.', { + 'feedback_id': feedback_doc.get('id'), + 'actor_id': actor.get('id'), + 'notification_id': notification.get('id'), + }) + return None + + +def _apply_feedback_review_update(feedback_id, data, actor): + """Save a reviewer's review of one feedback record. Returns ``(body, status)``. + + Shared by PATCH /feedback/review/ and the bulk ``update`` operation. Only the fields + sent change; the reviewer is recorded as ``adminReview.analyzedBy``. ``etag``, when + sent, must match the stored record. ``notify_user: true`` sends the user a notification + with the response once the review is saved. + """ + if not isinstance(data, dict): + return {"error": "The request body must be an object."}, 400 + problem = _validate_feedback_review_changes(data) + if problem: + return {"error": problem}, 400 + expected_etag = data.get('etag') + if expected_etag is not None and not isinstance(expected_etag, str): + return {"error": "The etag must be text."}, 400 + if not actor.get('id'): + return {'error': 'No user ID found in session'}, 403 + + try: + feedback_doc = cosmos_feedback_container.read_item(item=feedback_id, partition_key=feedback_id) + except CosmosResourceNotFoundError: + return {"error": "Feedback not found"}, 404 + except Exception as e: + log_event('[FEEDBACK_REVIEW] A feedback record could not be read for review.', { + 'feedback_id': feedback_id, + 'error_type': type(e).__name__, + }, level=logging.ERROR) + return {"error": "Failed to read feedback item"}, 500 + if expected_etag and feedback_doc.get('_etag') != expected_etag: + return _feedback_record_changed_body(), 409 + + notify_user = data.get('notify_user') is True + reviewed_at = datetime.utcnow().isoformat() + + def apply_review(record): + admin_review = dict(record.get("adminReview") or {}) + admin_review["acknowledged"] = data.get("acknowledged", admin_review.get("acknowledged", False)) + for field in FEEDBACK_REVIEW_TEXT_FIELDS: + admin_review[field] = data.get(field, admin_review.get(field)) + admin_review["reviewTimestamp"] = reviewed_at + admin_review["analyzedBy"] = {'id': actor['id'], 'displayName': actor.get('name') or actor.get('email') or ''} + if notify_user: + admin_review["userNotifiedAt"] = reviewed_at + record["adminReview"] = admin_review + + try: + # A save that names the version it read is written on that version or not at all; + # one that doesn't is merged field by field onto a newer version. + stored = replace_review_record( + cosmos_feedback_container, + feedback_id, + apply_review, + base_item=feedback_doc, + attempts=_feedback_write_attempts(expected_etag), + ) + except ReviewRecordConflict: + return _feedback_record_changed_body(), 409 + except CosmosResourceNotFoundError: + return {"error": "Feedback not found"}, 404 + except Exception as e: + log_event('[FEEDBACK_REVIEW] A feedback review could not be saved.', { + 'feedback_id': feedback_id, + 'error_type': type(e).__name__, + }, level=logging.ERROR) + return {"error": "Failed to save changes"}, 500 + + body = {"success": True, "etag": stored.get('_etag'), "notified": False} + if notify_user: + warning = _notify_feedback_user(stored, actor) + body["notified"] = warning is None + if warning: + body["notification_warning"] = warning + return body, 200 + + +def _archive_feedback(feedback_id, archived, actor, expected_etag=None): + """Archive or restore one feedback record. Returns ``(body, status)``. + + Shared by the archive route and the bulk ``archive`` operation. Only the archive fields + change, conditionally on the stored version, so a concurrent review save survives. + """ + if not isinstance(archived, bool): + return {'error': 'The archived field must be a boolean.'}, 400 + if not actor.get('id'): + return {'error': 'No user ID found in session'}, 403 + + try: + feedback_doc = cosmos_feedback_container.read_item(item=feedback_id, partition_key=feedback_id) + if expected_etag and feedback_doc.get('_etag') != expected_etag: + return _feedback_record_changed_body(), 409 + was_archived = bool(feedback_doc.get('is_archived')) + + def apply_archive(record): + apply_archive_state(record, archived, actor['id']) + + stored = replace_review_record( + cosmos_feedback_container, + feedback_id, + apply_archive, + base_item=feedback_doc, + attempts=_feedback_write_attempts(expected_etag), + ) + except CosmosResourceNotFoundError: + return {'error': 'Feedback not found'}, 404 + except ReviewRecordConflict: + return _feedback_record_changed_body(), 409 + except Exception as e: + log_event( + '[FEEDBACK_LIFECYCLE] Failed to update feedback archive state', + {'feedback_id': feedback_id, 'error_type': type(e).__name__}, + level=logging.ERROR, + ) + return {'error': 'Failed to update feedback archive state'}, 500 + + lifecycle_action = 'archive' if archived else 'unarchive' + audit_logged = log_review_lifecycle_action( + 'feedback', + lifecycle_action, + stored, + actor, + was_archived=was_archived, + ) + if not audit_logged: + _log_feedback_audit_failure(feedback_id, lifecycle_action) + + message = 'Feedback archived successfully.' if archived else 'Feedback unarchived successfully.' + return _feedback_lifecycle_body(message, audit_logged), 200 + + +def _delete_feedback(feedback_id, actor, expected_etag=None): + """Permanently delete one feedback record, keeping its audit entry. Returns ``(body, status)``.""" + if not actor.get('id'): + return {'error': 'No user ID found in session'}, 403 + + try: + feedback_doc = cosmos_feedback_container.read_item(item=feedback_id, partition_key=feedback_id) + if expected_etag and feedback_doc.get('_etag') != expected_etag: + return _feedback_record_changed_body(), 409 + if feedback_doc.get('_etag'): + cosmos_feedback_container.delete_item( + item=feedback_id, + partition_key=feedback_id, + etag=feedback_doc.get('_etag'), + match_condition=MatchConditions.IfNotModified, + ) + else: + cosmos_feedback_container.delete_item(item=feedback_id, partition_key=feedback_id) + except CosmosResourceNotFoundError: + return {'error': 'Feedback not found'}, 404 + except exceptions.CosmosAccessConditionFailedError: + return _feedback_record_changed_body(), 409 + except Exception as e: + log_event( + '[FEEDBACK_LIFECYCLE] Failed to delete feedback', + {'feedback_id': feedback_id, 'error_type': type(e).__name__}, + level=logging.ERROR, + ) + return {'error': 'Failed to delete feedback'}, 500 + + audit_logged = log_review_lifecycle_action( + 'feedback', + 'delete', + feedback_doc, + actor, + ) + if not audit_logged: + _log_feedback_audit_failure(feedback_id, 'delete') + + return _feedback_lifecycle_body('Feedback permanently deleted.', audit_logged), 200 + + def register_route_backend_feedback(bp): @bp.route("/feedback/submit", methods=["POST"]) @@ -414,19 +802,18 @@ def feedback_submit(): def feedback_review_get(): """ Return feedback for admin review with pagination and filtering. + + Query parameters: page, page_size, type, ack and archive as before, plus the Review + center's search, user_id, date (YYYY-MM-DD) and days (7, 30 or 90). Each record + carries userDisplayName and userEmail. """ try: page = request.args.get('page', 1, type=int) page_size = request.args.get('page_size', 10, type=int) - filter_type, filter_ack_bool, archive_state = _parse_feedback_filters( - include_archive_state=True, - ) - items = _query_feedback_items( - filter_type=filter_type, - filter_ack_bool=filter_ack_bool, - archive_state=archive_state, - ) + filters = _parse_feedback_list_filters() + items, users = _load_admin_feedback(filters) paginated_items, page, page_size = _paginate_feedback_items(items, page, page_size) + _with_feedback_user_names(paginated_items, users) total_count = len(items) return jsonify({ @@ -446,23 +833,56 @@ def feedback_review_get(): traceback.print_exc() return jsonify({"error": f"Failed to retrieve feedback: {str(e)}"}), 500 + @bp.route("/feedback/review/ids", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @feedback_admin_required + @enabled_required("enable_user_feedback") + def feedback_review_ids(): + """Return the ids of the feedback matching the list filters, for "select all matching". + + Takes the same filters as GET /feedback/review. At most 500 ids are returned: + ``total`` is how many matched, and ``capped`` says whether the cap applied. + """ + try: + filters = _parse_feedback_list_filters() + items, _users = _load_admin_feedback(filters) + return jsonify(cap_review_ids([item.get('id') for item in items])), 200 + except ValueError: + return jsonify({"error": "Invalid request parameters."}), 400 + except Exception as e: + log_event('[FEEDBACK_REVIEW] Matching feedback ids could not be listed.', { + 'error_type': type(e).__name__, + }, level=logging.ERROR) + return jsonify({"error": "The matching feedback could not be listed."}), 500 + @bp.route("/feedback/review/stats", methods=["GET"]) @swagger_route(security=get_auth_security()) @login_required @feedback_admin_required @enabled_required("enable_user_feedback") def feedback_review_stats(): - """Return aggregate feedback review statistics for the admin page.""" + """Return aggregate feedback review statistics for the admin page. + + With ``days`` (7, 30 or 90) the response also carries the Review center dashboard: + feedback awaiting review, negative feedback and the acknowledgement rate in the + window, archived feedback, feedback per day by rating and the oldest feedback + awaiting review. Every other field is unchanged. + """ try: filter_type, filter_ack_bool, archive_state = _parse_feedback_filters( include_archive_state=True, ) + days = parse_review_window(request.args.get('days')) items = _query_feedback_items( filter_type=filter_type, filter_ack_bool=filter_ack_bool, archive_state=archive_state, ) - return jsonify(_build_feedback_stats(items)) + stats = _build_feedback_stats(items) + if days: + stats.update(_build_feedback_window_stats(days)) + return jsonify(stats) except ValueError: logging.exception("Invalid request parameters for feedback review stats") return jsonify({"error": "Invalid request parameters."}), 400 @@ -476,22 +896,68 @@ def feedback_review_stats(): @feedback_admin_required @enabled_required("enable_user_feedback") def feedback_review_export(): - """Export feedback review rows as CSV for the active filter set.""" + """Export feedback review rows as CSV for the active filter set. + + Takes the same filters as GET /feedback/review, so an export matches the list. + """ try: - filter_type, filter_ack_bool, archive_state = _parse_feedback_filters( - include_archive_state=True, - ) - items = _query_feedback_items( - filter_type=filter_type, - filter_ack_bool=filter_ack_bool, - archive_state=archive_state, - ) + filters = _parse_feedback_list_filters() + items, _users = _load_admin_feedback(filters) return _build_feedback_export_response(items, 'feedback_review_export', include_user_id=True) except ValueError as e: return jsonify({"error": str(e)}), 400 except Exception as e: return jsonify({"error": f"Failed to export feedback: {str(e)}"}), 500 + @bp.route("/feedback/review/bulk", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @feedback_admin_required + @enabled_required("enable_user_feedback") + def feedback_review_bulk(): + """Apply up to 100 review operations to feedback, each as its single route would. + + Body: ``{"operations": [{"id", "op", "etag"?, ...}]}``. ``update`` carries + ``changes``, the same fields PATCH /feedback/review/ accepts; ``archive`` carries + ``archived``; ``delete`` carries nothing more. Each result repeats the single route's + response with ``ok`` and ``status``, in request order; one failure never stops the + others. + """ + actor = _get_feedback_admin_actor() + if not actor.get('id'): + return jsonify({'error': 'No user ID found in session'}), 403 + try: + operations = parse_review_bulk_operations(request.get_json(silent=True)) + except ReviewRequestError as error: + return jsonify({'error': error.message, 'code': error.code}), error.status + + results = [] + for operation in operations: + if operation.get('error'): + results.append(review_bulk_result( + operation, operation['error']['body'], operation['error']['status'], + )) + continue + if operation['op'] == 'update': + changes = {key: value for key, value in operation['changes'].items() if key != 'etag'} + if operation['etag']: + changes['etag'] = operation['etag'] + body, status = _apply_feedback_review_update(operation['id'], changes, actor) + elif operation['op'] == 'archive': + body, status = _archive_feedback(operation['id'], operation['archived'], actor, operation['etag']) + else: + body, status = _delete_feedback(operation['id'], actor, operation['etag']) + results.append(review_bulk_result(operation, body, status)) + + summary = summarize_review_bulk_results(results) + log_event('[FEEDBACK_REVIEW] Bulk feedback review applied.', { + 'actor_id': actor.get('id'), + 'operation_count': len(results), + 'succeeded': summary['succeeded'], + 'failed': summary['failed'], + }) + return jsonify(summary), 200 + @bp.route("/feedback/review/", methods=["GET"]) @swagger_route(security=get_auth_security()) @login_required @@ -501,6 +967,8 @@ def feedback_review_get_single(feedbackId): """ Fetch a single feedback item by its ID. Needed for the edit modal after switching to pagination. + + Also carries ``etag`` (send it back with a save) and the user's display name and email. """ try: # Assuming feedbackId is the partition key as well @@ -516,9 +984,11 @@ def feedback_review_get_single(feedbackId): "feedbackType": _normalize_feedback_type(feedback_doc.get("feedbackType")) or feedback_doc.get("feedbackType"), "reason": feedback_doc.get("reason"), "timestamp": feedback_doc.get("timestamp"), - "adminReview": feedback_doc.get("adminReview", {}) + "adminReview": feedback_doc.get("adminReview", {}), + "etag": feedback_doc.get("_etag"), } result.update(serialize_archive_metadata(feedback_doc)) + _with_feedback_user_names([result]) return jsonify(result) except CosmosResourceNotFoundError: # Import this if not already done @@ -536,42 +1006,15 @@ def feedback_review_get_single(feedbackId): @enabled_required("enable_user_feedback") def feedback_review_update(feedbackId): """ - Patch admin fields: acknowledged, analysisNotes, responseToUser, actionTaken + Patch admin fields: acknowledged, analysisNotes, responseToUser, actionTaken. + + The reviewer is recorded as adminReview.analyzedBy. ``etag``, when sent, must match + the stored record, or 409 ``record_changed`` is returned. ``notify_user: true`` sends + the user a notification with the response to their feedback. """ data = request.get_json() - - try: - # Assume feedbackId is the partition key - feedback_doc = cosmos_feedback_container.read_item( - item=feedbackId, partition_key=feedbackId - ) - except CosmosResourceNotFoundError: - return jsonify({"error": "Feedback not found"}), 404 - except Exception as e: - print(f"Error reading feedback item {feedbackId} for update: {e}") - return jsonify({"error": "Failed to read feedback item"}), 500 - - - admin_review_data = feedback_doc.get("adminReview", {}) # Get current or default dict - - # Update fields based on request data - admin_review_data["acknowledged"] = data.get("acknowledged", admin_review_data.get("acknowledged", False)) - admin_review_data["analysisNotes"] = data.get("analysisNotes", admin_review_data.get("analysisNotes")) - admin_review_data["responseToUser"] = data.get("responseToUser", admin_review_data.get("responseToUser")) - admin_review_data["actionTaken"] = data.get("actionTaken", admin_review_data.get("actionTaken")) - admin_review_data["reviewTimestamp"] = datetime.utcnow().isoformat() - # Optionally add analyzedBy from session user - # if 'user' in session: - # admin_review_data["analyzedBy"] = session['user'].get('oid') or session['user'].get('sub') - - feedback_doc["adminReview"] = admin_review_data # Assign updated dict back - - try: - cosmos_feedback_container.upsert_item(feedback_doc) - return jsonify({"success": True}) - except Exception as e: - print(f"Error updating feedback item {feedbackId}: {e}") - return jsonify({"error": "Failed to save changes"}), 500 + body, status = _apply_feedback_review_update(feedbackId, data, _get_feedback_admin_actor()) + return jsonify(body), status @bp.route("/feedback/review//archive", methods=["PATCH"]) @swagger_route(security=get_auth_security()) @@ -579,47 +1022,20 @@ def feedback_review_update(feedbackId): @feedback_admin_required @enabled_required("enable_user_feedback") def feedback_review_archive(feedbackId): - """Archive or unarchive a feedback review record.""" - data = request.get_json() or {} - archived = data.get('archived') - if not isinstance(archived, bool): - return jsonify({'error': 'The archived field must be a boolean.'}), 400 - - actor = _get_feedback_admin_actor() - if not actor.get('id'): - return jsonify({'error': 'No user ID found in session'}), 403 + """Archive or unarchive a feedback review record. - try: - feedback_doc = cosmos_feedback_container.read_item( - item=feedbackId, - partition_key=feedbackId, - ) - was_archived = bool(feedback_doc.get('is_archived')) - apply_archive_state(feedback_doc, archived, actor['id']) - cosmos_feedback_container.upsert_item(feedback_doc) - except CosmosResourceNotFoundError: - return jsonify({'error': 'Feedback not found'}), 404 - except Exception as e: - log_event( - '[FEEDBACK_LIFECYCLE] Failed to update feedback archive state', - {'feedback_id': feedbackId, 'error': str(e)}, - level=logging.ERROR, - ) - return jsonify({'error': 'Failed to update feedback archive state'}), 500 - - lifecycle_action = 'archive' if archived else 'unarchive' - audit_logged = log_review_lifecycle_action( - 'feedback', - lifecycle_action, - feedback_doc, - actor, - was_archived=was_archived, + ``etag``, when sent, must match the stored record or 409 ``record_changed`` is returned. + """ + data = request.get_json() or {} + if not isinstance(data, dict): + return jsonify({'error': 'The request body must be an object.'}), 400 + body, status = _archive_feedback( + feedbackId, + data.get('archived'), + _get_feedback_admin_actor(), + data.get('etag') if isinstance(data.get('etag'), str) else None, ) - if not audit_logged: - _log_feedback_audit_failure(feedbackId, lifecycle_action) - - message = 'Feedback archived successfully.' if archived else 'Feedback unarchived successfully.' - return _feedback_lifecycle_response(message, audit_logged), 200 + return jsonify(body), status @bp.route("/feedback/review/", methods=["DELETE"]) @swagger_route(security=get_auth_security()) @@ -628,42 +1044,8 @@ def feedback_review_archive(feedbackId): @enabled_required("enable_user_feedback") def feedback_review_delete(feedbackId): """Permanently delete a feedback review record.""" - actor = _get_feedback_admin_actor() - if not actor.get('id'): - return jsonify({'error': 'No user ID found in session'}), 403 - - try: - feedback_doc = cosmos_feedback_container.read_item( - item=feedbackId, - partition_key=feedbackId, - ) - cosmos_feedback_container.delete_item( - item=feedbackId, - partition_key=feedbackId, - ) - except CosmosResourceNotFoundError: - return jsonify({'error': 'Feedback not found'}), 404 - except Exception as e: - log_event( - '[FEEDBACK_LIFECYCLE] Failed to delete feedback', - {'feedback_id': feedbackId, 'error': str(e)}, - level=logging.ERROR, - ) - return jsonify({'error': 'Failed to delete feedback'}), 500 - - audit_logged = log_review_lifecycle_action( - 'feedback', - 'delete', - feedback_doc, - actor, - ) - if not audit_logged: - _log_feedback_audit_failure(feedbackId, 'delete') - - return _feedback_lifecycle_response( - 'Feedback permanently deleted.', - audit_logged, - ), 200 + body, status = _delete_feedback(feedbackId, _get_feedback_admin_actor()) + return jsonify(body), status @bp.route("/feedback/retest/", methods=["POST"]) diff --git a/application/single_app/route_backend_safety.py b/application/single_app/route_backend_safety.py index 2b722460c..9aaabf554 100644 --- a/application/single_app/route_backend_safety.py +++ b/application/single_app/route_backend_safety.py @@ -4,6 +4,7 @@ import io import logging +from azure.core import MatchConditions from azure.core.exceptions import AzureError from azure.cosmos.exceptions import CosmosAccessConditionFailedError, CosmosResourceNotFoundError from flask import make_response @@ -14,6 +15,7 @@ from functions_chat_content_review import ( ChatContentReviewConflict, content_checks_report_enabled, + count_unchecked_chat_content, list_unchecked_chat_content, recheck_chat_message, ) @@ -26,9 +28,28 @@ create_approval_request, get_approval_roles_for_request_type, mark_approval_executed, + withdraw_approval_request, ) from functions_authentication import * +from functions_review_center import ( + REVIEW_RECORD_CHANGED_CODE, + ReviewRequestError, + cap_review_ids, + daily_counts, + in_review_window, + normalize_review_search, + parse_review_bulk_operations, + parse_review_date, + parse_review_window, + resolve_review_users, + review_bulk_result, + review_day, + review_text_matches, + review_window, + summarize_review_bulk_results, +) from functions_review_lifecycle import ( + ARCHIVE_STATE_ALL, apply_archive_state, append_archive_query_filter, log_review_lifecycle_action, @@ -36,29 +57,133 @@ serialize_archive_metadata, ) from functions_safety_remediation import ( + SAFETY_LOG_WRITE_ATTEMPTS, SAFETY_REMEDIATION_BLOCK, SAFETY_REMEDIATION_SUSPEND, SAFETY_REMEDIATION_WARNING, + SAFETY_REQUEST_STATUSES, + SAFETY_WARNING_REPLACED_CODE, + SAFETY_WARNING_REPLACED_MESSAGE, + SAFETY_WARNING_SEND_CLAIM_FIELDS, + SafetyLogConflict, + acknowledge_safety_warning, + build_safety_action_execution_updates, + claim_safety_warning_send, execute_safety_violation_action, + is_interrupted_safety_warning_send, + list_pending_safety_warnings, + mark_interrupted_safety_warning_send, + present_safety_warning_send_state, + record_safety_warning_send, + reconcile_pending_safety_log, + reconcile_pending_safety_logs, resolve_safety_target_user, + safety_remediation_state, + safety_warning_send_in_progress, + serialize_safety_warning_state, + write_safety_log_updates, ) +from functions_activity_logging import log_general_admin_action from functions_settings import * from swagger_wrapper import swagger_route, get_auth_security ALLOWED_SAFETY_PAGE_SIZES = {10, 20, 50, 100} ALLOWED_SAFETY_STATUSES = {'New', 'In-Review', 'Resolved', 'Dismissed'} -ALLOWED_SAFETY_ACTIONS = {'None', 'WarnUser', 'SuspendUser', 'Escalate', 'BlockUser'} +# Escalate was a label with no workflow behind it. It can no longer be chosen, but a record +# that already carries it stays editable, so it is still accepted when it is unchanged. +SAFETY_ACTION_ESCALATE_LEGACY = 'Escalate' +SELECTABLE_SAFETY_ACTIONS = {'None', 'WarnUser', 'SuspendUser', 'BlockUser'} +ALLOWED_SAFETY_ACTIONS = SELECTABLE_SAFETY_ACTIONS | {SAFETY_ACTION_ESCALATE_LEGACY} +SAFETY_ESCALATE_RETIRED_MESSAGE = ( + 'Escalate is no longer available as a safety action. ' + 'Choose None, Warn user, Suspend user or Block user.' +) +SAFETY_WARNING_SEND_FAILED_MESSAGE = 'The warning notification could not be sent.' +# Of two overlapping saves that would send a warning -- a double-click, or two reviewers -- +# only the one that claims the violation first sends it; the other is refused with this code. +SAFETY_WARNING_IN_PROGRESS_CODE = 'safety_warning_in_progress' +SAFETY_WARNING_SENDING_MESSAGE = ( + 'A warning for this violation is being sent right now, so nothing was saved. ' + 'Reload the violation in a moment to see the result.' +) +SAFETY_WARNING_CLAIM_CONFLICT_MESSAGE = ( + 'Another save of this violation got there first, so this one saved nothing and sent no ' + 'warning. Reload the violation in a moment to see the result.' +) +SAFETY_WARNING_NOT_RECORDED_CODE = 'safety_warning_not_recorded' +SAFETY_WARNING_NOT_RECORDED_MESSAGE = ( + 'The warning was sent to the user, but it could not be recorded on this violation. ' + 'Reload the violation before saving it again, so the warning is not sent twice.' +) SAFETY_REMEDIATION_ACTIONS = { SAFETY_REMEDIATION_WARNING, SAFETY_REMEDIATION_SUSPEND, SAFETY_REMEDIATION_BLOCK, } +# Suspend and block restrict access, so another eligible reviewer must approve them. +SAFETY_APPROVAL_REQUIRED_ACTIONS = { + SAFETY_REMEDIATION_SUSPEND, + SAFETY_REMEDIATION_BLOCK, +} SAFETY_ACTION_REQUEST_TYPE_MAP = { SAFETY_REMEDIATION_WARNING: TYPE_WARN_USER, SAFETY_REMEDIATION_SUSPEND: TYPE_SUSPEND_USER, SAFETY_REMEDIATION_BLOCK: TYPE_BLOCK_USER, } +SAFETY_PENDING_UPDATE_MESSAGE = 'This violation already has a pending remediation approval request.' +SAFETY_PENDING_DELETE_MESSAGE = ( + 'This safety violation cannot be deleted while a remediation approval request is pending.' +) +SAFETY_REMEDIATION_PENDING_CODE = 'remediation_pending' +SAFETY_RECORD_CHANGED_MESSAGE = ( + 'This violation changed after you opened it. Reload it to see the latest version, then try again.' +) +SAFETY_REQUEST_NOT_RECORDED_MESSAGE = ( + 'This violation changed while the request was being created, so nothing was requested. ' + 'Reload it to see the latest version, then try again.' +) +SAFETY_REQUEST_WITHDRAWN_COMMENT = ( + 'Withdrawn automatically: the safety violation changed while this request was being created, ' + 'so the request was never recorded on it.' +) +SAFETY_ARCHIVE_IN_PROGRESS_MESSAGE = ( + 'This safety violation cannot be archived or restored while a warning for it ' + 'is being sent. Try again in a moment.' +) +# "Open" is the reviewer's queue: everything not yet resolved or dismissed. +SAFETY_LIST_STATUS_OPEN = 'open' +SAFETY_OPEN_STATUSES = {'New', 'In-Review'} +SAFETY_WARNING_FILTERS = {'pending', 'acknowledged', 'not_tracked'} +# The remediation request fields an approval request writes beside a reviewer's own changes, +# and the archive fields, so a conflicting write is merged field by field and never drops +# another writer's change. A reviewer's save writes only the review fields it was sent. +SAFETY_REQUEST_FIELDS = ( + 'action_request_id', + 'action_request_type', + 'action_requested_at', + 'action_approved_at', + 'action_executed_at', + 'action_request_status', + 'action_request_decided_at', + 'action_execution_error', + 'action_notification_title', + 'action_notification_message', + 'action_datetime_to_allow', +) +SAFETY_ARCHIVE_FIELDS = ( + 'is_archived', + 'archived_at', + 'archived_by', + 'unarchived_at', + 'unarchived_by', + 'last_updated', +) +SAFETY_REPEAT_USERS_LIMIT = 10 +SAFETY_ACTION_LABELS = { + SAFETY_REMEDIATION_SUSPEND: 'suspension', + SAFETY_REMEDIATION_BLOCK: 'block', +} def _get_safety_session_user_id(): @@ -162,6 +287,7 @@ def _query_safety_logs( filter_status=None, filter_action=None, archive_state='active', + reconcile=False, ): query = "SELECT * FROM c" where_clauses = [] @@ -192,8 +318,14 @@ def _query_safety_logs( parameters=parameters, enable_cross_partition_query=True, )) + if reconcile: + # Before anything is added to the records: a settled violation is written back + # whole, so it must still be exactly the stored document. + logs = reconcile_pending_safety_logs(logs) for log_item in logs: log_item.update(serialize_archive_metadata(log_item)) + present_safety_warning_send_state(log_item) + log_item.update(serialize_safety_warning_state(log_item)) return strip_private_chat_checks(logs) if user_id else logs @@ -262,6 +394,349 @@ def _build_safety_stats(logs): return stats +def _safety_record_time(log_item): + return log_item.get('created_at') or log_item.get('timestamp') + + +def _safety_category_names(log_item): + names = [] + for entry in log_item.get('triggered_categories') or []: + if isinstance(entry, dict): + name = str(entry.get('category') or '').strip() + if name: + names.append(name) + return list(dict.fromkeys(names)) + + +def _safety_highest_severity(log_item): + severities = [ + entry.get('severity') + for entry in log_item.get('triggered_categories') or [] + if isinstance(entry, dict) + and isinstance(entry.get('severity'), (int, float)) + and not isinstance(entry.get('severity'), bool) + ] + return int(max(severities)) if severities else None + + +def _safety_request_state(log_item): + return str(log_item.get('action_request_status') or '').strip().lower() + + +def _parse_safety_list_filters(): + """Read the Review center list filters. Raises ValueError for a value that can't be used.""" + args = request.args + severity_text = (args.get('severity') or '').strip() + if severity_text and not severity_text.isdigit(): + raise ReviewRequestError('The severity must be a whole number.', code='invalid_severity') + request_state = (args.get('request') or '').strip().lower() or None + if request_state and request_state not in SAFETY_REQUEST_STATUSES: + raise ReviewRequestError('Unknown remediation request state.', code='invalid_request_state') + warning = (args.get('warning') or '').strip().lower() or None + if warning and warning not in SAFETY_WARNING_FILTERS: + raise ReviewRequestError('Unknown warning acknowledgment state.', code='invalid_warning_state') + return { + 'status': (args.get('status') or '').strip() or None, + 'action': (args.get('action') or '').strip() or None, + 'archive': normalize_archive_state(args.get('archive', None, type=str)), + 'search': normalize_review_search(args.get('search')), + 'user_id': (args.get('user_id') or '').strip() or None, + 'category': (args.get('category') or '').strip().lower() or None, + 'severity': int(severity_text) if severity_text else None, + 'request': request_state, + 'warning': warning, + 'restricted': (args.get('restricted') or '').strip().lower() in ('1', 'true'), + 'date': parse_review_date(args.get('date')), + 'days': parse_review_window(args.get('days')), + } + + +def _safety_restricts_user_now(log_item, users): + """True for an applied suspension or block whose user is still restricted.""" + if log_item.get('action') not in SAFETY_APPROVAL_REQUIRED_ACTIONS or _safety_request_state(log_item) != 'executed': + return False + access = (users.get(log_item.get('user_id')) or {}).get('access') or {} + return access.get('restricted') is True + + +def _safety_log_matches(log_item, filters, users, window): + status = str(log_item.get('status') or 'New') + if filters['status'] == SAFETY_LIST_STATUS_OPEN and status not in SAFETY_OPEN_STATUSES: + return False + if filters['user_id'] and log_item.get('user_id') != filters['user_id']: + return False + if filters['category'] and filters['category'] not in { + name.lower() for name in _safety_category_names(log_item) + }: + return False + if filters['severity'] is not None and _safety_highest_severity(log_item) != filters['severity']: + return False + if filters['request'] and _safety_request_state(log_item) != filters['request']: + return False + if filters['warning'] and log_item.get('warning_acknowledgment_status') != filters['warning']: + return False + if filters['restricted'] and not _safety_restricts_user_now(log_item, users): + return False + if filters['date'] and review_day(_safety_record_time(log_item)) != filters['date']: + return False + if window and not in_review_window(_safety_record_time(log_item), window): + return False + if filters['search']: + user = users.get(log_item.get('user_id')) or {} + return review_text_matches( + filters['search'], + log_item.get('id'), + log_item.get('message'), + log_item.get('notes'), + log_item.get('user_notes'), + log_item.get('user_id'), + user.get('display_name'), + user.get('email'), + ' '.join(_safety_category_names(log_item)), + ) + return True + + +def _load_admin_safety_logs(filters): + """The violations a Review center list or "select all matching" covers, newest first. + + Violations whose remediation request was decided or has expired are settled first, so + a list never shows a violation locked by a request that can no longer be decided. + Returns ``(logs, users)``, where ``users`` holds the names already looked up. + """ + status = filters['status'] + logs = _query_safety_logs( + filter_status=None if status == SAFETY_LIST_STATUS_OPEN else status, + filter_action=filters['action'], + archive_state=filters['archive'], + reconcile=True, + ) + users = {} + if filters['search'] or filters['restricted']: + users = resolve_review_users( + [log_item.get('user_id') for log_item in logs], + include_access=filters['restricted'], + ) + window = review_window(filters['days']) if filters['days'] else None + selected = [log_item for log_item in logs if _safety_log_matches(log_item, filters, users, window)] + return selected, users + + +def _with_safety_user_names(items, users=None): + """Add each record's user display name and email, looked up in one batch.""" + names = dict(users or {}) + missing = [item.get('user_id') for item in items if item.get('user_id') not in names] + if missing: + names.update(resolve_review_users(missing)) + for item in items: + entry = names.get(item.get('user_id')) or {} + item['user_display_name'] = entry.get('display_name') or None + item['user_email'] = entry.get('email') or None + return items + + +def _count_other_user_violations(user_id, log_id): + """How many other violations the same user has, or None when that can't be read.""" + if not user_id: + return 0 + try: + rows = cosmos_safety_container.query_items( + query="SELECT c.id, c.content_origin FROM c WHERE c.user_id = @user_id", + parameters=[{"name": "@user_id", "value": user_id}], + enable_cross_partition_query=True, + ) + return sum( + 1 for row in rows + if isinstance(row, dict) + and row.get('id') != log_id + and row.get('content_origin', 'user') == 'user' + ) + except Exception as exc: + log_event( + '[SAFETY_VIOLATIONS] The user violation count could not be read.', + {'safety_log_id': log_id, 'error_type': type(exc).__name__}, + level=logging.WARNING, + ) + return None + + +def _build_safety_window_stats(days): + """The Safety dashboard for the last ``days`` days, read across active and archived records.""" + window = review_window(days) + logs = _query_safety_logs(archive_state=ARCHIVE_STATE_ALL, reconcile=True) + in_window = [log_item for log_item in logs if in_review_window(_safety_record_time(log_item), window)] + tracked_warnings = [ + log_item for log_item in logs + if log_item.get('warning_acknowledgment_status') in ('pending', 'acknowledged') + ] + sent_in_window = [ + log_item for log_item in tracked_warnings + if in_review_window(log_item.get('warning_issued_at'), window) + ] + + restricting = sorted({ + log_item.get('user_id') for log_item in logs + if log_item.get('user_id') + and log_item.get('action') in SAFETY_APPROVAL_REQUIRED_ACTIONS + and _safety_request_state(log_item) == 'executed' + }) + restricted_user_count = 0 + if restricting: + access_by_user = resolve_review_users(restricting, include_access=True) + restricted_user_count = sum( + 1 for user_id in restricting + if ((access_by_user.get(user_id) or {}).get('access') or {}).get('restricted') is True + ) if access_by_user else None + + try: + unchecked_chat_count = count_unchecked_chat_content() + except Exception as exc: + log_event( + '[CHAT_CONTENT_CHECKS] Unchecked chat content could not be counted for the dashboard.', + {'error_type': type(exc).__name__}, + level=logging.WARNING, + ) + unchecked_chat_count = None + + per_user = {} + severity_counts = {} + action_counts = {} + for log_item in in_window: + severity = _safety_highest_severity(log_item) + severity_counts[severity] = severity_counts.get(severity, 0) + 1 + action = str(log_item.get('action') or 'None') + action_counts[action] = action_counts.get(action, 0) + 1 + user_id = log_item.get('user_id') + if user_id and log_item.get('content_origin', 'user') == 'user': + per_user[user_id] = per_user.get(user_id, 0) + 1 + repeat = sorted( + ((user_id, count) for user_id, count in per_user.items() if count >= 2), + key=lambda pair: (-pair[1], pair[0]), + )[:SAFETY_REPEAT_USERS_LIMIT] + repeat_names = resolve_review_users([user_id for user_id, _count in repeat]) + + return { + 'window': { + 'days': window['days'], + 'start_date': window['start_date'], + 'end_date': window['end_date'], + }, + 'received_count': len(in_window), + 'open_count': sum( + 1 for log_item in logs + if not log_item.get('isArchived') and str(log_item.get('status') or 'New') in SAFETY_OPEN_STATUSES + ), + 'pending_remediation_count': sum(1 for log_item in logs if _safety_request_state(log_item) == 'pending'), + 'restricted_user_count': restricted_user_count, + 'warnings_sent_count': len(sent_in_window), + 'warnings_acknowledged_count': sum( + 1 for log_item in sent_in_window if log_item.get('warning_acknowledgment_status') == 'acknowledged' + ), + 'warnings_pending_count': sum( + 1 for log_item in tracked_warnings if log_item.get('warning_acknowledgment_status') == 'pending' + ), + 'unchecked_chat_count': unchecked_chat_count, + 'daily_by_category': daily_counts( + in_window, + window, + _safety_record_time, + lambda log_item: _safety_category_names(log_item) or ['Uncategorized'], + ), + 'severity_mix': [ + {'severity': severity, 'count': count} + for severity, count in sorted( + severity_counts.items(), + key=lambda pair: (pair[0] is None, pair[0] if pair[0] is not None else 0), + ) + ], + 'action_mix': [ + {'action': action, 'count': count} + for action, count in sorted(action_counts.items(), key=lambda pair: (-pair[1], pair[0])) + ], + 'repeat_users': [ + { + 'user_id': user_id, + 'display_name': (repeat_names.get(user_id) or {}).get('display_name') or None, + 'email': (repeat_names.get(user_id) or {}).get('email') or None, + 'count': count, + } + for user_id, count in repeat + ], + } + + +def _record_changed_body(): + return {'error': SAFETY_RECORD_CHANGED_MESSAGE, 'code': REVIEW_RECORD_CHANGED_CODE} + + +def _write_attempts(expected_etag): + """How often a save may write: once when it names the version it read, so a conflict is + refused rather than merged onto a newer version; otherwise it is merged field by field.""" + return 1 if expected_etag else SAFETY_LOG_WRITE_ATTEMPTS + + +def _remediation_unchanged(read_item): + """A guard for a reviewer's save: the violation's request and warning are as the save read them. + + A conflicting write is merged onto a fresh read only while nothing a remediation decision + rests on has moved -- no warning is being sent or was recorded since, and the violation + waits on the same request -- so a save never lands on another save's warning or request. + """ + expected = safety_remediation_state(read_item) + + def guard(current): + return not safety_warning_send_in_progress(current) and safety_remediation_state(current) == expected + + return guard + + +def _withdraw_safety_request(approval, actor, log_id): + """Withdraw a remediation request this save created but could not record on its violation. + + Nothing links the violation to the request then, so it is withdrawn at once instead of + being left for a reviewer to approve. Should withdrawing fail, approval still refuses to + carry out a request its violation is not waiting on. Returns True when it was withdrawn. + """ + approval = approval or {} + try: + withdrawn = withdraw_approval_request( + approval_id=approval.get('id'), + group_id=approval.get('group_id'), + withdrawn_by_id=actor.get('id'), + withdrawn_by_email=actor.get('email') or '', + withdrawn_by_name=actor.get('name') or '', + comment=SAFETY_REQUEST_WITHDRAWN_COMMENT, + ) + except Exception as exc: + log_event( + '[SAFETY_REMEDIATION] A remediation request its violation could not record was not withdrawn.', + {'safety_log_id': log_id, 'approval_id': approval.get('id'), 'error_type': type(exc).__name__}, + level=logging.ERROR, + ) + return False + log_event( + '[SAFETY_REMEDIATION] A remediation request was withdrawn because its violation changed while it was created.', + { + 'safety_log_id': log_id, + 'approval_id': approval.get('id'), + 'actor_id': actor.get('id'), + 'withdrawn': withdrawn is not None, + }, + level=logging.WARNING, + ) + return withdrawn is not None + + +def _response_parts(result): + """The JSON body and status of a route-style result, for one bulk operation.""" + if isinstance(result, tuple): + response, status = result[0], result[1] + else: + response, status = result, getattr(result, 'status_code', 200) + body = response.get_json(silent=True) if hasattr(response, 'get_json') else response + return (body if isinstance(body, dict) else {}), int(status) + + def _build_safety_export_response(logs, filename_prefix, include_user_id=False): output = io.StringIO() writer = csv.writer(output) @@ -312,7 +787,7 @@ def _build_safety_export_response(logs, filename_prefix, include_user_id=False): return response -def _safety_lifecycle_response(message, audit_logged): +def _safety_lifecycle_body(message, audit_logged): response = { 'success': True, 'message': message, @@ -322,7 +797,11 @@ def _safety_lifecycle_response(message, audit_logged): response['audit_warning'] = ( 'The record was updated, but the audit activity could not be recorded.' ) - return jsonify(response) + return response + + +def _safety_lifecycle_response(message, audit_logged): + return jsonify(_safety_lifecycle_body(message, audit_logged)) def _log_safety_audit_failure(log_id, lifecycle_action): @@ -335,6 +814,276 @@ def _log_safety_audit_failure(log_id, lifecycle_action): level=logging.ERROR, ) + +def _archive_safety_log(log_id, archived, actor, expected_etag=None): + """Archive or restore one violation. Returns ``(body, status)``. + + Shared by the archive route and the bulk ``archive`` operation. Only the archive fields + are written, conditionally on the stored version, so a concurrent change to anything + else on the record survives. ``expected_etag`` refuses a record that changed since the + caller read it. + """ + if not isinstance(archived, bool): + return {'error': 'The archived field must be a boolean.'}, 400 + if not actor.get('id'): + return {'error': 'No user ID found in session'}, 403 + + try: + item = cosmos_safety_container.read_item(item=log_id, partition_key=log_id) + if expected_etag and item.get('_etag') != expected_etag: + return _record_changed_body(), 409 + # A warning being sent is recorded on the record when it finishes; wait for that. + if safety_warning_send_in_progress(item): + return { + 'error': SAFETY_ARCHIVE_IN_PROGRESS_MESSAGE, + 'code': SAFETY_WARNING_IN_PROGRESS_CODE, + }, 409 + was_archived = bool(item.get('is_archived')) + apply_archive_state(item, archived, actor['id']) + item['last_updated'] = datetime.utcnow().isoformat() + stored = write_safety_log_updates( + log_id, + {field: item.get(field) for field in SAFETY_ARCHIVE_FIELDS if field in item}, + base_item=item, + guard=lambda current: not safety_warning_send_in_progress(current), + attempts=_write_attempts(expected_etag), + ) + if stored is None: + return { + 'error': SAFETY_ARCHIVE_IN_PROGRESS_MESSAGE, + 'code': SAFETY_WARNING_IN_PROGRESS_CODE, + }, 409 + except exceptions.CosmosResourceNotFoundError: + return {'error': 'Safety violation not found'}, 404 + except SafetyLogConflict: + return _record_changed_body(), 409 + except Exception as e: + log_event( + '[SAFETY_LIFECYCLE] Failed to update safety violation archive state', + {'safety_log_id': log_id, 'error_type': type(e).__name__}, + level=logging.ERROR, + ) + return {'error': 'Failed to update safety violation archive state'}, 500 + + lifecycle_action = 'archive' if archived else 'unarchive' + audit_logged = log_review_lifecycle_action( + 'safety_violation', + lifecycle_action, + item, + actor, + was_archived=was_archived, + ) + if not audit_logged: + _log_safety_audit_failure(log_id, lifecycle_action) + + message = ( + 'Safety violation archived successfully.' + if archived + else 'Safety violation unarchived successfully.' + ) + return _safety_lifecycle_body(message, audit_logged), 200 + + +def _delete_safety_log(log_id, actor, expected_etag=None): + """Permanently delete one violation, keeping its audit history. Returns ``(body, status)``. + + Shared by the delete route and the bulk ``delete`` operation. A violation whose + remediation request is still pending is refused; one whose request was decided or has + expired is settled first, so it can be deleted. The delete is conditional on the version + read, so a violation that changes in the meantime is not deleted. + """ + if not actor.get('id'): + return {'error': 'No user ID found in session'}, 403 + + try: + item = cosmos_safety_container.read_item(item=log_id, partition_key=log_id) + if expected_etag and item.get('_etag') != expected_etag: + return _record_changed_body(), 409 + item = reconcile_pending_safety_log(item) + if str(item.get('action_request_status') or '').strip().lower() == 'pending': + return { + 'error': SAFETY_PENDING_DELETE_MESSAGE, + 'code': SAFETY_REMEDIATION_PENDING_CODE, + }, 409 + if safety_warning_send_in_progress(item): + return { + 'error': ( + 'This safety violation cannot be deleted while a warning for it is ' + 'being sent. Try again in a moment.' + ), + 'code': SAFETY_WARNING_IN_PROGRESS_CODE, + }, 409 + if item.get('_etag'): + cosmos_safety_container.delete_item( + item=log_id, + partition_key=log_id, + etag=item.get('_etag'), + match_condition=MatchConditions.IfNotModified, + ) + else: + cosmos_safety_container.delete_item(item=log_id, partition_key=log_id) + except exceptions.CosmosResourceNotFoundError: + return {'error': 'Safety violation not found'}, 404 + except CosmosAccessConditionFailedError: + return _record_changed_body(), 409 + except Exception as e: + log_event( + '[SAFETY_LIFECYCLE] Failed to delete safety violation', + {'safety_log_id': log_id, 'error_type': type(e).__name__}, + level=logging.ERROR, + ) + return {'error': 'Failed to delete safety violation'}, 500 + + audit_logged = log_review_lifecycle_action( + 'safety_violation', + 'delete', + item, + actor, + ) + if not audit_logged: + _log_safety_audit_failure(log_id, 'delete') + + return _safety_lifecycle_body('Safety violation permanently deleted.', audit_logged), 200 + + +def _log_safety_warning_sent(item, actor, notification_id): + """Record the reviewer's warning in the activity log, the audit an approval used to be.""" + return log_general_admin_action( + admin_user_id=actor.get('id'), + admin_email=actor.get('email') or '', + action='safety_violation_warning_sent', + description='Sent a safety violation warning to a user.', + additional_context={ + 'record_type': 'safety_violation', + 'record_id': item.get('id'), + 'target_user_id': item.get('user_id'), + 'notification_id': notification_id, + }, + ) + + +def _send_safety_warning_now(item, actor, notification_title, notification_message): + """Send a warning as the reviewer saves it, and record it on the violation. + + A suspension or block restricts access, so it waits for a second reviewer. A warning + restricts nothing, so it is sent at once: the reviewer's decision is audited, and the + user has to acknowledge the warning before carrying on. + + Nothing is sent until this save has claimed the violation, with a write conditional on + the version it read, so of two overlapping saves -- a double-click, or two reviewers -- + only one sends the warning. The outcome is then written on the claimed version. + Returns ``(response, status)``. + """ + item['action_request_id'] = None + item['action_request_type'] = None + item['action_requested_at'] = None + item['action_approved_at'] = None + item['action_notification_title'] = notification_title or None + item['action_notification_message'] = notification_message or None + item['action_datetime_to_allow'] = None + item['action_executed_at'] = None + item['action_execution_error'] = None + item['last_updated'] = datetime.utcnow().isoformat() + + try: + claimed, claim_id = claim_safety_warning_send(item) + except CosmosAccessConditionFailedError: + log_event( + '[SAFETY_REMEDIATION] An overlapping save claimed the safety warning first; this save sent nothing.', + {'safety_log_id': item.get('id'), 'actor_id': actor.get('id')}, + level=logging.WARNING, + ) + return jsonify({ + 'error': SAFETY_WARNING_CLAIM_CONFLICT_MESSAGE, + 'code': SAFETY_WARNING_IN_PROGRESS_CODE, + }), 409 + + try: + execution_result = execute_safety_violation_action( + action=SAFETY_REMEDIATION_WARNING, + safety_log=claimed, + notification_title=notification_title, + notification_message=notification_message, + datetime_to_allow=None, + actor=actor, + ) + except Exception as exc: + log_event( + '[SAFETY_REMEDIATION] A safety warning could not be sent.', + { + 'safety_log_id': item.get('id'), + 'actor_id': actor.get('id'), + 'error_type': type(exc).__name__, + }, + level=logging.ERROR, + ) + try: + recorded = record_safety_warning_send(claimed, claim_id, { + 'action_request_status': 'failed', + 'action_executed_at': None, + 'action_execution_error': SAFETY_WARNING_SEND_FAILED_MESSAGE, + }) + except Exception: + recorded = None + if recorded is None: + log_event( + '[SAFETY_REMEDIATION] A failed safety warning could not be recorded on its violation.', + {'safety_log_id': item.get('id'), 'actor_id': actor.get('id')}, + level=logging.ERROR, + ) + return jsonify({ + 'error': 'The warning could not be sent. The rest of the review was saved; save it again to retry.', + }), 500 + + notification_id = execution_result.get('notification_id') + updates = build_safety_action_execution_updates(SAFETY_REMEDIATION_WARNING, execution_result) + record_error_type = None + try: + stored = record_safety_warning_send(claimed, claim_id, updates) + except Exception as exc: + stored = None + record_error_type = type(exc).__name__ + # The warning reached the user either way, so the reviewer's decision is audited. + audit_logged = _log_safety_warning_sent(stored or claimed, actor, notification_id) + + if stored is None: + log_event( + '[SAFETY_REMEDIATION] A safety warning was sent, but it could not be recorded on its violation.', + { + 'safety_log_id': item.get('id'), + 'target_user_id': item.get('user_id'), + 'actor_id': actor.get('id'), + 'notification_id': notification_id, + 'error_type': record_error_type or 'claim_lost', + }, + level=logging.ERROR, + ) + return jsonify({ + 'error': SAFETY_WARNING_NOT_RECORDED_MESSAGE, + 'code': SAFETY_WARNING_NOT_RECORDED_CODE, + 'audit_logged': audit_logged, + }), 500 if record_error_type else 409 + + log_event( + '[SAFETY_REMEDIATION] Safety warning sent without a second reviewer.', + { + 'safety_log_id': item.get('id'), + 'target_user_id': item.get('user_id'), + 'actor_id': actor.get('id'), + 'actor_email': actor.get('email'), + 'notification_id': notification_id, + }, + ) + response = { + 'message': 'Warning sent to the user.', + 'approval_required': False, + 'approval_id': None, + 'audit_logged': audit_logged, + } + if not response['audit_logged']: + response['audit_warning'] = 'The warning was sent, but the audit activity could not be recorded.' + return jsonify(response), 200 + def register_route_backend_safety(bp): def chat_check_error(error): if isinstance(error, (CosmosAccessConditionFailedError, CosmosResourceNotFoundError)): @@ -405,21 +1154,22 @@ def get_safety_logs(): Query Parameters: page (int): The page number to retrieve (default: 1). page_size (int): The number of items per page (default: 10). - status (str): Filter logs by status. + status (str): Filter logs by status, or "open" for New and In-Review. action (str): Filter logs by action. + archive (str): "active" (default) or "archived". + search (str): Text matched against the message, notes, categories and user. + user_id, category, severity, request, warning, restricted, date, days: + the Review center's narrower filters, used by its dashboard links. + Each log carries ``user_display_name`` and ``user_email``. Violations whose + remediation request was decided or has expired are settled before they are listed. """ try: page = int(request.args.get('page', 1)) page_size = int(request.args.get('page_size', 10)) - filter_status, filter_action, archive_state = _parse_safety_filters( - include_archive_state=True, - ) - logs = _query_safety_logs( - filter_status=filter_status, - filter_action=filter_action, - archive_state=archive_state, - ) + filters = _parse_safety_list_filters() + logs, users = _load_admin_safety_logs(filters) paginated_items, page, page_size = _paginate_safety_logs(logs, page, page_size) + _with_safety_user_names(paginated_items, users) return jsonify({ "logs": paginated_items, @@ -435,23 +1185,56 @@ def get_safety_logs(): logging.exception("Error in get_safety_logs") return jsonify({"error": "An internal error occurred while fetching safety logs."}), 500 + @bp.route('/api/safety/logs/ids', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @safety_violation_admin_required + @content_checks_report_enabled + def get_safety_log_ids(): + """Return the ids of the violations matching the list filters, for "select all matching". + + Takes the same filters as GET /api/safety/logs. At most 500 ids are returned: + ``total`` is how many matched, and ``capped`` says whether the cap applied. + """ + try: + filters = _parse_safety_list_filters() + logs, _users = _load_admin_safety_logs(filters) + return jsonify(cap_review_ids([log_item.get('id') for log_item in logs])), 200 + except ValueError: + return jsonify({"error": "Invalid request parameters."}), 400 + except Exception as e: + log_event('[SAFETY_VIOLATIONS] Matching violation ids could not be listed.', { + 'error_type': type(e).__name__, + }, level=logging.ERROR) + return jsonify({"error": "The matching violations could not be listed."}), 500 + @bp.route('/api/safety/logs/stats', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required @safety_violation_admin_required @content_checks_report_enabled def get_safety_log_stats(): - """Return aggregate safety violation statistics for the admin page.""" + """Return aggregate safety violation statistics for the admin page. + + With ``days`` (7, 30 or 90) the response also carries the Review center dashboard: + open, pending remediation, restricted users, warnings sent and acknowledged, + unchecked chat content, violations per day by category, and the severity, action + and repeat-user breakdowns for the window. Every other field is unchanged. + """ try: filter_status, filter_action, archive_state = _parse_safety_filters( include_archive_state=True, ) + days = parse_review_window(request.args.get('days')) logs = _query_safety_logs( filter_status=filter_status, filter_action=filter_action, archive_state=archive_state, ) - return jsonify(_build_safety_stats(logs)), 200 + stats = _build_safety_stats(logs) + if days: + stats.update(_build_safety_window_stats(days)) + return jsonify(stats), 200 except ValueError: logging.exception("Invalid request parameters in get_safety_log_stats") return jsonify({"error": "Invalid request parameters."}), 400 @@ -459,22 +1242,59 @@ def get_safety_log_stats(): logging.exception("Error in get_safety_log_stats") return jsonify({"error": "Failed to retrieve safety stats."}), 500 + @bp.route('/api/safety/logs/', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @safety_violation_admin_required + @content_checks_report_enabled + def get_safety_log(log_id): + """Return one violation for the Review center editor. + + Besides the stored record, the response carries ``etag`` (send it back with a save), + the user's display name and email, whether the user's access is restricted now, + and how many other violations the user has. + """ + try: + item = cosmos_safety_container.read_item(item=log_id, partition_key=log_id) + except exceptions.CosmosResourceNotFoundError: + return jsonify({'error': 'Safety violation not found'}), 404 + except Exception as e: + log_event('[SAFETY_VIOLATIONS] A violation could not be read.', { + 'safety_log_id': log_id, + 'error_type': type(e).__name__, + }, level=logging.ERROR) + return jsonify({'error': 'The violation could not be loaded.'}), 500 + + item = reconcile_pending_safety_log(item) + record = dict(item) + record.update(serialize_archive_metadata(item)) + present_safety_warning_send_state(record) + record.update(serialize_safety_warning_state(record)) + user_id = item.get('user_id') + users = resolve_review_users([user_id], include_access=True) + entry = users.get(user_id) or {} + record.update({ + 'etag': item.get('_etag'), + 'user_display_name': entry.get('display_name') or None, + 'user_email': entry.get('email') or None, + 'user_access': entry.get('access'), + 'user_violation_count': _count_other_user_violations(user_id, log_id), + }) + return jsonify(record), 200 + @bp.route('/api/safety/logs/export', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required @safety_violation_admin_required @content_checks_report_enabled def export_safety_logs(): - """Export safety violation rows as CSV for the active filter set.""" + """Export safety violation rows as CSV for the active filter set. + + Takes the same filters as GET /api/safety/logs, so an export matches the list. + """ try: - filter_status, filter_action, archive_state = _parse_safety_filters( - include_archive_state=True, - ) - logs = _query_safety_logs( - filter_status=filter_status, - filter_action=filter_action, - archive_state=archive_state, - ) + filters = _parse_safety_list_filters() + logs, _users = _load_admin_safety_logs(filters) return _build_safety_export_response(logs, 'admin_safety_violations_export', include_user_id=True) except ValueError as e: logging.exception("Invalid parameters when exporting safety logs") @@ -491,8 +1311,32 @@ def update_safety_log(log_id): """ Updates status, action, and notes on a safety log. Also sets timestamps (created_at if missing, and last_updated). + + Warn user sends the warning as soon as the review is saved; saving the record again + does not send it twice, and of two overlapping saves only the one that claims the + violation first sends it. Suspend user and Block user restrict access, so they create an + approval request that another eligible reviewer must approve. Escalate can no longer + be chosen; it is only accepted unchanged on a record that already carries it. + + Saving a suspension or block again with the same action requests nothing more unless + the body carries ``reissue: true``. ``etag``, when sent, must match the stored record, + or the save is refused with 409 ``record_changed``; it is then written on that version + only, never merged onto a newer one. A suspension or block that can't be recorded on + the violation, because another save moved it on meanwhile, is withdrawn and refused + with 409 ``record_changed``. """ data = request.get_json() or {} + if not isinstance(data, dict): + return jsonify({'error': 'The request body must be an object.'}), 400 + return apply_safety_review_update(log_id, data) + + def apply_safety_review_update(log_id, data): + """The single-record save, shared with the bulk ``update`` operation. + + Returns the same response the PATCH route sends, so a bulk update behaves exactly as + saving that violation on its own would: a warning is sent at once, and a suspension + or block creates an approval request. + """ status = data.get("status") action = data.get("action") notes = data.get("notes") @@ -507,37 +1351,86 @@ def update_safety_log(log_id): if action and action not in ALLOWED_SAFETY_ACTIONS: return jsonify({'error': 'Invalid safety action'}), 400 + if notes is not None and not isinstance(notes, str): + return jsonify({'error': 'Notes must be text.'}), 400 + item = cosmos_safety_container.read_item(item=log_id, partition_key=log_id) + expected_etag = data.get('etag') + if expected_etag and item.get('_etag') != expected_etag: + return jsonify(_record_changed_body()), 409 + # A request that was denied or has expired no longer locks the violation. + item = reconcile_pending_safety_log(item) + # What this save decides from. Its write lands only while that still holds, so it + # never overwrites a warning being sent or recorded, or another request. + unchanged_remediation = _remediation_unchanged(item) + previous_action = str(item.get('action') or 'None') + existing_request_status = str(item.get('action_request_status') or '').strip().lower() + + if action == SAFETY_ACTION_ESCALATE_LEGACY and previous_action != SAFETY_ACTION_ESCALATE_LEGACY: + return jsonify({'error': SAFETY_ESCALATE_RETIRED_MESSAGE}), 400 if action in SAFETY_REMEDIATION_ACTIONS and item.get("content_origin", "user") != "user": return jsonify({"error": "AI-generated findings cannot be used to warn or restrict a user."}), 400 + # Only what this save changes is written, so a concurrent change to anything else + # on the violation survives it. + review_updates = {} if not item.get("created_at"): item["created_at"] = datetime.utcnow().isoformat() + review_updates['created_at'] = item['created_at'] - existing_request_status = str(item.get('action_request_status') or '').strip().lower() if existing_request_status == 'pending': return jsonify({ - 'error': 'This violation already has a pending remediation approval request.' + 'error': SAFETY_PENDING_UPDATE_MESSAGE, + 'code': SAFETY_REMEDIATION_PENDING_CODE, }), 409 + # While another save is sending a warning, the violation waits for it to finish. + if safety_warning_send_in_progress(item): + return jsonify({ + 'error': SAFETY_WARNING_SENDING_MESSAGE, + 'code': SAFETY_WARNING_IN_PROGRESS_CODE, + }), 409 + interrupted_send = is_interrupted_safety_warning_send(item) + if interrupted_send: + mark_interrupted_safety_warning_send(item) + existing_request_status = 'failed' + + # A suspension or block is requested again only when the action changes or the + # reviewer asks to re-issue it. Saving notes or a status on one already requested + # or applied must not create a second approval request. + restriction_requested = action in SAFETY_APPROVAL_REQUIRED_ACTIONS and ( + action != previous_action or data.get('reissue') is True + ) normalized_datetime_to_allow = None - if action in SAFETY_REMEDIATION_ACTIONS: + if action == SAFETY_REMEDIATION_WARNING or restriction_requested: normalized_datetime_to_allow = _validate_safety_remediation_request(action, datetime_to_allow) if status: item["status"] = status + review_updates['status'] = status if action: item["action"] = action + review_updates['action'] = action if notes is not None: item["notes"] = notes + review_updates['notes'] = notes actor = _get_safety_actor_context() if not actor.get('id'): return jsonify({'error': 'No user ID found in session'}), 403 - if action in SAFETY_REMEDIATION_ACTIONS: + # Saving a warned record again, for example to resolve it, must not warn twice. + warning_already_sent = ( + action == SAFETY_REMEDIATION_WARNING + and previous_action == SAFETY_REMEDIATION_WARNING + and existing_request_status == 'executed' + ) + if action == SAFETY_REMEDIATION_WARNING and not warning_already_sent: + return _send_safety_warning_now(item, actor, notification_title, notification_message) + + if restriction_requested: target_user = resolve_safety_target_user(item.get('user_id')) request_type = SAFETY_ACTION_REQUEST_TYPE_MAP[action] approval_reason = notes or notification_message or f"Requested {action} for safety violation {log_id}." @@ -563,6 +1456,7 @@ def update_safety_log(log_id): item['action_request_id'] = approval.get('id') item['action_request_type'] = request_type item['action_requested_at'] = approval.get('created_at') + item['action_request_decided_at'] = None item['action_execution_error'] = None item['action_notification_title'] = notification_title or None item['action_notification_message'] = notification_message or None @@ -604,10 +1498,50 @@ def update_safety_log(log_id): item['action_executed_at'] = None item["last_updated"] = datetime.utcnow().isoformat() + review_updates['last_updated'] = item['last_updated'] + + if restriction_requested: + request_updates = {field: item.get(field) for field in SAFETY_REQUEST_FIELDS if field in item} + if interrupted_send: + # A save that stopped while sending a warning can never record it over this request. + request_updates.update({field: None for field in SAFETY_WARNING_SEND_CLAIM_FIELDS}) + try: + recorded = write_safety_log_updates( + log_id, + {**review_updates, **request_updates}, + base_item=item, + guard=unchanged_remediation, + attempts=_write_attempts(expected_etag), + ) + except SafetyLogConflict: + recorded = None + except Exception: + _withdraw_safety_request(approval, actor, log_id) + raise + if recorded is None: + # The request exists, but the violation moved on -- another save sent a + # warning or created a request, or the version this save named changed -- + # so nothing links the two. Withdraw it rather than leave it approvable. + _withdraw_safety_request(approval, actor, log_id) + return jsonify({ + 'error': SAFETY_REQUEST_NOT_RECORDED_MESSAGE, + 'code': REVIEW_RECORD_CHANGED_CODE, + }), 409 + else: + try: + written = write_safety_log_updates( + log_id, + review_updates, + base_item=item, + guard=unchanged_remediation, + attempts=_write_attempts(expected_etag), + ) + except SafetyLogConflict: + written = None + if written is None: + return jsonify(_record_changed_body()), 409 - cosmos_safety_container.upsert_item(item) - - if action in SAFETY_REMEDIATION_ACTIONS: + if restriction_requested: if item.get('action_request_status') == 'pending': return jsonify({ 'message': 'Safety log updated and remediation approval request created.', @@ -621,67 +1555,124 @@ def update_safety_log(log_id): 'approval_id': item.get('action_request_id'), }), 200 + if warning_already_sent: + return jsonify({ + 'message': 'Safety log updated. The warning was already sent, so it was not sent again.', + 'approval_required': False, + 'warning_already_sent': True, + }), 200 + + if action in SAFETY_APPROVAL_REQUIRED_ACTIONS: + label = SAFETY_ACTION_LABELS[action] + if existing_request_status == 'executed': + return jsonify({ + 'message': ( + f'Safety log updated. The {label} was already applied, so it was not requested again. ' + f'To request it again, select "Request this {label} again" and save.' + ), + 'approval_required': False, + 'remediation_already_applied': True, + }), 200 + return jsonify({ + 'message': ( + f'Safety log updated. No new {label} was requested. ' + f'To request it again, select "Request this {label} again" and save.' + ), + 'approval_required': False, + 'remediation_unchanged': True, + 'remediation_status': existing_request_status or None, + }), 200 + return jsonify({"message": "Safety log updated successfully."}), 200 + except exceptions.CosmosResourceNotFoundError: + return jsonify({'error': 'Safety violation not found'}), 404 + except SafetyLogConflict: + return jsonify(_record_changed_body()), 409 except exceptions.CosmosHttpResponseError as e: - return jsonify({"error": str(e)}), 404 + log_event('[SAFETY_VIOLATIONS] Failed to update safety log', { + 'safety_log_id': log_id, + 'error_type': type(e).__name__, + }, level=logging.ERROR) + return jsonify({'error': 'Failed to update safety log.'}), 500 except ValueError as e: return jsonify({'error': str(e)}), 400 except Exception as e: log_event('[SAFETY_VIOLATIONS] Failed to update safety log', { 'safety_log_id': log_id, - 'error': str(e), + 'error_type': type(e).__name__, }, level=logging.ERROR) - return jsonify({'error': f'Failed to update safety log: {str(e)}'}), 500 + return jsonify({'error': 'Failed to update safety log.'}), 500 - @bp.route('/api/safety/logs//archive', methods=['PATCH']) + @bp.route('/api/safety/logs/bulk', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required @safety_violation_admin_required @content_checks_report_enabled - def archive_safety_log(log_id): - """Archive or unarchive a safety violation record.""" - data = request.get_json() or {} - archived = data.get('archived') - if not isinstance(archived, bool): - return jsonify({'error': 'The archived field must be a boolean.'}), 400 - + def bulk_update_safety_logs(): + """Apply up to 100 review operations to violations, each as its single route would. + + Body: ``{"operations": [{"id", "op", "etag"?, ...}]}``. ``update`` carries + ``changes``, the same fields PATCH /api/safety/logs/ accepts, and behaves + exactly as that save would: Warn user sends the warning, Suspend user and Block user + create approval requests. ``archive`` carries ``archived``; ``delete`` carries + nothing more. Each result repeats the single route's response with ``ok`` and + ``status``, in request order; one failure never stops the others. + """ actor = _get_safety_actor_context() if not actor.get('id'): return jsonify({'error': 'No user ID found in session'}), 403 - try: - item = cosmos_safety_container.read_item(item=log_id, partition_key=log_id) - was_archived = bool(item.get('is_archived')) - apply_archive_state(item, archived, actor['id']) - item['last_updated'] = datetime.utcnow().isoformat() - cosmos_safety_container.upsert_item(item) - except exceptions.CosmosResourceNotFoundError: - return jsonify({'error': 'Safety violation not found'}), 404 - except Exception as e: - log_event( - '[SAFETY_LIFECYCLE] Failed to update safety violation archive state', - {'safety_log_id': log_id, 'error': str(e)}, - level=logging.ERROR, - ) - return jsonify({'error': 'Failed to update safety violation archive state'}), 500 - - lifecycle_action = 'archive' if archived else 'unarchive' - audit_logged = log_review_lifecycle_action( - 'safety_violation', - lifecycle_action, - item, - actor, - was_archived=was_archived, - ) - if not audit_logged: - _log_safety_audit_failure(log_id, lifecycle_action) + operations = parse_review_bulk_operations(request.get_json(silent=True)) + except ReviewRequestError as error: + return jsonify({'error': error.message, 'code': error.code}), error.status + + results = [] + for operation in operations: + if operation.get('error'): + results.append(review_bulk_result( + operation, operation['error']['body'], operation['error']['status'], + )) + continue + if operation['op'] == 'update': + changes = {key: value for key, value in operation['changes'].items() if key != 'etag'} + if operation['etag']: + changes['etag'] = operation['etag'] + body, status = _response_parts(apply_safety_review_update(operation['id'], changes)) + elif operation['op'] == 'archive': + body, status = _archive_safety_log(operation['id'], operation['archived'], actor, operation['etag']) + else: + body, status = _delete_safety_log(operation['id'], actor, operation['etag']) + results.append(review_bulk_result(operation, body, status)) + + summary = summarize_review_bulk_results(results) + log_event('[SAFETY_VIOLATIONS] Bulk safety review applied.', { + 'actor_id': actor.get('id'), + 'operation_count': len(results), + 'succeeded': summary['succeeded'], + 'failed': summary['failed'], + }) + return jsonify(summary), 200 + + @bp.route('/api/safety/logs//archive', methods=['PATCH']) + @swagger_route(security=get_auth_security()) + @login_required + @safety_violation_admin_required + @content_checks_report_enabled + def archive_safety_log(log_id): + """Archive or unarchive a safety violation record. - message = ( - 'Safety violation archived successfully.' - if archived - else 'Safety violation unarchived successfully.' + ``etag``, when sent, must match the stored record or 409 ``record_changed`` is returned. + """ + data = request.get_json() or {} + if not isinstance(data, dict): + return jsonify({'error': 'The request body must be an object.'}), 400 + body, status = _archive_safety_log( + log_id, + data.get('archived'), + _get_safety_actor_context(), + data.get('etag') if isinstance(data.get('etag'), str) else None, ) - return _safety_lifecycle_response(message, audit_logged), 200 + return jsonify(body), status @bp.route('/api/safety/logs/', methods=['DELETE']) @swagger_route(security=get_auth_security()) @@ -690,43 +1681,8 @@ def archive_safety_log(log_id): @content_checks_report_enabled def delete_safety_log(log_id): """Permanently delete a safety violation without deleting its audit history.""" - actor = _get_safety_actor_context() - if not actor.get('id'): - return jsonify({'error': 'No user ID found in session'}), 403 - - try: - item = cosmos_safety_container.read_item(item=log_id, partition_key=log_id) - if str(item.get('action_request_status') or '').strip().lower() == 'pending': - return jsonify({ - 'error': ( - 'This safety violation cannot be deleted while a remediation ' - 'approval request is pending.' - ) - }), 409 - cosmos_safety_container.delete_item(item=log_id, partition_key=log_id) - except exceptions.CosmosResourceNotFoundError: - return jsonify({'error': 'Safety violation not found'}), 404 - except Exception as e: - log_event( - '[SAFETY_LIFECYCLE] Failed to delete safety violation', - {'safety_log_id': log_id, 'error': str(e)}, - level=logging.ERROR, - ) - return jsonify({'error': 'Failed to delete safety violation'}), 500 - - audit_logged = log_review_lifecycle_action( - 'safety_violation', - 'delete', - item, - actor, - ) - if not audit_logged: - _log_safety_audit_failure(log_id, 'delete') - - return _safety_lifecycle_response( - 'Safety violation permanently deleted.', - audit_logged, - ), 200 + body, status = _delete_safety_log(log_id, _get_safety_actor_context()) + return jsonify(body), status @bp.route('/api/safety/logs/my', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -848,9 +1804,91 @@ def update_my_safety_log(log_id): item["user_notes"] = user_notes item["last_updated"] = datetime.utcnow().isoformat() - cosmos_safety_container.upsert_item(item) + # Only the user's own fields, merged onto a fresh copy after a conflict, so a + # reviewer saving the same violation at that moment is never overwritten. + write_safety_log_updates( + log_id, + {field: item.get(field) for field in ('user_notes', 'created_at', 'last_updated') if field in item}, + base_item=item, + guard=lambda current: current.get("user_id") == user_id, + ) return jsonify({"message": "Safety log updated successfully."}), 200 + except SafetyLogConflict: + return jsonify({"error": "The content-check record changed while it was being saved. Try again."}), 409 except exceptions.CosmosHttpResponseError as e: log_event("[CONTENT_SAFETY] User content-check note could not be saved.", extra={"error_type": type(e).__name__}, level=logging.WARNING) return jsonify({"error": "The content-check note could not be saved."}), 404 + + # A warning that was sent must stay acknowledgeable even if an administrator later turns + # content checks reporting off, so these two routes are not gated on that setting. + @bp.route('/api/safety/warnings/pending', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def get_pending_safety_warnings(): + """Return the signed-in user's own safety warnings that still need acknowledgment. + + Only what the user was sent is returned: the title, message, when it was issued, + the violation id and its triggered categories. + """ + user_id = _get_safety_session_user_id() + if not user_id: + return jsonify({"error": "No user ID found in session"}), 403 + + try: + warnings = list_pending_safety_warnings(user_id) + except Exception as e: + log_event( + "[SAFETY_WARNINGS] Pending safety warnings could not be read.", + extra={"user_id": user_id, "error_type": type(e).__name__}, + level=logging.WARNING, + ) + return jsonify({"error": "Your warnings could not be loaded."}), 500, {"Cache-Control": "no-store"} + + return jsonify({"warnings": warnings, "count": len(warnings)}), 200, {"Cache-Control": "no-store"} + + @bp.route('/api/safety/warnings//acknowledge', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def acknowledge_pending_safety_warning(log_id): + """Record that the signed-in user acknowledged one of their own safety warnings. + + Repeating it changes nothing. Any record that isn't the caller's own warning is + answered 404, so the response never confirms that another user's record exists. + The body may carry ``issued_at``, as listed by the pending route: when the + violation has since been warned about again, the warning the user read was + replaced, and 409 ``safety_warning_replaced`` records nothing. + """ + user_id = _get_safety_session_user_id() + if not user_id: + return jsonify({"error": "No user ID found in session"}), 403 + + payload = request.get_json(silent=True) + issued_at = payload.get('issued_at') if isinstance(payload, dict) else None + if issued_at is not None and not isinstance(issued_at, str): + return jsonify({"error": "issued_at must be a string."}), 400 + + try: + status, warning = acknowledge_safety_warning(log_id, user_id, issued_at=issued_at) + except Exception as e: + log_event( + "[SAFETY_WARNINGS] A safety warning acknowledgment could not be saved.", + extra={"user_id": user_id, "error_type": type(e).__name__}, + level=logging.ERROR, + ) + return jsonify({"error": "Your acknowledgment could not be saved. Try again."}), 500 + + if status == 'not_found': + return jsonify({"error": "Warning not found."}), 404 + if status == 'replaced': + return jsonify({ + "error": SAFETY_WARNING_REPLACED_MESSAGE, + "code": SAFETY_WARNING_REPLACED_CODE, + }), 409 + return jsonify({ + "success": True, + "already_acknowledged": status == 'already_acknowledged', + "warning": warning, + }), 200 diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index 2f032c303..99cb683d7 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -565,6 +565,8 @@ def user_settings(): 'v2AdminRailCollapsed', # Whether the V2 Approvals categories rail is collapsed to icons. 'v2ApprovalsRailCollapsed', + # Whether the V2 admin Review center sections rail is collapsed to icons. + 'v2ReviewRailCollapsed', # Whether the V2 User Settings sections rail is collapsed to icons. 'v2UserSettingsRailCollapsed', # Whether the V2 Control Center section rail is collapsed to icons. diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py index 2e7d3926f..70e1e32c0 100644 --- a/application/single_app/route_backend_v2.py +++ b/application/single_app/route_backend_v2.py @@ -170,6 +170,7 @@ keyvault_model_endpoint_save_helper, ) from functions_keyvault_errors import KeyVaultSecretStorageError +from functions_safety_remediation import count_pending_safety_warnings from functions_source_review import ( get_source_review_runtime_capabilities, is_source_review_enabled_for_user, @@ -1112,6 +1113,20 @@ def v2_bootstrap(): except Exception as exc: logger.warning(f"[V2_BOOTSTRAP] Failed to resolve workspace sections: {exc}") + # Safety warnings an administrator sent that still need the user's + # acknowledgment. Only the count rides here, so the interface asks for the + # warnings themselves only when there are some. A failed read counts none; the + # next bootstrap, on reload or when the tab comes back, reads again. + pending_safety_warnings = 0 + try: + pending_safety_warnings = count_pending_safety_warnings(user_id) + except Exception as exc: + log_event( + "[V2_BOOTSTRAP] Pending safety warnings could not be counted.", + extra={"user_id": user_id, "error_type": type(exc).__name__}, + level=logging.WARNING, + ) + payload = { "version": VERSION, "user": { @@ -1148,6 +1163,7 @@ def v2_bootstrap(): "notices": _build_notices(public_settings, user_settings_dict), "workspace": workspace, "workspace_uploads": _build_workspace_uploads(public_settings), + "safety_warnings": {"pending": pending_safety_warnings}, "settings": public_settings, } diff --git a/application/single_app/static/js/access-restricted.js b/application/single_app/static/js/access-restricted.js new file mode 100644 index 000000000..195606f70 --- /dev/null +++ b/application/single_app/static/js/access-restricted.js @@ -0,0 +1,32 @@ +// access-restricted.js +// Shows a suspension's restore time in the reader's own locale and time zone. The page +// already renders it in UTC, so the text only improves when this runs. + +(function () { + function formatLocalDateTime(value) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return ''; + } + try { + return parsed.toLocaleString(undefined, { dateStyle: 'full', timeStyle: 'short' }); + } catch (error) { + return parsed.toLocaleString(); + } + } + + function localizeRestoreTimes() { + document.querySelectorAll('time[data-local-datetime]').forEach(function (element) { + const formatted = formatLocalDateTime(element.dataset.localDatetime || ''); + if (formatted) { + element.textContent = formatted; + } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', localizeRestoreTimes); + } else { + localizeRestoreTimes(); + } +}()); diff --git a/application/single_app/static/js/admin/admin-safety-violations.js b/application/single_app/static/js/admin/admin-safety-violations.js index 7154ab557..25aa781ea 100644 --- a/application/single_app/static/js/admin/admin-safety-violations.js +++ b/application/single_app/static/js/admin/admin-safety-violations.js @@ -13,11 +13,17 @@ const SAFETY_VIEW_STORAGE_KEY = 'simplechat.admin.safetyViolations.viewMode'; const SAFETY_REMEDIATION_ACTIONS = new Set(['WarnUser', 'SuspendUser', 'BlockUser']); + // Escalate is no longer an action. A record that already carries it keeps it, labelled + // as legacy, and only that record's editor offers it again. + const LEGACY_ESCALATE_ACTION = 'Escalate'; + // Every value the action select can hold. The select is read through this list, so only + // these constant strings, never text taken from the page, reach data attributes or a save. + const ACTION_VALUES = ['None', 'WarnUser', 'SuspendUser', 'BlockUser', LEGACY_ESCALATE_ACTION]; const ACTION_LABELS = { None: 'None', WarnUser: 'Warn user', SuspendUser: 'Suspend user', - Escalate: 'Escalate', + Escalate: 'Escalated (legacy)', BlockUser: 'Block user', }; @@ -415,6 +421,8 @@ const requestStatus = String(logItem.action_request_status || '').toLowerCase(); if (requestStatus === 'pending') { actionLabel += ' (Pending approval)'; + } else if (requestStatus === 'sending') { + actionLabel += ' (Sending)'; } else if (requestStatus === 'failed') { actionLabel += ' (Execution failed)'; } @@ -470,10 +478,99 @@ return messageLines.join('\n'); } + function isExecutedWarning(logItem) { + return logItem.action === 'WarnUser' + && String(logItem.action_request_status || '').toLowerCase() === 'executed'; + } + + function isRestrictionAction(action) { + return action === 'SuspendUser' || action === 'BlockUser'; + } + + // The action chosen in the review, as the matching entry of ACTION_VALUES. An empty or + // unknown value reads as None, as an empty one always has. + function readSelectedAction() { + const selectedValue = document.getElementById('editAction')?.value || ''; + return ACTION_VALUES.find(function (value) { + return value === selectedValue; + }) || 'None'; + } + + // A suspension or block the violation already records is requested again only on purpose, + // whatever became of the last request, and never while one is waiting or a warning is sent. + function offersReissue(logItem, action) { + const requestStatus = String(logItem.action_request_status || '').toLowerCase(); + return isRestrictionAction(action) + && logItem.action === action + && requestStatus !== 'pending' + && requestStatus !== 'sending'; + } + + function describeExistingRestriction(logItem, action) { + const noun = action === 'BlockUser' ? 'block' : 'suspension'; + const requestStatus = String(logItem.action_request_status || '').toLowerCase(); + if (requestStatus === 'pending') { + return `This ${noun} is waiting for another eligible reviewer to approve it. The violation can't be changed until the request is decided.`; + } + const states = { + executed: `This ${noun} was approved and applied.`, + denied: `This ${noun} request was denied.`, + expired: `This ${noun} request expired without a decision.`, + failed: `This ${noun} was approved but could not be applied.`, + }; + const where = states[requestStatus] || `This violation already records a ${noun}.`; + return `${where} Saving updates the review only and requests nothing new. To ask another eligible reviewer to approve it again, select "Request this ${noun} again".`; + } + + function isFutureDate(isoValue) { + const parsedDate = isoValue ? new Date(isoValue) : null; + return Boolean(parsedDate) && !Number.isNaN(parsedDate.getTime()) && parsedDate.getTime() > Date.now(); + } + + function syncLegacyEscalateOption(selectElement, logItem) { + if (!selectElement) { + return; + } + + const existingOption = Array.from(selectElement.options).find(function (option) { + return option.value === LEGACY_ESCALATE_ACTION; + }); + if (logItem.action === LEGACY_ESCALATE_ACTION) { + if (!existingOption) { + const legacyOption = document.createElement('option'); + legacyOption.value = LEGACY_ESCALATE_ACTION; + legacyOption.textContent = ACTION_LABELS.Escalate; + selectElement.appendChild(legacyOption); + } + } else if (existingOption) { + existingOption.remove(); + } + } + + function updateWarningAcknowledgment(logItem) { + const acknowledgmentElement = document.getElementById('safetyWarningAcknowledgment'); + if (!acknowledgmentElement) { + return; + } + + const status = isExecutedWarning(logItem) ? logItem.warning_acknowledgment_status : null; + let text = ''; + if (status === 'acknowledged') { + text = `Warning acknowledged ${formatDateTime(logItem.warning_acknowledged_at)}`; + } else if (status === 'pending') { + text = 'Warning sent. Not yet acknowledged by the user.'; + } else if (status === 'not_tracked') { + text = 'Warning sent before acknowledgment was tracked.'; + } + acknowledgmentElement.textContent = text; + setElementHidden(acknowledgmentElement, !text); + } + function updateRemediationFields(logItem, forcePopulate) { - const action = document.getElementById('editAction')?.value || 'None'; + const action = readSelectedAction(); const remediationFields = document.getElementById('safetyRemediationFields'); const remediationHelp = document.getElementById('safetyRemediationHelp'); + const notificationGroup = document.getElementById('safetyNotificationGroup'); const notificationMessage = document.getElementById('editNotificationMessage'); const suspendGroup = document.getElementById('safetySuspendUntilGroup'); const suspendInput = document.getElementById('editSuspendUntil'); @@ -484,22 +581,55 @@ const shouldShow = SAFETY_REMEDIATION_ACTIONS.has(action); setElementHidden(remediationFields, !shouldShow); + const reissueGroup = document.getElementById('safetyReissueGroup'); + const reissueInput = document.getElementById('editReissue'); + const reissueOffered = shouldShow && offersReissue(logItem, action); + if (reissueGroup && reissueInput) { + setElementHidden(reissueGroup, !reissueOffered); + if (forcePopulate || !reissueOffered) { + reissueInput.checked = false; + } + const noun = action === 'BlockUser' ? 'block' : 'suspension'; + document.getElementById('editReissueLabel').textContent = `Request this ${noun} again`; + document.getElementById('editReissueHelp').textContent = action === 'SuspendUser' + ? 'Creates a new approval request with the notification and restore date below. It applies only after another eligible reviewer approves it.' + : 'Creates a new approval request with the notification below. It applies only after another eligible reviewer approves it.'; + } + // Saving the same suspension or block again, without asking for it again, requests + // nothing, so the notification and restore date would not be used. Nor would they for + // one still waiting for approval, which can't be changed until it is decided. + const awaitingApproval = isRestrictionAction(action) + && logItem.action === action + && String(logItem.action_request_status || '').toLowerCase() === 'pending'; + const requestsNothing = awaitingApproval || (reissueOffered && !(reissueInput && reissueInput.checked)); + if (!shouldShow) { remediationHelp.textContent = ''; notificationMessage.value = ''; notificationMessage.dataset.generatedMessage = ''; notificationMessage.dataset.action = action; suspendInput.value = ''; + suspendInput.dataset.action = action; setElementHidden(suspendGroup, true); return; } + // A warning already sent is not sent again, so saving this record again only updates it. + const warningAlreadySent = action === 'WarnUser' && isExecutedWarning(logItem); + setElementHidden(notificationGroup, warningAlreadySent || requestsNothing); + const helpTextMap = { - WarnUser: 'Warn user sends a notification to the affected user. If this reviewer also has the required Control Center approval role, the warning is approved and sent immediately.', - SuspendUser: 'Suspend user uses the Control Center access restriction workflow. Reviewers without approval authority create a pending request instead of applying the suspension immediately.', - BlockUser: 'Block user applies a permanent access restriction through the same Control Center access workflow, with no automatic restore date.', + WarnUser: 'Warn user sends the warning to the affected user as soon as you save, without a second reviewer. The user must acknowledge it the next time they use SimpleChat.', + SuspendUser: 'Suspend user restricts access until the restore date. Because it restricts access, saving creates an approval request, and the suspension applies only after another eligible reviewer approves it.', + BlockUser: 'Block user restricts access with no automatic restore date. Saving creates an approval request, and the block applies only after another eligible reviewer approves it.', }; - remediationHelp.textContent = helpTextMap[action] || ''; + if (warningAlreadySent) { + remediationHelp.textContent = 'This warning was already sent. Saving updates the review without sending the warning again.'; + } else if (requestsNothing) { + remediationHelp.textContent = describeExistingRestriction(logItem, action); + } else { + remediationHelp.textContent = helpTextMap[action] || ''; + } const generatedMessage = buildDefaultNotificationMessage(logItem, action); const savedMessage = logItem.action === action ? logItem.action_notification_message : ''; @@ -512,14 +642,16 @@ notificationMessage.dataset.generatedMessage = nextMessage; notificationMessage.dataset.action = action; - const showSuspendUntil = action === 'SuspendUser'; + const showSuspendUntil = action === 'SuspendUser' && !requestsNothing; setElementHidden(suspendGroup, !showSuspendUntil); - if (showSuspendUntil) { - const restoreDate = logItem.action === action ? logItem.action_datetime_to_allow : ''; - suspendInput.value = toLocalDateTimeInputValue(restoreDate); - } else { - suspendInput.value = ''; - } + if (forcePopulate || suspendInput.dataset.action !== action) { + // The last restore time is offered again only while it is still ahead. + const restoreDate = logItem.action === action && isFutureDate(logItem.action_datetime_to_allow) + ? logItem.action_datetime_to_allow + : ''; + suspendInput.value = action === 'SuspendUser' ? toLocalDateTimeInputValue(restoreDate) : ''; + } + suspendInput.dataset.action = action; } function createTextCell(text, className, title) { @@ -594,7 +726,7 @@ setTextContent('safetyResolvedCount', data.resolved_count || 0); setTextContent('safetyDismissedCount', data.dismissed_count || 0); setTextContent('safetyRecentCount', data.recent_30_day_count || 0); - setTextContent('safetyEscalatedCount', (data.escalate_count || 0) + (data.block_user_count || 0)); + setTextContent('safetyBlockedCount', data.block_user_count || 0); setTextContent('safetyStatsNewSummary', data.new_count || 0); setTextContent('safetyStatsInReviewSummary', data.in_review_count || 0); @@ -603,7 +735,12 @@ setTextContent('safetyStatsNoneActionSummary', data.none_action_count || 0); setTextContent('safetyStatsWarnSummary', data.warn_user_count || 0); setTextContent('safetyStatsSuspendSummary', data.suspend_user_count || 0); - setTextContent('safetyStatsEscalateSummary', (data.escalate_count || 0) + (data.block_user_count || 0)); + setTextContent('safetyStatsBlockSummary', data.block_user_count || 0); + // Escalations recorded before the action was removed are still counted, and only + // shown when there are any. + const legacyEscalations = data.escalate_count || 0; + setTextContent('safetyStatsLegacyEscalateSummary', legacyEscalations); + setElementHidden(document.getElementById('safetyStatsLegacyEscalateRow'), legacyEscalations <= 0); } async function renderSafetyRows(items) { @@ -752,6 +889,7 @@ setTextContent('editMessage', item.message || ''); appendCategoryBadges(document.getElementById('editCategories'), item, 'No triggered categories'); document.getElementById('editStatus').value = item.status || 'New'; + syncLegacyEscalateOption(document.getElementById('editAction'), item); document.getElementById('editAction').value = item.action || 'None'; for (const option of document.getElementById('editAction').options) { option.disabled = item.content_origin === 'assistant' && SAFETY_REMEDIATION_ACTIONS.has(option.value); @@ -759,6 +897,7 @@ document.getElementById('editNotes').value = item.notes || ''; document.getElementById('editLogId').value = item.id || ''; setTextContent('safetyEditStatus', ''); + updateWarningAcknowledgment(item); const archiveButton = document.getElementById('archiveSafetyBtn'); if (archiveButton) { archiveButton.dataset.archived = item.isArchived ? 'true' : 'false'; @@ -782,14 +921,18 @@ return; } - const action = document.getElementById('editAction')?.value || 'None'; + const action = readSelectedAction(); const payload = { status: document.getElementById('editStatus')?.value || 'New', action: action, notes: document.getElementById('editNotes')?.value || '', }; - if (SAFETY_REMEDIATION_ACTIONS.has(action)) { + // A suspension or block is requested when it is newly chosen, or asked for again. + const activeItem = state.activeItem || {}; + const reissue = offersReissue(activeItem, action) && Boolean(document.getElementById('editReissue')?.checked); + const requestsRestriction = isRestrictionAction(action) && (activeItem.action !== action || reissue); + if (action === 'WarnUser' || requestsRestriction) { payload.notification_message = document.getElementById('editNotificationMessage')?.value || ''; if (action === 'SuspendUser') { @@ -798,8 +941,14 @@ if (!payload.datetime_to_allow) { throw new Error('Restore access date is required for a suspension.'); } + if (!isFutureDate(payload.datetime_to_allow)) { + throw new Error('Choose a restore date and time in the future.'); + } } } + if (reissue) { + payload.reissue = true; + } if (statusElement) { statusElement.textContent = 'Saving review...'; @@ -986,12 +1135,21 @@ if (saveButton) { saveButton.addEventListener('click', function () { + if (saveButton.disabled) { + return; + } + // A second click while the save is in flight would send a second request. + saveButton.disabled = true; + saveButton.setAttribute('aria-busy', 'true'); saveSafetyChanges().catch(function (error) { const statusElement = document.getElementById('safetyEditStatus'); if (statusElement) { statusElement.textContent = error.message; statusElement.className = 'small text-danger me-auto'; } + }).finally(function () { + saveButton.disabled = false; + saveButton.removeAttribute('aria-busy'); }); }); } @@ -1006,6 +1164,15 @@ }); } + const reissueInput = document.getElementById('editReissue'); + if (reissueInput) { + reissueInput.addEventListener('change', function () { + if (state.activeItem) { + updateRemediationFields(state.activeItem, false); + } + }); + } + if (archiveButton) { archiveButton.addEventListener('click', function () { const logId = document.getElementById('editLogId')?.value || ''; diff --git a/application/single_app/static/js/profile/profile-tabs.js b/application/single_app/static/js/profile/profile-tabs.js index 8de4c556e..01fe9df03 100644 --- a/application/single_app/static/js/profile/profile-tabs.js +++ b/application/single_app/static/js/profile/profile-tabs.js @@ -1253,6 +1253,14 @@ } } + // Escalate is no longer recorded; older records keep it and are labelled as legacy. + function formatViolationAction(action) { + if (action === 'Escalate') { + return 'Escalated (legacy)'; + } + return action || 'None'; + } + function renderViolationTableRows(items) { const tbody = document.querySelector('#profile-violations-table tbody'); if (!tbody) { @@ -1272,7 +1280,7 @@ row.appendChild(createTextCell(logItem.message || '', 'table-message-cell', logItem.message || '')); row.appendChild(createSafetyCategoryCell(logItem)); row.appendChild(createTextCell(logItem.status || 'New')); - row.appendChild(createTextCell(logItem.action || 'None')); + row.appendChild(createTextCell(formatViolationAction(logItem.action))); row.appendChild(createTextCell(logItem.user_notes || '', 'table-note-cell', logItem.user_notes || '')); const detailsCell = document.createElement('td'); @@ -1346,7 +1354,7 @@ setTextContent('profile-violation-detail-message', selectedItem.message || ''); appendSafetyCategoryBadges(document.getElementById('profile-violation-detail-categories'), selectedItem, '-'); setTextContent('profile-violation-detail-status', selectedItem.status || 'New'); - setTextContent('profile-violation-detail-action', selectedItem.action || 'None'); + setTextContent('profile-violation-detail-action', formatViolationAction(selectedItem.action)); document.getElementById('profile-violation-detail-hidden-id').value = selectedItem.id || ''; document.getElementById('profile-violation-detail-user-notes').value = selectedItem.user_notes || ''; setTextContent('profile-violation-save-status', ''); diff --git a/application/single_app/templates/access_restricted.html b/application/single_app/templates/access_restricted.html new file mode 100644 index 000000000..438563ea3 --- /dev/null +++ b/application/single_app/templates/access_restricted.html @@ -0,0 +1,191 @@ + + + + + + + Access restricted - {{ app_settings.app_title or 'SimpleChat' }} + + + + + + {% if app_settings.classification_banner_enabled and app_settings.classification_banner_text %} +
+ {{ app_settings.classification_banner_text }} +
+ {% endif %} + + +
+
+
+ {% if app_settings.show_logo %} + {% if app_settings.custom_logo_base64 %} + + {% else %} + + {% endif %} + {% endif %} + {% if not app_settings.hide_app_title %} +

{{ app_settings.app_title or 'SimpleChat' }}

+ {% endif %} +
+ + {% if restriction %} +

Access restricted

+

{{ restriction.title }}

+
{{ restriction.message }}
+ +
+
Access returns
+ {% if restriction.kind == 'suspended' and restriction.until %} +
+ +
+ {% else %} +
No automatic restore date. Contact your administrator about this decision.
+ {% endif %} + {% if restriction.reference_id %} +
Reference
+
{{ restriction.reference_id }}
+ {% endif %} +
+ + + {% else %} +

Your access is available

+

Your account is not restricted. You can continue to the application.

+ + + {% endif %} +
+
+ + + + diff --git a/application/single_app/templates/admin_safety_violations.html b/application/single_app/templates/admin_safety_violations.html index 1233a89fb..8e4d5dcd4 100644 --- a/application/single_app/templates/admin_safety_violations.html +++ b/application/single_app/templates/admin_safety_violations.html @@ -289,8 +289,8 @@

Unchecked chat content

-
-
-
Escalated
+
-
+
Blocked
@@ -333,8 +333,12 @@
Action Distribution
-
- Escalate or block - - + Block user + - +
+
+ Escalated (legacy) + -
@@ -362,7 +366,6 @@
Action Distribution
- @@ -471,16 +474,21 @@ - +
-
+
+ + +
+
+
-
This message is sent to the affected user. If approval is required, it is sent after the request is approved.
+
This message is sent to the affected user. A warning is sent when you save; a suspension or block is sent once another reviewer approves it.
diff --git a/application/single_app/templates/my_safety_violations.html b/application/single_app/templates/my_safety_violations.html index 34b66a15b..789172297 100644 --- a/application/single_app/templates/my_safety_violations.html +++ b/application/single_app/templates/my_safety_violations.html @@ -102,7 +102,6 @@

My Safety Violations

-
@@ -243,6 +242,14 @@ const viewModal = new bootstrap.Modal(document.getElementById('viewViolationModal')); // Bootstrap 5 modal instance // --- DATA FETCHING & RENDERING --- + // Escalate is no longer recorded; older records keep it and are labelled as legacy. + function formatViolationAction(action) { + if (action === "Escalate") { + return "Escalated (legacy)"; + } + return action || "None"; + } + function fetchMySafetyLogs() { // Show loading state tableBody.html(` @@ -296,7 +303,7 @@ row.append($("").text(log.message || "")); row.append($("").text(triggered)); row.append($("").text(log.status || "New")); - row.append($("").text(log.action || "None")); + row.append($("").text(formatViolationAction(log.action))); row.append($("").attr('title', userNotes).text(userNotes)); // Truncate notes // View/Edit button const viewBtn = $("") @@ -381,7 +388,7 @@ .join(", "); $("#detailCategories").text(triggered || "None"); $("#detailStatus").text(log.status || "New"); - $("#detailAction").text(log.action || "None"); + $("#detailAction").text(formatViolationAction(log.action)); // Editable user notes $("#detailUserNotes").val(log.user_notes || ""); diff --git a/application/single_app/templates/profile.html b/application/single_app/templates/profile.html index e5c6859a6..dab8f3e7e 100644 --- a/application/single_app/templates/profile.html +++ b/application/single_app/templates/profile.html @@ -1940,7 +1940,6 @@
My Safety Violatio -
diff --git a/application/v2_ui/src/App.tsx b/application/v2_ui/src/App.tsx index 951149dc9..e382ee1a0 100644 --- a/application/v2_ui/src/App.tsx +++ b/application/v2_ui/src/App.tsx @@ -12,6 +12,7 @@ import { useUserSettingsStore } from './stores/userSettingsStore'; import { initializeTheme, hydrateUiPreferences } from './stores/uiStore'; import { startImageApprovalTracking } from './lib/imageProposalResume'; import { useNotificationRuntime } from './lib/useNotificationRuntime'; +import { useSafetyWarningRuntime } from './lib/useSafetyWarningRuntime'; import { useWorkflowAlertRuntime } from './lib/useWorkflowAlertRuntime'; import { useWorkflowRunTracker } from './lib/useWorkflowRunTracker'; import { workflowRunTrackerShouldRun } from './lib/workflowRunTracker'; @@ -19,8 +20,6 @@ import { restorePersistedRuns } from './stores/orchestrationStore'; import { ChatPage } from './pages/ChatPage'; import { HomePage } from './pages/HomePage'; import { AdminSettingsPage } from './pages/AdminSettingsPage'; -import { AdminFeedbackReviewPage } from './pages/AdminFeedbackReviewPage'; -import { AdminSafetyViolationsPage } from './pages/AdminSafetyViolationsPage'; import { AdminActionEditorPage, AdminAgentEditorPage } from './pages/AdminGlobalEditorPages'; import { SettingsPage } from './pages/SettingsPage'; import { WorkspacePage } from './pages/workspace/WorkspacePage'; @@ -33,8 +32,10 @@ import { PublicDirectoryPage } from './pages/PublicDirectoryPage'; import { clearWorkspaceEditorDrafts } from './lib/workspaceEditorDrafts'; import { ContentReviewPage } from './pages/ContentReviewPage'; import { TermsOfUsePage } from './pages/TermsOfUsePage'; +import { AccessRestrictedPage } from './pages/AccessRestrictedPage'; import { ApprovalsPage } from './pages/ApprovalsPage'; import { ControlCenterPage } from './pages/ControlCenterPage'; +import { ReviewCenterPage } from './pages/review/ReviewCenterPage'; import { SupportLatestFeaturesPage } from './pages/SupportLatestFeaturesPage'; import { SupportSendFeedbackPage } from './pages/SupportSendFeedbackPage'; @@ -102,6 +103,10 @@ export function App() { // Every other call is refused until the terms are accepted, so this page loads nothing // the shell needs and renders on its own. const onTermsPage = location.pathname === '/terms-of-use'; + // The same holds while an administrator has suspended or blocked the account: the server + // sends every V2 page here, and only this page's own call is answered. + const onAccessRestrictedPage = location.pathname === '/access-restricted'; + const standalonePage = onTermsPage || onAccessRestrictedPage; useEffect(() => { clearWorkspaceEditorDrafts(); @@ -109,7 +114,7 @@ export function App() { useEffect(() => { initializeTheme(); - if (onTermsPage) { + if (standalonePage) { return; } void load(); @@ -128,7 +133,7 @@ export function App() { // run that was still going. The restored record has no stream behind it; the run // history fetched when the panel opens is what settles it. restorePersistedRuns(); - }, [load, loadUserSettings, onTermsPage]); + }, [load, loadUserSettings, standalonePage]); /** * Re-read the payload when the tab comes back to the front. @@ -146,7 +151,7 @@ export function App() { * spurious wake-up is one wasted request. */ useEffect(() => { - if (onTermsPage) { + if (standalonePage) { return undefined; } const onVisible = () => { @@ -163,7 +168,7 @@ export function App() { document.removeEventListener('visibilitychange', onVisible); window.removeEventListener('focus', onVisible); }; - }, [refreshBootstrap, onTermsPage]); + }, [refreshBootstrap, standalonePage]); useEffect(() => { const title = data?.branding?.app_title; @@ -205,6 +210,12 @@ export function App() { useNotificationRuntime(Boolean(data) && !error); // Workflow alerts that ask to pop up. It listens to the bell's count rather than polling. useWorkflowAlertRuntime(Boolean(data) && !error); + // Safety warnings an administrator sent, which stay on screen until acknowledged. + // Bootstrap says how many are waiting; they are read only when there are some. + useSafetyWarningRuntime( + data && !error && !standalonePage ? data.safety_warnings?.pending ?? 0 : null, + data, + ); // The saved workflows chats started: one tracker for the tab, for the run cards, the chat // list's running tag and the results each run posts back to its chat. useWorkflowRunTracker(Boolean(data) && !error && workflowRunTrackerShouldRun(data?.features)); @@ -213,6 +224,10 @@ export function App() { return ; } + if (onAccessRestrictedPage) { + return ; + } + if (loading) { return ; } @@ -236,8 +251,15 @@ export function App() { } /> } /> } /> - } /> - } /> + {/* Feedback and safety review, one section each, with each section's pages + and a record's editor as real paths. */} + } /> + } /> + } /> + } /> + {/* The pages the Review center replaced, kept as links that still arrive. */} + } /> + } /> {/* The global editors return here, with their section in view. */} } /> } /> diff --git a/application/v2_ui/src/components/approvals/ApprovalsDashboard.tsx b/application/v2_ui/src/components/approvals/ApprovalsDashboard.tsx new file mode 100644 index 000000000..054978e16 --- /dev/null +++ b/application/v2_ui/src/components/approvals/ApprovalsDashboard.tsx @@ -0,0 +1,214 @@ +// ApprovalsDashboard.tsx +// The Approvals page's Dashboard category: what is waiting on you, what you asked for, and +// what was decided recently, counted over the requests you can see. +// +// Every figure opens the list it counts, filtered through the address the same way a reviewer +// would filter it by hand. The server counts only requests the caller can see, through the +// same visibility rules as the request list. + +import { useEffect, useState } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { TriangleAlert } from 'lucide-react'; +import { + CHART_COLORS, + ChartDataTable, + ChartPanel, + StatTile, + WindowSelect, + makeDatasets, +} from '../dashboard/DashboardParts'; +import { cartesianOptions, StatsChart, type StatsChartConfigBuilder } from '../settings/StatsChart'; +import { Skeleton } from '../ui/primitives'; +import { + errorMessage, + fetchApprovalStats, + formatDateTime, + requestTypeLabel, + type ApprovalStats, +} from '../../lib/approvalsApi'; +import { readReviewWindow, REVIEW_WINDOWS, safeApprovalRequestHref, type ReviewWindow } from '../../lib/reviewCenter'; + +const OUTCOMES = [ + { key: 'approved', label: 'Approved', status: 'approved', color: 'blue' }, + { key: 'executed', label: 'Executed', status: 'executed', color: 'green' }, + { key: 'denied', label: 'Denied', status: 'denied', color: 'rose' }, + { key: 'failed', label: 'Failed', status: 'all', color: 'amber' }, + { key: 'expired', label: 'Expired', status: 'all', color: 'purple' }, +] as const; + +export function ApprovalsDashboard({ reloadKey }: { reloadKey: number }) { + const [searchParams, setSearchParams] = useSearchParams(); + const days = readReviewWindow(searchParams.get('days')); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(''); + fetchApprovalStats(days, controller.signal) + .then((next) => setStats(next)) + .catch((cause) => { + if (!controller.signal.aborted) setError(errorMessage(cause, 'The approvals summary could not be loaded.')); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [days, reloadKey]); + + const setDays = (value: ReviewWindow) => { + const next = new URLSearchParams(searchParams); + next.set('days', value); + setSearchParams(next, { replace: true }); + }; + + const decided = stats?.decided_in_window ?? {}; + const decidedValues = OUTCOMES.map((outcome) => Number(decided[outcome.key] ?? 0)); + const outcomeConfig: StatsChartConfigBuilder = (theme) => ({ + type: 'bar', + data: { + labels: OUTCOMES.map((outcome) => outcome.label), + datasets: makeDatasets([{ label: 'Requests', values: decidedValues, color: 'blue' }]).map((dataset) => ({ + ...dataset, + backgroundColor: OUTCOMES.map((outcome) => CHART_COLORS[outcome.color].fill), + borderColor: OUTCOMES.map((outcome) => CHART_COLORS[outcome.color].border), + })), + }, + options: cartesianOptions(theme, false), + }); + + return ( +
+
+
+

+ Counted over the requests you can see. Select a figure to open the requests it counts. +

+ +
+ + {error ? ( +

+

+ ) : null} + + {loading && !stats ? ( +
+ {Array.from({ length: 4 }, (_, index) => )} + Loading the approvals summary +
+ ) : stats ? ( + <> +
+ + + + +
+ +
+ + `${outcome.label} ${decidedValues[index]}`).join(', ')}.`} + /> + outcome.label)} + series={[{ label: 'Requests', values: decidedValues }]} + /> +
    + {OUTCOMES.filter((outcome) => outcome.status !== 'all').map((outcome) => ( +
  • + + {outcome.label}: {Number(decided[outcome.key] ?? 0).toLocaleString()} + +
  • + ))} +
+
+ + + {stats.pending_by_type?.length ? ( +
    + {stats.pending_by_type.map((entry) => ( +
  • + + {requestTypeLabel(entry.request_type)} + {entry.count.toLocaleString()} + +
  • + ))} +
+ ) : ( +

Nothing is pending.

+ )} +
+
+ + + {stats.oldest_actionable?.length ? ( +
    + {stats.oldest_actionable.map((entry) => ( +
  1. + + + {requestTypeLabel(entry.request_type)} + {entry.group_name ? · {entry.group_name} : null} + + + Requested {formatDateTime(entry.created_at)} · expires {formatDateTime(entry.expires_at)} + + +
  2. + ))} +
+ ) : ( +

Nothing is waiting on you.

+ )} +
+ + ) : null} +
+
+ ); +} diff --git a/application/v2_ui/src/components/approvals/GenericApprovalsPanel.tsx b/application/v2_ui/src/components/approvals/GenericApprovalsPanel.tsx index 86a77cfca..ff24d7f5a 100644 --- a/application/v2_ui/src/components/approvals/GenericApprovalsPanel.tsx +++ b/application/v2_ui/src/components/approvals/GenericApprovalsPanel.tsx @@ -8,7 +8,7 @@ // content screening items point at Content Review, the same as the classic page. import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Check, ExternalLink, Loader2, X } from 'lucide-react'; import { CONTENT_SCREENING_TYPE, @@ -25,6 +25,7 @@ import { type ApprovalRequest, type ApprovalStatusFilter, } from '../../lib/approvalsApi'; +import { useBootstrapStore } from '../../stores/bootstrapStore'; import { toast } from '../../stores/toastStore'; import { GlassButton, Skeleton } from '../ui/primitives'; import { @@ -54,6 +55,32 @@ const STATUS_OPTIONS: Array<[ApprovalStatusFilter, string]> = [ ['executed', 'Executed'], ]; +type ShowFilter = 'all' | 'mine' | 'requested'; + +const SHOW_OPTIONS: Array<[ShowFilter, string]> = [ + ['all', 'Everything I can see'], + ['mine', 'Waiting on me'], + ['requested', 'My requests'], +]; + +/** The list filters a link can carry, so a dashboard figure opens the requests it counted. */ +const FILTER_PARAMS = ['status', 'type', 'show', 'expiring'] as const; +const DAY_MS = 24 * 60 * 60 * 1000; + +function readStatus(value: string | null): ApprovalStatusFilter { + return STATUS_OPTIONS.some(([option]) => option === value) ? (value as ApprovalStatusFilter) : 'pending'; +} + +function readShow(value: string | null): ShowFilter { + return SHOW_OPTIONS.some(([option]) => option === value) ? (value as ShowFilter) : 'all'; +} + +function expiresWithinADay(approval: ApprovalRequest): boolean { + if (approval.status !== 'pending' || !approval.expires_at) return false; + const expires = new Date(approval.expires_at).getTime(); + return Number.isFinite(expires) && expires - Date.now() <= DAY_MS; +} + function statusLabel(approval: ApprovalRequest): string { if (approval.status === 'denied' && approval.auto_denied) return 'Auto-denied'; return approval.status; @@ -100,8 +127,12 @@ export function GenericApprovalsPanel({ onCountChange?: (count: number) => void; }) { const navigate = useNavigate(); - const [status, setStatus] = useState('pending'); - const [typeFilter, setTypeFilter] = useState('all'); + const [searchParams, setSearchParams] = useSearchParams(); + const userId = useBootstrapStore((state) => state.data?.user?.id); + const status = readStatus(searchParams.get('status')); + const typeFilter = searchParams.get('type') || 'all'; + const show = readShow(searchParams.get('show')); + const expiring = searchParams.get('expiring') === '1'; const [search, setSearch] = useState(''); const [page, setPage] = useState(1); const [items, setItems] = useState([]); @@ -110,10 +141,28 @@ export function GenericApprovalsPanel({ const [localReload, setLocalReload] = useState(0); useEffect(() => { - setTypeFilter('all'); setPage(1); }, [category]); + /** The list filters in the address, without the selected request's group. */ + const filterQuery = (extra?: Record) => { + const params = new URLSearchParams(extra); + for (const key of FILTER_PARAMS) { + const value = searchParams.get(key); + if (value) params.set(key, value); + } + const query = params.toString(); + return query ? `?${query}` : ''; + }; + + const updateFilter = (key: (typeof FILTER_PARAMS)[number], value: string, defaultValue: string) => { + const next = new URLSearchParams(searchParams); + if (value === defaultValue) next.delete(key); + else next.set(key, value); + setSearchParams(next, { replace: true }); + setPage(1); + }; + useEffect(() => { const controller = new AbortController(); setLoading(true); @@ -150,23 +199,28 @@ export function GenericApprovalsPanel({ const filtered = useMemo(() => { const query = search.trim().toLowerCase(); return inCategory.filter( - (item) => (typeFilter === 'all' || item.request_type === typeFilter) && (!query || searchText(item).includes(query)), + (item) => (typeFilter === 'all' || item.request_type === typeFilter) + && (show === 'all' + || (show === 'mine' ? item.status === 'pending' && item.can_approve === true : item.requester_id === userId)) + && (!expiring || expiresWithinADay(item)) + && (!query || searchText(item).includes(query)), ); - }, [inCategory, search, typeFilter]); + }, [expiring, inCategory, search, show, typeFilter, userId]); const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const safePage = Math.min(page, pageCount); const visible = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE); const select = (approval: ApprovalRequest) => { - const query = approval.group_id && !isM365RequestType(approval.request_type) - ? `?${new URLSearchParams({ group_id: approval.group_id }).toString()}` - : ''; - navigate(`/approvals/${category}/${encodeURIComponent(approval.id)}${query}`); + const group = approval.group_id && !isM365RequestType(approval.request_type) + ? { group_id: approval.group_id } + : undefined; + navigate(`/approvals/${category}/${encodeURIComponent(approval.id)}${filterQuery(group)}`); }; const selectedSummary = selectedId ? items.find((item) => item.id === selectedId) : undefined; const refreshList = useCallback(() => setLocalReload((value) => value + 1), []); + const filtersApplied = typeFilter !== 'all' || show !== 'all' || expiring; const list = ( <> @@ -175,19 +229,34 @@ export function GenericApprovalsPanel({ label="Status" value={status} testId="v2-approvals-status-filter" - onChange={(value) => { setStatus(value as ApprovalStatusFilter); setPage(1); }} + onChange={(value) => updateFilter('status', value, 'pending')} options={STATUS_OPTIONS} /> + updateFilter('show', value, 'all')} + options={SHOW_OPTIONS} + /> {typeOptions.length > 2 ? ( { setTypeFilter(value); setPage(1); }} + onChange={(value) => updateFilter('type', value, 'all')} options={typeOptions} /> ) : null} + {expiring ? ( +
+ Only pending requests that expire within 24 hours. + +
+ ) : null} {loading ? (
@@ -218,7 +287,7 @@ export function GenericApprovalsPanel({ ) : ( - {search || typeFilter !== 'all' ? 'No requests match these filters.' : emptyTitle} + {search || filtersApplied ? 'No requests match these filters.' : emptyTitle} )} ); @@ -243,7 +312,7 @@ export function GenericApprovalsPanel({ navigate(`/approvals/${category}`)} + onBack={() => navigate(`/approvals/${category}${filterQuery()}`)} list={list} detail={detail} /> diff --git a/application/v2_ui/src/components/controlCenter/DashboardSection.tsx b/application/v2_ui/src/components/controlCenter/DashboardSection.tsx index 9268ebc96..35cec93ac 100644 --- a/application/v2_ui/src/components/controlCenter/DashboardSection.tsx +++ b/application/v2_ui/src/components/controlCenter/DashboardSection.tsx @@ -1,20 +1,22 @@ // DashboardSection.tsx // Overview metrics and activity charts for the V2 Control Center. -import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { Download, MessageSquareText, RefreshCw } from 'lucide-react'; import { api } from '../../lib/apiClient'; import { cartesianOptions, StatsChart, type StatsChartConfigBuilder } from '../settings/StatsChart'; -import { APPROVALS_URL, KpiCard } from './ControlCenterPrimitives'; - -type Metric = { - value: number | null; - delta: number | null; - percent_change?: number | null; - previous?: number; - available?: boolean; -}; +import { + CHART_COLORS, + ChartDataTable, + ChartPanel, + MetricTile as SharedMetricTile, + makeDatasets, + type DashboardMetric, +} from '../dashboard/DashboardParts'; +import { APPROVALS_URL } from './ControlCenterPrimitives'; + +type Metric = DashboardMetric; type DashboardSummary = { period: { start_date: string; end_date: string; days: number; timezone: string }; @@ -103,15 +105,6 @@ const EMPTY_FILTERS: TokenFilters = { token_type: '', }; -const CHART_COLORS = { - blue: { border: '#4f8cff', fill: 'rgba(79, 140, 255, 0.24)' }, - cyan: { border: '#22b8cf', fill: 'rgba(34, 184, 207, 0.30)' }, - green: { border: '#37b679', fill: 'rgba(55, 182, 121, 0.28)' }, - amber: { border: '#e8a23a', fill: 'rgba(232, 162, 58, 0.28)' }, - purple: { border: '#a78bfa', fill: 'rgba(167, 139, 250, 0.28)' }, - rose: { border: '#f472b6', fill: 'rgba(244, 114, 182, 0.28)' }, -}; - const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; function rangeDates(days: number): { startDate: string; endDate: string } { @@ -122,23 +115,6 @@ function rangeDates(days: number): { startDate: string; endDate: string } { return { startDate: start.toISOString().slice(0, 10), endDate }; } -function metricDetail(metric: Metric, days: number): string { - if (metric.value === null || metric.available === false) { - return 'Not available for this period'; - } - if (metric.delta === null) { - return 'Current status; historical status snapshots are not recorded'; - } - if (metric.delta === 0) { - return `No change vs the previous ${days}-day period`; - } - const direction = metric.delta > 0 ? '↑' : '↓'; - const percentage = metric.percent_change === null || metric.percent_change === undefined - ? '' - : ` (${Math.abs(metric.percent_change)}%)`; - return `${direction} ${Math.abs(metric.delta).toLocaleString()} vs previous period${percentage}`; -} - function buildParams( startDate: string, endDate: string, @@ -161,19 +137,6 @@ function seriesValues(series: ActivitySeries, dates: string[]): number[] { return dates.map((date) => Number(series[date] ?? 0)); } -function makeDatasets(series: { label: string; values: number[]; color: keyof typeof CHART_COLORS }[]) { - return series.map(({ label, values, color }) => ({ - label, - data: values, - borderColor: CHART_COLORS[color].border, - backgroundColor: CHART_COLORS[color].fill, - borderWidth: 2, - borderRadius: 3, - pointRadius: 1, - tension: 0.3, - })); -} - function safeControlCenterHref(value: string): string { try { const url = new URL(value, window.location.origin); @@ -189,77 +152,14 @@ function safeControlCenterHref(value: string): string { } } -function MetricTile({ - label, - metric, - to, - days, - valueLabel, -}: { +function MetricTile(props: { label: string; metric: Metric; to: string; days: number; valueLabel?: string; }) { - const value = metric.value === null || metric.available === false - ? 'Not tracked' - : valueLabel ?? metric.value.toLocaleString(); - return ( - - - - ); -} - -function ChartDataTable({ - title, - dates, - series, -}: { - title: string; - dates: string[]; - series: { label: string; values: number[] }[]; -}) { - return ( -
- View {title.toLowerCase()} as a data table -
- - - - - - {series.map((item) => )} - - - - {dates.map((date, index) => ( - - - {series.map((item) => )} - - ))} - -
{title} chart data by day
Date{item.label}
{date}{item.values[index] ?? 0}
-
-
- ); -} - -function ChartPanel({ - title, - children, -}: { - title: string; - children: ReactNode; -}) { - return ( -
-

{title}

- {children} -
- ); + return ; } function TokenFilterSelect({ diff --git a/application/v2_ui/src/components/dashboard/DashboardParts.tsx b/application/v2_ui/src/components/dashboard/DashboardParts.tsx new file mode 100644 index 000000000..b0d503826 --- /dev/null +++ b/application/v2_ui/src/components/dashboard/DashboardParts.tsx @@ -0,0 +1,222 @@ +// DashboardParts.tsx +// The pieces V2 dashboards draw with: tiles that open what they count, chart panels, the data +// table every chart offers in place of reading the chart, and the window picker. +// +// Moved out of the Control Center dashboard so the Review center and the Approvals dashboard +// draw the same way. The Control Center passes its own link check to MetricTile, so its tiles +// behave exactly as before. + +import type { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { safeSameOriginUrl } from '../../lib/apiClient'; +import type { ChartThemeColors } from '../../lib/chartRuntime'; +import { KpiCard } from '../controlCenter/ControlCenterPrimitives'; +import { cartesianOptions } from '../settings/StatsChart'; + +export type DashboardMetric = { + value: number | null; + delta: number | null; + percent_change?: number | null; + previous?: number; + available?: boolean; +}; + +/** Series colours shared by the dashboards, so a series keeps its colour from one to the next. */ +export const CHART_COLORS = { + blue: { border: '#4f8cff', fill: 'rgba(79, 140, 255, 0.24)' }, + cyan: { border: '#22b8cf', fill: 'rgba(34, 184, 207, 0.30)' }, + green: { border: '#37b679', fill: 'rgba(55, 182, 121, 0.28)' }, + amber: { border: '#e8a23a', fill: 'rgba(232, 162, 58, 0.28)' }, + purple: { border: '#a78bfa', fill: 'rgba(167, 139, 250, 0.28)' }, + rose: { border: '#f472b6', fill: 'rgba(244, 114, 182, 0.28)' }, +}; + +export type ChartColor = keyof typeof CHART_COLORS; +export const CHART_COLOR_ORDER = Object.keys(CHART_COLORS) as ChartColor[]; + +export function makeDatasets(series: { label: string; values: number[]; color: ChartColor }[]) { + return series.map(({ label, values, color }) => ({ + label, + data: values, + borderColor: CHART_COLORS[color].border, + backgroundColor: CHART_COLORS[color].fill, + borderWidth: 2, + borderRadius: 3, + pointRadius: 1, + tension: 0.3, + })); +} + +/** The shared cartesian styling with the series stacked, for counts split into parts. */ +export function stackedBarOptions(theme: ChartThemeColors) { + const options = cartesianOptions(theme, true); + return { + ...options, + scales: { + x: { ...options.scales.x, stacked: true }, + y: { ...options.scales.y, stacked: true }, + }, + }; +} + +export function metricDetail(metric: DashboardMetric, days: number): string { + if (metric.value === null || metric.available === false) { + return 'Not available for this period'; + } + if (metric.delta === null) { + return 'Current status; historical status snapshots are not recorded'; + } + if (metric.delta === 0) { + return `No change vs the previous ${days}-day period`; + } + const direction = metric.delta > 0 ? '↑' : '↓'; + const percentage = metric.percent_change === null || metric.percent_change === undefined + ? '' + : ` (${Math.abs(metric.percent_change)}%)`; + return `${direction} ${Math.abs(metric.delta).toLocaleString()} vs previous period${percentage}`; +} + +function safeSameOriginHref(value: string): string { + return safeSameOriginUrl(value, '/'); +} + +const TILE_LINK_CLASS = 'block rounded-2xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent'; + +export function MetricTile({ + label, + metric, + to, + days, + valueLabel, + safeHref = safeSameOriginHref, +}: { + label: string; + metric: DashboardMetric; + to: string; + days: number; + valueLabel?: string; + /** Narrows where the tile may lead; any same-origin path by default. */ + safeHref?: (value: string) => string; +}) { + const value = metric.value === null || metric.available === false + ? 'Not tracked' + : valueLabel ?? metric.value.toLocaleString(); + return ( + + + + ); +} + +/** + * A count that opens the records it counts. Without a destination it is a plain tile, for a + * figure there is no list of. + */ +export function StatTile({ + label, + value, + detail, + to, + testId, +}: { + label: string; + value: ReactNode; + detail?: string; + to?: string | null; + testId?: string; +}) { + if (!to) { + return
; + } + return ( + + + + ); +} + +export function ChartDataTable({ + title, + dates, + series, + rowHeader = 'Date', +}: { + title: string; + /** The row labels: days for a daily chart, or the categories of a breakdown. */ + dates: string[]; + series: { label: string; values: number[] }[]; + rowHeader?: string; +}) { + return ( +
+ View {title.toLowerCase()} as a data table +
+ + + + + + {series.map((item) => )} + + + + {dates.map((date, index) => ( + + + {series.map((item) => )} + + ))} + +
+ {title} chart data by {rowHeader === 'Date' ? 'day' : rowHeader.toLowerCase()} +
{rowHeader}{item.label}
{date}{item.values[index] ?? 0}
+
+
+ ); +} + +export function ChartPanel({ + title, + description, + children, +}: { + title: string; + description?: string; + children: ReactNode; +}) { + return ( +
+

{title}

+ {description ?

{description}

: null} + {children} +
+ ); +} + +export function WindowSelect({ + id, + value, + options, + onChange, + label = 'Period', +}: { + id: string; + value: T; + options: readonly T[]; + onChange: (value: T) => void; + label?: string; +}) { + return ( + + ); +} diff --git a/application/v2_ui/src/components/layout/AppShell.tsx b/application/v2_ui/src/components/layout/AppShell.tsx index 47bda1fb8..2aba188fb 100644 --- a/application/v2_ui/src/components/layout/AppShell.tsx +++ b/application/v2_ui/src/components/layout/AppShell.tsx @@ -10,6 +10,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { Sidebar } from './Sidebar'; import { Toaster } from '../ui/Toaster'; +import { SafetyWarningDialogHost } from '../notifications/SafetyWarningDialog'; import { WorkflowAlertCardHost } from '../notifications/WorkflowAlertCard'; import { WorkflowAlertLiveRegion } from '../notifications/WorkflowAlertLiveRegion'; import { useBootstrapStore } from '../../stores/bootstrapStore'; @@ -81,6 +82,7 @@ export function AppShell({ children }: { children: ReactNode }) { +
); } diff --git a/application/v2_ui/src/components/layout/CategoryRail.tsx b/application/v2_ui/src/components/layout/CategoryRail.tsx new file mode 100644 index 000000000..7f7a907cf --- /dev/null +++ b/application/v2_ui/src/components/layout/CategoryRail.tsx @@ -0,0 +1,222 @@ +// CategoryRail.tsx +// The page shell Approvals and the admin Review center share: a page header, a collapsible +// rail of categories on the left, and the chosen category's content beside it. +// +// The rail collapses to icons and remembers that per page, through a user setting each page +// names. Below the lg breakpoint it gives way to a picker above the content, so every +// category stays one choice away on a phone. The active category comes from the page's +// address; the rail only reports a choice. + +import type { ReactNode } from 'react'; +import clsx from 'clsx'; +import { PanelLeftClose, PanelLeftOpen, type LucideIcon } from 'lucide-react'; +import { PageHeader } from './PageHeader'; + +export interface RailItem { + id: string; + label: string; + /** + * The name read for the entry where its label alone would be ambiguous, such as two + * sections each with a "Dashboard". It must contain the visible label. + */ + accessibleLabel?: string; + description: string; + Icon: LucideIcon; +} + +export interface RailSection { + id: string; + /** Shown above the section's entries; omit it for a single unnamed list. */ + label?: string; + items: RailItem[]; +} + +export function railItems(sections: readonly RailSection[]): RailItem[] { + return sections.flatMap((section) => section.items); +} + +export function CategoryRailPage({ + testId, + title, + description, + actions, + railLabel, + railTestId, + itemTestIdPrefix, + collapseNoun, + listId, + sections, + activeId, + activeCount, + countTestId, + collapsed, + onToggleCollapsed, + onSelect, + pickerId, + pickerLabel, + pickerTestId, + showHeading = true, + children, +}: { + testId: string; + title: string; + description: string; + actions?: ReactNode; + /** The rail's accessible name, such as "Approval categories". */ + railLabel: string; + railTestId: string; + /** Each entry's test id is this prefix followed by the entry's id. */ + itemTestIdPrefix: string; + /** What the collapse control names, such as "approval categories". */ + collapseNoun: string; + listId: string; + sections: readonly RailSection[]; + activeId: string; + /** Shown beside the active entry while the rail is expanded. */ + activeCount?: number | null; + countTestId?: string; + collapsed: boolean; + onToggleCollapsed: () => void; + onSelect: (id: string) => void; + pickerId: string; + pickerLabel: string; + pickerTestId: string; + /** Whether the active entry's name and description head the content on wide screens. */ + showHeading?: boolean; + children: ReactNode; +}) { + const items = railItems(sections); + const active = items.find((item) => item.id === activeId) ?? items[0]; + const collapseLabel = collapsed ? `Expand ${collapseNoun}` : `Collapse ${collapseNoun}`; + + const renderItem = (item: RailItem) => { + const isActive = item.id === active?.id; + return ( + + ); + }; + + return ( +
+ +
+ + +
+
+
+ + +
+ {showHeading && active ? ( +
+

{active.accessibleLabel ?? active.label}

+

{active.description}

+
+ ) : null} +
+
{children}
+
+
+
+ ); +} diff --git a/application/v2_ui/src/components/layout/Sidebar.tsx b/application/v2_ui/src/components/layout/Sidebar.tsx index 2ea89a695..920b29d70 100644 --- a/application/v2_ui/src/components/layout/Sidebar.tsx +++ b/application/v2_ui/src/components/layout/Sidebar.tsx @@ -23,15 +23,14 @@ import { ChevronDown, ChevronLeft, ChevronRight, + ClipboardCheck, FolderOpen, Globe2, LogOut, MessageSquarePlus, - MessageSquareHeart, MessagesSquare, Moon, Settings, - ShieldAlert, ShieldCheck, SlidersHorizontal, Sparkles, @@ -43,6 +42,7 @@ import { useBootstrapStore } from '../../stores/bootstrapStore'; import { useChatStore } from '../../stores/chatStore'; import { classicChatHref } from '../../lib/conversationUrl'; import { DEFAULT_PUBLIC_WORKSPACE_LABELS, usePublicWorkspaceLabels } from '../../lib/publicWorkspaceLabels'; +import { reviewAccessInput, reviewSections } from '../../lib/reviewAccess'; import { ConversationRail } from '../chat/ConversationRail'; import { NavExtras } from './NavExtras'; import { SupportMenu } from './SupportMenu'; @@ -165,21 +165,9 @@ function UserMenu({ collapsed }: { collapsed: boolean }) { const controlCenter = bootstrap?.control_center; const isAdmin = Boolean(user?.is_admin); const canOpenControlCenter = Object.values(controlCenter ?? {}).some(Boolean); - const roles = user?.roles ?? []; - const settings = bootstrap?.settings; - const features = bootstrap?.features; - const canReviewFeedback = Boolean(features?.enable_user_feedback) && ( - settings?.require_member_of_feedback_admin === true - ? roles.includes('FeedbackAdmin') - : roles.includes('Admin') - ); - const canReviewSafety = Boolean( - features?.enable_content_safety || features?.enable_content_screening, - ) && ( - settings?.require_member_of_safety_violation_admin === true - ? roles.includes('SafetyViolationAdmin') - : roles.includes('Admin') - ); + // One entry for the Review center whenever either of its sections is open to this user; + // the same rules as the server's feedback and safety review decorators. + const canReview = reviewSections(reviewAccessInput(bootstrap)).length > 0; const activeConversationId = useChatStore((state) => state.activeConversationId); const [open, setOpen] = useState(false); const containerRef = useRef(null); @@ -257,14 +245,9 @@ function UserMenu({ collapsed }: { collapsed: boolean }) { Admin Settings )} - {canReviewFeedback && ( - setOpen(false)} className={itemClass}> - Feedback Review - - )} - {canReviewSafety && ( - setOpen(false)} className={itemClass}> - Safety Violations + {canReview && ( + setOpen(false)} className={itemClass}> + Review center )} {canOpenControlCenter && ( diff --git a/application/v2_ui/src/components/notifications/SafetyWarningDialog.tsx b/application/v2_ui/src/components/notifications/SafetyWarningDialog.tsx new file mode 100644 index 000000000..15b00bad8 --- /dev/null +++ b/application/v2_ui/src/components/notifications/SafetyWarningDialog.tsx @@ -0,0 +1,112 @@ +// SafetyWarningDialog.tsx +// The dialog a safety warning from an administrator appears in, over whatever page is open. +// +// A warning has to be acknowledged, so the dialog has no close button and Escape or a click +// outside it leaves it on screen -- as a workflow alert that needs acknowledgment never tucks +// away. "I understand" records the acknowledgment on the server and shows the next waiting +// warning, if there is one. A reload, another tab or another device shows it again until then. +// +// Everything shown is the warning's own text, rendered as text. + +import { Loader2, ShieldAlert } from 'lucide-react'; +import { Modal } from '../ui/Modal'; +import { GlassButton } from '../ui/primitives'; +import { describeSafetyWarningCategories, formatSafetyWarningDate, safetyWarningKey } from '../../lib/safetyWarnings'; +import { refreshNotificationCount } from '../../stores/notificationStore'; +import { useSafetyWarningStore } from '../../stores/safetyWarningStore'; + +const DIALOG_TITLE = 'A warning from your administrators'; + +function keepOpen(): void { + // Escape and the backdrop do nothing: only "I understand" closes a warning. +} + +export function SafetyWarningDialogHost() { + const warning = useSafetyWarningStore((state) => state.warnings[0] ?? null); + const waiting = useSafetyWarningStore((state) => state.warnings.length); + const acknowledgingKey = useSafetyWarningStore((state) => state.acknowledgingKey); + const error = useSafetyWarningStore((state) => state.error); + const acknowledge = useSafetyWarningStore((state) => state.acknowledge); + + if (!warning) { + return null; + } + + const busy = acknowledgingKey === safetyWarningKey(warning); + const issued = formatSafetyWarningDate(warning.issuedAt); + const categories = describeSafetyWarningCategories(warning.categories); + + const onAcknowledge = async () => { + if (await acknowledge(warning)) { + // The warning's notice in the bell was marked read with it. + void refreshNotificationCount(); + } + }; + + return ( + + + +
+

{DIALOG_TITLE}

+

+ {waiting > 1 ? `Warning 1 of ${waiting}. ` : ''} + Read it, then confirm you understand it to carry on. +

+
+
+ } + footer={ + <> + {error ? ( +

+ {error} +

+ ) : null} + void onAcknowledge()} + data-testid="v2-safety-warning-acknowledge" + > + {busy ? + + } + > +
+

{warning.title}

+

+ {warning.message} +

+
+ {issued ? ( + <> +
Sent
+
{issued}
+ + ) : null} + {categories ? ( + <> +
Flagged categories
+
{categories}
+ + ) : null} +
Reference
+
{warning.id}
+
+
+ + ); +} diff --git a/application/v2_ui/src/components/review/FeedbackRetest.tsx b/application/v2_ui/src/components/review/FeedbackRetest.tsx new file mode 100644 index 000000000..a29f0562d --- /dev/null +++ b/application/v2_ui/src/components/review/FeedbackRetest.tsx @@ -0,0 +1,70 @@ +// FeedbackRetest.tsx +// Sends a feedback record's prompt to the current model configuration and shows the new +// answer beside the response the user rated, so a reviewer can tell whether a change since +// then already addresses the feedback. +// +// The retest runs only when asked: it calls the model, which takes time and costs tokens. +// Nothing is saved; the record keeps the response the user saw. + +import { useState } from 'react'; +import { FlaskConical, Loader2 } from 'lucide-react'; +import { GlassButton } from '../ui/primitives'; +import { errorText, retestFeedbackPrompt } from '../../lib/reviewCenterApi'; +import type { FeedbackRecord } from '../../lib/reviewCenter'; +import { ReviewNotice, ReviewTextBlock } from './ReviewParts'; + +export function FeedbackRetest({ record, testIdPrefix }: { record: FeedbackRecord; testIdPrefix: string }) { + const [running, setRunning] = useState(false); + const [answer, setAnswer] = useState(null); + const [error, setError] = useState(''); + + const run = async () => { + setRunning(true); + setError(''); + try { + const result = await retestFeedbackPrompt(record.id, record.prompt ?? ''); + setAnswer(result.retestResponse || 'The model returned no response.'); + } catch (cause) { + setError(errorText(cause, 'The prompt could not be retested.')); + } finally { + setRunning(false); + } + }; + + return ( +
+
+ void run()} + disabled={running || !record.prompt} + data-testid={`${testIdPrefix}-retest`} + > + {running ? +

+ Sends the original prompt to the current model configuration. Nothing is saved. +

+
+ {error ? {error} : null} +
+ +
+ {answer !== null ? ( + + ) : ( +
+

Retest response

+

+ {running ? 'Waiting for the model…' : 'Retest the prompt to compare a current answer here.'} +

+
+ )} +
+
+
+ ); +} diff --git a/application/v2_ui/src/components/review/ReviewBulkBar.tsx b/application/v2_ui/src/components/review/ReviewBulkBar.tsx new file mode 100644 index 000000000..8a24ae5f7 --- /dev/null +++ b/application/v2_ui/src/components/review/ReviewBulkBar.tsx @@ -0,0 +1,130 @@ +// ReviewBulkBar.tsx +// The bar a Review center workbench shows once records are checked: how many, the actions +// that apply to all of them, progress while they run, and what happened to each. +// +// Every bulk action reports per record. A batch that partly fails says which records were +// left unchanged and why, in the server's words, so the reviewer can act on each one +// rather than guess. The section supplies its own action buttons; this bar only frames +// them, which is where a later "Triage with AI" command joins the same bar. + +import type { ReactNode } from 'react'; +import { CheckCheck, Loader2, X } from 'lucide-react'; +import { GlassButton } from '../ui/primitives'; +import { ReviewNotice } from './ReviewParts'; + +export interface BulkRunProgress { + /** What is running, such as "Archiving". */ + label: string; + done: number; + total: number; +} + +export interface BulkRunReport { + /** The sentence that leads the report, such as "Archived 18 of 20 violations." */ + summary: string; + failures: { id: string; label: string; message: string }[]; + /** How the report reads; by default a warning when any record failed. */ + tone?: 'ok' | 'warn'; +} + +export function ReviewBulkBar({ + count, + summary, + offerMatching, + matchingBusy = false, + onSelectMatching, + onClear, + actions, + progress, + report, + onDismissReport, + testIdPrefix, +}: { + /** How many records are checked. */ + count: number; + /** The sentence describing what is checked. */ + summary: string; + /** "Select all N matching", offered when the whole page is checked and more records match. */ + offerMatching?: { total: number; noun: string } | null; + matchingBusy?: boolean; + onSelectMatching?: () => void; + onClear: () => void; + actions: ReactNode; + progress: BulkRunProgress | null; + report: BulkRunReport | null; + onDismissReport: () => void; + testIdPrefix: string; +}) { + if (!count && !progress && !report) return null; + return ( +
+ {count || progress ? ( +
+

+

+ {offerMatching && onSelectMatching && !progress ? ( + + ) : null} +
+ {progress ? ( +

+

+ ) : ( + <> + {actions} + + + + )} +
+
+ ) : null} + {report ? ( + +
+
+

{report.summary}

+ {report.failures.length ? ( +
    + {report.failures.map((failure) => ( +
  • + {failure.label}: {failure.message} +
  • + ))} +
+ ) : null} +
+ +
+
+ ) : null} +
+ ); +} diff --git a/application/v2_ui/src/components/review/ReviewEditorPlaceholder.tsx b/application/v2_ui/src/components/review/ReviewEditorPlaceholder.tsx new file mode 100644 index 000000000..233ba9547 --- /dev/null +++ b/application/v2_ui/src/components/review/ReviewEditorPlaceholder.tsx @@ -0,0 +1,22 @@ +// ReviewEditorPlaceholder.tsx +// What a Review center editor page shows while its record loads, or in place of a record +// that could not be loaded: the state, and the way back to the workbench. + +import type { ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ArrowLeft } from 'lucide-react'; +import { GlassButton } from '../ui/primitives'; + +export function ReviewEditorPlaceholder({ backTo, children }: { backTo: string; children: ReactNode }) { + const navigate = useNavigate(); + return ( +
+
+ navigate(backTo)}> + +
+
{children}
+
+ ); +} diff --git a/application/v2_ui/src/components/review/ReviewList.tsx b/application/v2_ui/src/components/review/ReviewList.tsx new file mode 100644 index 000000000..aa9ae8dff --- /dev/null +++ b/application/v2_ui/src/components/review/ReviewList.tsx @@ -0,0 +1,164 @@ +// ReviewList.tsx +// The list column of a Review center workbench: one-line rows, each with a checkbox for bulk +// actions and a button that opens the row in the detail pane. +// +// Drawn the way the Workflows workbench draws its list -- a sticky header, one line per row +// with its status at the end -- with a checkbox in front of each row. Up and Down move between +// rows, and Home and End go to the first and last, keeping to the control in focus: from a +// checkbox to the next row's checkbox, from a row to the next row. Only the current row's +// controls are in the tab order, so the list is one stop on the way through the page. +// Shift-clicking a checkbox selects the range from the last one checked. + +import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'; +import { clsx } from 'clsx'; +import { Skeleton } from '../ui/primitives'; +import { useIndeterminate } from './ReviewParts'; + +export interface ReviewListRow { + id: string; + title: string; + meta: string; + /** The row's state, drawn at its end. Decorative: the meta and the detail say it in words. */ + status: ReactNode; +} + +export function ReviewList({ + label, + columnLabel, + statusLabel, + rows, + loading, + empty, + selectedId, + onSelect, + checkedIds, + onToggleCheck, + onToggleAll, + footer, + testIdPrefix, +}: { + /** The list's accessible name, such as "Feedback". */ + label: string; + columnLabel: string; + statusLabel: string; + rows: readonly ReviewListRow[]; + loading: boolean; + /** Shown in place of rows when the page has none. */ + empty: ReactNode; + selectedId: string | null; + onSelect: (id: string) => void; + checkedIds: readonly string[]; + onToggleCheck: (id: string, range: boolean) => void; + onToggleAll: () => void; + footer?: ReactNode; + testIdPrefix: string; +}) { + const [focusedId, setFocusedId] = useState(null); + const checked = new Set(checkedIds); + const pageChecked = rows.length > 0 && rows.every((row) => checked.has(row.id)); + const somePageChecked = rows.some((row) => checked.has(row.id)); + const selectAllRef = useIndeterminate(pageChecked, somePageChecked); + const tabStopId = + (focusedId && rows.some((row) => row.id === focusedId) && focusedId) + || (selectedId && rows.some((row) => row.id === selectedId) && selectedId) + || rows[0]?.id; + + const onKeyDown = (event: KeyboardEvent) => { + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + const target = event.target as HTMLElement; + const kind = target.dataset.reviewControl; + if (kind !== 'row' && kind !== 'check') return; + const controls = Array.from( + event.currentTarget.querySelectorAll(`[data-review-control="${kind}"]`), + ); + if (!controls.length) return; + event.preventDefault(); + const index = controls.indexOf(target); + const next = event.key === 'Home' ? 0 + : event.key === 'End' ? controls.length - 1 + : event.key === 'ArrowDown' ? Math.min(controls.length - 1, index + 1) + : Math.max(0, index - 1); + controls[next]?.focus(); + }; + + return ( +
+
+ + {columnLabel} + {statusLabel} +
+ {loading && !rows.length ? ( +
+ Loading + {Array.from({ length: 5 }).map((_, index) => )} +
+ ) : rows.length ? ( +
    + {rows.map((row) => { + const isSelected = row.id === selectedId; + const isChecked = checked.has(row.id); + const tabIndex = row.id === tabStopId ? 0 : -1; + const metaId = `${testIdPrefix}-meta-${row.id}`; + return ( +
  • + setFocusedId(row.id)} + onChange={() => undefined} + onClick={(event: MouseEvent) => onToggleCheck(row.id, event.shiftKey)} + data-testid={`${testIdPrefix}-check-${row.id}`} + className="h-4 w-4 shrink-0 accent-[var(--color-accent)]" + /> + +
  • + ); + })} +
+ ) : ( + empty + )} + {footer} +
+ ); +} diff --git a/application/v2_ui/src/components/review/ReviewParts.tsx b/application/v2_ui/src/components/review/ReviewParts.tsx new file mode 100644 index 000000000..ec486f3ed --- /dev/null +++ b/application/v2_ui/src/components/review/ReviewParts.tsx @@ -0,0 +1,328 @@ +// ReviewParts.tsx +// Small pieces the Review center's dashboards, workbenches and editors share: tone badges, +// notices, labelled filter selects, the pager, the filter chips a dashboard link leaves on a +// list, and the ARIA tabs of a record's detail. + +import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'; +import { clsx } from 'clsx'; +import { Search, X } from 'lucide-react'; +import { GlassButton } from '../ui/primitives'; +import { REVIEW_PAGE_SIZES, type ReviewTone } from '../../lib/reviewCenter'; + +const TONE_CLASS: Readonly> = { + ok: 'bg-ok-soft text-ok', + warn: 'bg-warn-soft text-warn', + danger: 'bg-danger-soft text-danger', + info: 'bg-info-soft text-info', + neutral: 'bg-surface-2 text-text-2', + accent: 'bg-accent-soft text-accent', +}; + +export function ToneBadge({ tone, children, testId }: { tone: ReviewTone; children: ReactNode; testId?: string }) { + return ( + + {children} + + ); +} + +export function ReviewNotice({ + tone = 'info', + children, + testId, +}: { + tone?: 'info' | 'ok' | 'warn' | 'danger'; + children: ReactNode; + testId?: string; +}) { + return ( +
+ {children} +
+ ); +} + +const SELECT_CLASS = clsx( + 'min-h-9 min-w-0 max-w-full rounded-lg border border-edge bg-surface-1 px-2.5 py-1.5 text-sm text-text-1', + 'focus:border-accent focus:outline-none', +); + +export function FilterSelect({ + label, + value, + options, + onChange, + testId, +}: { + label: string; + value: T; + options: ReadonlyArray; + onChange: (value: T) => void; + testId?: string; +}) { + // Labelled by reference rather than by wrapping, so the select's name is the label alone. + const id = useId(); + return ( +
+ + +
+ ); +} + +export interface FilterChip { + key: string; + label: string; + onRemove: () => void; +} + +/** + * The search box of a workbench. What is typed is sent once typing pauses, so the address + * and the list follow the search without a request for every key. + */ +export function ReviewSearch({ + value, + onCommit, + label, + testId, +}: { + value: string; + onCommit: (value: string) => void; + label: string; + testId?: string; +}) { + const [draft, setDraft] = useState(value); + const commit = useRef(onCommit); + commit.current = onCommit; + + useEffect(() => setDraft(value), [value]); + useEffect(() => { + if (draft === value) return undefined; + const timer = window.setTimeout(() => commit.current(draft), 350); + return () => window.clearTimeout(timer); + }, [draft, value]); + + return ( +
+
+ ); +} + +/** The narrower filters a dashboard link applied, each removable on its own. */ +export function FilterChips({ chips }: { chips: readonly FilterChip[] }) { + if (!chips.length) return null; + return ( +
    + {chips.map((chip) => ( +
  • + +
  • + ))} +
+ ); +} + +export function ReviewPager({ + page, + pageSize, + total, + loading, + noun, + onPage, + onPageSize, +}: { + page: number; + pageSize: number; + total: number; + loading: boolean; + noun: string; + onPage: (page: number) => void; + onPageSize: (size: number) => void; +}) { + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + const sizeId = useId(); + return ( + + ); +} + +/** A labelled read-only fact in a detail pane or editor. Nothing is drawn for an empty value. */ +export function ReviewFact({ label, children }: { label: string; children: ReactNode }) { + if (children === null || children === undefined || children === '' || children === false) return null; + return ( +
+
{label}
+
{children}
+
+ ); +} + +/** Long text, such as a prompt or an AI response, in a bounded scroll box. */ +export function ReviewTextBlock({ label, text, empty, testId }: { label: string; text?: string | null; empty: string; testId?: string }) { + return ( +
+

{label}

+

+ {text || empty} +

+
+ ); +} + +export interface ReviewTab { + id: T; + label: string; +} + +/** + * The tabs of a selected record's detail, drawn as the Workflows workbench draws them: arrow + * keys move between tabs, and the panel below scrolls on its own. + */ +export function ReviewDetailTabs({ + label, + tabs, + active, + onChange, + children, +}: { + label: string; + tabs: readonly ReviewTab[]; + active: T; + onChange: (tab: T) => void; + children: ReactNode; +}) { + const baseId = useId(); + const refs = useRef>>({}); + const shown = tabs.some((tab) => tab.id === active) ? active : tabs[0]?.id; + + const onKeyDown = (event: KeyboardEvent) => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key) || !tabs.length) return; + event.preventDefault(); + const index = tabs.findIndex((tab) => tab.id === shown); + const next = event.key === 'Home' ? 0 + : event.key === 'End' ? tabs.length - 1 + : event.key === 'ArrowRight' ? (index + 1) % tabs.length + : (index - 1 + tabs.length) % tabs.length; + onChange(tabs[next].id); + refs.current[tabs[next].id]?.focus(); + }; + + return ( + <> +
+ {tabs.map((tab) => { + const selected = tab.id === shown; + return ( + + ); + })} +
+
+ {children} +
+ + ); +} + +/** Keeps a checkbox's indeterminate state in step, which has no attribute of its own. */ +export function useIndeterminate(checked: boolean, partial: boolean) { + const ref = useRef(null); + useEffect(() => { + if (ref.current) ref.current.indeterminate = partial && !checked; + }, [checked, partial]); + return ref; +} diff --git a/application/v2_ui/src/components/review/ReviewWorkbenchLayout.tsx b/application/v2_ui/src/components/review/ReviewWorkbenchLayout.tsx new file mode 100644 index 000000000..432617f3d --- /dev/null +++ b/application/v2_ui/src/components/review/ReviewWorkbenchLayout.tsx @@ -0,0 +1,78 @@ +// ReviewWorkbenchLayout.tsx +// The frame of a Review center workbench, drawn as the Workflows workbench is: the search, +// filters and bulk bar above, then a list of one-line rows beside the selected record's +// detail once there is room, stacked on narrow screens. + +import type { ReactNode } from 'react'; +import { useLocation } from 'react-router-dom'; +import { CircleCheck, TriangleAlert } from 'lucide-react'; +import { isRecord } from '../../lib/workspaceAuthoring'; + +/** What a record's editor leaves for the workbench after a save, in the location state. */ +export interface ReviewSavedNotice { + message: string; + warning?: boolean; +} + +/** The save an editor just returned from, if the workbench was opened that way. */ +export function useReviewSavedNotice(): ReviewSavedNotice | null { + const location = useLocation(); + const state = isRecord(location.state) ? location.state : null; + const notice = state && isRecord(state.reviewNotice) ? state.reviewNotice : null; + if (!notice || typeof notice.message !== 'string' || !notice.message) return null; + return { message: notice.message, warning: notice.warning === true }; +} + +export function ReviewSavedStatus({ notice, testId }: { notice: ReviewSavedNotice | null; testId: string }) { + if (!notice) return null; + const Icon = notice.warning ? TriangleAlert : CircleCheck; + return ( +

+

+ ); +} + +export function ReviewWorkbenchLayout({ + testId, + label, + header, + list, + pager, + detail, +}: { + testId: string; + /** The workbench's name, for the group around it. */ + label: string; + /** Search, filters, notices and the bulk bar. */ + header: ReactNode; + list: ReactNode; + pager: ReactNode; + detail: ReactNode; +}) { + return ( +
+
{header}
+
+
+
+
{list}
+ {pager} +
+
{detail}
+
+
+
+ ); +} + +/** The detail pane's placeholder while nothing is selected. */ +export function ReviewDetailEmpty({ title, description }: { title: string; description: string }) { + return ( +
+

{title}

+

{description}

+
+ ); +} diff --git a/application/v2_ui/src/components/review/useReviewWorkbench.ts b/application/v2_ui/src/components/review/useReviewWorkbench.ts new file mode 100644 index 000000000..96e7a8e46 --- /dev/null +++ b/application/v2_ui/src/components/review/useReviewWorkbench.ts @@ -0,0 +1,189 @@ +// useReviewWorkbench.ts +// The state a Review center workbench keeps: the page of records its filters address, the +// records checked for a bulk action, and the bulk run in progress with its report. +// +// The filters, the page and the open record live in the address, so a workbench can be +// linked to, reloaded and returned to from a record's editor exactly as it was left. The +// checked records do not: they are a working set, pruned to the rows still shown after +// every reload. + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { errorText, type BulkOperation, type MatchingIds, type ReviewPage } from '../../lib/reviewCenterApi'; +import { + buildBulkReport, + readReviewPaging, + type ReviewBulkResult, +} from '../../lib/reviewCenter'; +import { + checkedIds, + EMPTY_REVIEW_SELECTION, + keepFailures, + matchingSelection, + pruneReviewSelection, + selectionSummary, + selectMatching, + toggleChecked, + togglePageChecked, + type ReviewSelection, +} from '../../lib/reviewSelection'; +import type { BulkRunProgress, BulkRunReport } from './ReviewBulkBar'; + +export interface ReviewNoun { + singular: string; + plural: string; +} + +export function useReviewWorkbench({ + filterKey, + reloadKey, + loadPage, + loadMatchingIds, + runBulk, + noun, + describe, +}: { + /** The filters as their query string: a change reloads the list and drops "every matching". */ + filterKey: string; + /** Bumped by the page's Refresh. */ + reloadKey: number; + loadPage: (page: number, pageSize: number, signal: AbortSignal) => Promise>; + loadMatchingIds: () => Promise; + runBulk: ( + operations: readonly BulkOperation[], + onProgress: (done: number, total: number) => void, + ) => Promise; + noun: ReviewNoun; + /** How a record is named in a bulk report. */ + describe: (id: string, items: readonly T[]) => string; +}) { + const [searchParams, setSearchParams] = useSearchParams(); + const paging = readReviewPaging(searchParams); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [reload, setReload] = useState(0); + const [selection, setSelection] = useState(EMPTY_REVIEW_SELECTION); + const [matchingBusy, setMatchingBusy] = useState(false); + const [progress, setProgress] = useState(null); + const [report, setReport] = useState(null); + const loadRef = useRef(loadPage); + loadRef.current = loadPage; + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(''); + loadRef.current(paging.page, paging.pageSize, controller.signal) + .then((result) => { + if (controller.signal.aborted) return; + setItems(result.items); + setTotal(result.total); + setSelection((current) => pruneReviewSelection( + current, + result.items.map((item) => item.id), + filterKey, + )); + }) + .catch((cause) => { + if (!controller.signal.aborted) setError(errorText(cause, `The ${noun.plural} could not be loaded.`)); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + // The noun only words the error. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterKey, paging.page, paging.pageSize, reloadKey, reload]); + + const orderedIds = useMemo(() => items.map((item) => item.id), [items]); + const checked = checkedIds(selection, filterKey); + const matching = matchingSelection(selection, filterKey); + const pageFullyChecked = orderedIds.length > 0 && orderedIds.every((id) => checked.includes(id)); + + /** Change address parameters, leaving the rest as they are. */ + const updateParams = useCallback((changes: Record, options?: { keepPage?: boolean }) => { + setSearchParams((current) => { + const next = new URLSearchParams(current); + for (const [key, value] of Object.entries(changes)) { + if (value === null || value === '') next.delete(key); + else next.set(key, value); + } + if (!options?.keepPage) next.delete('page'); + return next; + }, { replace: true }); + }, [setSearchParams]); + + const toggle = (id: string, range: boolean) => + setSelection((current) => toggleChecked(current, id, range, orderedIds, filterKey)); + const togglePage = () => setSelection((current) => togglePageChecked(current, orderedIds, filterKey)); + const clearSelection = () => setSelection(EMPTY_REVIEW_SELECTION); + + const chooseMatching = async () => { + setMatchingBusy(true); + setReport(null); + try { + const result = await loadMatchingIds(); + setSelection(selectMatching(result, filterKey)); + } catch (cause) { + setReport({ summary: errorText(cause, `Every matching ${noun.singular} could not be selected.`), failures: [] }); + } finally { + setMatchingBusy(false); + } + }; + + /** + * Run one bulk operation, in batches, then report on each record and reload. Without + * `ids` it runs over the checked records, and only those it could not change stay + * checked; with `ids`, such as one record's own Archive, the checked records are left + * as they are. + */ + const runBulkOperation = async ( + label: string, + verb: string, + build: (id: string) => BulkOperation, + ids?: readonly string[], + ): Promise => { + const targets = ids ? [...ids] : [...checked]; + if (!targets.length) return []; + setReport(null); + setProgress({ label, done: 0, total: targets.length }); + const snapshot = items; + try { + const results = await runBulk(targets.map(build), (done, all) => setProgress({ label, done, total: all })); + const built = buildBulkReport(results, verb, noun, (id) => describe(id, snapshot)); + setReport(built); + if (!ids) setSelection(keepFailures(built.failures.map((failure) => failure.id))); + return results; + } finally { + setProgress(null); + setReload((value) => value + 1); + } + }; + + return { + searchParams, + paging, + updateParams, + items, + total, + loading, + error, + reload: () => setReload((value) => value + 1), + orderedIds, + checked, + matching, + pageFullyChecked, + toggle, + togglePage, + clearSelection, + chooseMatching, + matchingBusy, + runBulkOperation, + progress, + report, + setReport, + summary: selectionSummary(selection, filterKey, noun), + }; +} diff --git a/application/v2_ui/src/components/settings/ViolationsTab.tsx b/application/v2_ui/src/components/settings/ViolationsTab.tsx index ae9a1c7ee..0e0893a68 100644 --- a/application/v2_ui/src/components/settings/ViolationsTab.tsx +++ b/application/v2_ui/src/components/settings/ViolationsTab.tsx @@ -24,7 +24,12 @@ const PAGE_SIZE = 10; const STATUSES = ['New', 'In-Review', 'Resolved', 'Dismissed']; /** Actions an administrator can record against a violation. */ -const ACTIONS = ['None', 'WarnUser', 'SuspendUser', 'Escalate', 'BlockUser']; +const ACTIONS = ['None', 'WarnUser', 'SuspendUser', 'BlockUser']; + +/** Escalate is no longer recorded; older records keep it and are labelled as legacy. */ +function actionLabel(action: string): string { + return action === 'Escalate' ? 'Escalated (legacy)' : action; +} interface TriggeredCategory { category?: string; @@ -41,6 +46,32 @@ interface SafetyLog { admin_notes?: string; created_at?: string; last_updated?: string; + /** Set on a warning the administrators sent: `pending` until it is acknowledged. */ + warning_acknowledgment_status?: 'pending' | 'acknowledged' | 'not_tracked' | null; + warning_acknowledged_at?: string | null; +} + +/** Whether the user has acknowledged a warning they were sent, as one line. */ +function WarningAcknowledgment({ log }: { log: SafetyLog }) { + if (log.warning_acknowledgment_status === 'acknowledged') { + const acknowledged = log.warning_acknowledged_at ? new Date(log.warning_acknowledged_at) : null; + return ( +

+

+ ); + } + if (log.warning_acknowledgment_status === 'pending') { + return ( +

+ Not yet acknowledged. The warning is shown to you until you confirm you understand it. +

+ ); + } + return null; } interface LogsResponse { @@ -315,7 +346,7 @@ export function ViolationsTab() { {log.action && log.action !== 'None' && ( - {log.action} + {actionLabel(log.action)} )} {log.created_at && ( @@ -331,6 +362,8 @@ export function ViolationsTab() {

)} + + {(log.triggered_categories ?? []).length > 0 && (

{(log.triggered_categories ?? []) diff --git a/application/v2_ui/src/components/workspace/WorkspaceEditorFrame.tsx b/application/v2_ui/src/components/workspace/WorkspaceEditorFrame.tsx index 24b36830b..8f4256246 100644 --- a/application/v2_ui/src/components/workspace/WorkspaceEditorFrame.tsx +++ b/application/v2_ui/src/components/workspace/WorkspaceEditorFrame.tsx @@ -138,9 +138,10 @@ export function WorkspaceEditorFrame({ state.workspaceEditorFrom === currentLocation.key; // A save navigates to backTo (the collection or the agent return path). Personal editors // live under /workspace, but a group editor returns to /groups//actions, so the bypass - // matches the frame's own backTo as well as the personal family it always did. + // matches the frame's own backTo as well as the personal family it always did. A Review + // center editor's backTo carries the list's filters, so only its path is compared. if (currentEditorTransition && state.workspaceEditorSaved === true - && (nextLocation.pathname === backTo || /^\/workspace\/(?:agents|actions)(?:\/|$)/.test(nextLocation.pathname))) return false; + && (nextLocation.pathname === backTo.split(/[?#]/)[0] || /^\/workspace\/(?:agents|actions)(?:\/|$)/.test(nextLocation.pathname))) return false; if (currentEditorTransition && state.preserveWorkspaceDraft === true) { const goingToAction = isActionEditorNewPath(nextLocation.pathname) && isAgentEditorPath(currentLocation.pathname) && diff --git a/application/v2_ui/src/lib/accessRestriction.ts b/application/v2_ui/src/lib/accessRestriction.ts new file mode 100644 index 000000000..6d154ae6a --- /dev/null +++ b/application/v2_ui/src/lib/accessRestriction.ts @@ -0,0 +1,95 @@ +// accessRestriction.ts +// The call behind the V2 Access restricted page. The server decides everything -- whether the +// account is restricted, until when, and what the user was told -- so the page only renders +// what this returns. The route only ever describes the signed-in user's own account. + +import { request } from './apiClient'; +import type { TermsOfUseBranding } from './termsOfUse'; + +export type AccessRestrictionKind = 'suspended' | 'blocked'; + +export interface AccessRestriction { + kind: AccessRestrictionKind; + /** ISO 8601 UTC. Set only for a suspension, which ends on its own. */ + until: string | null; + /** Plain text. Rendered as text, never as HTML. */ + title: string; + /** Plain text. Rendered as text, never as HTML. */ + message: string; + /** The safety violation the restriction was applied for, when there is one. */ + referenceId: string | null; +} + +export interface AccessRestrictionStatus { + restricted: boolean; + restriction: AccessRestriction | null; + branding: Partial; +} + +/** Shown when the server sends no notice text, matching the server's own fallback copy. */ +const FALLBACK_COPY: Record = { + suspended: { + title: 'Your access is temporarily suspended', + message: + 'An administrator has temporarily suspended your access to this application. ' + + 'Your access is restored automatically at the time shown.', + }, + blocked: { + title: 'Your access has been blocked', + message: + 'An administrator has blocked your access to this application. ' + + 'Contact your administrator if you have questions about this decision.', + }, +}; + +function text(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +/** Read the route's answer defensively; anything it can't vouch for falls back to safe copy. */ +export function parseAccessRestrictionStatus(payload: unknown): AccessRestrictionStatus { + const body = (payload && typeof payload === 'object' ? payload : {}) as Record; + const branding = (body.branding && typeof body.branding === 'object' + ? body.branding + : {}) as Partial; + if (body.restricted !== true) { + return { restricted: false, restriction: null, branding }; + } + const raw = (body.restriction && typeof body.restriction === 'object' + ? body.restriction + : {}) as Record; + const kind: AccessRestrictionKind = raw.kind === 'suspended' && text(raw.until) ? 'suspended' : 'blocked'; + return { + restricted: true, + restriction: { + kind, + until: kind === 'suspended' ? text(raw.until) : null, + title: text(raw.title) || FALLBACK_COPY[kind].title, + message: text(raw.message) || FALLBACK_COPY[kind].message, + referenceId: text(raw.reference_id) || null, + }, + branding, + }; +} + +export async function fetchAccessRestriction(signal?: AbortSignal): Promise { + return parseAccessRestrictionStatus( + await request('/api/v2/access-restriction', { signal }), + ); +} + +/** A restore time in the reader's own locale and time zone, or null when it can't be read. */ +export function formatRestoreTime(until: string | null | undefined, locale?: string): string | null { + if (!until) { + return null; + } + const parsed = new Date(until); + if (Number.isNaN(parsed.getTime())) { + return null; + } + try { + return parsed.toLocaleString(locale, { dateStyle: 'full', timeStyle: 'short' }); + } catch { + return parsed.toLocaleString(); + } +} diff --git a/application/v2_ui/src/lib/apiClient.ts b/application/v2_ui/src/lib/apiClient.ts index 7f499da88..f16582a7a 100644 --- a/application/v2_ui/src/lib/apiClient.ts +++ b/application/v2_ui/src/lib/apiClient.ts @@ -91,6 +91,49 @@ export function safeTermsOfUseUrl(next: unknown): string { return `${V2_TERMS_OF_USE_PATH}?${new URLSearchParams({ next: safeSameOriginUrl(next, '/v2') }).toString()}`; } +/** Where the server's access gate sends a suspended or blocked V2 user. */ +export const V2_ACCESS_RESTRICTED_PATH = '/v2/access-restricted'; + +/** + * True when a failed response is the server's access gate: an administrator suspended or + * blocked this account, so every call is refused until that ends. + */ +export function isAccessRestricted(status: number, payload: unknown): boolean { + return ( + status === 403 && + typeof payload === 'object' && + payload !== null && + (payload as Record).error === 'access_restricted' + ); +} + +/** + * Leave for the Access restricted page when the server says this account is restricted. + * + * A restriction can be applied while a tab is open, after which every call is refused. The + * page says why and how long it lasts, which beats a screen full of failures. Skipped on + * the page itself so a refused call there cannot loop. + */ +export function redirectToAccessRestricted(): boolean { + if (typeof window === 'undefined' || !window.location) { + return false; + } + if (window.location.pathname === V2_ACCESS_RESTRICTED_PATH) { + return false; + } + window.location.assign(V2_ACCESS_RESTRICTED_PATH); + return true; +} + +/** Follow the server's gates when a failed response is one of them. */ +function followServerGate(status: number, payload: unknown): void { + if (isTermsOfUseRequired(status, payload)) { + redirectToTermsOfUse(); + } else if (isAccessRestricted(status, payload)) { + redirectToAccessRestricted(); + } +} + /** * Leave for the Terms of Use page when the server says acceptance is now required. * @@ -165,9 +208,7 @@ export async function requestWithStatus(path: string, options: RequestOptions if (!response.ok) { const { message, payload } = await readErrorMessage(response); - if (isTermsOfUseRequired(response.status, payload)) { - redirectToTermsOfUse(); - } + followServerGate(response.status, payload); throw new ApiError(message, response.status, payload); } @@ -219,9 +260,7 @@ export async function uploadFileWithStatus( if (!response.ok) { const { message, payload } = await readErrorMessage(response); - if (isTermsOfUseRequired(response.status, payload)) { - redirectToTermsOfUse(); - } + followServerGate(response.status, payload); throw new ApiError(message, response.status, payload); } diff --git a/application/v2_ui/src/lib/approvalsApi.ts b/application/v2_ui/src/lib/approvalsApi.ts index f7b9ea2fe..695ddc1c0 100644 --- a/application/v2_ui/src/lib/approvalsApi.ts +++ b/application/v2_ui/src/lib/approvalsApi.ts @@ -53,11 +53,11 @@ export const GROUP_REQUEST_TYPES = [ 'delete_documents', 'delete_group', 'delete_user_documents', - 'warn_user', - 'suspend_user', - 'block_user', ] as const; +/** Warn, suspend and block requests raised from safety violation reviews. */ +export const SAFETY_REMEDIATION_TYPES = ['warn_user', 'suspend_user', 'block_user'] as const; + export const M365_REQUEST_TYPES = ['m365_source_sharing', 'm365_extended_analysis', 'm365_workflow_run_as'] as const; export type M365RequestType = (typeof M365_REQUEST_TYPES)[number]; @@ -140,6 +140,29 @@ export function approveApprovalRequest(id: string, groupId: string | undefined, }); } +export interface ApprovalStats { + window?: { days: number }; + waiting_on_me?: number; + my_pending_requests?: number; + expiring_within_24h?: number; + pending_visible?: number; + decided_in_window?: Partial>; + pending_by_type?: { request_type: string; count: number }[]; + oldest_actionable?: { + id: string; + group_id?: string; + request_type: string; + group_name?: string; + created_at?: string; + expires_at?: string; + }[]; +} + +/** What the Approvals dashboard counts: only requests the caller can see, over the last `days`. */ +export function fetchApprovalStats(days: string, signal?: AbortSignal) { + return api.get(`/api/approvals/stats?${new URLSearchParams({ days }).toString()}`, signal); +} + export function denyApprovalRequest(id: string, groupId: string | undefined, comment: string) { return api.post(`/api/approvals/${encodeURIComponent(id)}/deny`, { group_id: groupId, diff --git a/application/v2_ui/src/lib/notificationLinks.ts b/application/v2_ui/src/lib/notificationLinks.ts index f5f51e8c7..b2817aa73 100644 --- a/application/v2_ui/src/lib/notificationLinks.ts +++ b/application/v2_ui/src/lib/notificationLinks.ts @@ -422,7 +422,12 @@ export function resolveNotificationLink( } if (path === '/profile') { - return route(url.searchParams.get('tab') === 'violations' ? '/settings?tab=violations' : '/settings'); + // The classic profile tabs that V2 Settings also has: a safety violation notice opens + // Violations, and a reply to the user's feedback opens Feedback. + const tab = url.searchParams.get('tab'); + if (tab === 'violations') return route('/settings?tab=violations'); + if (tab === 'feedback') return route('/settings?tab=feedback'); + return route('/settings'); } // Anything already written for V2 is a route in this application. The router adds its diff --git a/application/v2_ui/src/lib/notifications.ts b/application/v2_ui/src/lib/notifications.ts index 509654c30..06150373b 100644 --- a/application/v2_ui/src/lib/notifications.ts +++ b/application/v2_ui/src/lib/notifications.ts @@ -252,6 +252,9 @@ function describeType(type: string, category: string | undefined): Omit>; + settings: Readonly>; +} + +interface BootstrapLike { + user?: { roles?: readonly string[] | null } | null; + features?: Readonly> | null; + settings?: unknown; +} + +/** The parts of the bootstrap payload the access rules read. */ +export function reviewAccessInput(data: BootstrapLike | null | undefined): ReviewAccessInput { + const settings = data?.settings; + return { + roles: Array.isArray(data?.user?.roles) ? data.user.roles : [], + features: data?.features ?? {}, + settings: settings && typeof settings === 'object' && !Array.isArray(settings) + ? (settings as Record) + : {}, + }; +} + +/** + * Feedback review: the FeedbackAdmin role when Admin Settings requires it, otherwise Admin, + * and only while user feedback is turned on. + */ +export function canReviewFeedback(input: ReviewAccessInput): boolean { + if (input.features.enable_user_feedback !== true) return false; + return input.settings.require_member_of_feedback_admin === true + ? input.roles.includes('FeedbackAdmin') + : input.roles.includes('Admin'); +} + +/** + * Safety review: the SafetyViolationAdmin role when Admin Settings requires it, otherwise + * Admin, and only while content safety or content screening is on. + */ +export function canReviewSafety(input: ReviewAccessInput): boolean { + if (!(input.features.enable_content_safety === true || input.features.enable_content_screening === true)) { + return false; + } + return input.settings.require_member_of_safety_violation_admin === true + ? input.roles.includes('SafetyViolationAdmin') + : input.roles.includes('Admin'); +} + +/** The sections this user may open, in the order the rail lists them. */ +export function reviewSections(input: ReviewAccessInput): ReviewSectionId[] { + const sections: ReviewSectionId[] = []; + if (canReviewFeedback(input)) sections.push('feedback'); + if (canReviewSafety(input)) sections.push('safety'); + return sections; +} + +/** Where /admin/review leads: the first section this user may open, or null for none. */ +export function defaultReviewPath(input: ReviewAccessInput): string | null { + const [first] = reviewSections(input); + return first ? `/admin/review/${first}` : null; +} + +/** + * Whether the Approvals page offers its Safety remediation category: the roles that can act + * on warn, suspend and block requests or raise them. The approvals API still decides which + * requests each person sees. + */ +export function canSeeSafetyRemediationApprovals(roles: readonly string[]): boolean { + return roles.includes('Admin') || roles.includes('ControlCenterAdmin') || roles.includes('SafetyViolationAdmin'); +} diff --git a/application/v2_ui/src/lib/reviewCenter.ts b/application/v2_ui/src/lib/reviewCenter.ts new file mode 100644 index 000000000..910baf45f --- /dev/null +++ b/application/v2_ui/src/lib/reviewCenter.ts @@ -0,0 +1,727 @@ +// reviewCenter.ts +// What the admin Review center says about each feedback record and safety violation, which +// filters its addresses carry, and the defaults its editors start from. +// +// The Review center's workbenches list records as one-line rows beside a detail pane, the way +// the Workflows workbench does. These are the decisions they make about each row -- how a state +// reads, which tone it takes, what a filter in the address means -- kept apart from the +// components so they run in a test. Everything here reads the records the review APIs return; +// nothing is inferred beyond them. + +import type { ReviewSectionId } from './reviewAccess'; + +export type ReviewTone = 'ok' | 'warn' | 'danger' | 'info' | 'neutral' | 'accent'; + +export const REVIEW_PAGE_SIZES = [10, 20, 50, 100] as const; +export const DEFAULT_REVIEW_PAGE_SIZE = 20; +export const REVIEW_WINDOWS = ['7', '30', '90'] as const; +export type ReviewWindow = (typeof REVIEW_WINDOWS)[number]; +export const DEFAULT_REVIEW_WINDOW: ReviewWindow = '30'; +/** The most operations one bulk request carries; larger selections are sent in batches. */ +export const REVIEW_BULK_BATCH = 100; +export type ArchiveFilter = 'active' | 'archived' | 'all'; + +const ARCHIVE_FILTERS: readonly ArchiveFilter[] = ['active', 'archived', 'all']; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export function readReviewWindow(value: string | null | undefined): ReviewWindow { + return (REVIEW_WINDOWS as readonly string[]).includes(value ?? '') ? (value as ReviewWindow) : DEFAULT_REVIEW_WINDOW; +} + +function readWindowFilter(value: string | null): '' | ReviewWindow { + return (REVIEW_WINDOWS as readonly string[]).includes(value ?? '') ? (value as ReviewWindow) : ''; +} + +function readArchive(value: string | null): ArchiveFilter { + return ARCHIVE_FILTERS.includes(value as ArchiveFilter) ? (value as ArchiveFilter) : 'active'; +} + +function readDate(value: string | null): string { + return value && DATE_PATTERN.test(value) ? value : ''; +} + +function readText(value: string | null, limit = 200): string { + return (value ?? '').slice(0, limit); +} + +export function formatReviewDate(value?: string | null): string { + if (!value) return 'Not recorded'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +/** One line of text for a list row: whitespace collapsed and cut to `limit` characters. */ +export function reviewExcerpt(value: string | null | undefined, limit = 120): string { + const flattened = (value ?? '').split(/\s+/).filter(Boolean).join(' '); + return flattened.length <= limit ? flattened : `${flattened.slice(0, limit - 1).trimEnd()}…`; +} + +/* -------------------------------------------------------------------------- */ +/* Paging and selection carried in the address */ +/* -------------------------------------------------------------------------- */ + +export interface ReviewPaging { + page: number; + pageSize: number; + selected: string; +} + +export function readReviewPaging(params: URLSearchParams): ReviewPaging { + const page = Number.parseInt(params.get('page') ?? '', 10); + const size = Number.parseInt(params.get('size') ?? '', 10); + return { + page: Number.isFinite(page) && page > 0 ? page : 1, + pageSize: (REVIEW_PAGE_SIZES as readonly number[]).includes(size) ? size : DEFAULT_REVIEW_PAGE_SIZE, + selected: readText(params.get('selected')), + }; +} + +/* -------------------------------------------------------------------------- */ +/* Feedback */ +/* -------------------------------------------------------------------------- */ + +export interface FeedbackReviewer { + id?: string; + displayName?: string; +} + +export interface FeedbackAdminReview { + acknowledged?: boolean; + analysisNotes?: string | null; + responseToUser?: string | null; + actionTaken?: string | null; + reviewTimestamp?: string | null; + analyzedBy?: FeedbackReviewer | string | null; + userNotifiedAt?: string | null; +} + +export interface FeedbackRecord { + id: string; + userId?: string; + userDisplayName?: string | null; + userEmail?: string | null; + prompt?: string; + aiResponse?: string; + feedbackType?: string; + reason?: string; + timestamp?: string; + isArchived?: boolean; + adminReview?: FeedbackAdminReview; + etag?: string; +} + +export type FeedbackRating = '' | 'Positive' | 'Negative' | 'Neutral'; +export const FEEDBACK_RATINGS: readonly Exclude[] = ['Positive', 'Negative', 'Neutral']; + +export interface FeedbackFilters { + type: FeedbackRating; + ack: '' | 'true' | 'false'; + archive: ArchiveFilter; + search: string; + userId: string; + date: string; + days: '' | ReviewWindow; +} + +export const DEFAULT_FEEDBACK_FILTERS: FeedbackFilters = { + type: '', + ack: '', + archive: 'active', + search: '', + userId: '', + date: '', + days: '', +}; + +export function readFeedbackFilters(params: URLSearchParams): FeedbackFilters { + const type = params.get('type') ?? ''; + const ack = params.get('ack') ?? ''; + return { + type: (FEEDBACK_RATINGS as readonly string[]).includes(type) ? (type as FeedbackRating) : '', + ack: ack === 'true' || ack === 'false' ? ack : '', + archive: readArchive(params.get('archive')), + search: readText(params.get('search')), + userId: readText(params.get('user_id')), + date: readDate(params.get('date')), + days: readWindowFilter(params.get('days')), + }; +} + +/** The filters as query parameters, defaults left out. The list API reads the same names. */ +export function feedbackFilterParams(filters: FeedbackFilters): URLSearchParams { + const params = new URLSearchParams(); + if (filters.type) params.set('type', filters.type); + if (filters.ack) params.set('ack', filters.ack); + if (filters.archive !== 'active') params.set('archive', filters.archive); + if (filters.search.trim()) params.set('search', filters.search.trim()); + if (filters.userId) params.set('user_id', filters.userId); + if (filters.date) params.set('date', filters.date); + if (filters.days) params.set('days', filters.days); + return params; +} + +export function feedbackRatingTone(rating?: string): ReviewTone { + if (rating === 'Positive') return 'ok'; + if (rating === 'Negative') return 'danger'; + return 'neutral'; +} + +export function feedbackReviewState(record: FeedbackRecord): { label: string; tone: ReviewTone } { + if (record.adminReview?.acknowledged) return { label: 'Acknowledged', tone: 'ok' }; + return { label: 'Awaiting review', tone: 'warn' }; +} + +export function feedbackUserLabel(record: FeedbackRecord): string { + return record.userDisplayName || record.userEmail || record.userId || 'Unknown user'; +} + +export function feedbackRowTitle(record: FeedbackRecord): string { + return reviewExcerpt(record.prompt) || 'No prompt captured'; +} + +/** What a row says beside its prompt: the rating, the review state, who sent it and when. */ +export function feedbackRowMeta(record: FeedbackRecord): string { + return [ + record.feedbackType || 'Unrated', + feedbackReviewState(record).label, + feedbackUserLabel(record), + formatReviewDate(record.timestamp), + ].join(' · '); +} + +export function feedbackReviewerName(review?: FeedbackAdminReview | null): string { + const reviewer = review?.analyzedBy; + if (!reviewer) return ''; + if (typeof reviewer === 'string') return reviewer; + return reviewer.displayName || reviewer.id || ''; +} + +/** How many filters other than the search narrow the feedback list. */ +export function feedbackFiltersApplied(filters: FeedbackFilters): number { + return [filters.type, filters.ack, filters.archive !== 'active' ? 'x' : '', filters.userId, filters.date, filters.days] + .filter(Boolean).length; +} + +/* -------------------------------------------------------------------------- */ +/* Safety violations */ +/* -------------------------------------------------------------------------- */ + +export interface TriggeredCategory { + category?: string; + severity?: number; +} + +export interface UserAccessState { + restricted: boolean; + kind?: string | null; + until?: string | null; +} + +export interface SafetyRecord { + id: string; + user_id?: string; + user_display_name?: string | null; + user_email?: string | null; + message?: string; + triggered_categories?: TriggeredCategory[]; + status?: string; + action?: string; + notes?: string; + user_notes?: string; + created_at?: string; + last_updated?: string; + content_origin?: string; + action_request_status?: string | null; + action_request_id?: string | null; + action_request_type?: string | null; + action_requested_at?: string | null; + action_request_decided_at?: string | null; + action_approved_at?: string | null; + action_executed_at?: string | null; + action_execution_error?: string | null; + action_notification_title?: string | null; + action_notification_message?: string | null; + action_datetime_to_allow?: string | null; + /** Set on an executed warning: `pending` until the user acknowledges it. */ + warning_acknowledgment_status?: 'pending' | 'acknowledged' | 'not_tracked' | null; + warning_acknowledged_at?: string | null; + warning_issued_at?: string | null; + isArchived?: boolean; + etag?: string; + user_access?: UserAccessState | null; + user_violation_count?: number | null; +} + +export const SAFETY_STATUSES = ['New', 'In-Review', 'Resolved', 'Dismissed'] as const; +export type SafetyStatus = (typeof SAFETY_STATUSES)[number]; +// Escalate is no longer an action. A record that already carries it keeps it, labelled as +// legacy, and only that record's review offers it again. +export const ACTIONS = ['None', 'WarnUser', 'SuspendUser', 'BlockUser']; +export const LEGACY_ESCALATE_ACTION = 'Escalate'; +export const LEGACY_ESCALATE_LABEL = 'Escalated (legacy)'; +export const REMEDIATION_ACTIONS: ReadonlySet = new Set(['WarnUser', 'SuspendUser', 'BlockUser']); +/** Suspend and block restrict access, so another eligible reviewer must approve them. */ +export const APPROVAL_REQUIRED_ACTIONS: ReadonlySet = new Set(['SuspendUser', 'BlockUser']); +export const SAFETY_REQUEST_STATES = ['pending', 'executed', 'failed', 'denied', 'expired'] as const; +export type SafetyRequestState = (typeof SAFETY_REQUEST_STATES)[number]; +export const SAFETY_WARNING_STATES = ['pending', 'acknowledged', 'not_tracked'] as const; + +const ACTION_LABELS: Readonly> = { + None: 'No action', + WarnUser: 'Warn user', + SuspendUser: 'Suspend user', + BlockUser: 'Block user', + [LEGACY_ESCALATE_ACTION]: LEGACY_ESCALATE_LABEL, +}; + +export function safetyActionLabel(action?: string | null): string { + const value = action || 'None'; + return ACTION_LABELS[value] ?? value.replace(/([a-z])([A-Z])/g, '$1 $2'); +} + +/** The actions a record's review may choose: legacy Escalate only on a record that has it. */ +export function selectableSafetyActions(record: Pick): string[] { + return record.action === LEGACY_ESCALATE_ACTION ? [...ACTIONS, LEGACY_ESCALATE_ACTION] : [...ACTIONS]; +} + +export function safetyStatusTone(status?: string | null): ReviewTone { + if (status === 'Resolved') return 'ok'; + if (status === 'Dismissed') return 'neutral'; + if (status === 'In-Review') return 'info'; + return 'warn'; +} + +export function safetyRequestState(record: Pick): string { + return (record.action_request_status || '').trim().toLowerCase(); +} + +/** The violation waits on an approval request and cannot be changed or deleted until it is decided. */ +export function isRemediationPending(record: SafetyRecord): boolean { + return safetyRequestState(record) === 'pending'; +} + +/** + * Another save is sending this violation's warning right now. Like a pending request it + * holds the violation until it finishes; a send that stopped part way reads as failed. + */ +export function isWarningSending(record: SafetyRecord): boolean { + return safetyRequestState(record) === 'sending'; +} + +/** The violation cannot be saved, archived or deleted until its request or send settles. */ +export function isSafetyRecordLocked(record: SafetyRecord): boolean { + return isRemediationPending(record) || isWarningSending(record); +} + +/** A warning that was sent. Saving the record again does not resend it. */ +export function isExecutedWarning(record: SafetyRecord): boolean { + return record.action === 'WarnUser' && safetyRequestState(record) === 'executed'; +} + +export function topCategory(record: SafetyRecord): TriggeredCategory | null { + let best: TriggeredCategory | null = null; + for (const entry of record.triggered_categories ?? []) { + if (!entry?.category) continue; + if (!best || (entry.severity ?? -1) > (best.severity ?? -1)) best = entry; + } + return best; +} + +export function categorySummary(record: SafetyRecord): string { + return (record.triggered_categories ?? []) + .filter((entry) => entry?.category) + .map((entry) => `${entry.category} (severity ${entry.severity ?? '?'})`) + .join(', '); +} + +export function safetyUserLabel(record: SafetyRecord): string { + return record.user_display_name || record.user_email || record.user_id || 'Unknown user'; +} + +export function safetyRowTitle(record: SafetyRecord): string { + return reviewExcerpt(record.message) || 'No message captured'; +} + +/** What a row says beside its message: the top category, the user and when it was flagged. */ +export function safetyRowMeta(record: SafetyRecord): string { + const top = topCategory(record); + return [ + top ? `${top.category} · severity ${top.severity ?? '?'}` : 'No category recorded', + safetyUserLabel(record), + formatReviewDate(record.created_at), + ].join(' · '); +} + +/** The action a violation records and how far it has got, as a short badge. */ +export function safetyActionBadge(record: SafetyRecord): { label: string; detail: string; tone: ReviewTone } { + const label = safetyActionLabel(record.action); + const state = safetyRequestState(record); + if (isExecutedWarning(record)) { + if (record.warning_acknowledgment_status === 'acknowledged') return { label, detail: 'Acknowledged', tone: 'ok' }; + if (record.warning_acknowledgment_status === 'pending') return { label, detail: 'Not yet acknowledged', tone: 'warn' }; + return { label, detail: 'Sent', tone: 'neutral' }; + } + switch (state) { + case 'pending': + return { label, detail: 'Pending approval', tone: 'warn' }; + case 'sending': + return { label, detail: 'Sending', tone: 'info' }; + case 'executed': + return { label, detail: 'Applied', tone: 'ok' }; + case 'failed': + return { label, detail: 'Failed', tone: 'danger' }; + case 'denied': + return { label, detail: 'Denied', tone: 'neutral' }; + case 'expired': + return { label, detail: 'Expired', tone: 'neutral' }; + default: + return { label, detail: '', tone: record.action && record.action !== 'None' ? 'info' : 'neutral' }; + } +} + +/** Whether the user has acknowledged a warning that was sent. */ +export function warningAcknowledgmentText(record: SafetyRecord): string | null { + if (!isExecutedWarning(record)) return null; + if (record.warning_acknowledgment_status === 'acknowledged') { + return `Warning acknowledged ${formatReviewDate(record.warning_acknowledged_at)}`; + } + if (record.warning_acknowledgment_status === 'pending') { + return 'Warning sent. Not yet acknowledged by the user.'; + } + return 'Warning sent before acknowledgment was tracked.'; +} + +/** + * Where a remediation request stands, in a sentence. Never quotes the stored failure text, + * which can carry a technical error; the approval request has the details. + */ +export function remediationStatusText(record: SafetyRecord): string | null { + const state = safetyRequestState(record); + const kind = record.action === 'BlockUser' ? 'block' : record.action === 'SuspendUser' ? 'suspension' : 'action'; + switch (state) { + case 'pending': + return `The ${kind} is waiting for another eligible reviewer to approve it. Requested ${formatReviewDate(record.action_requested_at)}.`; + case 'sending': + return 'Another save is sending this warning now. Reload in a moment to see whether it was sent.'; + case 'executed': + return record.action === 'WarnUser' + ? `The warning was sent ${formatReviewDate(record.action_executed_at)}.` + : `The ${kind} was approved and applied ${formatReviewDate(record.action_executed_at)}.`; + case 'failed': + return record.action === 'WarnUser' + ? 'The warning could not be sent. Save the review again to retry.' + : `The ${kind} was approved but could not be applied. Open the approval request for details.`; + case 'denied': + return `The ${kind} request was denied ${formatReviewDate(record.action_request_decided_at)}. It can be requested again.`; + case 'expired': + return `The ${kind} request expired without a decision. It can be requested again.`; + default: + return null; + } +} + +/** + * Whether a violation's review offers to request its suspension or block again: only when + * the action stays the same, and never while a request waits or a warning is being sent. + */ +export function offersRestrictionReissue(record: SafetyRecord, action: string): boolean { + const state = safetyRequestState(record); + return APPROVAL_REQUIRED_ACTIONS.has(action) + && (record.action || 'None') === action + && state !== 'pending' + && state !== 'sending'; +} + +/** + * What saving the same suspension or block again does when it isn't asked for again, by + * where its last request stands. Mirrors the classic review page. + */ +export function existingRestrictionText(record: SafetyRecord, action: string): string { + const noun = action === 'BlockUser' ? 'block' : 'suspension'; + const where: Readonly> = { + executed: `This ${noun} was approved and applied.`, + denied: `This ${noun} request was denied.`, + expired: `This ${noun} request expired without a decision.`, + failed: `This ${noun} was approved but could not be applied.`, + }; + const state = where[safetyRequestState(record)] ?? `This violation already records a ${noun}.`; + return `${state} Saving updates the review only and requests nothing new. To ask another eligible reviewer to approve it again, select "Request this ${noun} again".`; +} + +export interface SafetyFilters { + status: '' | 'open' | SafetyStatus; + action: string; + archive: ArchiveFilter; + search: string; + userId: string; + category: string; + severity: string; + request: '' | SafetyRequestState; + warning: '' | (typeof SAFETY_WARNING_STATES)[number]; + restricted: boolean; + date: string; + days: '' | ReviewWindow; +} + +export const DEFAULT_SAFETY_FILTERS: SafetyFilters = { + status: '', + action: '', + archive: 'active', + search: '', + userId: '', + category: '', + severity: '', + request: '', + warning: '', + restricted: false, + date: '', + days: '', +}; + +export function readSafetyFilters(params: URLSearchParams): SafetyFilters { + const status = params.get('status') ?? ''; + const action = params.get('action') ?? ''; + const request = params.get('request') ?? ''; + const warning = params.get('warning') ?? ''; + const severity = params.get('severity') ?? ''; + return { + status: status === 'open' || (SAFETY_STATUSES as readonly string[]).includes(status) + ? (status as SafetyFilters['status']) + : '', + action: [...ACTIONS, LEGACY_ESCALATE_ACTION].includes(action) ? action : '', + archive: readArchive(params.get('archive')), + search: readText(params.get('search')), + userId: readText(params.get('user_id')), + category: readText(params.get('category'), 100), + severity: /^\d{1,2}$/.test(severity) ? severity : '', + request: (SAFETY_REQUEST_STATES as readonly string[]).includes(request) ? (request as SafetyRequestState) : '', + warning: (SAFETY_WARNING_STATES as readonly string[]).includes(warning) + ? (warning as SafetyFilters['warning']) + : '', + restricted: params.get('restricted') === '1', + date: readDate(params.get('date')), + days: readWindowFilter(params.get('days')), + }; +} + +/** The filters as query parameters, defaults left out. The list API reads the same names. */ +export function safetyFilterParams(filters: SafetyFilters): URLSearchParams { + const params = new URLSearchParams(); + if (filters.status) params.set('status', filters.status); + if (filters.action) params.set('action', filters.action); + if (filters.archive !== 'active') params.set('archive', filters.archive); + if (filters.search.trim()) params.set('search', filters.search.trim()); + if (filters.userId) params.set('user_id', filters.userId); + if (filters.category) params.set('category', filters.category); + if (filters.severity) params.set('severity', filters.severity); + if (filters.request) params.set('request', filters.request); + if (filters.warning) params.set('warning', filters.warning); + if (filters.restricted) params.set('restricted', '1'); + if (filters.date) params.set('date', filters.date); + if (filters.days) params.set('days', filters.days); + return params; +} + +/** How many filters other than the search narrow the violation list. */ +export function safetyFiltersApplied(filters: SafetyFilters): number { + return [ + filters.status, filters.action, filters.archive !== 'active' ? 'x' : '', filters.userId, filters.category, + filters.severity, filters.request, filters.warning, filters.restricted ? 'x' : '', filters.date, filters.days, + ].filter(Boolean).length; +} + +/* -------------------------------------------------------------------------- */ +/* Remediation editor defaults */ +/* -------------------------------------------------------------------------- */ + +/** The title the server sends when none is given. Mirrors _default_notification_title. */ +export function defaultNotificationTitle(action: string): string { + if (action === 'WarnUser') return 'Safety Violation Warning'; + if (action === 'SuspendUser') return 'Account Suspension Notice'; + if (action === 'BlockUser') return 'Account Access Blocked'; + return 'Safety Violation Notice'; +} + +/** The message the server sends when none is given. Mirrors _default_notification_message. */ +export function defaultNotificationMessage( + record: SafetyRecord, + action: string, + notes: string, + restoreAt?: string | null, +): string { + const lines = [ + 'A safety review has been completed for recent activity in your workspace.', + `Violation ID: ${record.id || 'Unknown'}`, + ]; + const categories = (record.triggered_categories ?? []) + .filter((entry) => entry?.category) + .map((entry) => (entry.severity !== undefined && entry.severity !== null + ? `${entry.category}(s=${entry.severity})` + : `${entry.category}`)) + .join(', '); + if (categories) lines.push(`Triggered categories: ${categories}`); + if (action === 'WarnUser') { + lines.push('Action taken: Warning issued. Please review our acceptable use requirements before continuing.'); + } else if (action === 'SuspendUser') { + lines.push('Action taken: Your access has been temporarily suspended pending the date below.'); + if (restoreAt) lines.push(`Access restores automatically after: ${restoreAt}`); + } else if (action === 'BlockUser') { + lines.push('Action taken: Your access has been blocked with no automatic restore date.'); + } + if (notes.trim()) lines.push(`Admin notes: ${notes.trim()}`); + return lines.join('\n'); +} + +export type SuspendPreset = '24h' | '7d' | '30d' | 'custom'; + +export const SUSPEND_PRESETS: readonly { id: SuspendPreset; label: string; hours?: number }[] = [ + { id: '24h', label: '24 hours', hours: 24 }, + { id: '7d', label: '7 days', hours: 24 * 7 }, + { id: '30d', label: '30 days', hours: 24 * 30 }, + { id: 'custom', label: 'Custom date' }, +]; + +/** When a preset suspension ends, counted from `now`; null for the custom date. */ +export function suspendPresetUntil(preset: SuspendPreset, now: Date = new Date()): Date | null { + const hours = SUSPEND_PRESETS.find((option) => option.id === preset)?.hours; + return hours ? new Date(now.getTime() + hours * 3600 * 1000) : null; +} + +/** A time as a `datetime-local` value in the reader's time zone, or '' when it can't be read. */ +export function toLocalDateTimeInput(value?: string | Date | null): string { + if (!value) return ''; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16); +} + +/** A `datetime-local` value as an ISO 8601 time, or null when it is empty or unreadable. */ +export function fromLocalDateTimeInput(value: string): string | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +/* -------------------------------------------------------------------------- */ +/* Bulk results */ +/* -------------------------------------------------------------------------- */ + +export interface ReviewBulkResult { + id: string; + op: string; + ok: boolean; + status: number; + code?: string; + error?: string; + message?: string; + [key: string]: unknown; +} + +export interface ReviewBulkResponse { + results: ReviewBulkResult[]; + succeeded: number; + failed: number; +} + +export function chunkIds(items: readonly T[], size = REVIEW_BULK_BATCH): T[][] { + const chunks: T[][] = []; + for (let start = 0; start < items.length; start += size) { + chunks.push(items.slice(start, start + size)); + } + return chunks; +} + +export interface BulkOutcome { + succeeded: string[]; + failures: { id: string; message: string }[]; +} + +/** Every result from one or more bulk responses, split into what succeeded and why the rest failed. */ +export function bulkOutcome(results: readonly ReviewBulkResult[]): BulkOutcome { + const outcome: BulkOutcome = { succeeded: [], failures: [] }; + for (const result of results) { + if (result.ok) outcome.succeeded.push(result.id); + else outcome.failures.push({ id: result.id, message: result.error || result.message || 'The change could not be made.' }); + } + return outcome; +} + +/** "3 violations" / "1 feedback record", for counts in confirmations and results. */ +export function countLabel(count: number, singular: string, plural: string): string { + return `${count.toLocaleString()} ${count === 1 ? singular : plural}`; +} + +export interface BulkReport { + summary: string; + failures: { id: string; label: string; message: string }[]; +} + +/** + * What a bulk action did, for the report under the bulk bar: a sentence for the whole run, + * then each record it could not change, named the way the list names it, with the reason + * the server gave. + */ +export function buildBulkReport( + results: readonly ReviewBulkResult[], + verb: string, + noun: { singular: string; plural: string }, + describe: (id: string) => string, +): BulkReport { + const outcome = bulkOutcome(results); + const total = results.length; + const done = outcome.succeeded.length; + const failed = outcome.failures.length; + const summary = failed + ? `${verb} ${done.toLocaleString()} of ${countLabel(total, noun.singular, noun.plural)}. ${failed.toLocaleString()} ${failed === 1 ? 'was' : 'were'} not changed.` + : `${verb} ${countLabel(done, noun.singular, noun.plural)}.`; + return { + summary, + failures: outcome.failures.map((failure) => ({ ...failure, label: describe(failure.id) })), + }; +} + +/* -------------------------------------------------------------------------- */ +/* Addresses */ +/* -------------------------------------------------------------------------- */ + +export type ReviewView = 'dashboard' | 'queue' | 'violations' | 'unchecked'; + +const VIEW_SEGMENTS: Readonly> = { + dashboard: '', + queue: '/queue', + violations: '/violations', + unchecked: '/unchecked', +}; + +function withQuery(path: string, params?: URLSearchParams | null): string { + const query = params?.toString() ?? ''; + return query ? `${path}?${query}` : path; +} + +/** A Review center page: a section's dashboard, workbench or unchecked queue, with its filters. */ +export function safeReviewViewHref(section: ReviewSectionId, view: ReviewView, params?: URLSearchParams | null): string { + const sectionSegment = section === 'safety' ? 'safety' : 'feedback'; + return withQuery(`/admin/review/${sectionSegment}${VIEW_SEGMENTS[view]}`, params); +} + +/** One record's editor, keeping the workbench filters so Back returns to the same list. */ +export function safeReviewRecordHref(section: ReviewSectionId, recordId: string, params?: URLSearchParams | null): string { + const base = section === 'safety' ? '/admin/review/safety/violations' : '/admin/review/feedback/queue'; + return withQuery(`${base}/${encodeURIComponent(recordId)}`, params); +} + +/** The approval request a violation's remediation created, on the Approvals page. */ +export function safeApprovalRequestHref(approvalId: string, groupId?: string | null): string { + const params = groupId ? new URLSearchParams({ group_id: groupId }) : null; + return withQuery(`/approvals/all/${encodeURIComponent(approvalId)}`, params); +} + +/** The feedback workbench filtered as a dashboard tile or chart says. */ +export function safeFeedbackQueueHref(filters: Partial): string { + return safeReviewViewHref('feedback', 'queue', feedbackFilterParams({ ...DEFAULT_FEEDBACK_FILTERS, ...filters })); +} + +/** The violations workbench filtered as a dashboard tile or chart says. */ +export function safeViolationsHref(filters: Partial): string { + return safeReviewViewHref('safety', 'violations', safetyFilterParams({ ...DEFAULT_SAFETY_FILTERS, ...filters })); +} diff --git a/application/v2_ui/src/lib/reviewCenterApi.ts b/application/v2_ui/src/lib/reviewCenterApi.ts new file mode 100644 index 000000000..85f261393 --- /dev/null +++ b/application/v2_ui/src/lib/reviewCenterApi.ts @@ -0,0 +1,380 @@ +// reviewCenterApi.ts +// Every request the admin Review center makes. +// +// The Review center reads and writes through the same review APIs the classic Feedback Review +// and Safety Violations pages use, plus the dashboard, "select all matching" and bulk +// endpoints added for it. The server owns every decision: who may review, what a save does +// and whether a record changed underneath the reviewer. + +import { ApiError, api } from './apiClient'; +import { + REVIEW_BULK_BATCH, + chunkIds, + feedbackFilterParams, + safetyFilterParams, + type FeedbackFilters, + type FeedbackRecord, + type ReviewBulkResponse, + type ReviewBulkResult, + type ReviewWindow, + type SafetyFilters, + type SafetyRecord, +} from './reviewCenter'; + +/** The server's code for a save refused because the record changed after it was opened. */ +export const RECORD_CHANGED_CODE = 'record_changed'; +/** The server's code for a violation locked by a remediation request still being decided. */ +export const REMEDIATION_PENDING_CODE = 'remediation_pending'; +/** The server's code for a save refused while another save is sending the same warning. */ +export const WARNING_IN_PROGRESS_CODE = 'safety_warning_in_progress'; +/** The server's code for a warning that was sent but could not be recorded on the violation. */ +export const WARNING_NOT_RECORDED_CODE = 'safety_warning_not_recorded'; + +export function errorCode(error: unknown): string | null { + if (!(error instanceof ApiError)) return null; + const payload = error.payload as { code?: unknown } | null; + return payload && typeof payload === 'object' && typeof payload.code === 'string' ? payload.code : null; +} + +export function isRecordChanged(error: unknown): boolean { + return errorCode(error) === RECORD_CHANGED_CODE; +} + +/** + * Whether the record must be read again before another save can succeed: it changed after + * it was opened, a warning is being sent, or a request now locks it. + */ +export function needsReload(error: unknown): boolean { + const code = errorCode(error); + return code === RECORD_CHANGED_CODE || code === WARNING_IN_PROGRESS_CODE || code === REMEDIATION_PENDING_CODE + || code === WARNING_NOT_RECORDED_CODE; +} + +export function errorText(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} + +export interface ReviewPage { + items: T[]; + page: number; + pageSize: number; + total: number; +} + +export interface MatchingIds { + ids: string[]; + total: number; + capped: boolean; + cap: number; +} + +export interface DailySeries { + dates: string[]; + series: { key: string; counts: number[] }[]; +} + +export interface DashboardWindow { + days: number; + start_date: string; + end_date: string; +} + +export type BulkOperation = + | { id: string; op: 'update'; changes: Record; etag?: string } + | { id: string; op: 'archive'; archived: boolean; etag?: string } + | { id: string; op: 'delete'; etag?: string }; + +function pagedQuery(filters: URLSearchParams, page: number, pageSize: number): string { + const params = new URLSearchParams(filters); + params.set('page', String(page)); + params.set('page_size', String(pageSize)); + return params.toString(); +} + +/** + * Send operations in batches of at most 100, one batch after another, and return every + * result in order. A batch that fails as a whole reports each of its operations as failed, + * so the caller can still say what happened to everything it asked for. + */ +async function runBulk( + path: string, + operations: readonly BulkOperation[], + onProgress?: (done: number, total: number) => void, +): Promise { + const results: ReviewBulkResult[] = []; + let done = 0; + onProgress?.(0, operations.length); + for (const batch of chunkIds(operations, REVIEW_BULK_BATCH)) { + try { + const response = await api.post(path, { operations: batch }); + results.push(...(Array.isArray(response?.results) ? response.results : [])); + } catch (error) { + const message = errorText(error, 'The change could not be made.'); + for (const operation of batch) { + results.push({ id: operation.id, op: operation.op, ok: false, status: 0, error: message }); + } + } + done += batch.length; + onProgress?.(done, operations.length); + } + return results; +} + +/* -------------------------------------------------------------------------- */ +/* Feedback */ +/* -------------------------------------------------------------------------- */ + +export interface FeedbackStats { + total_count?: number; + positive_count?: number; + negative_count?: number; + neutral_count?: number; + acknowledged_count?: number; + unacknowledged_count?: number; + recent_30_day_count?: number; + window?: DashboardWindow; + received_count?: number; + awaiting_review_count?: number; + negative_count_in_window?: number; + acknowledged_count_in_window?: number; + acknowledgement_rate?: number | null; + archived_count?: number; + daily_by_rating?: DailySeries; + oldest_awaiting?: { + id: string; + feedbackType?: string; + timestamp?: string; + userId?: string; + userDisplayName?: string | null; + promptExcerpt?: string; + }[]; +} + +export async function fetchFeedbackPage( + filters: FeedbackFilters, + page: number, + pageSize: number, + signal?: AbortSignal, +): Promise> { + const response = await api.get<{ + feedback?: FeedbackRecord[]; + page?: number; + page_size?: number; + total_count?: number; + }>(`/feedback/review?${pagedQuery(feedbackFilterParams(filters), page, pageSize)}`, signal); + return { + items: Array.isArray(response?.feedback) ? response.feedback : [], + page: response?.page ?? page, + pageSize: response?.page_size ?? pageSize, + total: response?.total_count ?? 0, + }; +} + +export function fetchFeedbackIds(filters: FeedbackFilters, signal?: AbortSignal): Promise { + const query = feedbackFilterParams(filters).toString(); + return api.get(`/feedback/review/ids${query ? `?${query}` : ''}`, signal); +} + +export function fetchFeedbackRecord(id: string, signal?: AbortSignal): Promise { + return api.get(`/feedback/review/${encodeURIComponent(id)}`, signal); +} + +export function fetchFeedbackStats(days: ReviewWindow, signal?: AbortSignal): Promise { + return api.get(`/feedback/review/stats?${new URLSearchParams({ days })}`, signal); +} + +export interface FeedbackReviewChanges { + acknowledged?: boolean; + analysisNotes?: string; + responseToUser?: string; + actionTaken?: string; + notify_user?: boolean; + etag?: string; +} + +export function saveFeedbackReview(id: string, changes: FeedbackReviewChanges) { + return api.patch<{ success?: boolean; etag?: string; notified?: boolean; notification_warning?: string }>( + `/feedback/review/${encodeURIComponent(id)}`, + changes, + ); +} + +export function retestFeedbackPrompt(id: string, prompt: string) { + return api.post<{ retestResponse?: string }>(`/feedback/retest/${encodeURIComponent(id)}`, { prompt }); +} + +export function bulkFeedback(operations: readonly BulkOperation[], onProgress?: (done: number, total: number) => void) { + return runBulk('/feedback/review/bulk', operations, onProgress); +} + +/* -------------------------------------------------------------------------- */ +/* Safety */ +/* -------------------------------------------------------------------------- */ + +export interface SafetyStats { + total_count?: number; + new_count?: number; + in_review_count?: number; + resolved_count?: number; + dismissed_count?: number; + warn_user_count?: number; + suspend_user_count?: number; + escalate_count?: number; + block_user_count?: number; + none_action_count?: number; + recent_30_day_count?: number; + window?: DashboardWindow; + received_count?: number; + open_count?: number; + pending_remediation_count?: number; + restricted_user_count?: number | null; + warnings_sent_count?: number; + warnings_acknowledged_count?: number; + warnings_pending_count?: number; + unchecked_chat_count?: number | null; + daily_by_category?: DailySeries; + severity_mix?: { severity: number | null; count: number }[]; + action_mix?: { action: string; count: number }[]; + repeat_users?: { user_id: string; display_name?: string | null; email?: string | null; count: number }[]; +} + +export async function fetchSafetyPage( + filters: SafetyFilters, + page: number, + pageSize: number, + signal?: AbortSignal, +): Promise> { + const response = await api.get<{ + logs?: SafetyRecord[]; + page?: number; + page_size?: number; + total_count?: number; + }>(`/api/safety/logs?${pagedQuery(safetyFilterParams(filters), page, pageSize)}`, signal); + return { + items: Array.isArray(response?.logs) ? response.logs : [], + page: response?.page ?? page, + pageSize: response?.page_size ?? pageSize, + total: response?.total_count ?? 0, + }; +} + +export function fetchSafetyIds(filters: SafetyFilters, signal?: AbortSignal): Promise { + const query = safetyFilterParams(filters).toString(); + return api.get(`/api/safety/logs/ids${query ? `?${query}` : ''}`, signal); +} + +export function fetchSafetyRecord(id: string, signal?: AbortSignal): Promise { + return api.get(`/api/safety/logs/${encodeURIComponent(id)}`, signal); +} + +export function fetchSafetyStats(days: ReviewWindow, signal?: AbortSignal): Promise { + return api.get(`/api/safety/logs/stats?${new URLSearchParams({ days })}`, signal); +} + +export interface SafetyReviewChanges { + status?: string; + action?: string; + notes?: string; + notification_title?: string; + notification_message?: string; + datetime_to_allow?: string; + reissue?: boolean; + etag?: string; +} + +export interface SafetyReviewResult { + message?: string; + approval_required?: boolean; + approval_id?: string | null; + warning_already_sent?: boolean; + remediation_already_applied?: boolean; + remediation_unchanged?: boolean; + remediation_status?: string | null; + audit_warning?: string; +} + +export function saveSafetyReview(id: string, changes: SafetyReviewChanges) { + return api.patch(`/api/safety/logs/${encodeURIComponent(id)}`, changes); +} + +export function bulkSafety(operations: readonly BulkOperation[], onProgress?: (done: number, total: number) => void) { + return runBulk('/api/safety/logs/bulk', operations, onProgress); +} + +/* -------------------------------------------------------------------------- */ +/* Unchecked chat content */ +/* -------------------------------------------------------------------------- */ + +export interface UncheckedChatItem { + source: string; + conversation_id: string; + message_id: string; + role?: string; + timestamp?: string; + etag?: string; + check?: { + checkpoint?: string; + attempted_at?: string; + status?: string; + scanners?: { scanner?: string; complete?: boolean; error_code?: string }[]; + }; +} + +export interface UncheckedChatFilters { + source: 'all' | 'chat' | 'shared'; + checkpoint: '' | 'chat_input' | 'chat_output'; + scanner: '' | 'content_screening' | 'content_safety'; +} + +export const DEFAULT_UNCHECKED_FILTERS: UncheckedChatFilters = { source: 'all', checkpoint: '', scanner: '' }; + +export function uncheckedKey(item: Pick): string { + return `${item.source}:${item.conversation_id}:${item.message_id}`; +} + +export async function fetchUncheckedChat( + filters: UncheckedChatFilters, + continuation: string | null, + signal?: AbortSignal, +): Promise<{ items: UncheckedChatItem[]; continuation: string | null }> { + const params = new URLSearchParams({ source: filters.source, page_size: '25' }); + if (filters.checkpoint) params.set('checkpoint', filters.checkpoint); + if (filters.scanner) params.set('scanner', filters.scanner); + if (continuation) params.set('continuation', continuation); + const response = await api.get<{ items?: UncheckedChatItem[]; continuation?: string | null }>( + `/api/safety/chat-checks?${params}`, + signal, + ); + if (!Array.isArray(response?.items)) throw new Error('The unchecked-message response was invalid.'); + return { items: response.items, continuation: response.continuation ?? null }; +} + +export interface RecheckOutcome { + removed?: boolean; + check?: { status?: string }; +} + +export function recheckChatMessage(item: UncheckedChatItem) { + return api.post('/api/safety/chat-checks/recheck', { + source: item.source, + conversation_id: item.conversation_id, + message_id: item.message_id, + etag: item.etag, + }); +} + +/** What a recheck did, in a sentence the reviewer can act on. */ +export function recheckOutcomeText(outcome: RecheckOutcome): { message: string; warning: boolean } { + if (outcome.removed) return { message: 'The AI reply was removed from saved chat and its shared copies.', warning: false }; + if (outcome.check?.status === 'passed') return { message: 'The message passed its required checks.', warning: false }; + if (outcome.check?.status === 'findings') { + return { + message: 'The submitted message was flagged for review. Earlier model calls and actions have not been undone.', + warning: true, + }; + } + return { + message: 'The check could not finish. The message remains marked not checked and can be retried.', + warning: true, + }; +} diff --git a/application/v2_ui/src/lib/reviewSelection.ts b/application/v2_ui/src/lib/reviewSelection.ts new file mode 100644 index 000000000..5e3422181 --- /dev/null +++ b/application/v2_ui/src/lib/reviewSelection.ts @@ -0,0 +1,139 @@ +// reviewSelection.ts +// Which records a Review center workbench has checked for a bulk action. +// +// A workbench checks records in one of two ways. Rows checked on the page follow the same +// click and Shift+click rules as every other list (lib/listSelection.ts), and are pruned to +// the rows still shown after each reload, so a bulk action never reaches a record the +// reviewer can no longer see. "Every record matching these filters" is resolved by the +// server, up to its cap, and belongs to the filters it was resolved for: changing a filter +// drops it rather than applying an old answer to a new question. + +import { + applySelection, + EMPTY_SELECTION, + pruneSelection, + toggleSelectAll, + type SelectionState, +} from './listSelection'; + +export interface MatchingSelection { + ids: string[]; + /** How many records match, which can be more than `ids` holds. */ + total: number; + /** True when more records match than one selection may hold. */ + capped: boolean; + cap: number; + /** The filters the ids were resolved for, as their query string. */ + filterKey: string; +} + +export interface ReviewSelection { + page: SelectionState; + matching: MatchingSelection | null; +} + +export const EMPTY_REVIEW_SELECTION: ReviewSelection = { page: EMPTY_SELECTION, matching: null }; + +function activeMatching(selection: ReviewSelection, filterKey: string): MatchingSelection | null { + return selection.matching && selection.matching.filterKey === filterKey ? selection.matching : null; +} + +/** The records a bulk action would apply to under the current filters. */ +export function checkedIds(selection: ReviewSelection, filterKey: string): string[] { + return activeMatching(selection, filterKey)?.ids ?? selection.page.ids; +} + +/** The "every matching record" selection, when it is the one in use for these filters. */ +export function matchingSelection(selection: ReviewSelection, filterKey: string): MatchingSelection | null { + return activeMatching(selection, filterKey); +} + +/** + * A row's checkbox clicked, with Shift for a range. From an "every matching" selection + * this keeps the rows of the page that were part of it and continues from there, so a + * click never silently widens or empties what the reviewer chose. + */ +export function toggleChecked( + selection: ReviewSelection, + id: string, + range: boolean, + orderedIds: readonly string[], + filterKey: string, +): ReviewSelection { + let page = selection.page; + const matching = activeMatching(selection, filterKey); + if (matching) { + const matched = new Set(matching.ids); + const ids = orderedIds.filter((rowId) => matched.has(rowId)); + page = { ids, anchorId: ids[0] ?? null }; + } + return { page: applySelection(page, id, range ? 'range' : 'toggle', orderedIds), matching: null }; +} + +/** The header checkbox: check every row on the page, or clear when they all are. */ +export function togglePageChecked( + selection: ReviewSelection, + orderedIds: readonly string[], + filterKey: string, +): ReviewSelection { + if (activeMatching(selection, filterKey)) return EMPTY_REVIEW_SELECTION; + return { page: toggleSelectAll(selection.page, orderedIds), matching: null }; +} + +/** Every record the server found for the current filters, up to its cap. */ +export function selectMatching( + result: { ids: string[]; total: number; capped: boolean; cap: number }, + filterKey: string, +): ReviewSelection { + return { + page: EMPTY_SELECTION, + matching: { + ids: [...result.ids], + total: result.total, + capped: result.capped, + cap: result.cap, + filterKey, + }, + }; +} + +/** + * After a reload: checked rows are kept only while they are still shown, and an "every + * matching" selection only while the filters it was resolved for still apply. + */ +export function pruneReviewSelection( + selection: ReviewSelection, + orderedIds: readonly string[], + filterKey: string, +): ReviewSelection { + const matching = activeMatching(selection, filterKey); + const page = pruneSelection(selection.page, orderedIds); + if (matching === selection.matching && page === selection.page) return selection; + return { page, matching }; +} + +/** + * After a bulk action, only the records it could not change stay checked, so the reviewer + * can see why and try again. The next reload prunes any that are no longer shown. + */ +export function keepFailures(failedIds: readonly string[]): ReviewSelection { + return failedIds.length + ? { page: { ids: [...failedIds], anchorId: failedIds[0] ?? null }, matching: null } + : EMPTY_REVIEW_SELECTION; +} + +/** What the bulk bar says is selected. */ +export function selectionSummary( + selection: ReviewSelection, + filterKey: string, + noun: { singular: string; plural: string }, +): string { + const matching = activeMatching(selection, filterKey); + const count = matching ? matching.ids.length : selection.page.ids.length; + const label = `${count.toLocaleString()} ${count === 1 ? noun.singular : noun.plural}`; + if (!matching) return `${label} selected`; + if (matching.capped) { + return `${label} selected, the first ${matching.cap.toLocaleString()} of ${matching.total.toLocaleString()} matching`; + } + return `All ${label} matching these filters selected`; +} diff --git a/application/v2_ui/src/lib/safetyWarnings.ts b/application/v2_ui/src/lib/safetyWarnings.ts new file mode 100644 index 000000000..428dd403c --- /dev/null +++ b/application/v2_ui/src/lib/safetyWarnings.ts @@ -0,0 +1,125 @@ +// safetyWarnings.ts +// Safety warnings an administrator sent the signed-in user. A warning has to be acknowledged: +// it stays on screen until the user says they understand it. The server keeps the record of +// which warnings are still waiting, so a reload, another tab or another device shows them too. +// +// Everything here is the user's own data -- what they were sent and when -- rendered as text. + +import { api } from './apiClient'; + +export interface SafetyWarningCategory { + category: string; + severity: number | null; +} + +export interface SafetyWarning { + /** + * The safety violation the warning was sent for. A reviewer can warn about the same + * violation again, so one warning is this with `issuedAt`: see safetyWarningKey. + */ + id: string; + /** Plain text. Rendered as text, never as HTML. */ + title: string; + /** Plain text. Rendered as text, never as HTML. */ + message: string; + issuedAt: string | null; + categories: SafetyWarningCategory[]; +} + +/** The server's answer when the warning acknowledged was replaced by a newer one. */ +export const SAFETY_WARNING_REPLACED_CODE = 'safety_warning_replaced'; + +const FALLBACK_TITLE = 'Safety Violation Warning'; + +/** Identifies one warning sent: the violation, and when it was sent. */ +export function safetyWarningKey(warning: Pick): string { + return JSON.stringify([warning.id, warning.issuedAt]); +} + +function text(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function parseCategory(value: unknown): SafetyWarningCategory[] { + if (!value || typeof value !== 'object') { + return []; + } + const raw = value as Record; + const category = text(raw.category); + if (!category) { + return []; + } + const severity = typeof raw.severity === 'number' && Number.isFinite(raw.severity) ? raw.severity : null; + return [{ category, severity }]; +} + +/** One warning from the route, or null when it isn't one the dialog can show. */ +export function parseSafetyWarning(value: unknown): SafetyWarning | null { + if (!value || typeof value !== 'object') { + return null; + } + const raw = value as Record; + const id = text(raw.id); + const message = text(raw.message); + if (!id || !message) { + return null; + } + return { + id, + title: text(raw.title) || FALLBACK_TITLE, + message, + issuedAt: text(raw.issued_at) || null, + categories: Array.isArray(raw.triggered_categories) + ? raw.triggered_categories.flatMap(parseCategory) + : [], + }; +} + +/** The route's answer, oldest warning first. Throws when the answer isn't a warning list. */ +export function parsePendingSafetyWarnings(payload: unknown): SafetyWarning[] { + const body = (payload && typeof payload === 'object' ? payload : {}) as Record; + if (!Array.isArray(body.warnings)) { + throw new Error('The pending warnings response was invalid.'); + } + return body.warnings + .map(parseSafetyWarning) + .filter((warning): warning is SafetyWarning => warning !== null); +} + +export async function fetchPendingSafetyWarnings(signal?: AbortSignal): Promise { + return parsePendingSafetyWarnings(await api.get('/api/safety/warnings/pending', signal)); +} + +/** + * Acknowledge the warning the user read. Sending when it was sent lets the server refuse + * (409 SAFETY_WARNING_REPLACED_CODE) when the violation has since been warned about again. + */ +export async function acknowledgeSafetyWarning(warning: Pick): Promise { + await api.post( + `/api/safety/warnings/${encodeURIComponent(warning.id)}/acknowledge`, + warning.issuedAt ? { issued_at: warning.issuedAt } : {}, + ); +} + +/** The flagged categories as one line, such as "Hate (severity 4), Violence (severity 2)". */ +export function describeSafetyWarningCategories(categories: SafetyWarningCategory[]): string { + return categories + .map(({ category, severity }) => (severity === null ? category : `${category} (severity ${severity})`)) + .join(', '); +} + +/** When a warning was sent, in the reader's own locale, or null when it can't be read. */ +export function formatSafetyWarningDate(value: string | null, locale?: string): string | null { + if (!value) { + return null; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + try { + return parsed.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }); + } catch { + return parsed.toLocaleString(); + } +} diff --git a/application/v2_ui/src/lib/types.ts b/application/v2_ui/src/lib/types.ts index 620e60eb8..24c3222b6 100644 --- a/application/v2_ui/src/lib/types.ts +++ b/application/v2_ui/src/lib/types.ts @@ -1195,6 +1195,13 @@ export interface BootstrapPayload { workspace_uploads?: { categories: { name: string; extensions: string[] }[]; }; + /** + * Safety warnings an administrator sent that still need this user's acknowledgment. + * Only the count: the warnings themselves are read when it is above zero. + */ + safety_warnings?: { + pending: number; + }; /** Sanitized settings. Never contains keys, secrets or connection strings. */ settings: Json; } diff --git a/application/v2_ui/src/lib/useSafetyWarningRuntime.ts b/application/v2_ui/src/lib/useSafetyWarningRuntime.ts new file mode 100644 index 000000000..3b1c64e3b --- /dev/null +++ b/application/v2_ui/src/lib/useSafetyWarningRuntime.ts @@ -0,0 +1,41 @@ +// useSafetyWarningRuntime.ts +// Reads the safety warnings waiting for the signed-in user's acknowledgment, for the dialog in +// SafetyWarningDialog.tsx. +// +// Bootstrap carries how many there are, and the warnings themselves are read only when that +// count is above zero, so a user with nothing to acknowledge costs no extra request. App.tsx +// reads bootstrap again whenever the tab comes back to the front, so the count -- and with it +// this read -- follows a warning sent while the tab was away, or one acknowledged in another +// tab, which the server then no longer counts. + +import { useEffect } from 'react'; +import { fetchPendingSafetyWarnings } from './safetyWarnings'; +import { useSafetyWarningStore } from '../stores/safetyWarningStore'; + +/** + * @param pending Warnings bootstrap says are waiting, or null before a session has loaded. + * @param revision The bootstrap payload, so each fresh read of it is followed even when the + * count is unchanged. + */ +export function useSafetyWarningRuntime(pending: number | null, revision: unknown): void { + useEffect(() => { + if (pending === null) { + return undefined; + } + if (pending <= 0) { + useSafetyWarningStore.getState().receive([]); + return undefined; + } + const controller = new AbortController(); + void fetchPendingSafetyWarnings(controller.signal) + .then((warnings) => { + if (!controller.signal.aborted) { + useSafetyWarningStore.getState().receive(warnings); + } + }) + .catch(() => { + // What is already known stays; the next bootstrap read tries again. + }); + return () => controller.abort(); + }, [pending, revision]); +} diff --git a/application/v2_ui/src/lib/userSettings.ts b/application/v2_ui/src/lib/userSettings.ts index afd6c71dc..db6b9b32e 100644 --- a/application/v2_ui/src/lib/userSettings.ts +++ b/application/v2_ui/src/lib/userSettings.ts @@ -90,6 +90,8 @@ export interface UserSettings { /** Whether the Approvals categories rail shows icons only. Its own key for the same reason. */ v2ApprovalsRailCollapsed?: boolean; + /** Whether the admin Review center rail shows icons only. Its own key for the same reason. */ + v2ReviewRailCollapsed?: boolean; /** Whether the V2 Control Center section rail is collapsed to icons. */ v2ControlCenterRailCollapsed?: boolean; @@ -249,6 +251,8 @@ export const WRITABLE_USER_SETTING_KEYS = [ 'v2AdminRailCollapsed', // Whether the Approvals categories rail is showing icons only. 'v2ApprovalsRailCollapsed', + // Whether the admin Review center rail is showing icons only. + 'v2ReviewRailCollapsed', // Whether the User Settings sections rail is showing icons only. 'v2UserSettingsRailCollapsed', // Separate from the shell and Admin Settings rails so each keeps its own layout. diff --git a/application/v2_ui/src/pages/AccessRestrictedPage.tsx b/application/v2_ui/src/pages/AccessRestrictedPage.tsx new file mode 100644 index 000000000..a808a4cb7 --- /dev/null +++ b/application/v2_ui/src/pages/AccessRestrictedPage.tsx @@ -0,0 +1,214 @@ +// AccessRestrictedPage.tsx +// The V2 Access restricted page. When an administrator suspends or blocks an account, the +// server's access gate sends every V2 page here instead of showing an error, so the user can +// read what they were told, see whether access comes back on its own, and sign out. +// +// Rendered outside the application shell: every other API call is refused while the account +// is restricted, so nothing the shell needs could load. It reads one route, which only ever +// describes the signed-in user's own account. + +import { useEffect, useState } from 'react'; +import { CalendarClock, CircleCheck, LogIn, LogOut, ShieldAlert, TriangleAlert } from 'lucide-react'; +import { GlassButton, GlassPanel, Skeleton } from '../components/ui/primitives'; +import { useUiStore } from '../stores/uiStore'; +import { ApiError, apiUrl } from '../lib/apiClient'; +import { + fetchAccessRestriction, + formatRestoreTime, + type AccessRestrictionStatus, +} from '../lib/accessRestriction'; + +const LINK_BUTTON = + 'inline-flex h-10 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition-colors'; + +export function AccessRestrictedPage() { + const theme = useUiStore((state) => state.theme); + const [status, setStatus] = useState(null); + const [loadError, setLoadError] = useState(null); + const [sessionExpired, setSessionExpired] = useState(false); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setLoadError(null); + fetchAccessRestriction(controller.signal) + .then((result) => { + if (!controller.signal.aborted) { + setStatus(result); + } + }) + .catch((error: unknown) => { + if (controller.signal.aborted) { + return; + } + if (error instanceof ApiError && error.status === 401) { + setSessionExpired(true); + return; + } + setLoadError('Your account status could not be loaded. Try again in a moment.'); + }); + return () => controller.abort(); + }, [attempt]); + + const branding = status?.branding; + const appTitle = branding?.app_title || 'SimpleChat'; + useEffect(() => { + document.title = status?.restricted ? `Access restricted - ${appTitle}` : appTitle; + }, [status, appTitle]); + + const banner = branding?.classification_banner; + const themedLogoUrl = theme === 'dark' ? branding?.logo_dark_url : branding?.logo_url; + const logoUrl = branding?.show_logo ? themedLogoUrl : null; + const restriction = status?.restriction ?? null; + const restoreTime = restriction?.kind === 'suspended' ? formatRestoreTime(restriction.until) : null; + + const signOut = ( + + + ); + + return ( +

+ {banner?.enabled && banner.text ? ( +
+ {banner.text} +
+ ) : null} + +
+ + {(logoUrl || (branding && !branding.hide_app_title)) && ( +
+ {logoUrl ? ( + + ) : null} + {branding && !branding.hide_app_title ? ( + {appTitle} + ) : null} +
+ )} + + {sessionExpired ? ( +
+
+ ) : loadError ? ( +
+
+ ) : !status ? ( +
+ + + + Loading your account status +
+ ) : restriction ? ( + <> +
+ + +
+

+ Access restricted +

+

{restriction.title}

+
+
+ + {/* Plain text the administrator wrote. Never HTML. */} +
+ {restriction.message} +
+ +
+
+
+
+ {restriction.kind === 'suspended' && restriction.until ? ( + + ) : ( + 'No automatic restore date. Contact your administrator about this decision.' + )} +
+ {restriction.referenceId ? ( + <> +
Reference
+
+ {restriction.referenceId} +
+ + ) : null} +
+ +
{signOut}
+ + ) : ( + <> +
+ + +
+

Your access is available

+

+ Your account is not restricted, so you can continue to {appTitle}. +

+
+
+
+ {signOut} + + Continue + +
+ + )} +
+
+
+ ); +} diff --git a/application/v2_ui/src/pages/AdminFeedbackReviewPage.tsx b/application/v2_ui/src/pages/AdminFeedbackReviewPage.tsx deleted file mode 100644 index c7e98234f..000000000 --- a/application/v2_ui/src/pages/AdminFeedbackReviewPage.tsx +++ /dev/null @@ -1,603 +0,0 @@ -// AdminFeedbackReviewPage.tsx - -import { useEffect, useMemo, useState } from 'react'; -import { Archive, Check, Download, Eye, Grid2X2, List, RefreshCw, RotateCcw, Trash2 } from 'lucide-react'; -import { api, apiUrl, ApiError } from '../lib/apiClient'; -import { PageHeader } from '../components/layout/PageHeader'; -import { AdminModal } from '../components/admin/AdminModal'; -import { ConfirmDialog } from '../components/ui/ConfirmDialog'; -import { GlassButton, GlassPanel, Skeleton } from '../components/ui/primitives'; - -const PAGE_SIZES = [10, 20, 50, 100]; -const VIEW_STORAGE_KEY = 'simplechat.v2.admin.feedback.viewMode'; - -interface AdminReview { - acknowledged?: boolean; - analysisNotes?: string; - responseToUser?: string; - actionTaken?: string; - reviewTimestamp?: string; -} - -interface FeedbackItem { - id: string; - userId?: string; - prompt?: string; - aiResponse?: string; - feedbackType?: string; - reason?: string; - timestamp?: string; - isArchived?: boolean; - adminReview?: AdminReview; -} - -interface FeedbackPageResponse { - feedback?: FeedbackItem[]; - page?: number; - page_size?: number; - total_count?: number; -} - -interface FeedbackStats { - total_count?: number; - positive_count?: number; - negative_count?: number; - neutral_count?: number; - acknowledged_count?: number; - unacknowledged_count?: number; - recent_30_day_count?: number; - latest_timestamp?: string; -} - -interface Filters { - type: string; - acknowledged: string; - archive: string; -} - -interface ReviewDraft { - acknowledged: boolean; - analysisNotes: string; - responseToUser: string; - actionTaken: string; -} - -function formatDate(value?: string): string { - if (!value) return 'N/A'; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); -} - -function queryFor(filters: Filters, page?: number, pageSize?: number): string { - const params = new URLSearchParams(); - if (page !== undefined) params.set('page', String(page)); - if (pageSize !== undefined) params.set('page_size', String(pageSize)); - if (filters.type) params.set('type', filters.type); - if (filters.acknowledged) params.set('ack', filters.acknowledged); - params.set('archive', filters.archive); - return params.toString(); -} - -function TypeBadge({ value }: { value?: string }) { - const tone = value === 'Positive' - ? 'bg-ok-soft text-ok' - : value === 'Negative' - ? 'bg-danger-soft text-danger' - : 'bg-surface-2 text-text-2'; - return {value || 'Unknown'}; -} - -function AcknowledgedBadge({ value }: { value: boolean }) { - return ( - - {value ? 'Acknowledged' : 'Awaiting review'} - - ); -} - -function Stat({ label, value }: { label: string; value?: number }) { - return ( - -
{value ?? 0}
-
{label}
-
- ); -} - -function ActionButtons({ - item, - onReview, - onRetest, - onArchive, - onDelete, - busy, -}: { - item: FeedbackItem; - onReview: (item: FeedbackItem) => void; - onRetest: (item: FeedbackItem) => void; - onArchive: (item: FeedbackItem) => void; - onDelete: (item: FeedbackItem) => void; - busy: boolean; -}) { - return ( -
- onReview(item)} disabled={busy}> - Review - - onRetest(item)} disabled={busy}> - Retest - - onArchive(item)} disabled={busy}> - {item.isArchived ? : } - {item.isArchived ? 'Restore' : 'Archive'} - - onDelete(item)} disabled={busy}> - Delete - -
- ); -} - -export function AdminFeedbackReviewPage() { - const [filters, setFilters] = useState({ type: '', acknowledged: '', archive: 'active' }); - const [draftFilters, setDraftFilters] = useState(filters); - const [items, setItems] = useState([]); - const [stats, setStats] = useState(null); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - const [totalCount, setTotalCount] = useState(0); - const [statsError, setStatsError] = useState(null); - const [viewMode, setViewMode] = useState<'list' | 'cards'>(() => { - try { - return window.localStorage.getItem(VIEW_STORAGE_KEY) === 'cards' ? 'cards' : 'list'; - } catch { - return 'list'; - } - }); - const [loading, setLoading] = useState(true); - const [busyId, setBusyId] = useState(null); - const [error, setError] = useState(null); - const [notice, setNotice] = useState<{ message: string; warning?: boolean } | null>(null); - const [selected, setSelected] = useState(null); - const [draft, setDraft] = useState(null); - const [detailLoading, setDetailLoading] = useState(false); - const [detailError, setDetailError] = useState(null); - const [saving, setSaving] = useState(false); - const [retestItem, setRetestItem] = useState(null); - const [retestText, setRetestText] = useState(''); - const [retestLoading, setRetestLoading] = useState(false); - const [retestError, setRetestError] = useState(null); - const [pendingDelete, setPendingDelete] = useState(null); - const [deleteError, setDeleteError] = useState(null); - const [refresh, setRefresh] = useState(0); - - const filterQuery = useMemo(() => queryFor(filters), [filters]); - - useEffect(() => { - const controller = new AbortController(); - setLoading(true); - setError(null); - const listQuery = queryFor(filters, page, pageSize); - void Promise.allSettled([ - api.get(`/feedback/review?${listQuery}`, controller.signal), - api.get(`/feedback/review/stats?${filterQuery}`, controller.signal), - ]).then(([listResult, statsResult]) => { - if (controller.signal.aborted) return; - if (listResult.status === 'fulfilled') { - const response = listResult.value; - setItems(response?.feedback ?? []); - setPage(response?.page ?? page); - setPageSize(response?.page_size ?? pageSize); - setTotalCount(response?.total_count ?? 0); - } else { - setError(listResult.reason instanceof Error - ? listResult.reason.message - : 'Feedback could not be loaded.'); - setItems([]); - } - setStats(statsResult.status === 'fulfilled' ? statsResult.value : null); - setStatsError(statsResult.status === 'rejected' - ? statsResult.reason instanceof Error - ? statsResult.reason.message - : 'Feedback statistics could not be loaded.' - : null); - }).finally(() => { - if (!controller.signal.aborted) setLoading(false); - }); - return () => controller.abort(); - }, [filters, filterQuery, page, pageSize, refresh]); - - const setMode = (mode: 'list' | 'cards') => { - setViewMode(mode); - try { - window.localStorage.setItem(VIEW_STORAGE_KEY, mode); - } catch { - // The current session's selection remains usable when storage is unavailable. - } - }; - - const openReview = async (item: FeedbackItem) => { - setSelected(item); - setDraft(null); - setDetailError(null); - setDetailLoading(true); - try { - const detail = await api.get(`/feedback/review/${encodeURIComponent(item.id)}`); - setSelected(detail); - setDraft({ - acknowledged: Boolean(detail.adminReview?.acknowledged), - analysisNotes: detail.adminReview?.analysisNotes ?? '', - responseToUser: detail.adminReview?.responseToUser ?? '', - actionTaken: detail.adminReview?.actionTaken ?? '', - }); - } catch (caught) { - setDetailError(caught instanceof Error ? caught.message : 'Feedback details could not be loaded.'); - } finally { - setDetailLoading(false); - } - }; - - const saveReview = async () => { - if (!selected || !draft) return; - setSaving(true); - setDetailError(null); - try { - await api.patch(`/feedback/review/${encodeURIComponent(selected.id)}`, draft); - setSelected(null); - setDraft(null); - setRefresh((value) => value + 1); - setNotice({ message: 'Feedback review saved.' }); - } catch (caught) { - setDetailError(caught instanceof ApiError ? caught.message : 'Feedback review could not be saved.'); - } finally { - setSaving(false); - } - }; - - const updateArchive = async (item: FeedbackItem) => { - setBusyId(item.id); - setError(null); - try { - const result = await api.patch<{ message?: string; audit_warning?: string }>( - `/feedback/review/${encodeURIComponent(item.id)}/archive`, - { archived: !item.isArchived }, - ); - setNotice({ - message: result.audit_warning || result.message || 'Feedback record updated.', - warning: Boolean(result.audit_warning), - }); - setRefresh((value) => value + 1); - if (selected?.id === item.id) setSelected(null); - } catch (caught) { - setError(caught instanceof Error ? caught.message : 'Feedback record could not be archived.'); - } finally { - setBusyId(null); - } - }; - - const deleteFeedback = async () => { - if (!pendingDelete) return; - const item = pendingDelete; - setBusyId(item.id); - setDeleteError(null); - try { - const result = await api.delete<{ message?: string; audit_warning?: string }>( - `/feedback/review/${encodeURIComponent(item.id)}`, - ); - setPendingDelete(null); - setDeleteError(null); - if (selected?.id === item.id) { - setSelected(null); - setDraft(null); - } - if (items.length === 1 && page > 1) setPage((value) => value - 1); - else setRefresh((value) => value + 1); - setNotice({ - message: result.audit_warning || result.message || 'Feedback record permanently deleted.', - warning: Boolean(result.audit_warning), - }); - } catch (caught) { - setDeleteError(caught instanceof Error ? caught.message : 'Feedback could not be deleted.'); - } finally { - setBusyId(null); - } - }; - - const runRetest = async (item: FeedbackItem) => { - setRetestItem(item); - setRetestText(''); - setRetestError(null); - setRetestLoading(true); - try { - const result = await api.post<{ retestResponse?: string }>( - `/feedback/retest/${encodeURIComponent(item.id)}`, - { prompt: item.prompt ?? '' }, - ); - setRetestText(result.retestResponse || 'No retest response was returned.'); - } catch (caught) { - setRetestError(caught instanceof Error ? caught.message : 'The prompt could not be retested.'); - } finally { - setRetestLoading(false); - } - }; - - const maxPage = Math.max(1, Math.ceil(totalCount / pageSize)); - const isBusy = (id: string) => busyId === id; - - return ( -
- - Export CSV - - } - /> -
- {notice && ( -

- {notice.message} -

- )} - {error &&

{error}

} - {statsError &&

Feedback statistics are unavailable: {statsError}

} - -
- - - - - - -
- - -
- - - -
- { - setPage(1); - setFilters(draftFilters); - }} - > - Apply filters - - { - const cleared = { type: '', acknowledged: '', archive: 'active' }; - setDraftFilters(cleared); - setFilters(cleared); - setPage(1); - }} - > - Clear - -
-
- - -
-
-
- - -
-
- - {loading ? ( -
- ) : items.length === 0 ? ( - - No feedback found for the current filters. - - ) : viewMode === 'list' ? ( - - - - - - - {items.map((item) => ( - - - - - - - - ))} - -
SubmittedPromptRatingReviewActions
{formatDate(item.timestamp)}

{item.prompt || 'No prompt captured.'}

void openReview(value)} onRetest={(value) => void runRetest(value)} onArchive={(value) => void updateArchive(value)} onDelete={(value) => { setDeleteError(null); setPendingDelete(value); }} busy={isBusy(item.id)} />
-
- ) : ( -
- {items.map((item) => ( - -
- {formatDate(item.timestamp)} -
-
-

{item.prompt || 'No prompt captured.'}

- {item.reason ?

{item.reason}

: null} - {item.adminReview?.actionTaken ?

Action: {item.adminReview.actionTaken}

: null} -
void openReview(value)} onRetest={(value) => void runRetest(value)} onArchive={(value) => void updateArchive(value)} onDelete={(value) => { setDeleteError(null); setPendingDelete(value); }} busy={isBusy(item.id)} />
-
- ))} -
- )} - -
- {totalCount ? `Page ${page} of ${maxPage} · ${totalCount} submissions` : 'No submissions'} -
- setPage((value) => Math.max(1, value - 1))}>Previous - = maxPage || loading} onClick={() => setPage((value) => Math.min(maxPage, value + 1))}>Next -
-
-
- - {selected && ( - { - if (!saving) { - setSelected(null); - setDraft(null); - } - }} - footer={ - <> - setSelected(null)} disabled={saving}>Close - {selected.isArchived ? ( - void updateArchive(selected)} disabled={saving || isBusy(selected.id)}> - Restore - - ) : ( - void updateArchive(selected)} disabled={saving || isBusy(selected.id)}> - Archive - - )} - void saveReview()} disabled={!draft || saving || detailLoading}> - {saving ? 'Saving…' : 'Save review'} - - - } - > - {detailLoading ? : detailError ? ( -

{detailError}

- ) : ( -
-
-

Prompt

{selected.prompt || 'No prompt captured.'}

-

Assistant response

{selected.aiResponse || 'No response captured.'}

-

User feedback

{selected.reason || 'No additional reason provided.'}

- {draft && ( - <> - -