feat: add exact credential help contexts
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user