From d1b9be4171ac8f4da92fe1e69591f9cd92483acc Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 09:43:25 -0400 Subject: [PATCH 1/3] Add V2 Terms of Use and Approval Requests pages - V2 users are gated to /v2/terms-of-use with new /api/v2/terms-of-use routes - Full-page V2 approvals with category rail, list, and detail pane covering group, M365, content screening, outgoing actions, waiting requests, and admin agent templates - Sidebar, notification bell, and admin links route to the V2 page - Functional and UI tests, docs, version 0.261.277 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/app.py | 36 +- application/single_app/config.py | 2 +- application/single_app/route_backend_users.py | 2 + application/single_app/route_backend_v2.py | 129 ++++ application/v2_ui/src/App.tsx | 24 +- .../admin/AgentTemplateApprovalsLink.tsx | 15 +- .../approvals/AgentTemplatesPanel.tsx | 366 ++++++++++ .../components/approvals/ApprovalParts.tsx | 313 ++++++++ .../approvals/GenericApprovalsPanel.tsx | 448 ++++++++++++ .../approvals/M365ApprovalDetail.tsx | 535 ++++++++++++++ .../approvals/PausedRequestsPanel.tsx | 261 +++++++ .../approvals/PendingActionsPanel.tsx | 562 ++++++++++++++ .../v2_ui/src/components/layout/Sidebar.tsx | 2 + application/v2_ui/src/lib/apiClient.ts | 43 ++ application/v2_ui/src/lib/approvalsApi.ts | 685 ++++++++++++++++++ .../v2_ui/src/lib/notificationLinks.ts | 4 +- application/v2_ui/src/lib/termsOfUse.ts | 45 ++ application/v2_ui/src/lib/userSettings.ts | 5 + application/v2_ui/src/pages/ApprovalsPage.tsx | 250 +++++++ .../v2_ui/src/pages/TermsOfUsePage.tsx | 204 ++++++ .../v2_ui/src/stores/bootstrapStore.ts | 8 +- docs/explanation/features/TERMS_OF_USE.md | 2 + .../features/V2_TERMS_AND_APPROVALS.md | 58 ++ docs/guides/review-approval-requests.md | 16 + .../test_v2_notifications_bell.py | 2 +- .../test_v2_terms_and_approvals.py | 204 ++++++ ui_tests/test_v2_approvals_and_terms_pages.py | 250 +++++++ ui_tests/test_v2_notifications_bell.py | 12 +- 28 files changed, 4458 insertions(+), 25 deletions(-) create mode 100644 application/v2_ui/src/components/approvals/AgentTemplatesPanel.tsx create mode 100644 application/v2_ui/src/components/approvals/ApprovalParts.tsx create mode 100644 application/v2_ui/src/components/approvals/GenericApprovalsPanel.tsx create mode 100644 application/v2_ui/src/components/approvals/M365ApprovalDetail.tsx create mode 100644 application/v2_ui/src/components/approvals/PausedRequestsPanel.tsx create mode 100644 application/v2_ui/src/components/approvals/PendingActionsPanel.tsx create mode 100644 application/v2_ui/src/lib/approvalsApi.ts create mode 100644 application/v2_ui/src/lib/termsOfUse.ts create mode 100644 application/v2_ui/src/pages/ApprovalsPage.tsx create mode 100644 application/v2_ui/src/pages/TermsOfUsePage.tsx create mode 100644 docs/explanation/features/V2_TERMS_AND_APPROVALS.md create mode 100644 functional_tests/test_v2_terms_and_approvals.py create mode 100644 ui_tests/test_v2_approvals_and_terms_pages.py diff --git a/application/single_app/app.py b/application/single_app/app.py index 80dde76cf..b577689b8 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -43,7 +43,7 @@ import time from datetime import datetime from flask import Blueprint, g, make_response -from urllib.parse import urlparse +from urllib.parse import urlencode, urlparse from route_frontend_authentication import * from route_frontend_profile import * @@ -923,6 +923,10 @@ def _is_idle_timeout_exempt(path): '/terms-of-use', '/terms-of-use/accept', '/terms-of-use/decline', + '/v2/terms-of-use', + '/api/v2/terms-of-use', + '/api/v2/terms-of-use/accept', + '/api/v2/terms-of-use/decline', '/robots933456.txt', '/favicon.ico', '/acceptable_use_policy.html', @@ -943,6 +947,31 @@ def _is_terms_of_use_exempt(path): return any(path.startswith(prefix) for prefix in TERMS_OF_USE_EXEMPT_PREFIXES) +V2_TERMS_OF_USE_PATH = '/v2/terms-of-use' + + +def _is_v2_request_path(path): + """True for V2 SPA pages and V2 API calls, which get the V2 Terms of Use page.""" + return path in ('/v2', '/api/v2') or path.startswith('/v2/') or path.startswith('/api/v2/') + + +def build_terms_of_use_url(path, query_string): + """Return the Terms of Use page for a blocked request. + + V2 pages and V2 API calls are sent to the V2 page so a V2 user never lands on the + classic interstitial. An API path is not a page to return to, so a blocked V2 API call + gets the bare V2 page and the SPA supplies its own location as ``next``. + """ + if _is_v2_request_path(path): + if path.startswith('/api/'): + return V2_TERMS_OF_USE_PATH + return f"{V2_TERMS_OF_USE_PATH}?{urlencode({'next': normalize_path_with_query(path, query_string)})}" + return url_for( + 'frontend_terms_of_use.terms_of_use', + next=normalize_path_with_query(path, query_string), + ) + + @app.before_request def enforce_terms_of_use(): """Block authenticated app usage until the current terms of use is accepted.""" @@ -956,10 +985,7 @@ def enforce_terms_of_use(): if has_terms_of_use_acceptance(request_settings, user_id=user_id): return None - terms_url = url_for( - 'frontend_terms_of_use.terms_of_use', - next=normalize_path_with_query(request.path, request.query_string), - ) + terms_url = build_terms_of_use_url(request.path, request.query_string) is_api_request = ( request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html diff --git a/application/single_app/config.py b/application/single_app/config.py index e4efb1724..a21fa6be3 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.276" +VERSION = "0.261.277" 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/route_backend_users.py b/application/single_app/route_backend_users.py index edd089156..fa4eca0c2 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -560,6 +560,8 @@ def user_settings(): # Whether the V2 Admin Settings categories rail is collapsed to icons. # Separate from the shell and workspace rails so each keeps its own state. 'v2AdminRailCollapsed', + # Whether the V2 Approvals categories rail is collapsed to icons. + 'v2ApprovalsRailCollapsed', # V2 document explorer: how the workspace documents list is presented # (view mode, visible columns, page size, details pane) and the saved # filter combinations pinned in its navigation rail. diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py index 306ab2d5c..ac87f99df 100644 --- a/application/single_app/route_backend_v2.py +++ b/application/single_app/route_backend_v2.py @@ -172,6 +172,14 @@ is_source_review_enabled_for_user, is_url_access_enabled_for_user, ) +from functions_terms_of_use import ( + TERMS_OF_USE_RETURN_PATH_SESSION_KEY, + get_terms_of_use_config, + has_terms_of_use_acceptance, + normalize_terms_of_use_return_path, + record_terms_of_use_acceptance, + record_terms_of_use_decline, +) from functions_workspace_sections import build_workspace_section_availability from functions_support_latest_features import build_latest_features_payload from functions_workspace_context import ( @@ -204,6 +212,9 @@ logger = logging.getLogger(__name__) +# Where an accepted V2 Terms of Use returns when no safe local path was recorded. +V2_TERMS_OF_USE_DEFAULT_RETURN_PATH = "/v2" + # Describes each branding asset an administrator can replace: how to convert the # upload, which settings keys hold it, and the static path it is written to. The @@ -683,6 +694,124 @@ def v2_public_workspace_context(workspace_id): payload, status = {"error": "Workspace details are temporarily unavailable. Please retry."}, 503 return jsonify(payload), status, {"Cache-Control": "no-store"} + @bp.route("/api/v2/terms-of-use", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def v2_terms_of_use(): + """Return the Terms of Use the V2 interstitial page shows before the app loads. + + Exempt from the Terms of Use gate in app.py, since this is what the gate sends a V2 + user to. ``next`` is stored server-side, normalized to a local path, so the accept + call can return the user to where they were without trusting a client value then. + """ + user_id = get_current_user_id() + if not user_id: + return jsonify({"error": "User not authenticated."}), 401, {"Cache-Control": "no-store"} + + settings = get_settings() or {} + terms_config = get_terms_of_use_config(settings) + return_path = normalize_terms_of_use_return_path( + request.args.get("next"), + fallback=V2_TERMS_OF_USE_DEFAULT_RETURN_PATH, + ) + session[TERMS_OF_USE_RETURN_PATH_SESSION_KEY] = return_path + session.modified = True + + public_settings = sanitize_settings_for_user(settings) + branding = _build_branding(settings, public_settings) + payload = { + "enabled": terms_config["enabled"], + "required": bool( + terms_config["enabled"] + and not has_terms_of_use_acceptance(settings, user_id=user_id) + ), + "title": terms_config["title"], + "message": terms_config["message"] if terms_config["enabled"] else "", + "accept_button_text": terms_config["accept_button_text"], + "decline_button_text": terms_config["decline_button_text"], + "return_path": return_path, + "branding": { + "app_title": branding["app_title"], + "hide_app_title": branding["hide_app_title"], + "show_logo": branding["show_logo"], + "logo_url": branding["logo_url"], + "logo_dark_url": branding["logo_dark_url"], + "classification_banner": branding["classification_banner"], + }, + } + return jsonify(payload), 200, {"Cache-Control": "no-store"} + + @bp.route("/api/v2/terms-of-use/accept", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def v2_accept_terms_of_use(): + """Record acceptance and return the local path the V2 page should reopen.""" + user_id = get_current_user_id() + if not user_id: + return jsonify({"error": "User not authenticated."}), 401, {"Cache-Control": "no-store"} + + settings = get_settings() or {} + terms_config = get_terms_of_use_config(settings) + return_path = normalize_terms_of_use_return_path( + session.pop(TERMS_OF_USE_RETURN_PATH_SESSION_KEY, None), + fallback=V2_TERMS_OF_USE_DEFAULT_RETURN_PATH, + ) + session.modified = True + + if terms_config["enabled"]: + try: + record_terms_of_use_acceptance( + user_id=user_id, + settings=settings, + source="post_auth_v2", + ) + except Exception as exc: + log_event( + "[TERMS_OF_USE] V2 acceptance could not be recorded.", + extra={"user_id": user_id, "error_type": type(exc).__name__}, + level=logging.ERROR, + ) + return ( + jsonify({"error": "Your acceptance could not be saved. Please try again."}), + 500, + {"Cache-Control": "no-store"}, + ) + + return jsonify({"success": True, "redirect_url": return_path}), 200, {"Cache-Control": "no-store"} + + @bp.route("/api/v2/terms-of-use/decline", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def v2_decline_terms_of_use(): + """Record a decline, end the session and return where the browser should go.""" + user_id = get_current_user_id() + settings = get_settings() or {} + terms_config = get_terms_of_use_config(settings) + + if user_id and terms_config["enabled"]: + try: + record_terms_of_use_decline( + user_id=user_id, + settings=settings, + source="post_auth_v2", + ) + except Exception as exc: + log_event( + "[TERMS_OF_USE] V2 decline audit logging failed.", + extra={"user_id": user_id, "error_type": type(exc).__name__}, + level=logging.ERROR, + ) + + session.clear() + return ( + jsonify({"success": True, "redirect_url": terms_config["decline_redirect_url"]}), + 200, + {"Cache-Control": "no-store"}, + ) + @bp.route("/api/v2/bootstrap", methods=["GET"]) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/v2_ui/src/App.tsx b/application/v2_ui/src/App.tsx index af8e156e4..f285ebeb1 100644 --- a/application/v2_ui/src/App.tsx +++ b/application/v2_ui/src/App.tsx @@ -29,6 +29,8 @@ import { PublicWorkspacePage } from './pages/PublicWorkspacePage'; import { PublicDirectoryPage } from './pages/PublicDirectoryPage'; import { clearWorkspaceEditorDrafts } from './lib/workspaceEditorDrafts'; import { ContentReviewPage } from './pages/ContentReviewPage'; +import { TermsOfUsePage } from './pages/TermsOfUsePage'; +import { ApprovalsPage } from './pages/ApprovalsPage'; // Dev only: the workflow alert lab. Its one dynamic import is created only when // import.meta.env.DEV, which a production build replaces with false, so production never @@ -91,6 +93,9 @@ export function App() { (state) => (state.settings.fontSizePreference as string) || 'm', ); const location = useLocation(); + // 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'; useEffect(() => { clearWorkspaceEditorDrafts(); @@ -98,6 +103,9 @@ export function App() { useEffect(() => { initializeTheme(); + if (onTermsPage) { + return; + } void load(); // Loaded at startup rather than when the settings page opens, because preferences // shape the chat interface itself — the conversation list reads one of them. @@ -114,7 +122,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]); + }, [load, loadUserSettings, onTermsPage]); /** * Re-read the payload when the tab comes back to the front. @@ -132,6 +140,9 @@ export function App() { * spurious wake-up is one wasted request. */ useEffect(() => { + if (onTermsPage) { + return undefined; + } const onVisible = () => { if (document.visibilityState === 'visible') { void refreshBootstrap(); @@ -146,7 +157,7 @@ export function App() { document.removeEventListener('visibilitychange', onVisible); window.removeEventListener('focus', onVisible); }; - }, [refreshBootstrap]); + }, [refreshBootstrap, onTermsPage]); useEffect(() => { const title = data?.branding?.app_title; @@ -189,6 +200,10 @@ export function App() { // list's running tag and the results each run posts back to its chat. useWorkflowRunTracker(Boolean(data) && !error && workflowRunTrackerShouldRun(data?.features)); + if (onTermsPage) { + return ; + } + if (loading) { return ; } @@ -218,6 +233,11 @@ export function App() { } /> } /> } /> + {/* Categories and the selected request are real paths, so a notification + or a shared link opens the exact request. */} + } /> + } /> + } /> {help ?

{help}

: null} - (opens in a new tab) + ); } diff --git a/application/v2_ui/src/components/approvals/AgentTemplatesPanel.tsx b/application/v2_ui/src/components/approvals/AgentTemplatesPanel.tsx new file mode 100644 index 000000000..cdd348989 --- /dev/null +++ b/application/v2_ui/src/components/approvals/AgentTemplatesPanel.tsx @@ -0,0 +1,366 @@ +// AgentTemplatesPanel.tsx +// Admin review of agent templates submitted for the shared template gallery. +// +// Approving publishes the template to every user; rejecting returns it to the submitter +// with the reason, which is why the reason is required. Deleting removes the template +// outright, so it sits behind a confirmation. + +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Check, Loader2, Trash2, X } from 'lucide-react'; +import { + approveAgentTemplate, + deleteAgentTemplate, + errorMessage, + fetchAgentTemplate, + fetchAgentTemplates, + formatDateTime, + rejectAgentTemplate, + templateTitle, + textList, + type AgentTemplate, + type TemplateStatusFilter, +} from '../../lib/approvalsApi'; +import { toast } from '../../stores/toastStore'; +import { ConfirmDialog } from '../ui/ConfirmDialog'; +import { GlassButton, Skeleton } from '../ui/primitives'; +import { + ApprovalSplit, + DetailEmpty, + DetailShell, + Fact, + Facts, + ListMessage, + ListPager, + ListRow, + ListSelect, + ListToolbar, + Notice, + StatusBadge, + fieldClass, +} from './ApprovalParts'; + +const PAGE_SIZE = 20; + +const STATUS_OPTIONS: Array<[string, string]> = [ + ['pending', 'Pending'], + ['approved', 'Approved'], + ['rejected', 'Rejected'], + ['all', 'All'], +]; + +function submitter(template: AgentTemplate): string { + return template.created_by_name || template.created_by_email || 'Unknown'; +} + +export function AgentTemplatesPanel({ + selectedId, + reloadKey, + onCountChange, +}: { + selectedId?: string; + reloadKey: number; + onCountChange?: (count: number) => void; +}) { + const navigate = useNavigate(); + const [status, setStatus] = useState('pending'); + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [localReload, setLocalReload] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setLoadError(''); + fetchAgentTemplates(status, controller.signal) + .then((items) => setTemplates(items)) + .catch((error) => { + if (controller.signal.aborted) return; + setTemplates([]); + setLoadError(errorMessage(error, 'Agent templates could not be loaded.')); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [status, reloadKey, localReload]); + + useEffect(() => { + if (status === 'pending') onCountChange?.(templates.length); + }, [templates, status, onCountChange]); + + useEffect(() => setPage(1), [status, search]); + + const filtered = useMemo(() => { + const query = search.trim().toLowerCase(); + if (!query) return templates; + return templates.filter((template) => + [templateTitle(template), template.helper_text, template.description, submitter(template), textList(template.tags)] + .join(' ') + .toLowerCase() + .includes(query), + ); + }, [templates, search]); + + const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const visible = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + const handleChanged = (removed: boolean) => { + setLocalReload((value) => value + 1); + if (removed) navigate('/approvals/agent-templates'); + }; + + const list = ( + <> + + setStatus(value as TemplateStatusFilter)} + options={STATUS_OPTIONS} + testId="v2-agent-templates-status-filter" + /> + + {loading ? ( +
+ + +
+ ) : loadError ? ( +
+ {loadError} +
+ ) : visible.length ? ( + <> +
    + {visible.map((template) => ( + navigate(`/approvals/agent-templates/${encodeURIComponent(template.id)}`)} + title={templateTitle(template)} + testId={`v2-agent-template-row-${template.id}`} + meta={`${submitter(template)} · ${formatDateTime(template.created_at)}`} + badge={} + /> + ))} +
+ + + ) : ( + {search ? 'No templates match this search.' : 'No templates in this view.'} + )} + + ); + + const detail = selectedId ? ( + + ) : ( + + ); + + return ( + navigate('/approvals/agent-templates')} + list={list} + detail={detail} + /> + ); +} + +function AgentTemplateDetail({ templateId, onChanged }: { templateId: string; onChanged: (removed: boolean) => void }) { + const [template, setTemplate] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + const [notes, setNotes] = useState(''); + const [reason, setReason] = useState(''); + const [actionError, setActionError] = useState(''); + const [busy, setBusy] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [reloadKey, setReloadKey] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setLoadError(''); + fetchAgentTemplate(templateId, controller.signal) + .then((item) => setTemplate(item)) + .catch((error) => { + if (controller.signal.aborted) return; + setTemplate(null); + setLoadError(errorMessage(error, 'Unable to load template.')); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [templateId, reloadKey]); + + const decide = async (kind: 'approve' | 'reject') => { + if (kind === 'reject' && !reason.trim()) { + setActionError('A rejection reason is required.'); + return; + } + setBusy(true); + setActionError(''); + try { + if (kind === 'approve') { + await approveAgentTemplate(templateId, notes); + toast.success('Template approved!'); + } else { + await rejectAgentTemplate(templateId, reason, notes); + toast.success('Template rejected.'); + } + setNotes(''); + setReason(''); + setReloadKey((value) => value + 1); + onChanged(false); + } catch (error) { + setActionError(errorMessage(error, 'Failed to update template.')); + } finally { + setBusy(false); + } + }; + + const remove = async () => { + setBusy(true); + try { + await deleteAgentTemplate(templateId); + toast.success('Template deleted.'); + setConfirmDelete(false); + onChanged(true); + } catch (error) { + setActionError(errorMessage(error, 'Failed to delete template.')); + setConfirmDelete(false); + } finally { + setBusy(false); + } + }; + + if (loading) { + return ( +
+ + +
+ ); + } + if (loadError || !template) { + return ( +
+ {loadError || 'Unable to load template.'} +
+ ); + } + + const status = template.status || 'pending'; + const settings = + typeof template.additional_settings === 'string' + ? template.additional_settings + : template.additional_settings + ? JSON.stringify(template.additional_settings, null, 2) + : ''; + + return ( + } + > + + {template.helper_text || template.description} + {formatDateTime(template.updated_at)} + {textList(template.tags)} + {textList(template.actions_to_load)} + {template.review_notes} + {template.rejection_reason} + + {template.description ? ( +
+

Description

+

{template.description}

+
+ ) : null} +
+

Instructions

+
+                    {template.instructions || ''}
+                
+
+ {settings ? ( +
+

Additional settings

+
+                        {settings}
+                    
+
+ ) : null} + {actionError ? {actionError} : null} +
+ {status === 'pending' ? ( + <> +