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 Quick Access is not an authorization boundary. Every contribution keeps its
own permission requirements and View surface. Full-page routes remain the own permission requirements and View surface. Full-page routes remain the
canonical fallback. Launch context version 1 contains only the tenant/account canonical fallback. Launch-context version 2 contains only the tenant/account
identity, safe active-object reference, acting-assignment identifiers, identity, a reference-contract-version-1 active object, acting-assignment
temporal selection, exact View identity, and return route. Cross-tenant object identifiers, temporal selection, exact View identity, View recommendations or
references are discarded and unknown context versions are ignored. The focus, and a safe return route. Cross-tenant object references are discarded
destination reauthorizes every read and effect. Disabling this module removes and unknown versions fail closed. View focus applies only when at least one
the rail without making any domain state unavailable through its owning focused tool is currently enabled, context-compatible, and authorized; a
module. 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. " "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, " "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. " "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, " "Launch-context version 2 carries only versioned, bounded references to the current object, acting assignment, "
"temporal selection, View revision, and return location. The destination rechecks access, and " "temporal selection, exact View revision, and return location. Owner modules recheck access when a tool opens and "
"the breadcrumb return action restores the originating route without copying protected content." "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", layer="configured",
documentation_types=("user",), documentation_types=("user",),
@@ -143,8 +145,11 @@ DOCUMENTATION = (
"Eine Kategorie in der rechten Leiste oeffnet kompakte Werkzeuge, ohne die aktuelle Aufgabe zu verlassen. " "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. " "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. " "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, " "Startkontext Version 2 uebergibt nur versionierte, begrenzte Verweise auf Objekt, handelnde Zuordnung, "
"Zeitbezug, Ansichtsversion und Ruecksprungort uebergeben. Das Ziel prueft den Zugriff erneut." "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; " "The catalogue follows installed module registrations. System settings constrain tenants; "
"tenant settings constrain users. An item may remain available, be blocked, or be forced. " "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. " "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", layer="configured",
documentation_types=("admin",), documentation_types=("admin",),
@@ -177,7 +185,10 @@ DOCUMENTATION = (
"Der Katalog folgt den Registrierungen installierter Module. Systemeinstellungen begrenzen Mandanten, " "Der Katalog folgt den Registrierungen installierter Module. Systemeinstellungen begrenzen Mandanten, "
"Mandanteneinstellungen begrenzen Benutzer. Ein Eintrag kann verfuegbar, gesperrt oder erzwungen sein. " "Mandanteneinstellungen begrenzen Benutzer. Ein Eintrag kann verfuegbar, gesperrt oder erzwungen sein. "
"Ansichten und Berechtigungen bilden weitere Grenzen; Schnellzugriff erteilt selbst keinen Datenzugriff. " "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): class CatalogueToolResponse(BaseModel):
contract_version: str
id: str id: str
module_id: str module_id: str
category_id: str category_id: str
@@ -55,6 +56,10 @@ class CatalogueToolResponse(BaseModel):
order: int order: int
default_enabled: bool default_enabled: bool
modes: list[str] modes: list[str]
availability: str
accepted_reference_kinds: list[str]
returned_reference_kinds: list[str]
help_context_id: str | None = None
class CatalogueResponse(BaseModel): class CatalogueResponse(BaseModel):
@@ -91,6 +91,7 @@ def build_catalogue(
continue continue
tools.append( tools.append(
CatalogueToolResponse( CatalogueToolResponse(
contract_version=tool.contract_version,
id=tool.id, id=tool.id,
module_id=tool.module_id, module_id=tool.module_id,
category_id=tool.category_id, category_id=tool.category_id,
@@ -104,6 +105,10 @@ def build_catalogue(
order=tool.order, order=tool.order,
default_enabled=tool.default_enabled, default_enabled=tool.default_enabled,
modes=list(tool.modes), 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)) 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 = { export type QuickAccessTool = {
contract_version: "1";
id: string; id: string;
module_id: string; module_id: string;
category_id: string; category_id: string;
@@ -40,6 +41,10 @@ export type QuickAccessTool = {
order: number; order: number;
default_enabled: boolean; default_enabled: boolean;
modes: string[]; modes: string[];
availability: "global" | "active_object";
accepted_reference_kinds: string[];
returned_reference_kinds: string[];
help_context_id?: string | null;
}; };
export type QuickAccessCatalogue = { 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 { Link, useLocation } from "react-router";
import { import {
DismissibleAlert, DismissibleAlert,
DocumentationHelpLink,
IconButton, IconButton,
LoadingFrame, LoadingFrame,
dispatchQuickAccessResult, dispatchQuickAccessResult,
@@ -51,12 +52,46 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
[contributions] [contributions]
); );
const availableToolIds = useMemo(() => new Set(tools.map((tool) => tool.id)), [tools]); const availableToolIds = useMemo(() => new Set(tools.map((tool) => tool.id)), [tools]);
const categories = useMemo( const metadataById = useMemo(() => new Map(tools.map((tool) => [tool.id, tool])), [tools]);
() => (effective?.categories ?? []).map((category) => ({ 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, ...category,
tools: category.tools.filter((tool) => tool.enabled && availableToolIds.has(tool.id)) tools: category.tools.filter((tool) => {
})).filter((category) => category.enabled && category.tools.length > 0), if (!tool.enabled || !availableToolIds.has(tool.id)) return false;
[availableToolIds, effective] 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; 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"> <LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
{activeCategory.tools.map((tool) => { {activeCategory.tools.map((tool) => {
const renderer = renderers.get(tool.id); const renderer = renderers.get(tool.id);
const metadata = metadataById.get(tool.id);
return ( 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 className="quick-access-tool-heading">
<div> <div>
<strong>{translateText(tool.label)}</strong> <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} {tool.description ? <small>{translateText(tool.description)}</small> : null}
</div> </div>
{metadata?.helpContextId ? (
<DocumentationHelpLink reference={{ contextId: metadata.helpContextId, moduleId: tool.module_id }} />
) : null}
{tool.full_page_path ? ( {tool.full_page_path ? (
<button <button
type="button" type="button"
@@ -214,7 +263,21 @@ export default function QuickAccessRail({ settings, auth, tools, launchContext }
active: true, active: true,
launchContext, launchContext,
complete: (result) => { 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(); closeDrawer();
} }
}) })
@@ -196,8 +196,8 @@ export default function QuickAccessSettingsPanel({
<p>{isPersonal ? "i18n:govoplan-quick-access.personal_help" : "i18n:govoplan-quick-access.admin_help"}</p> <p>{isPersonal ? "i18n:govoplan-quick-access.personal_help" : "i18n:govoplan-quick-access.admin_help"}</p>
</div> </div>
<div className="quick-access-settings-actions"> <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="secondary" disabled={!dirty || saving} onClick={reset}><RotateCcw size={16} aria-hidden="true" />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="primary" disabled={!dirty || saving || !canWrite} onClick={() => void save()}><Save size={16} aria-hidden="true" />i18n:govoplan-quick-access.save</Button>
</div> </div>
</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.loading": "Schnellzugriff wird geladen",
"i18n:govoplan-quick-access.open_full_page": "Vollständige Seite öffnen", "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.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.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.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.", "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.loading": "Loading Quick Access",
"i18n:govoplan-quick-access.open_full_page": "Open full page", "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.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.settings_title": "Quick Access",
"i18n:govoplan-quick-access.personal_help": "Choose and order the tools kept beside your current work.", "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.", "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, quickAccessModule } from "./module";
export { default as QuickAccessRail } from "./components/QuickAccessRail";
export * from "./api/quickAccess"; export * from "./api/quickAccess";
+15
View File
@@ -124,6 +124,21 @@
padding: 14px 16px 16px; 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-tool-heading,
.quick-access-settings-heading, .quick-access-settings-heading,
.quick-access-preference-row, .quick-access-preference-row,