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 5cc11cf7e..04ab51580 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.286"
+VERSION = "0.261.287"
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 327106c59..f49953c52 100644
--- a/application/single_app/route_backend_users.py
+++ b/application/single_app/route_backend_users.py
@@ -563,6 +563,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',
# Whether the V2 User Settings sections rail is collapsed to icons.
'v2UserSettingsRailCollapsed',
# Whether the V2 Control Center section rail is collapsed to icons.
diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py
index 0963e1418..e6fe69b35 100644
--- a/application/single_app/route_backend_v2.py
+++ b/application/single_app/route_backend_v2.py
@@ -173,6 +173,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 (
@@ -206,6 +214,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
@@ -720,6 +731,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 8f2f33d6e..b214b7673 100644
--- a/application/v2_ui/src/App.tsx
+++ b/application/v2_ui/src/App.tsx
@@ -32,6 +32,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';
import { ControlCenterPage } from './pages/ControlCenterPage';
// Dev only: the workflow alert lab. Its one dynamic import is created only when
@@ -95,6 +97,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();
@@ -102,6 +107,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.
@@ -118,7 +126,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.
@@ -136,6 +144,9 @@ export function App() {
* spurious wake-up is one wasted request.
*/
useEffect(() => {
+ if (onTermsPage) {
+ return undefined;
+ }
const onVisible = () => {
if (document.visibilityState === 'visible') {
void refreshBootstrap();
@@ -150,7 +161,7 @@ export function App() {
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', onVisible);
};
- }, [refreshBootstrap]);
+ }, [refreshBootstrap, onTermsPage]);
useEffect(() => {
const title = data?.branding?.app_title;
@@ -196,6 +207,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 ;
}
@@ -229,6 +244,11 @@ export function App() {
} />
} />
} />
+ {/* Categories and the selected request are real paths, so a notification
+ or a shared link opens the exact request. */}
+ } />
+ } />
+ } />
{help ?
+ {confirmDelete ? (
+ }
+ busy={busy}
+ onConfirm={() => void remove()}
+ onClose={() => setConfirmDelete(false)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/application/v2_ui/src/components/approvals/ApprovalParts.tsx b/application/v2_ui/src/components/approvals/ApprovalParts.tsx
new file mode 100644
index 000000000..ead6c384e
--- /dev/null
+++ b/application/v2_ui/src/components/approvals/ApprovalParts.tsx
@@ -0,0 +1,313 @@
+// ApprovalParts.tsx
+// Small building blocks shared by every Approvals panel: the list/detail split, status
+// badges, fact rows, notices, and list filters.
+
+import type { ReactNode } from 'react';
+import { clsx } from 'clsx';
+import { ArrowLeft, Search } from 'lucide-react';
+
+/**
+ * The center list and right-hand detail of one Approvals category.
+ *
+ * On wide screens both are visible side by side. On narrow screens only one is: the list,
+ * or the selected item's detail with a way back to the list.
+ */
+export function ApprovalSplit({
+ list,
+ detail,
+ hasSelection,
+ onBack,
+ listLabel,
+}: {
+ list: ReactNode;
+ detail: ReactNode;
+ hasSelection: boolean;
+ onBack: () => void;
+ listLabel: string;
+}) {
+ return (
+
;
+}
+
+/** One labelled value. Renders nothing for an empty value so the list stays meaningful. */
+export function Fact({ label, children }: { label: string; children: ReactNode }) {
+ if (children === null || children === undefined || children === '' || children === false) return null;
+ return (
+ <>
+
+ This authorizes only this workflow revision and its sources, instructions, inputs, schedule, and destinations.
+ Changes require renewed authorization. A connected account alone is not approval.
+
+
+ {sourceList(approval)}
+
+ {review ? (
+
+
Workflow revision to authorize
+ {review.map(([label, value]) => (
+
+
{label}
+
{value}
+
+ ))}
+
+ ) : (
+
+ The workflow revision details are unavailable. Approval is disabled until the server supplies the instructions,
+ inputs, schedule, and destinations to review. You can still choose No.
+
+ )}
+
+ void onSubmit({ choice })} />
+
+ {action.workflow_id
+ ? 'Only the selected Run as user can send or cancel this action.'
+ : 'Only the action owner can send or cancel this action. This view is read-only.'}
+
+ );
+}
diff --git a/application/v2_ui/src/stores/bootstrapStore.ts b/application/v2_ui/src/stores/bootstrapStore.ts
index 5187d1ba6..0507cff8d 100644
--- a/application/v2_ui/src/stores/bootstrapStore.ts
+++ b/application/v2_ui/src/stores/bootstrapStore.ts
@@ -5,7 +5,7 @@
import { create } from 'zustand';
import { fetchBootstrap } from '../lib/endpoints';
-import { ApiError } from '../lib/apiClient';
+import { ApiError, isTermsOfUseRequired } from '../lib/apiClient';
import type { BootstrapPayload, PromptOption } from '../lib/types';
/**
@@ -59,6 +59,12 @@ export const useBootstrapStore = create((set, get) => ({
const data = await fetchBootstrap();
set({ data, loading: false });
} catch (error) {
+ // The terms gate refused the call and apiClient is already navigating to the
+ // Terms of Use page. Staying on the boot screen avoids flashing a misleading
+ // "session expired" panel during that navigation.
+ if (error instanceof ApiError && isTermsOfUseRequired(error.status, error.payload)) {
+ return;
+ }
const isAuthError = error instanceof ApiError && error.isAuthError;
set({
loading: false,
diff --git a/docs/explanation/features/TERMS_OF_USE.md b/docs/explanation/features/TERMS_OF_USE.md
index 3456b88dd..140db3915 100644
--- a/docs/explanation/features/TERMS_OF_USE.md
+++ b/docs/explanation/features/TERMS_OF_USE.md
@@ -21,6 +21,7 @@ Terms of Use lets administrators require users to accept configurable terms, rul
* **Redirect safety**: User-controlled return paths are local-only and stored server-side in the session. The decline destination is admin-configured and may be local or an HTTPS URL.
* **Server-side enforcement**: Authenticated browser requests are redirected to the Terms of Use page until accepted. Authenticated API requests receive a `403` response with `terms_of_use_required`.
* **Audit logging**: Accepted and declined events are written to activity logs when a user identity is known.
+* **V2 interface** (from **0.261.287**): A request under `/v2` is gated to `/v2/terms-of-use` instead of the classic page, so V2 users stay in the V2 experience. The V2 page reads and answers the terms through `GET /api/v2/terms-of-use`, `POST /api/v2/terms-of-use/accept` and `POST /api/v2/terms-of-use/decline`, which share the classic acceptance, versioning, redirect-safety and audit logic. See [V2 Terms of Use and Approval Requests](V2_TERMS_AND_APPROVALS.md).
## Usage Instructions
@@ -42,6 +43,7 @@ Users who decline are logged out locally and redirected to the configured cancel
* Functional coverage: `functional_tests/test_terms_of_use.py`
* Route policy coverage: `functional_tests/route_tests/`
* UI template coverage: `ui_tests/test_terms_of_use_ui.py`
+* V2 coverage: `functional_tests/test_v2_terms_and_approvals.py`, `ui_tests/test_v2_approvals_and_terms_pages.py`
## Known Limitations
diff --git a/docs/explanation/features/V2_TERMS_AND_APPROVALS.md b/docs/explanation/features/V2_TERMS_AND_APPROVALS.md
new file mode 100644
index 000000000..c5745fa2a
--- /dev/null
+++ b/docs/explanation/features/V2_TERMS_AND_APPROVALS.md
@@ -0,0 +1,58 @@
+# V2 Terms of Use and Approval Requests
+
+## Overview
+
+Implemented in version: **0.261.287**
+
+Brings two surfaces that still sent V2 users back to the classic interface into the React V2 UI:
+
+* **Terms of Use**: when the terms gate fires for a `/v2` request, the user now lands on a V2 page at `/v2/terms-of-use` instead of the classic `/terms-of-use` template.
+* **Approval Requests**: a V2 page at `/v2/approvals` with full parity with the classic `/approvals` page -- generic group approvals, Microsoft 365 approvals, content-screening requests, outgoing Microsoft 365 actions, paused (waiting) requests, and admin agent template approvals -- laid out like V2 Admin Settings.
+
+Dependencies: the existing Terms of Use feature ([TERMS_OF_USE.md](TERMS_OF_USE.md)), the approvals and Microsoft 365 APIs, and the React V2 shell ([REACT_V2_UI.md](REACT_V2_UI.md)).
+
+## Technical Specifications
+
+### Terms of Use
+
+* `app.py`
+ * `build_terms_of_use_url(path, query_string)` picks `/v2/terms-of-use` for a V2 page or V2 API request and the classic page otherwise.
+ * `/v2/terms-of-use` and the three V2 API paths are added to `TERMS_OF_USE_EXEMPT_PATHS`.
+* `route_backend_v2.py` (all `@swagger_route`, `@login_required`, `@user_required`):
+ * `GET /api/v2/terms-of-use?next=` returns `enabled`, `required`, the title, plain-text message, button labels, a local-only `return_path`, and sanitized branding.
+ * `POST /api/v2/terms-of-use/accept` records acceptance with the classic logic and returns `redirect_url`.
+ * `POST /api/v2/terms-of-use/decline` logs out and returns the configured cancel destination.
+* Frontend
+ * `lib/termsOfUse.ts`, `pages/TermsOfUsePage.tsx`. The message is rendered as text, never HTML.
+ * `App.tsx` renders the terms page alone without loading bootstrap or user settings, since every other call is refused until acceptance.
+ * `lib/apiClient.ts` sends the tab to `/v2/terms-of-use?next=` when any call returns the `terms_of_use_required` 403, for example after the terms change or a daily acceptance lapses while a tab is open.
+
+### Approval Requests
+
+* `pages/ApprovalsPage.tsx`: category rail (collapsible, persisted as the `v2ApprovalsRailCollapsed` user setting), a phone-width category picker, header with description and **Refresh**, and a pending-count badge for the active category.
+* `components/approvals/`
+ * `ApprovalParts.tsx`: shared list/detail split, toolbar, rows, pager, and notices.
+ * `GenericApprovalsPanel.tsx`: All, Group, Microsoft 365 and Content screening categories from one `/api/approvals` fetch per status, filtered on the client.
+ * `M365ApprovalDetail.tsx`: Microsoft 365 approval decisions, with the CSRF retry the classic page uses.
+ * `PendingActionsPanel.tsx`: `/api/msgraph/pending-actions` send, send now, and cancel.
+ * `PausedRequestsPanel.tsx`: `/api/m365/requests` with continuation, resume, and connect-and-resume.
+ * `AgentTemplatesPanel.tsx`: `/api/admin/agent-templates` approve, reject (reason required), and delete. Admin only.
+* `lib/approvalsApi.ts`: every approvals call and request-type label.
+* Routes: `/approvals`, `/approvals/:category`, `/approvals/:category/:itemId`. Classic deep links (`?approval_id&group_id`, `?m365_approval`, `#agent-template-approvals`) redirect to the matching route.
+* Navigation: a sidebar **Approval requests** entry; the notification bell and the Agent Templates admin link now open the V2 page.
+* `route_backend_users.py`: `v2ApprovalsRailCollapsed` is an allowed user setting.
+
+## Usage Instructions
+
+* Terms of Use are configured as before in Admin Settings; nothing new to enable.
+* Users open **Approval requests** from the V2 sidebar, pick a category, select a request, and decide in the right pane. See [Review approval requests](../../guides/review-approval-requests.md).
+
+## Testing and Validation
+
+* `functional_tests/test_v2_terms_and_approvals.py`: terms URL selection, exempt paths, route decorators, and frontend wiring.
+* `functional_tests/test_v2_notifications_bell.py`: approval notifications route into the V2 page.
+* `ui_tests/test_v2_approvals_and_terms_pages.py`: rail/list/detail layout, approve flow, classic deep link, admin-only category, phone picker, and the terms page rendering and accept redirect.
+
+## Known Limitations
+
+* The approvals list API filters one request type at a time and ignores search, so the page reads up to 2,000 records per status and filters them in the browser.
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md
index 0b33c5b0e..ff76861cb 100644
--- a/docs/explanation/release_notes.md
+++ b/docs/explanation/release_notes.md
@@ -2,6 +2,21 @@
For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes).
+### **(v0.261.287)**
+
+#### New Features
+
+* **V2 Terms of Use Page**
+ * V2 users who must accept the Terms of Use now see a V2 page at `/v2/terms-of-use` instead of being sent to the classic interface. Accepting returns them to the V2 page they were opening; declining signs them out to the configured destination.
+ * Acceptance, versioning, recurrence, redirect safety, and audit logging are shared with the classic page. A V2 tab whose acceptance lapses while open is sent to the terms page on its next request.
+ * (Ref: `app.py` `build_terms_of_use_url`, `/api/v2/terms-of-use` routes in `route_backend_v2.py`, `TermsOfUsePage.tsx`, [V2 Terms of Use and Approval Requests](features/V2_TERMS_AND_APPROVALS.md))
+
+* **V2 Approval Requests Page**
+ * **Approval requests** in the V2 sidebar opens a full-page view laid out like Admin Settings: a collapsible category rail, the request list, and a detail pane with the decision controls.
+ * Covers everything the classic page does: all requests, group requests, Microsoft 365 approvals, content screening (when enabled), outgoing Microsoft 365 actions, waiting requests with resume or connect-and-resume, and admin agent template approvals.
+ * Each request has its own address. Notification links and classic bookmarks open the matching request, and the Admin Settings agent template link opens the V2 queue.
+ * (Ref: `ApprovalsPage.tsx`, `components/approvals/`, `approvalsApi.ts`, `notificationLinks.ts`, `Sidebar.tsx`, `v2ApprovalsRailCollapsed` user setting)
+
### **(v0.261.286)**
#### Bug Fixes
diff --git a/docs/guides/review-approval-requests.md b/docs/guides/review-approval-requests.md
index 4427dad06..d742d31be 100644
--- a/docs/guides/review-approval-requests.md
+++ b/docs/guides/review-approval-requests.md
@@ -49,6 +49,22 @@ Approvals create a checkpoint before sensitive actions execute. Use this page wh
8. For templates, use **Agent Template Approvals** and its status filters.
+## In the V2 interface
+
+From **0.261.287**, **Approval requests** in the V2 sidebar opens a full-page view laid out like V2 Admin Settings. A left rail groups the queues so you review one kind of request at a time, the center lists the requests in that queue, and the right pane shows the selected request with its decision controls.
+
+| Category | What it holds |
+| --- | --- |
+| **All requests** | Every approval request you can see. |
+| **Group requests** | Ownership changes, document and group deletion, and user actions on groups. |
+| **Microsoft 365** | Source-sharing, extended file analysis, and workflow Run as approvals. |
+| **Content screening** | Screened documents waiting for review. Shown only while content screening is turned on. |
+| **Outgoing actions** | Emails and other Microsoft 365 actions waiting for you to send or cancel. |
+| **Waiting requests** | Saved chat requests paused for an approval or a sign-in, with **Resume** or connect-and-resume. |
+| **Agent templates** | Templates submitted for the shared gallery. Shown to admins only. |
+
+The badge on the active category counts its pending items, and **Refresh** reloads the current queue. Each selected request has its own address, such as `/v2/approvals/group/`, so it can be bookmarked or shared with another reviewer. Links in notifications and older bookmarks (`?approval_id=`, `?m365_approval=`, `#agent-template-approvals`) open the matching request. On a narrow screen the rail becomes a category picker, and the rail can be collapsed on wider screens; that choice is remembered.
+
## Microsoft 365 outgoing actions
From **0.261.038**, the Microsoft 365 outgoing-actions section references the
diff --git a/functional_tests/test_v2_notifications_bell.py b/functional_tests/test_v2_notifications_bell.py
index 9bb34c389..f3d8f12cf 100644
--- a/functional_tests/test_v2_notifications_bell.py
+++ b/functional_tests/test_v2_notifications_bell.py
@@ -133,7 +133,7 @@
(
"/approvals?approval_id=a-1",
{"group_id": "g-1"},
- {"kind": "classic", "href": "/approvals?approval_id=a-1", "groupId": "g-1"},
+ {"kind": "route", "path": "/approvals?approval_id=a-1"},
),
(
"/workflow-activity?workflowId=w-1&runId=r-1",
diff --git a/functional_tests/test_v2_terms_and_approvals.py b/functional_tests/test_v2_terms_and_approvals.py
new file mode 100644
index 000000000..fc64a8175
--- /dev/null
+++ b/functional_tests/test_v2_terms_and_approvals.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+# test_v2_terms_and_approvals.py
+"""
+Functional test for the V2 Terms of Use page and the V2 Approval requests page.
+Version: 0.261.287
+Implemented in: 0.261.287
+
+This test ensures the Terms of Use gate sends V2 requests to the V2 page, the V2 terms
+endpoints exist with the required decorators and are exempt from the gate, the approvals
+rail preference is writable, and the V2 approvals page is wired to every request kind the
+classic page serves.
+"""
+
+import ast
+import os
+import sys
+from pathlib import Path
+from urllib.parse import parse_qs, urlencode, urlparse
+
+sys.path.append(os.path.dirname(os.path.abspath(__file__)))
+
+from test_support.versioning import assert_app_version_at_least # noqa: E402
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+APP_DIR = REPO_ROOT / "application" / "single_app"
+V2_SRC = REPO_ROOT / "application" / "v2_ui" / "src"
+
+
+def _read(path):
+ return path.read_text(encoding="utf-8")
+
+
+def _load_app_functions(*names):
+ """Compile only the named top-level helpers from app.py, without importing the app."""
+ source = _read(APP_DIR / "app.py")
+ tree = ast.parse(source)
+ wanted = set(names)
+ nodes = []
+ for node in tree.body:
+ if isinstance(node, ast.FunctionDef) and node.name in wanted:
+ nodes.append(node)
+ elif isinstance(node, ast.Assign) and any(
+ isinstance(target, ast.Name) and target.id in wanted for target in node.targets
+ ):
+ nodes.append(node)
+ module = ast.Module(body=nodes, type_ignores=[])
+ namespace = {
+ "urlencode": urlencode,
+ "url_for": lambda endpoint, **kwargs: f"/terms-of-use?{urlencode(kwargs)}",
+ }
+ exec(compile(module, "app_helpers", "exec"), namespace) # noqa: S102 - trusted repo source
+ return namespace
+
+
+def test_gate_sends_v2_requests_to_the_v2_page():
+ """V2 pages keep their location as next; V2 API calls get the bare V2 page."""
+ helpers = _load_app_functions(
+ "V2_TERMS_OF_USE_PATH",
+ "_is_v2_request_path",
+ "normalize_path_with_query",
+ "build_terms_of_use_url",
+ )
+ build = helpers["build_terms_of_use_url"]
+
+ page_url = build("/v2/workspace/documents", b"tab=mine")
+ parsed = urlparse(page_url)
+ assert parsed.path == "/v2/terms-of-use", page_url
+ assert parse_qs(parsed.query) == {"next": ["/v2/workspace/documents?tab=mine"]}, page_url
+
+ assert build("/api/v2/bootstrap", b"") == "/v2/terms-of-use"
+ assert build("/v2", b"").startswith("/v2/terms-of-use?")
+
+ classic = build("/chats", b"")
+ assert classic.startswith("/terms-of-use?"), classic
+ # A path that merely starts with the letters is not a V2 path.
+ assert build("/v2x", b"").startswith("/terms-of-use?")
+ print("PASS: the gate routes V2 and classic requests to their own page")
+
+
+def test_v2_terms_paths_are_exempt_from_the_gate():
+ source = _read(APP_DIR / "app.py")
+ for path in (
+ "'/v2/terms-of-use'",
+ "'/api/v2/terms-of-use'",
+ "'/api/v2/terms-of-use/accept'",
+ "'/api/v2/terms-of-use/decline'",
+ ):
+ assert path in source, f"{path} should be exempt from the Terms of Use gate"
+ assert "terms_url = build_terms_of_use_url(request.path, request.query_string)" in source
+ print("PASS: the V2 terms paths are exempt")
+
+
+def test_v2_terms_routes_have_required_decorators():
+ tree = ast.parse(_read(APP_DIR / "route_backend_v2.py"))
+ expected = {
+ "v2_terms_of_use": ("/api/v2/terms-of-use", "GET"),
+ "v2_accept_terms_of_use": ("/api/v2/terms-of-use/accept", "POST"),
+ "v2_decline_terms_of_use": ("/api/v2/terms-of-use/decline", "POST"),
+ }
+ found = {}
+ for node in ast.walk(tree):
+ if isinstance(node, ast.FunctionDef) and node.name in expected:
+ decorators = [ast.unparse(item) for item in node.decorator_list]
+ found[node.name] = decorators
+ assert set(found) == set(expected), f"missing V2 terms routes: {set(expected) - set(found)}"
+ for name, (path, method) in expected.items():
+ decorators = found[name]
+ assert decorators[0] == f"bp.route('{path}', methods=['{method}'])", decorators
+ assert decorators[1] == "swagger_route(security=get_auth_security())", decorators
+ assert "login_required" in decorators and "user_required" in decorators, decorators
+ print("PASS: the V2 terms routes carry swagger and auth decorators")
+
+
+def test_v2_terms_frontend_is_wired():
+ app_tsx = _read(V2_SRC / "App.tsx")
+ assert "TermsOfUsePage" in app_tsx
+ assert "/terms-of-use" in app_tsx
+ api_client = _read(V2_SRC / "lib" / "apiClient.ts")
+ assert "terms" in api_client.lower()
+ page = _read(V2_SRC / "pages" / "TermsOfUsePage.tsx")
+ assert "dangerouslySetInnerHTML" not in page
+ print("PASS: the V2 terms page is routed and renders text only")
+
+
+def test_approvals_rail_preference_is_writable():
+ users_route = _read(APP_DIR / "route_backend_users.py")
+ assert "'v2ApprovalsRailCollapsed'" in users_route or '"v2ApprovalsRailCollapsed"' in users_route
+ user_settings = _read(V2_SRC / "lib" / "userSettings.ts")
+ assert user_settings.count("v2ApprovalsRailCollapsed") >= 2
+ print("PASS: the approvals rail preference is writable")
+
+
+def test_v2_approvals_page_covers_every_request_kind():
+ app_tsx = _read(V2_SRC / "App.tsx")
+ for route in ('path="/approvals"', 'path="/approvals/:category"', 'path="/approvals/:category/:itemId"'):
+ assert route in app_tsx, route
+
+ page = _read(V2_SRC / "pages" / "ApprovalsPage.tsx")
+ for category in ("'all'", "'group'", "'m365'", "'content-screening'", "'outgoing'", "'paused'", "'agent-templates'"):
+ assert f"id: {category}" in page, category
+ for panel in ("GenericApprovalsPanel", "PendingActionsPanel", "PausedRequestsPanel", "AgentTemplatesPanel"):
+ assert panel in page, panel
+ # Agent templates are for administrators only; content screening follows its feature.
+ assert "if (isAdmin)" in page
+ assert "enable_content_screening" in page
+ # Classic-style links still resolve.
+ for deep_link in ("m365_approval", "approval_id", "#agent-template-approvals"):
+ assert deep_link in page, deep_link
+
+ components = V2_SRC / "components" / "approvals"
+ for component in components.glob("*.tsx"):
+ assert "dangerouslySetInnerHTML" not in _read(component), component.name
+
+ api = _read(V2_SRC / "lib" / "approvalsApi.ts")
+ for endpoint in (
+ "/api/approvals",
+ "/api/m365/approvals/",
+ "/api/m365/requests",
+ "/api/msgraph/pending-actions",
+ "/api/admin/agent-templates",
+ ):
+ assert endpoint in api, endpoint
+ print("PASS: the V2 approvals page covers every request kind")
+
+
+def test_v2_navigation_points_at_the_v2_approvals_page():
+ sidebar = _read(V2_SRC / "components" / "layout" / "Sidebar.tsx")
+ assert "to: '/approvals'" in sidebar
+ links = _read(V2_SRC / "lib" / "notificationLinks.ts")
+ assert "return route(`/approvals${url.search}${url.hash}`);" in links
+ template_link = _read(V2_SRC / "components" / "admin" / "AgentTemplateApprovalsLink.tsx")
+ assert 'to="/approvals/agent-templates"' in template_link
+ print("PASS: V2 navigation opens the V2 approvals page")
+
+
+def test_version():
+ assert_app_version_at_least("0.261.287")
+ print("PASS: version is at least 0.261.287")
+
+
+if __name__ == "__main__":
+ tests = [
+ test_gate_sends_v2_requests_to_the_v2_page,
+ test_v2_terms_paths_are_exempt_from_the_gate,
+ test_v2_terms_routes_have_required_decorators,
+ test_v2_terms_frontend_is_wired,
+ test_approvals_rail_preference_is_writable,
+ test_v2_approvals_page_covers_every_request_kind,
+ test_v2_navigation_points_at_the_v2_approvals_page,
+ test_version,
+ ]
+ results = []
+ for test in tests:
+ print(f"\nRunning {test.__name__}...")
+ try:
+ test()
+ results.append(True)
+ except Exception as exc:
+ print(f"FAIL: {exc}")
+ import traceback
+ traceback.print_exc()
+ results.append(False)
+ print(f"\nResults: {sum(results)}/{len(results)} tests passed")
+ sys.exit(0 if all(results) else 1)
diff --git a/ui_tests/test_v2_approvals_and_terms_pages.py b/ui_tests/test_v2_approvals_and_terms_pages.py
new file mode 100644
index 000000000..0cb018320
--- /dev/null
+++ b/ui_tests/test_v2_approvals_and_terms_pages.py
@@ -0,0 +1,250 @@
+# test_v2_approvals_and_terms_pages.py
+"""
+Browser coverage for the V2 Approval Requests page and the V2 Terms of Use page.
+Version: 0.261.287
+Implemented in: 0.261.287
+
+Serve the real built SPA through Playwright request interception with a closed API
+boundary. Check that the approvals page lays out a category rail, a request list and a
+detail pane like Admin Settings; that a decision posts to the real approvals endpoint;
+that the admin-only agent template category is hidden from other users; that a phone
+width swaps the rail for a picker; and that the terms page renders the configured text
+as text and follows the server's redirect after acceptance. Unexpected requests fail.
+"""
+
+import copy
+import sys
+from pathlib import Path
+from urllib.parse import parse_qs, urlsplit
+
+import pytest
+from playwright.sync_api import Page, Route, expect
+
+sys.path.insert(0, str(Path(__file__).resolve().parent / "fixtures"))
+
+from playwright_connection import connect_options # noqa: E402,F401
+from v2_notification_stubs import is_notification_count, notification_count_payload # noqa: E402
+
+
+pytestmark = pytest.mark.ui
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+STATIC_ROOT = REPO_ROOT / "application" / "single_app" / "static"
+SPA_INDEX = STATIC_ROOT / "v2" / "index.html"
+ORIGIN = "http://simplechat.test"
+
+GROUP_APPROVAL = {
+ "id": "appr-group-1",
+ "group_id": "group-1",
+ "group_name": "Finance",
+ "request_type": "delete_documents",
+ "status": "pending",
+ "requester_name": "Riley Owner",
+ "requester_email": "riley@contoso.test",
+ "reason": "Quarterly cleanup",
+ "created_at": "2026-10-01T12:00:00+00:00",
+ "can_approve": True,
+ "can_deny": True,
+}
+
+
+class ApprovalsFixture:
+ """The approvals and terms pages with an in-memory API."""
+
+ def __init__(self, page: Page, *, is_admin=False):
+ self.page = page
+ self.is_admin = is_admin
+ self.approvals = [copy.deepcopy(GROUP_APPROVAL)]
+ self.decisions = []
+ self.terms_posts = []
+ self.terms_payload = {
+ "enabled": True,
+ "required": True,
+ "title": "Acceptable Use",
+ "message": "Line one not bold\nLine two",
+ "accept_button_text": "I agree",
+ "decline_button_text": "No thanks",
+ "return_path": "/v2/approvals",
+ "branding": {"app_title": "SimpleChat", "hide_app_title": False, "show_logo": False},
+ }
+ self.errors = []
+ self.unexpected_requests = []
+ page.on("pageerror", lambda error: self.errors.append(str(error)))
+ page.route("**/*", self._route)
+
+ def _bootstrap(self):
+ return {
+ "version": "0.261.287",
+ "user": {
+ "id": "user-1",
+ "display_name": "Test User",
+ "email": "test.user@contoso.test",
+ "is_admin": self.is_admin,
+ "roles": ["Admin"] if self.is_admin else [],
+ },
+ "branding": {"app_title": "SimpleChat", "show_logo": False, "hide_app_title": False},
+ "features": {},
+ "catalogs": {"models": [], "agents": [], "prompts": [], "initial_model_selection": None},
+ "scope": {"groups": [], "public_workspaces": []},
+ "navigation": {
+ "custom_pages": {"enabled": False, "items": []},
+ "external_links": {"enabled": False, "items": []},
+ },
+ "workspace": {"sections": {}},
+ "settings": {},
+ }
+
+ def _route(self, route: Route):
+ request = route.request
+ parsed = urlsplit(request.url)
+ path = parsed.path
+ if f"{parsed.scheme}://{parsed.netloc}" != ORIGIN:
+ self.unexpected_requests.append(request.url)
+ route.abort()
+ return
+ method = request.method
+ if method == "GET" and (path == "/v2" or path.startswith("/v2/")):
+ route.fulfill(path=str(SPA_INDEX), content_type="text/html")
+ elif method == "GET" and path.startswith("/static/"):
+ asset = (STATIC_ROOT / path.removeprefix("/static/")).resolve()
+ if asset.is_relative_to(STATIC_ROOT.resolve()) and asset.is_file():
+ route.fulfill(path=str(asset))
+ else:
+ self.unexpected_requests.append(path)
+ route.fulfill(status=404, body="Fixture asset not found")
+ elif method == "GET" and path == "/api/v2/bootstrap":
+ route.fulfill(json=self._bootstrap())
+ elif path == "/api/user/settings":
+ if method == "GET":
+ route.fulfill(json={"settings": self.preferences})
+ else:
+ self.preferences.update(request.post_data_json["settings"])
+ route.fulfill(json={"message": "Saved."})
+ elif is_notification_count(method, path):
+ route.fulfill(json=notification_count_payload())
+ elif method == "GET" and path == "/api/approvals":
+ status = parse_qs(parsed.query).get("status", ["pending"])[0]
+ items = [item for item in self.approvals if status == "all" or item["status"] == status]
+ route.fulfill(json={"approvals": items, "total_count": len(items), "total_pages": 1})
+ elif method == "GET" and path.startswith("/api/approvals/"):
+ approval_id = path.removeprefix("/api/approvals/")
+ match = next((item for item in self.approvals if item["id"] == approval_id), None)
+ if match:
+ route.fulfill(json=match)
+ else:
+ route.fulfill(status=404, json={"error": "Not found"})
+ elif method == "POST" and path.startswith("/api/approvals/") and path.endswith(("/approve", "/deny")):
+ approval_id, action = path.removeprefix("/api/approvals/").rsplit("/", 1)
+ self.decisions.append((approval_id, action, request.post_data_json))
+ for item in self.approvals:
+ if item["id"] == approval_id:
+ item["status"] = "approved" if action == "approve" else "denied"
+ item["can_approve"] = item["can_deny"] = False
+ route.fulfill(json={"success": True})
+ elif method == "GET" and path == "/api/v2/terms-of-use":
+ route.fulfill(json=self.terms_payload)
+ elif method == "POST" and path in ("/api/v2/terms-of-use/accept", "/api/v2/terms-of-use/decline"):
+ self.terms_posts.append(path.rsplit("/", 1)[-1])
+ target = "/v2/approvals" if path.endswith("accept") else "/logout"
+ route.fulfill(json={"success": True, "redirect_url": target})
+ else:
+ self.unexpected_requests.append(f"{method} {path}")
+ route.fulfill(status=404, json={"error": "Unexpected fixture request."})
+
+ def open(self, path, *, width=1440, height=900):
+ if not SPA_INDEX.is_file():
+ pytest.fail("Build the V2 SPA first: npm --prefix application/v2_ui run build")
+ self.preferences = {"darkModeEnabled": False, "v2RailCollapsed": width < 1024}
+ self.page.set_viewport_size({"width": width, "height": height})
+ self.page.goto(f"{ORIGIN}{path}", wait_until="networkidle")
+
+ def assert_clean(self):
+ assert not self.unexpected_requests, self.unexpected_requests
+ assert not self.errors, self.errors
+
+
+@pytest.fixture
+def approvals_ui(page):
+ fixture = ApprovalsFixture(page)
+ yield fixture
+ fixture.assert_clean()
+
+
+@pytest.fixture
+def admin_approvals_ui(page):
+ fixture = ApprovalsFixture(page, is_admin=True)
+ yield fixture
+ fixture.assert_clean()
+
+
+def test_approvals_page_lays_out_rail_list_and_detail(approvals_ui):
+ approvals_ui.open("/v2/approvals")
+ page = approvals_ui.page
+ expect(page.get_by_test_id("v2-approvals-page")).to_be_visible()
+ expect(page).to_have_url(f"{ORIGIN}/v2/approvals/all")
+
+ rail = page.get_by_test_id("v2-approvals-rail")
+ expect(rail).to_be_visible()
+ for category in ("all", "group", "m365", "outgoing", "paused"):
+ expect(page.get_by_test_id(f"v2-approvals-category-{category}")).to_be_visible()
+ # Agent templates are an admin queue, and content screening follows its feature flag.
+ expect(page.get_by_test_id("v2-approvals-category-agent-templates")).to_have_count(0)
+ expect(page.get_by_test_id("v2-approvals-category-content-screening")).to_have_count(0)
+ expect(page.get_by_test_id("v2-approvals-active-count")).to_have_text("1")
+
+ row = page.get_by_test_id("v2-approval-row-appr-group-1")
+ expect(row).to_contain_text("Delete All Documents")
+ row.click()
+ expect(page).to_have_url(f"{ORIGIN}/v2/approvals/all/appr-group-1?group_id=group-1")
+ expect(page.get_by_test_id("v2-approval-decision")).to_be_visible()
+
+ rail_box = rail.bounding_box()
+ row_box = row.bounding_box()
+ decision_box = page.get_by_test_id("v2-approval-decision").bounding_box()
+ assert rail_box["x"] + rail_box["width"] <= row_box["x"] + 1, "The list sits right of the rail"
+ assert row_box["x"] + row_box["width"] <= decision_box["x"] + 1, "The detail pane sits right of the list"
+
+ page.get_by_test_id("v2-approval-comment").fill("Looks fine")
+ page.get_by_test_id("v2-approval-approve").click()
+ expect(page.get_by_test_id("v2-approval-decision")).to_have_count(0)
+ assert approvals_ui.decisions == [
+ ("appr-group-1", "approve", {"group_id": "group-1", "comment": "Looks fine"}),
+ ]
+
+
+def test_classic_deep_link_opens_the_request(approvals_ui):
+ approvals_ui.open("/v2/approvals?approval_id=appr-group-1&group_id=group-1")
+ page = approvals_ui.page
+ expect(page).to_have_url(f"{ORIGIN}/v2/approvals/all/appr-group-1?group_id=group-1")
+ expect(page.get_by_test_id("v2-approval-decision")).to_be_visible()
+
+
+def test_admins_see_the_agent_template_queue(admin_approvals_ui):
+ admin_approvals_ui.open("/v2/approvals")
+ expect(admin_approvals_ui.page.get_by_test_id("v2-approvals-category-agent-templates")).to_be_visible()
+
+
+def test_phone_width_swaps_the_rail_for_a_picker(approvals_ui):
+ approvals_ui.open("/v2/approvals", width=390, height=844)
+ page = approvals_ui.page
+ expect(page.get_by_test_id("v2-approvals-rail")).to_be_hidden()
+ picker = page.get_by_test_id("v2-approvals-category-select")
+ expect(picker).to_be_visible()
+ picker.select_option("group")
+ expect(page).to_have_url(f"{ORIGIN}/v2/approvals/group")
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+
+
+def test_terms_page_renders_text_and_follows_the_accept_redirect(approvals_ui):
+ approvals_ui.open("/v2/terms-of-use?next=/v2/approvals")
+ page = approvals_ui.page
+ expect(page.get_by_test_id("v2-terms-of-use")).to_be_visible()
+ expect(page.get_by_role("heading", name="Acceptable Use")).to_be_visible()
+ message = page.get_by_test_id("v2-terms-of-use-message")
+ expect(message).to_contain_text("not bold")
+ expect(message.locator("b")).to_have_count(0)
+ expect(page.get_by_test_id("v2-terms-decline")).to_have_text("No thanks")
+
+ page.get_by_test_id("v2-terms-accept").click()
+ expect(page).to_have_url(f"{ORIGIN}/v2/approvals/all")
+ assert approvals_ui.terms_posts == ["accept"]
diff --git a/ui_tests/test_v2_notifications_bell.py b/ui_tests/test_v2_notifications_bell.py
index f2ad15163..dd9ab49e4 100644
--- a/ui_tests/test_v2_notifications_bell.py
+++ b/ui_tests/test_v2_notifications_bell.py
@@ -1398,6 +1398,12 @@ def test_reply_notices_open_in_place_on_the_chat_page(harness):
("n-v2", "/v2/workspace/prompts?tab=mine", {}, "/workspace/prompts?tab=mine"),
# A chat link naming no conversation opens the chat page, which names the one it shows.
("n-chat-page", "/chats", {}, "/chat?conversationId=conv-a"),
+ # V2 has its own approvals page, so an approval notice stays in V2. The server names the
+ # approver as the approval's group, which is not a group at all, so no group is made active.
+ (
+ "n-m365-approval", "/approvals?m365_approval=appr-1", {"approval_id": "appr-1", "group_id": "user-1"},
+ "/approvals?m365_approval=appr-1",
+ ),
]
@@ -1427,12 +1433,6 @@ def test_classic_links_open_the_matching_v2_page(harness):
lambda: workflow_notice("n-classic", "The digest step timed out.", group_id="grp-9"),
"/elsewhere", "/workflow-activity?workflowId=wf-1&runId=run-1", [{"groupId": "grp-9"}], 200,
),
- # The server names the approver as the approval's group, which is not a group at all. Classic
- # asks for it anyway and ignores the refusal; so does V2, and the page still opens.
- "m365-approval": (
- lambda: m365_approval_notice("n-classic"),
- "/elsewhere", "/approvals?m365_approval=appr-1", [{"groupId": "user-1"}], 404,
- ),
# Only the classic chat page draws the pending-action card, so it is not opened in V2.
"m365-pending-action": (
lambda: m365_pending_action_notice("n-classic"),