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 %}
+
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 @@
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.
+
{(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 = (
+
+
+ Sign out
+
+ );
+
+ return (
+