From ab55a8f8728700844d16d1745932e19b955f386d Mon Sep 17 00:00:00 2001
From: Paul Lizer
Date: Wed, 7 Oct 2026 22:46:53 -0400
Subject: [PATCH 1/6] Send safety warnings immediately and show restricted
users an Access restricted screen
Layer 1 of 3 of the Admin Review Center stack.
- Warn user executes when the safety reviewer saves the review, without an
approval request, and is audited in the activity log. Saving again does not
resend it. Suspend and Block still need a second eligible reviewer.
- Executed warnings must be acknowledged: new user-scoped routes
GET /api/safety/warnings/pending and
POST /api/safety/warnings//acknowledge (owner-only, idempotent,
ETag-conditional), a V2 dialog fed by a bootstrap pending count, and the
acknowledgment state in the admin review and the user's Violations tab.
- Escalate can no longer be chosen; legacy records keep it, labelled
"Escalated (legacy)". escalate_count stays in the stats JSON.
- Approved suspensions and blocks store the notice the user was sent in
settings.access.notice.
- user_required answers restricted users with a structured 403
(error: access_restricted) for API calls and redirects pages to
/v2/access-restricted or /access-restricted. New login-only routes serve
those pages and GET /api/v2/access-restriction (caller's own data only),
exempt from the Terms of Use gate.
- Version 0.261.296.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
application/single_app/app.py | 11 +
application/single_app/config.py | 2 +-
.../functions_access_restriction.py | 213 +++++++++++
.../single_app/functions_authentication.py | 135 +++++--
.../functions_safety_remediation.py | 271 +++++++++++++-
.../single_app/route_access_restriction.py | 127 +++++++
.../route_backend_control_center.py | 10 +-
.../single_app/route_backend_safety.py | 210 ++++++++++-
application/single_app/route_backend_v2.py | 16 +
.../single_app/static/js/access-restricted.js | 32 ++
.../js/admin/admin-safety-violations.js | 75 +++-
.../static/js/profile/profile-tabs.js | 12 +-
.../templates/access_restricted.html | 191 ++++++++++
.../templates/admin_safety_violations.html | 19 +-
.../templates/my_safety_violations.html | 13 +-
application/single_app/templates/profile.html | 1 -
application/v2_ui/src/App.tsx | 24 +-
.../v2_ui/src/components/layout/AppShell.tsx | 2 +
.../notifications/SafetyWarningDialog.tsx | 112 ++++++
.../src/components/settings/ViolationsTab.tsx | 37 +-
.../v2_ui/src/lib/accessRestriction.ts | 95 +++++
application/v2_ui/src/lib/apiClient.ts | 51 ++-
application/v2_ui/src/lib/safetyWarnings.ts | 107 ++++++
application/v2_ui/src/lib/types.ts | 7 +
.../v2_ui/src/lib/useSafetyWarningRuntime.ts | 41 ++
.../v2_ui/src/pages/AccessRestrictedPage.tsx | 214 +++++++++++
.../src/pages/AdminSafetyViolationsPage.tsx | 76 +++-
.../v2_ui/src/stores/bootstrapStore.ts | 14 +-
.../v2_ui/src/stores/safetyWarningStore.ts | 71 ++++
.../test_route_blueprint_policy_inventory.py | 44 +++
...t_route_unauthenticated_policy_contract.py | 34 ++
.../test_access_restricted_gate.py | 351 ++++++++++++++++++
.../test_safety_escalate_removal.py | 146 ++++++++
..._safety_violation_remediation_approvals.py | 284 ++++++++------
.../test_safety_warning_acknowledgment.py | 306 +++++++++++++++
.../test_support/safety_review_harness.py | 275 ++++++++++++++
...s_restriction_and_safety_warning_logic.mjs | 199 ++++++++++
...v2_access_restricted_and_safety_warning.py | 284 ++++++++++++++
38 files changed, 3881 insertions(+), 231 deletions(-)
create mode 100644 application/single_app/functions_access_restriction.py
create mode 100644 application/single_app/route_access_restriction.py
create mode 100644 application/single_app/static/js/access-restricted.js
create mode 100644 application/single_app/templates/access_restricted.html
create mode 100644 application/v2_ui/src/components/notifications/SafetyWarningDialog.tsx
create mode 100644 application/v2_ui/src/lib/accessRestriction.ts
create mode 100644 application/v2_ui/src/lib/safetyWarnings.ts
create mode 100644 application/v2_ui/src/lib/useSafetyWarningRuntime.ts
create mode 100644 application/v2_ui/src/pages/AccessRestrictedPage.tsx
create mode 100644 application/v2_ui/src/stores/safetyWarningStore.ts
create mode 100644 functional_tests/test_access_restricted_gate.py
create mode 100644 functional_tests/test_safety_escalate_removal.py
create mode 100644 functional_tests/test_safety_warning_acknowledgment.py
create mode 100644 functional_tests/test_support/safety_review_harness.py
create mode 100644 functional_tests/test_v2_access_restriction_and_safety_warning_logic.mjs
create mode 100644 ui_tests/test_v2_access_restricted_and_safety_warning.py
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 24a90b32c..031f58018 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.295"
+VERSION = "0.261.296"
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..f33837d4c 100644
--- a/application/single_app/functions_safety_remediation.py
+++ b/application/single_app/functions_safety_remediation.py
@@ -3,13 +3,22 @@
"""Helpers for safety violation remediation actions and notifications."""
import logging
-from datetime import datetime
-from typing import Any, Dict, Optional
+from datetime import datetime, 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,
+)
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 +26,18 @@
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'
+
def get_safety_log_item(log_id: str) -> Dict[str, Any]:
"""Return a safety log item by its document id."""
@@ -132,6 +153,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 +177,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 +241,233 @@ 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 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 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': log_item.get('warning_issued_at') or log_item.get('action_executed_at'),
+ '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) -> Tuple[str, Optional[Dict[str, Any]]]:
+ """Record that the signed-in user acknowledged their own warning.
+
+ Returns ``(status, warning)``. ``status`` is ``acknowledged``, ``already_acknowledged``
+ 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. 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
+
+ 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 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 c0bc7194c..30f84e700 100644
--- a/application/single_app/route_backend_control_center.py
+++ b/application/single_app/route_backend_control_center.py
@@ -61,6 +61,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,
@@ -8636,15 +8637,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..d00a78749 100644
--- a/application/single_app/route_backend_safety.py
+++ b/application/single_app/route_backend_safety.py
@@ -39,21 +39,40 @@
SAFETY_REMEDIATION_BLOCK,
SAFETY_REMEDIATION_SUSPEND,
SAFETY_REMEDIATION_WARNING,
+ acknowledge_safety_warning,
+ build_safety_action_execution_updates,
execute_safety_violation_action,
+ list_pending_safety_warnings,
resolve_safety_target_user,
+ 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.'
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 +213,7 @@ def _query_safety_logs(
))
for log_item in logs:
log_item.update(serialize_archive_metadata(log_item))
+ log_item.update(serialize_safety_warning_state(log_item))
return strip_private_chat_checks(logs) if user_id else logs
@@ -335,6 +355,91 @@ 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. 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
+
+ try:
+ execution_result = execute_safety_violation_action(
+ action=SAFETY_REMEDIATION_WARNING,
+ safety_log=item,
+ 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,
+ )
+ item['action_request_status'] = 'failed'
+ item['action_executed_at'] = None
+ item['action_execution_error'] = SAFETY_WARNING_SEND_FAILED_MESSAGE
+ item['last_updated'] = datetime.utcnow().isoformat()
+ cosmos_safety_container.upsert_item(item)
+ return jsonify({
+ 'error': 'The warning could not be sent. The rest of the review was saved; save it again to retry.',
+ }), 500
+
+ item.update(build_safety_action_execution_updates(SAFETY_REMEDIATION_WARNING, execution_result))
+ item['last_updated'] = datetime.utcnow().isoformat()
+ cosmos_safety_container.upsert_item(item)
+
+ notification_id = execution_result.get('notification_id')
+ 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': _log_safety_warning_sent(item, actor, notification_id),
+ }
+ 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 +596,11 @@ 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. 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 +618,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,7 +630,6 @@ 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.'
@@ -537,7 +651,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 +730,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 +744,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())
@@ -854,3 +990,63 @@ 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
+
+ try:
+ warnings = list_pending_safety_warnings(user_id)
+ except Exception as e:
+ log_event(
+ "[SAFETY_WARNINGS] Pending safety warnings could not be read.",
+ extra={"user_id": user_id, "error_type": type(e).__name__},
+ level=logging.WARNING,
+ )
+ return jsonify({"error": "Your warnings could not be loaded."}), 500, {"Cache-Control": "no-store"}
+
+ return jsonify({"warnings": warnings, "count": len(warnings)}), 200, {"Cache-Control": "no-store"}
+
+ @bp.route('/api/safety/warnings//acknowledge', methods=['POST'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @user_required
+ def acknowledge_pending_safety_warning(log_id):
+ """Record that the signed-in user acknowledged one of their own safety warnings.
+
+ Repeating it changes nothing. Any record that isn't the caller's own warning is
+ answered 404, so the response never confirms that another user's record exists.
+ """
+ user_id = _get_safety_session_user_id()
+ if not user_id:
+ return jsonify({"error": "No user ID found in session"}), 403
+
+ try:
+ status, warning = acknowledge_safety_warning(log_id, user_id)
+ 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
+ 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 d0c6e44aa..734140aff 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,
@@ -1026,6 +1027,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": {
@@ -1062,6 +1077,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..d679ae3ec 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',
};
@@ -470,10 +473,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 +542,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 +648,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 +657,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 +811,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 +819,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';
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 b214b7673..5296ea07c 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';
@@ -100,6 +102,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();
@@ -107,7 +113,7 @@ export function App() {
useEffect(() => {
initializeTheme();
- if (onTermsPage) {
+ if (standalonePage) {
return;
}
void load();
@@ -126,7 +132,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.
@@ -144,7 +150,7 @@ export function App() {
* spurious wake-up is one wasted request.
*/
useEffect(() => {
- if (onTermsPage) {
+ if (standalonePage) {
return undefined;
}
const onVisible = () => {
@@ -161,7 +167,7 @@ export function App() {
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', onVisible);
};
- }, [refreshBootstrap, onTermsPage]);
+ }, [refreshBootstrap, standalonePage]);
useEffect(() => {
const title = data?.branding?.app_title;
@@ -203,6 +209,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));
@@ -211,6 +223,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..ba3034453
--- /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 } 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 acknowledgingId = useSafetyWarningStore((state) => state.acknowledgingId);
+ const error = useSafetyWarningStore((state) => state.error);
+ const acknowledge = useSafetyWarningStore((state) => state.acknowledge);
+
+ if (!warning) {
+ return null;
+ }
+
+ const busy = acknowledgingId === warning.id;
+ const issued = formatSafetyWarningDate(warning.issuedAt);
+ const categories = describeSafetyWarningCategories(warning.categories);
+
+ const onAcknowledge = async () => {
+ if (await acknowledge(warning.id)) {
+ // 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..838144d1c
--- /dev/null
+++ b/application/v2_ui/src/lib/safetyWarnings.ts
@@ -0,0 +1,107 @@
+// 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. */
+ 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[];
+}
+
+const FALLBACK_TITLE = 'Safety Violation Warning';
+
+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));
+}
+
+export async function acknowledgeSafetyWarning(id: string): Promise {
+ await api.post(`/api/safety/warnings/${encodeURIComponent(id)}/acknowledge`, {});
+}
+
+/** 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 fb07d3132..e0bac7676 100644
--- a/application/v2_ui/src/lib/types.ts
+++ b/application/v2_ui/src/lib/types.ts
@@ -1181,6 +1181,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 (
+