feat: add exact credential help contexts

This commit is contained in:
2026-08-17 19:51:51 +02:00
parent ff88142471
commit d307e29145
6 changed files with 57 additions and 17 deletions
+2
View File
@@ -52,6 +52,8 @@ than adding custom `F1` listeners:
- translated label keys provide deterministic field identities for ordinary - translated label keys provide deterministic field identities for ordinary
`FormField`, `ToggleSwitch`, search, date/time, email, button, dialog, and card `FormField`, `ToggleSwitch`, search, date/time, email, button, dialog, and card
controls. controls.
- `TableActionGroup` action definitions carry the same identities so focused
row actions can resolve consequence-specific help.
Module routes, public routes, settings sections, and administration sections Module routes, public routes, settings sections, and administration sections
may also declare `helpContextId` and `helpTopicId`. Each module must keep a may also declare `helpContextId` and `helpTopicId`. Each module must keep a
+6
View File
@@ -42,6 +42,12 @@ Shared controls may set `helpModuleId` when their documentation owner differs
from the containing page; the retention editor uses this to resolve Policy help from the containing page; the retention editor uses this to resolve Policy help
from both administration and Campaign surfaces. from both administration and Campaign surfaces.
The shared reusable-credential manager keeps Access as its documentation owner
and publishes exact contexts for credential kind, secret replacement/removal,
module and server restrictions, lower-scope visibility, activation, save, and
irreversible deletion. This ensures F1 explains secret custody and the effect on
dependent connections from system, tenant, group, user, and personal surfaces.
The generated `help_review_candidates` list is therefore a content-depth queue, The generated `help_review_candidates` list is therefore a content-depth queue,
not a list of controls on which F1 cannot work. It should prioritize: not a list of controls on which F1 cannot work. It should prioritize:
@@ -36,6 +36,10 @@ assert.match(confirmDialog, /helpContextId=\{helpContextId\}/, "confirm dialogs
assert.match(credentials, /<ActionBlockerHint/, "credentials render the shared actionable blocker"); assert.match(credentials, /<ActionBlockerHint/, "credentials render the shared actionable blocker");
assert.match(credentials, /contextId: "access\.credentials"/, "credentials expose stable admin documentation"); assert.match(credentials, /contextId: "access\.credentials"/, "credentials expose stable admin documentation");
assert.match(credentials, /access\.credentials\.field\.secret/, "credential secrets expose exact handling guidance");
assert.match(credentials, /access\.credentials\.field\.inherit-to-lower-scopes/, "credential inheritance exposes exact scope guidance");
assert.match(credentials, /access\.credentials\.confirm-delete/, "credential deletion exposes exact consequence guidance");
assert.match(credentials, /helpModuleId="access"/, "embedded credential controls retain Access as their documentation owner");
assert.match(credentials, /disabledReason: writeDisabledReason/, "credential row actions retain actionable disabled reasons"); assert.match(credentials, /disabledReason: writeDisabledReason/, "credential row actions retain actionable disabled reasons");
assert.doesNotMatch(credentials, /<textarea/, "credentials use typed controls rather than a primary JSON editor"); assert.doesNotMatch(credentials, /<textarea/, "credentials use typed controls rather than a primary JSON editor");
@@ -98,6 +98,7 @@ const EMPTY_DRAFT: CredentialDraft = {
}; };
const CREDENTIAL_DOCUMENTATION = { const CREDENTIAL_DOCUMENTATION = {
moduleId: "access",
contextId: "access.credentials", contextId: "access.credentials",
documentationType: "admin" as const documentationType: "admin" as const
}; };
@@ -409,8 +410,10 @@ export default function CredentialEnvelopeManager({
return ( return (
<div className="credential-envelope-manager"> <div className="credential-envelope-manager">
{targetOptions.length > 0 && ( {targetOptions.length > 0 && (
<FormField label={targetLabel}> <FormField label={targetLabel} helpContextId="access.credentials.target" helpModuleId="access">
<select <select
data-help-context-id="access.credentials.target"
data-help-module-id="access"
value={selectedTargetId} value={selectedTargetId}
disabled={saving} disabled={saving}
onChange={(event) => setSelectedTargetId(event.target.value)} onChange={(event) => setSelectedTargetId(event.target.value)}
@@ -433,6 +436,8 @@ export default function CredentialEnvelopeManager({
)} )}
<Card <Card
title={title} title={title}
helpContextId="access.credentials"
helpModuleId="access"
actions={ actions={
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" /> <DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
@@ -440,6 +445,8 @@ export default function CredentialEnvelopeManager({
type="button" type="button"
title="Reload credentials" title="Reload credentials"
aria-label="Reload credentials" aria-label="Reload credentials"
helpContextId="access.credentials.action.reload"
helpModuleId="access"
onClick={() => void loadCredentials()} onClick={() => void loadCredentials()}
disabled={loading || !scopeReady} disabled={loading || !scopeReady}
disabledReason={loading ? "Credentials are already loading." : !scopeReady ? "Select a credential owner before reloading." : undefined} disabledReason={loading ? "Credentials are already loading." : !scopeReady ? "Select a credential owner before reloading." : undefined}
@@ -449,6 +456,8 @@ export default function CredentialEnvelopeManager({
<Button <Button
type="button" type="button"
variant="primary" variant="primary"
helpContextId="access.credentials.action.create"
helpModuleId="access"
onClick={openCreate} onClick={openCreate}
disabled={Boolean(writeDisabledReason) || loading} disabled={Boolean(writeDisabledReason) || loading}
disabledReason={loading ? "Wait until credentials have loaded." : writeDisabledReason} disabledReason={loading ? "Wait until credentials have loaded." : writeDisabledReason}
@@ -471,6 +480,8 @@ export default function CredentialEnvelopeManager({
id: "edit", id: "edit",
label: `Edit ${credential.name}`, label: `Edit ${credential.name}`,
icon: <Pencil size={15} />, icon: <Pencil size={15} />,
helpContextId: "access.credentials.action.edit",
helpModuleId: "access",
onClick: () => openEdit(credential), onClick: () => openEdit(credential),
disabled: !canWrite || saving, disabled: !canWrite || saving,
disabledReason: writeDisabledReason disabledReason: writeDisabledReason
@@ -479,6 +490,8 @@ export default function CredentialEnvelopeManager({
id: "delete", id: "delete",
label: `Delete ${credential.name}`, label: `Delete ${credential.name}`,
icon: <Trash2 size={15} />, icon: <Trash2 size={15} />,
helpContextId: "access.credentials.action.delete",
helpModuleId: "access",
onClick: () => setDeleting(credential), onClick: () => setDeleting(credential),
disabled: !canWrite || saving, disabled: !canWrite || saving,
disabledReason: writeDisabledReason, disabledReason: writeDisabledReason,
@@ -494,6 +507,8 @@ export default function CredentialEnvelopeManager({
<Dialog <Dialog
open={Boolean(editing)} open={Boolean(editing)}
title={editing === "new" ? "Add reusable credential" : "Edit reusable credential"} title={editing === "new" ? "Add reusable credential" : "Edit reusable credential"}
helpContextId="access.credentials.editor"
helpModuleId="access"
onClose={closeEditor} onClose={closeEditor}
closeDisabled={saving} closeDisabled={saving}
className="admin-dialog admin-dialog-wide adaptive-config-dialog" className="admin-dialog admin-dialog-wide adaptive-config-dialog"
@@ -501,7 +516,7 @@ export default function CredentialEnvelopeManager({
footer={ footer={
<> <>
<Button onClick={closeEditor} disabled={saving} disabledReason={saving ? "Wait for the credential save to finish." : undefined}>Cancel</Button> <Button onClick={closeEditor} disabled={saving} disabledReason={saving ? "Wait for the credential save to finish." : undefined}>Cancel</Button>
<Button variant="primary" onClick={() => void saveDraft()} disabled={saveDisabled} disabledReason={saveDisabledReason}> <Button helpContextId="access.credentials.action.save" helpModuleId="access" variant="primary" onClick={() => void saveDraft()} disabled={saveDisabled} disabledReason={saveDisabledReason}>
<KeyRound size={16} /> {saving ? "Saving" : "Save credential"} <KeyRound size={16} /> {saving ? "Saving" : "Save credential"}
</Button> </Button>
</> </>
@@ -514,32 +529,36 @@ export default function CredentialEnvelopeManager({
<p>The name and type are visible; secret values are never returned by the API.</p> <p>The name and type are visible; secret values are never returned by the API.</p>
</header> </header>
<div className="form-grid two"> <div className="form-grid two">
<FormField label="Name"> <FormField label="Name" helpContextId="access.credentials.field.name" helpModuleId="access">
<input value={draft.name} disabled={saving} onChange={(event) => setDraft({ ...draft, name: event.target.value })} autoFocus /> <input data-help-context-id="access.credentials.field.name" data-help-module-id="access" value={draft.name} disabled={saving} onChange={(event) => setDraft({ ...draft, name: event.target.value })} autoFocus />
</FormField> </FormField>
<FormField label="Type"> <FormField label="Type" helpContextId="access.credentials.field.type" helpModuleId="access">
<select value={draft.credentialKind} disabled={saving} onChange={(event) => setDraft({ ...draft, credentialKind: event.target.value as CredentialKind, secret: "", clearSecret: false })}> <select data-help-context-id="access.credentials.field.type" data-help-module-id="access" value={draft.credentialKind} disabled={saving} onChange={(event) => setDraft({ ...draft, credentialKind: event.target.value as CredentialKind, secret: "", clearSecret: false })}>
<option value="username_password">Username and password</option> <option value="username_password">Username and password</option>
<option value="token">Access token</option> <option value="token">Access token</option>
<option value="api_key">API key</option> <option value="api_key">API key</option>
</select> </select>
</FormField> </FormField>
<FormField label="Description"> <FormField label="Description" helpContextId="access.credentials.field.description" helpModuleId="access">
<input value={draft.description} disabled={saving} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /> <input data-help-context-id="access.credentials.field.description" data-help-module-id="access" value={draft.description} disabled={saving} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
</FormField> </FormField>
<FormField label={draft.credentialKind === "username_password" ? "Username" : "Account or key label"}> <FormField label={draft.credentialKind === "username_password" ? "Username" : "Account or key label"} helpContextId="access.credentials.field.account-label" helpModuleId="access">
<input value={draft.username} disabled={saving} onChange={(event) => setDraft({ ...draft, username: event.target.value })} /> <input data-help-context-id="access.credentials.field.account-label" data-help-module-id="access" value={draft.username} disabled={saving} onChange={(event) => setDraft({ ...draft, username: event.target.value })} />
</FormField> </FormField>
<FormField <FormField
label={secretFieldLabel(draft.credentialKind)} label={secretFieldLabel(draft.credentialKind)}
help={editing !== "new" ? "Leave blank to retain the configured secret." : undefined} help={editing !== "new" ? "Leave blank to retain the configured secret." : undefined}
helpContextId="access.credentials.field.secret"
helpModuleId="access"
> >
<PasswordField value={draft.secret} onValueChange={(secret) => setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" generator /> <PasswordField data-help-context-id="access.credentials.field.secret" data-help-module-id="access" value={draft.secret} onValueChange={(secret) => setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" generator />
</FormField> </FormField>
{editing !== "new" && ( {editing !== "new" && (
<ToggleSwitch <ToggleSwitch
checked={draft.clearSecret} checked={draft.clearSecret}
disabled={saving || Boolean(draft.secret)} disabled={saving || Boolean(draft.secret)}
helpContextId="access.credentials.field.clear-secret"
helpModuleId="access"
onChange={(clearSecret) => setDraft({ ...draft, clearSecret })} onChange={(clearSecret) => setDraft({ ...draft, clearSecret })}
label="Remove configured secret" label="Remove configured secret"
/> />
@@ -552,7 +571,7 @@ export default function CredentialEnvelopeManager({
<p>Empty module or server lists mean every module or server allowed by scope.</p> <p>Empty module or server lists mean every module or server allowed by scope.</p>
</header> </header>
<div className="form-grid two"> <div className="form-grid two">
<FormField label="Modules" help="Choose installed modules that may use this credential. Empty means every module allowed by scope."> <FormField label="Modules" help="Choose installed modules that may use this credential. Empty means every module allowed by scope." helpContextId="access.credentials.field.allowed-modules" helpModuleId="access">
<ReferenceMultiSelect <ReferenceMultiSelect
values={splitValues(draft.allowedModules)} values={splitValues(draft.allowedModules)}
onChange={(values) => setDraft({ ...draft, allowedModules: values.join(", ") })} onChange={(values) => setDraft({ ...draft, allowedModules: values.join(", ") })}
@@ -562,7 +581,7 @@ export default function CredentialEnvelopeManager({
disabled={saving} disabled={saving}
/> />
</FormField> </FormField>
<FormField label="Servers" help="Choose announced servers or enter an explicit module-qualified reference. Empty means every permitted server."> <FormField label="Servers" help="Choose announced servers or enter an explicit module-qualified reference. Empty means every permitted server." helpContextId="access.credentials.field.allowed-servers" helpModuleId="access">
<ReferenceMultiSelect <ReferenceMultiSelect
values={splitValues(draft.allowedServerRefs)} values={splitValues(draft.allowedServerRefs)}
onChange={(values) => setDraft({ ...draft, allowedServerRefs: values.join(", ") })} onChange={(values) => setDraft({ ...draft, allowedServerRefs: values.join(", ") })}
@@ -573,8 +592,8 @@ export default function CredentialEnvelopeManager({
disabled={saving} disabled={saving}
/> />
</FormField> </FormField>
<ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" /> <ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} helpContextId="access.credentials.field.inherit-to-lower-scopes" helpModuleId="access" onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" />
<ToggleSwitch checked={draft.isActive} disabled={saving} onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" /> <ToggleSwitch checked={draft.isActive} disabled={saving} helpContextId="access.credentials.field.active" helpModuleId="access" onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" />
</div> </div>
</section> </section>
</div> </div>
@@ -582,6 +601,8 @@ export default function CredentialEnvelopeManager({
<ConfirmDialog <ConfirmDialog
open={Boolean(deleting)} open={Boolean(deleting)}
helpContextId="access.credentials.confirm-delete"
helpModuleId="access"
title="Delete credential" title="Delete credential"
message={`Delete ${deleting?.name ?? "this credential"}? Connections that reference it will stop authenticating; the secret cannot be recovered.`} message={`Delete ${deleting?.name ?? "this credential"}? Connections that reference it will stop authenticating; the secret cannot be recovered.`}
confirmLabel="Delete" confirmLabel="Delete"
@@ -1,8 +1,9 @@
import type { MouseEvent, ReactNode } from "react"; import type { MouseEvent, ReactNode } from "react";
import Button from "../Button"; import Button from "../Button";
import { usePlatformLanguage } from "../../i18n/LanguageContext"; import { usePlatformLanguage } from "../../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../../types";
export type TableActionDefinition = { export type TableActionDefinition = PlatformInterfaceIdentityProps & {
id: string; id: string;
label: string; label: string;
icon: ReactNode; icon: ReactNode;
@@ -76,6 +77,10 @@ export default function TableActionGroup({
type="button" type="button"
variant={action.variant ?? "secondary"} variant={action.variant ?? "secondary"}
className="table-action-button" className="table-action-button"
interfaceId={action.interfaceId}
helpContextId={action.helpContextId}
helpModuleId={action.helpModuleId}
helpTopicId={action.helpTopicId}
aria-label={translatedLabel} aria-label={translatedLabel}
title={translatedLabel} title={translatedLabel}
disabled={action.disabled || action.applicable === false} disabled={action.disabled || action.applicable === false}
+3 -1
View File
@@ -37,7 +37,7 @@ assertEqual((emptyAction.match(/table-action-placeholder/g) ?? []).length, 3, "e
const contextActions = renderToStaticMarkup( const contextActions = renderToStaticMarkup(
<TableActionGroup <TableActionGroup
actions={[ actions={[
{ id: "inspect", label: "Inspect", icon: <span>I</span>, onClick: noop }, { id: "inspect", label: "Inspect", icon: <span>I</span>, helpContextId: "test.row.inspect", helpModuleId: "test-module", onClick: noop },
{ id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop }, { id: "edit", label: "Edit", icon: <span>E</span>, applicable: false, onClick: noop },
{ id: "remove", label: "Remove", icon: <span>R</span>, disabledReason: "Permission denied", onClick: noop } { id: "remove", label: "Remove", icon: <span>R</span>, disabledReason: "Permission denied", onClick: noop }
]} ]}
@@ -51,6 +51,8 @@ assertEqual((contextActions.match(/table-action-placeholder/g) ?? []).length, 1,
assertEqual(contextActions.includes("disabled-action-tooltip"), true, "disabled table actions can explain their unavailable state"); assertEqual(contextActions.includes("disabled-action-tooltip"), true, "disabled table actions can explain their unavailable state");
assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label"); assertEqual(contextActions.includes('aria-label="Inspect"'), true, "table actions expose an accessible label");
assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip"); assertEqual(contextActions.includes('title="Inspect"'), true, "table actions expose a native tooltip");
assertEqual(contextActions.includes('data-help-context-id="test.row.inspect"'), true, "table actions propagate exact contextual help");
assertEqual(contextActions.includes('data-help-module-id="test-module"'), true, "table actions propagate the documentation owner");
assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative"); assertEqual(contextActions.includes('aria-hidden="true"'), true, "table action icons stay decorative");
let propagationStopped = false; let propagationStopped = false;