feat: govern task-local quick actions

This commit is contained in:
2026-08-19 18:47:46 +02:00
parent d44ed78e5c
commit 5a906185a6
10 changed files with 139 additions and 23 deletions
+14 -7
View File
@@ -37,10 +37,17 @@ owner module.
Quick Access is not an authorization boundary. Every contribution keeps its
own permission requirements and View surface. Full-page routes remain the
canonical fallback. Launch context version 1 contains only the tenant/account
identity, safe active-object reference, acting-assignment identifiers,
temporal selection, exact View identity, and return route. Cross-tenant object
references are discarded and unknown context versions are ignored. The
destination reauthorizes every read and effect. Disabling this module removes
the rail without making any domain state unavailable through its owning
module.
canonical fallback. Launch-context version 2 contains only the tenant/account
identity, a reference-contract-version-1 active object, acting-assignment
identifiers, temporal selection, exact View identity, View recommendations or
focus, and a safe return route. Cross-tenant object references are discarded
and unknown versions fail closed. View focus applies only when at least one
focused tool is currently enabled, context-compatible, and authorized; a
recommendation only changes ordering and emphasis.
Each renderer performs owner-side reads and effects and explicitly reports
either a result-contract-version-1 completion with a typed owner reference or
a cancellation reason. Closing the drawer is not completion. The host may
listen for that correlated result while its unsaved page state remains mounted.
Disabling this module removes the rail without making any domain state
unavailable through its owning module.
+18 -7
View File
@@ -121,9 +121,11 @@ DOCUMENTATION = (
"Messages combines enabled Mail, Postbox, and future chat contributions in one overlay. "
"Personal settings can reorder or hide items that remain available under system, tenant, "
"permission, and View policy. Every item retains a link to its complete owning page. "
"That launch carries a versioned, bounded reference to the current object, acting assignment, "
"temporal selection, View revision, and return location. The destination rechecks access, and "
"the breadcrumb return action restores the originating route without copying protected content."
"Launch-context version 2 carries only versioned, bounded references to the current object, acting assignment, "
"temporal selection, exact View revision, and return location. Owner modules recheck access when a tool opens and "
"before each effect. A tool returns either an explicit version-1 completion with a typed owner reference or an "
"explicit cancellation; closing the drawer does not imply success. The overlay preserves unsaved host-page work, "
"and the complete owning page remains the fallback for work that exceeds the compact surface."
),
layer="configured",
documentation_types=("user",),
@@ -143,8 +145,11 @@ DOCUMENTATION = (
"Eine Kategorie in der rechten Leiste oeffnet kompakte Werkzeuge, ohne die aktuelle Aufgabe zu verlassen. "
"Nachrichten fuehrt Beitraege aus Mail, Postfach und kuenftigen Chat-Modulen in einer Einblendung zusammen. "
"Persoenliche Einstellungen koennen alle durch System, Mandant, Berechtigungen und Ansicht zugelassenen Eintraege ordnen oder ausblenden. "
"Beim Oeffnen der vollstaendigen Seite werden nur versionierte, begrenzte Verweise auf Objekt, handelnde Zuordnung, "
"Zeitbezug, Ansichtsversion und Ruecksprungort uebergeben. Das Ziel prueft den Zugriff erneut."
"Startkontext Version 2 uebergibt nur versionierte, begrenzte Verweise auf Objekt, handelnde Zuordnung, "
"Zeitbezug, genaue Ansichtsversion und Ruecksprungort. Das besitzende Modul prueft den Zugriff beim Oeffnen "
"und vor jeder Wirkung erneut. Ein Werkzeug meldet entweder einen ausdruecklichen Abschluss mit typisiertem "
"Besitzerverweis oder einen ausdruecklichen Abbruch; das Schliessen gilt nicht als Erfolg. Die Einblendung "
"erhaelt ungespeicherte Arbeit auf der Ausgangsseite, die vollstaendige Besitzerseite bleibt das Ausweichziel."
),
}
},
@@ -164,7 +169,10 @@ DOCUMENTATION = (
"The catalogue follows installed module registrations. System settings constrain tenants; "
"tenant settings constrain users. An item may remain available, be blocked, or be forced. "
"Views and permissions form additional ceilings and Quick Access never grants access to domain data. "
"Launch context version 1 rejects cross-tenant active-object references and unknown context versions fail closed."
"A View may recommend tools or focus the rail to a subset, but only currently enabled, context-compatible, authorized "
"tools participate. Workflow receives that same presentation from the exact resolved View revision. Launch-context "
"version 2, reference contract version 1, and result contract version 1 fail closed on unknown versions; cross-tenant "
"active-object and result references are rejected."
),
layer="configured",
documentation_types=("admin",),
@@ -177,7 +185,10 @@ DOCUMENTATION = (
"Der Katalog folgt den Registrierungen installierter Module. Systemeinstellungen begrenzen Mandanten, "
"Mandanteneinstellungen begrenzen Benutzer. Ein Eintrag kann verfuegbar, gesperrt oder erzwungen sein. "
"Ansichten und Berechtigungen bilden weitere Grenzen; Schnellzugriff erteilt selbst keinen Datenzugriff. "
"Startkontext Version 1 verwirft mandantenfremde Objektverweise; unbekannte Versionen werden abgelehnt."
"Eine Ansicht darf Werkzeuge empfehlen oder die Leiste auf eine Teilmenge fokussieren, jedoch nur innerhalb "
"der aktivierten, kontextgeeigneten und berechtigten Werkzeuge. Workflow verwendet dieselbe Darstellung aus "
"der genau aufgeloesten Ansichtsversion. Startkontext Version 2 sowie Verweis- und Ergebniskontrakt Version 1 "
"lehnen unbekannte Versionen ab; mandantenfremde Objekt- und Ergebnisverweise werden verworfen."
),
}
},
@@ -42,6 +42,7 @@ class CatalogueCategoryResponse(BaseModel):
class CatalogueToolResponse(BaseModel):
contract_version: str
id: str
module_id: str
category_id: str
@@ -55,6 +56,10 @@ class CatalogueToolResponse(BaseModel):
order: int
default_enabled: bool
modes: list[str]
availability: str
accepted_reference_kinds: list[str]
returned_reference_kinds: list[str]
help_context_id: str | None = None
class CatalogueResponse(BaseModel):
@@ -91,6 +91,7 @@ def build_catalogue(
continue
tools.append(
CatalogueToolResponse(
contract_version=tool.contract_version,
id=tool.id,
module_id=tool.module_id,
category_id=tool.category_id,
@@ -104,6 +105,10 @@ def build_catalogue(
order=tool.order,
default_enabled=tool.default_enabled,
modes=list(tool.modes),
availability=tool.availability,
accepted_reference_kinds=list(tool.accepted_reference_kinds),
returned_reference_kinds=list(tool.returned_reference_kinds),
help_context_id=tool.help_context_id,
)
)
tools.sort(key=lambda item: (item.category_id, item.order, item.id))
+5
View File
@@ -27,6 +27,7 @@ export type QuickAccessCategory = {
};
export type QuickAccessTool = {
contract_version: "1";
id: string;
module_id: string;
category_id: string;
@@ -40,6 +41,10 @@ export type QuickAccessTool = {
order: number;
default_enabled: boolean;
modes: string[];
availability: "global" | "active_object";
accepted_reference_kinds: string[];
returned_reference_kinds: string[];
help_context_id?: string | null;
};
export type QuickAccessCatalogue = {
+70 -7
View File
@@ -11,6 +11,7 @@ import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { Link, useLocation } from "react-router";
import {
DismissibleAlert,
DocumentationHelpLink,
IconButton,
LoadingFrame,
dispatchQuickAccessResult,
@@ -51,12 +52,46 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
[contributions]
);
const availableToolIds = useMemo(() => new Set(tools.map((tool) => tool.id)), [tools]);
const categories = useMemo(
() => (effective?.categories ?? []).map((category) => ({
const metadataById = useMemo(() => new Map(tools.map((tool) => [tool.id, tool])), [tools]);
const focusedToolIds = useMemo(
() => new Set(launchContext.viewContext?.focusedToolIds ?? []),
[launchContext.viewContext?.focusedToolIds]
);
const recommendedToolIds = useMemo(
() => new Set(launchContext.viewContext?.recommendedToolIds ?? []),
[launchContext.viewContext?.recommendedToolIds]
);
const categories = useMemo(() => {
const eligibleCategories = (effective?.categories ?? [])
.filter((category) => category.enabled)
.map((category) => ({
...category,
tools: category.tools.filter((tool) => tool.enabled && availableToolIds.has(tool.id))
})).filter((category) => category.enabled && category.tools.length > 0),
[availableToolIds, effective]
tools: category.tools.filter((tool) => {
if (!tool.enabled || !availableToolIds.has(tool.id)) return false;
const metadata = metadataById.get(tool.id);
if (metadata?.availability === "active_object" && !launchContext.activeObject) return false;
if (metadata?.acceptedReferenceKinds.length && launchContext.activeObject) {
const activeKind = `${launchContext.activeObject.ownerModule}.${launchContext.activeObject.kind}`;
if (!metadata.acceptedReferenceKinds.includes(activeKind)) return false;
}
return true;
})
}));
const hasEligibleFocus = eligibleCategories.some((category) =>
category.tools.some((tool) => focusedToolIds.has(tool.id))
);
return eligibleCategories.map((category) => ({
...category,
tools: category.tools.filter((tool) =>
!hasEligibleFocus || focusedToolIds.has(tool.id)
).sort((left, right) =>
Number(recommendedToolIds.has(right.id)) - Number(recommendedToolIds.has(left.id))
|| left.order - right.order
|| left.id.localeCompare(right.id)
)
})).filter((category) => category.enabled && category.tools.length > 0);
},
[availableToolIds, effective, focusedToolIds, launchContext.activeObject, metadataById, recommendedToolIds]
);
const activeCategory = categories.find((category) => category.id === activeCategoryId) ?? null;
@@ -189,13 +224,27 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
{activeCategory.tools.map((tool) => {
const renderer = renderers.get(tool.id);
const metadata = metadataById.get(tool.id);
return (
<section className="quick-access-tool" key={tool.id} data-tool-id={tool.id}>
<section
className={`quick-access-tool${recommendedToolIds.has(tool.id) ? " is-recommended" : ""}`}
key={tool.id}
data-tool-id={tool.id}
data-tool-contract-version={metadata?.contractVersion}
>
<div className="quick-access-tool-heading">
<div>
<strong>{translateText(tool.label)}</strong>
{recommendedToolIds.has(tool.id) ? (
<span className="quick-access-recommended">
{translateText("i18n:govoplan-quick-access.recommended_for_view")}
</span>
) : null}
{tool.description ? <small>{translateText(tool.description)}</small> : null}
</div>
{metadata?.helpContextId ? (
<DocumentationHelpLink reference={{ contextId: metadata.helpContextId, moduleId: tool.module_id }} />
) : null}
{tool.full_page_path ? (
<button
type="button"
@@ -214,7 +263,21 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
active: true,
launchContext,
complete: (result) => {
dispatchQuickAccessResult(tool.id, launchContext, result);
if (!dispatchQuickAccessResult(tool.id, launchContext, result, metadata?.returnedReferenceKinds ?? [])) {
setError("i18n:govoplan-quick-access.invalid_result");
return;
}
closeDrawer();
},
cancel: (reason = "user") => {
if (!dispatchQuickAccessResult(tool.id, launchContext, {
contractVersion: "1",
outcome: "cancelled",
reason
}, metadata?.returnedReferenceKinds ?? [])) {
setError("i18n:govoplan-quick-access.invalid_result");
return;
}
closeDrawer();
}
})
@@ -196,8 +196,8 @@ export default function QuickAccessSettingsPanel({
<p>{isPersonal ? "i18n:govoplan-quick-access.personal_help" : "i18n:govoplan-quick-access.admin_help"}</p>
</div>
<div className="quick-access-settings-actions">
<Button variant="secondary" icon={<RotateCcw size={16} />} disabled={!dirty || saving} onClick={reset}>i18n:govoplan-quick-access.discard</Button>
<Button variant="primary" icon={<Save size={16} />} disabled={!dirty || saving || !canWrite} onClick={() => void save()}>i18n:govoplan-quick-access.save</Button>
<Button variant="secondary" disabled={!dirty || saving} onClick={reset}><RotateCcw size={16} aria-hidden="true" />i18n:govoplan-quick-access.discard</Button>
<Button variant="primary" disabled={!dirty || saving || !canWrite} onClick={() => void save()}><Save size={16} aria-hidden="true" />i18n:govoplan-quick-access.save</Button>
</div>
</div>
+4
View File
@@ -8,6 +8,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-quick-access.loading": "Schnellzugriff wird geladen",
"i18n:govoplan-quick-access.open_full_page": "Vollständige Seite öffnen",
"i18n:govoplan-quick-access.compact_view_unavailable": "Die kompakte Ansicht ist nicht verfügbar. Verwenden Sie die vollständige Seite.",
"i18n:govoplan-quick-access.recommended_for_view": "Für diese Ansicht empfohlen",
"i18n:govoplan-quick-access.invalid_result": "Das Werkzeug hat ein ungültiges oder nicht zugelassenes Ergebnis zurückgegeben.",
"i18n:govoplan-quick-access.settings_title": "Schnellzugriff",
"i18n:govoplan-quick-access.personal_help": "Werkzeuge auswählen und ordnen, die neben der aktuellen Arbeit verfügbar bleiben.",
"i18n:govoplan-quick-access.admin_help": "Verfügbarkeit, erzwungene Einträge und Standardreihenfolge für nachgeordnete Ebenen festlegen.",
@@ -45,6 +47,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-quick-access.loading": "Loading Quick Access",
"i18n:govoplan-quick-access.open_full_page": "Open full page",
"i18n:govoplan-quick-access.compact_view_unavailable": "The compact view is unavailable. Use the full page.",
"i18n:govoplan-quick-access.recommended_for_view": "Recommended for this View",
"i18n:govoplan-quick-access.invalid_result": "The tool returned an invalid or undeclared result.",
"i18n:govoplan-quick-access.settings_title": "Quick Access",
"i18n:govoplan-quick-access.personal_help": "Choose and order the tools kept beside your current work.",
"i18n:govoplan-quick-access.admin_help": "Set availability, forced items, and default ordering for lower scopes.",
+1
View File
@@ -1,2 +1,3 @@
export { default, quickAccessModule } from "./module";
export { default as QuickAccessRail } from "./components/QuickAccessRail";
export * from "./api/quickAccess";
+15
View File
@@ -124,6 +124,21 @@
padding: 14px 16px 16px;
}
.quick-access-tool.is-recommended {
border-inline-start: 3px solid var(--accent);
padding-inline-start: 13px;
}
.quick-access-recommended {
width: fit-content;
border-radius: var(--radius-round);
padding: 1px 7px;
background: var(--accent-soft);
color: var(--accent);
font-size: 11px;
font-weight: 700;
}
.quick-access-tool-heading,
.quick-access-settings-heading,
.quick-access-preference-row,