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..cb343976b 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.297" 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_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_safety_remediation.py b/application/single_app/functions_safety_remediation.py index 806fea96b..2e3e8333f 100644 --- a/application/single_app/functions_safety_remediation.py +++ b/application/single_app/functions_safety_remediation.py @@ -3,13 +3,24 @@ """Helpers for safety violation remediation actions and notifications.""" import logging -from datetime import datetime -from typing import Any, Dict, Optional +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +from azure.core import MatchConditions +from azure.cosmos import exceptions as cosmos_exceptions from config import 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,6 +28,39 @@ 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.' +) + def get_safety_log_item(log_id: str) -> Dict[str, Any]: """Return a safety log item by its document id.""" @@ -132,6 +176,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 +200,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 +264,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..5073c3ca9 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -75,6 +75,7 @@ select_workspace_ids, workspace_members, workspace_row, ) from functions_safety_remediation import ( + build_safety_action_execution_updates, execute_safety_violation_action, get_safety_log_item, update_safety_log_action_state, @@ -8762,15 +8763,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_safety.py b/application/single_app/route_backend_safety.py index 2b722460c..7b6725974 100644 --- a/application/single_app/route_backend_safety.py +++ b/application/single_app/route_backend_safety.py @@ -39,21 +39,66 @@ SAFETY_REMEDIATION_BLOCK, SAFETY_REMEDIATION_SUSPEND, SAFETY_REMEDIATION_WARNING, + SAFETY_WARNING_REPLACED_CODE, + SAFETY_WARNING_REPLACED_MESSAGE, + 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, resolve_safety_target_user, + safety_warning_send_in_progress, + serialize_safety_warning_state, ) +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.' +# The signed-in user's own pending warnings are never cached, whatever the answer. +SAFETY_WARNINGS_NO_STORE_HEADERS = {'Cache-Control': 'no-store'} +# 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, @@ -194,6 +239,8 @@ def _query_safety_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 @@ -335,6 +382,145 @@ def _log_safety_audit_failure(log_id, lifecycle_action): level=logging.ERROR, ) + +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)): @@ -491,6 +677,12 @@ 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. """ data = request.get_json() or {} status = data.get("status") @@ -508,6 +700,11 @@ def update_safety_log(log_id): return jsonify({'error': 'Invalid safety action'}), 400 item = cosmos_safety_container.read_item(item=log_id, partition_key=log_id) + 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 @@ -515,12 +712,21 @@ def update_safety_log(log_id): if not item.get("created_at"): item["created_at"] = datetime.utcnow().isoformat() - 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.' }), 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 + if is_interrupted_safety_warning_send(item): + mark_interrupted_safety_warning_send(item) + existing_request_status = 'failed' + normalized_datetime_to_allow = None if action in SAFETY_REMEDIATION_ACTIONS: normalized_datetime_to_allow = _validate_safety_remediation_request(action, datetime_to_allow) @@ -537,7 +743,16 @@ def update_safety_log(log_id): 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 action in SAFETY_APPROVAL_REQUIRED_ACTIONS: 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}." @@ -607,7 +822,7 @@ def update_safety_log(log_id): cosmos_safety_container.upsert_item(item) - if action in SAFETY_REMEDIATION_ACTIONS: + if action in SAFETY_APPROVAL_REQUIRED_ACTIONS: if item.get('action_request_status') == 'pending': return jsonify({ 'message': 'Safety log updated and remediation approval request created.', @@ -621,17 +836,30 @@ 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 + return jsonify({"message": "Safety log updated successfully."}), 200 + except exceptions.CosmosResourceNotFoundError: + return jsonify({'error': 'Safety violation not found'}), 404 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']) @swagger_route(security=get_auth_security()) @@ -703,6 +931,14 @@ def delete_safety_log(log_id): 'approval request is pending.' ) }), 409 + if safety_warning_send_in_progress(item): + return jsonify({ + '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 cosmos_safety_container.delete_item(item=log_id, partition_key=log_id) except exceptions.CosmosResourceNotFoundError: return jsonify({'error': 'Safety violation not found'}), 404 @@ -854,3 +1090,76 @@ def update_my_safety_log(log_id): 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, SAFETY_WARNINGS_NO_STORE_HEADERS + + 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, SAFETY_WARNINGS_NO_STORE_HEADERS + + return jsonify({"warnings": warnings, "count": len(warnings)}), 200, SAFETY_WARNINGS_NO_STORE_HEADERS + + @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_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..8bf519c92 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,14 @@ 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'; const ACTION_LABELS = { None: 'None', WarnUser: 'Warn user', SuspendUser: 'Suspend user', - Escalate: 'Escalate', + Escalate: 'Escalated (legacy)', BlockUser: 'Block user', }; @@ -415,6 +418,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 +475,55 @@ return messageLines.join('\n'); } + function isExecutedWarning(logItem) { + return logItem.action === 'WarnUser' + && String(logItem.action_request_status || '').toLowerCase() === 'executed'; + } + + 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 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'); @@ -494,12 +544,18 @@ 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); + 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] || ''; + remediationHelp.textContent = warningAlreadySent + ? 'This warning was already sent. Saving updates the review without sending the warning again.' + : (helpTextMap[action] || ''); const generatedMessage = buildDefaultNotificationMessage(logItem, action); const savedMessage = logItem.action === action ? logItem.action_notification_message : ''; @@ -594,7 +650,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 +659,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 +813,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 +821,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'; @@ -986,12 +1049,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'); }); }); } 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..72dab218f 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,16 @@ - +
-
+
-
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..920d8b912 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'; @@ -33,6 +34,7 @@ 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 { SupportLatestFeaturesPage } from './pages/SupportLatestFeaturesPage'; @@ -102,6 +104,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 +115,7 @@ export function App() { useEffect(() => { initializeTheme(); - if (onTermsPage) { + if (standalonePage) { return; } void load(); @@ -128,7 +134,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 +152,7 @@ export function App() { * spurious wake-up is one wasted request. */ useEffect(() => { - if (onTermsPage) { + if (standalonePage) { return undefined; } const onVisible = () => { @@ -163,7 +169,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 +211,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 +225,10 @@ export function App() { return ; } + if (onAccessRestrictedPage) { + return ; + } + if (loading) { 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/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/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/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/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/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/AdminSafetyViolationsPage.tsx b/application/v2_ui/src/pages/AdminSafetyViolationsPage.tsx index 1c79d8077..c5940726a 100644 --- a/application/v2_ui/src/pages/AdminSafetyViolationsPage.tsx +++ b/application/v2_ui/src/pages/AdminSafetyViolationsPage.tsx @@ -11,9 +11,22 @@ import { GlassButton, GlassPanel, Skeleton } from '../components/ui/primitives'; const PAGE_SIZES = [10, 20, 50, 100]; const VIEW_STORAGE_KEY = 'simplechat.v2.admin.safety.viewMode'; const STATUSES = ['New', 'In-Review', 'Resolved', 'Dismissed']; -const ACTIONS = ['None', 'WarnUser', 'SuspendUser', 'Escalate', 'BlockUser']; +const ACTIONS = ['None', '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 review offers it again. +const LEGACY_ESCALATE_ACTION = 'Escalate'; const REMEDIATION_ACTIONS = new Set(['WarnUser', 'SuspendUser', 'BlockUser']); +function actionLabel(value: string): string { + if (value === LEGACY_ESCALATE_ACTION) return 'Escalated (legacy)'; + return value === 'None' ? 'None' : value.replace(/([a-z])([A-Z])/g, '$1 $2'); +} + +/** A warning that was sent. Saving the record again does not resend it. */ +function isExecutedWarning(log: SafetyLog): boolean { + return log.action === 'WarnUser' && (log.action_request_status || '').toLowerCase() === 'executed'; +} + interface TriggeredCategory { category?: string; severity?: number; @@ -34,6 +47,10 @@ interface SafetyLog { action_request_status?: string; action_notification_message?: string; action_datetime_to_allow?: string; + /** 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; } @@ -161,12 +178,28 @@ function StatusBadge({ value }: { value?: string }) { } function ActionBadge({ log }: { log: SafetyLog }) { - const label = log.action || 'None'; + const label = actionLabel(log.action || 'None'); const requestStatus = (log.action_request_status || '').toLowerCase(); - const suffix = requestStatus === 'pending' ? ' · Pending approval' : requestStatus === 'failed' ? ' · Failed' : ''; + let suffix = requestStatus === 'pending' ? ' · Pending approval' : requestStatus === 'sending' ? ' · Sending' : requestStatus === 'failed' ? ' · Failed' : ''; + if (isExecutedWarning(log)) { + if (log.warning_acknowledgment_status === 'acknowledged') suffix = ' · Acknowledged'; + else if (log.warning_acknowledgment_status === 'pending') suffix = ' · Not yet acknowledged'; + } return {label}{suffix}; } +/** Whether the user has acknowledged a warning that was sent, for the review dialog. */ +function warningAcknowledgmentText(log: SafetyLog): string | null { + if (!isExecutedWarning(log)) return null; + if (log.warning_acknowledgment_status === 'acknowledged') { + return `Warning acknowledged ${formatDate(log.warning_acknowledged_at ?? undefined)}`; + } + if (log.warning_acknowledgment_status === 'pending') { + return 'Warning sent. Not yet acknowledged by the user.'; + } + return 'Warning sent before acknowledgment was tracked.'; +} + function Stat({ label, value }: { label: string; value?: number }) { return ( @@ -336,7 +369,7 @@ export function AdminSafetyViolationsPage() { action, notes: draft.notes, }; - if (REMEDIATION_ACTIONS.has(action)) { + if (REMEDIATION_ACTIONS.has(action) && !(action === 'WarnUser' && isExecutedWarning(selected))) { payload.notification_message = draft.notificationMessage; if (action === 'SuspendUser') { const restoreDate = toIsoDateTime(draft.datetimeToAllow); @@ -350,15 +383,15 @@ export function AdminSafetyViolationsPage() { setSaving(true); setDetailError(null); try { - const result = await api.patch<{ message?: string; approval_required?: boolean }>( + const result = await api.patch<{ message?: string; approval_required?: boolean; audit_warning?: string }>( `/api/safety/logs/${encodeURIComponent(selected.id)}`, payload, ); setSelected(null); setDraft(null); setNotice({ - message: result.message || 'Safety review saved.', - warning: Boolean(result.approval_required), + message: result.audit_warning || result.message || 'Safety review saved.', + warning: Boolean(result.approval_required || result.audit_warning), }); setRefresh((value) => value + 1); } catch (caught) { @@ -596,7 +629,10 @@ export function AdminSafetyViolationsPage() { - + 0 ? `Blocked · ${stats?.escalate_count} escalated (legacy)` : 'Blocked'} + value={stats?.block_user_count} + />
@@ -610,7 +646,8 @@ export function AdminSafetyViolationsPage() {

Action distribution

None {stats?.none_action_count ?? 0}Warn {stats?.warn_user_count ?? 0} - Suspend {stats?.suspend_user_count ?? 0}Escalate {stats?.escalate_count ?? 0}Block {stats?.block_user_count ?? 0} + Suspend {stats?.suspend_user_count ?? 0}Block {stats?.block_user_count ?? 0} + {(stats?.escalate_count ?? 0) > 0 && Escalated (legacy) {stats?.escalate_count}}
@@ -628,7 +665,7 @@ export function AdminSafetyViolationsPage() { Action