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
|
||||
`FormField`, `ToggleSwitch`, search, date/time, email, button, dialog, and card
|
||||
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
|
||||
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 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,
|
||||
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, /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.doesNotMatch(credentials, /<textarea/, "credentials use typed controls rather than a primary JSON editor");
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ const EMPTY_DRAFT: CredentialDraft = {
|
||||
};
|
||||
|
||||
const CREDENTIAL_DOCUMENTATION = {
|
||||
moduleId: "access",
|
||||
contextId: "access.credentials",
|
||||
documentationType: "admin" as const
|
||||
};
|
||||
@@ -409,8 +410,10 @@ export default function CredentialEnvelopeManager({
|
||||
return (
|
||||
<div className="credential-envelope-manager">
|
||||
{targetOptions.length > 0 && (
|
||||
<FormField label={targetLabel}>
|
||||
<FormField label={targetLabel} helpContextId="access.credentials.target" helpModuleId="access">
|
||||
<select
|
||||
data-help-context-id="access.credentials.target"
|
||||
data-help-module-id="access"
|
||||
value={selectedTargetId}
|
||||
disabled={saving}
|
||||
onChange={(event) => setSelectedTargetId(event.target.value)}
|
||||
@@ -433,6 +436,8 @@ export default function CredentialEnvelopeManager({
|
||||
)}
|
||||
<Card
|
||||
title={title}
|
||||
helpContextId="access.credentials"
|
||||
helpModuleId="access"
|
||||
actions={
|
||||
<div className="button-row compact-actions">
|
||||
<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
|
||||
@@ -440,6 +445,8 @@ export default function CredentialEnvelopeManager({
|
||||
type="button"
|
||||
title="Reload credentials"
|
||||
aria-label="Reload credentials"
|
||||
helpContextId="access.credentials.action.reload"
|
||||
helpModuleId="access"
|
||||
onClick={() => void loadCredentials()}
|
||||
disabled={loading || !scopeReady}
|
||||
disabledReason={loading ? "Credentials are already loading." : !scopeReady ? "Select a credential owner before reloading." : undefined}
|
||||
@@ -449,6 +456,8 @@ export default function CredentialEnvelopeManager({
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
helpContextId="access.credentials.action.create"
|
||||
helpModuleId="access"
|
||||
onClick={openCreate}
|
||||
disabled={Boolean(writeDisabledReason) || loading}
|
||||
disabledReason={loading ? "Wait until credentials have loaded." : writeDisabledReason}
|
||||
@@ -471,6 +480,8 @@ export default function CredentialEnvelopeManager({
|
||||
id: "edit",
|
||||
label: `Edit ${credential.name}`,
|
||||
icon: <Pencil size={15} />,
|
||||
helpContextId: "access.credentials.action.edit",
|
||||
helpModuleId: "access",
|
||||
onClick: () => openEdit(credential),
|
||||
disabled: !canWrite || saving,
|
||||
disabledReason: writeDisabledReason
|
||||
@@ -479,6 +490,8 @@ export default function CredentialEnvelopeManager({
|
||||
id: "delete",
|
||||
label: `Delete ${credential.name}`,
|
||||
icon: <Trash2 size={15} />,
|
||||
helpContextId: "access.credentials.action.delete",
|
||||
helpModuleId: "access",
|
||||
onClick: () => setDeleting(credential),
|
||||
disabled: !canWrite || saving,
|
||||
disabledReason: writeDisabledReason,
|
||||
@@ -494,6 +507,8 @@ export default function CredentialEnvelopeManager({
|
||||
<Dialog
|
||||
open={Boolean(editing)}
|
||||
title={editing === "new" ? "Add reusable credential" : "Edit reusable credential"}
|
||||
helpContextId="access.credentials.editor"
|
||||
helpModuleId="access"
|
||||
onClose={closeEditor}
|
||||
closeDisabled={saving}
|
||||
className="admin-dialog admin-dialog-wide adaptive-config-dialog"
|
||||
@@ -501,7 +516,7 @@ export default function CredentialEnvelopeManager({
|
||||
footer={
|
||||
<>
|
||||
<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"}
|
||||
</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>
|
||||
</header>
|
||||
<div className="form-grid two">
|
||||
<FormField label="Name">
|
||||
<input value={draft.name} disabled={saving} onChange={(event) => setDraft({ ...draft, name: event.target.value })} autoFocus />
|
||||
<FormField label="Name" helpContextId="access.credentials.field.name" helpModuleId="access">
|
||||
<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 label="Type">
|
||||
<select value={draft.credentialKind} disabled={saving} onChange={(event) => setDraft({ ...draft, credentialKind: event.target.value as CredentialKind, secret: "", clearSecret: false })}>
|
||||
<FormField label="Type" helpContextId="access.credentials.field.type" helpModuleId="access">
|
||||
<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="token">Access token</option>
|
||||
<option value="api_key">API key</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Description">
|
||||
<input value={draft.description} disabled={saving} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
|
||||
<FormField label="Description" helpContextId="access.credentials.field.description" helpModuleId="access">
|
||||
<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 label={draft.credentialKind === "username_password" ? "Username" : "Account or key label"}>
|
||||
<input value={draft.username} disabled={saving} onChange={(event) => setDraft({ ...draft, username: event.target.value })} />
|
||||
<FormField label={draft.credentialKind === "username_password" ? "Username" : "Account or key label"} helpContextId="access.credentials.field.account-label" helpModuleId="access">
|
||||
<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
|
||||
label={secretFieldLabel(draft.credentialKind)}
|
||||
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>
|
||||
{editing !== "new" && (
|
||||
<ToggleSwitch
|
||||
checked={draft.clearSecret}
|
||||
disabled={saving || Boolean(draft.secret)}
|
||||
helpContextId="access.credentials.field.clear-secret"
|
||||
helpModuleId="access"
|
||||
onChange={(clearSecret) => setDraft({ ...draft, clearSecret })}
|
||||
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>
|
||||
</header>
|
||||
<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
|
||||
values={splitValues(draft.allowedModules)}
|
||||
onChange={(values) => setDraft({ ...draft, allowedModules: values.join(", ") })}
|
||||
@@ -562,7 +581,7 @@ export default function CredentialEnvelopeManager({
|
||||
disabled={saving}
|
||||
/>
|
||||
</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
|
||||
values={splitValues(draft.allowedServerRefs)}
|
||||
onChange={(values) => setDraft({ ...draft, allowedServerRefs: values.join(", ") })}
|
||||
@@ -573,8 +592,8 @@ export default function CredentialEnvelopeManager({
|
||||
disabled={saving}
|
||||
/>
|
||||
</FormField>
|
||||
<ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} 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.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} helpContextId="access.credentials.field.active" helpModuleId="access" onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -582,6 +601,8 @@ export default function CredentialEnvelopeManager({
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
helpContextId="access.credentials.confirm-delete"
|
||||
helpModuleId="access"
|
||||
title="Delete credential"
|
||||
message={`Delete ${deleting?.name ?? "this credential"}? Connections that reference it will stop authenticating; the secret cannot be recovered.`}
|
||||
confirmLabel="Delete"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { MouseEvent, ReactNode } from "react";
|
||||
import Button from "../Button";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
import type { PlatformInterfaceIdentityProps } from "../../types";
|
||||
|
||||
export type TableActionDefinition = {
|
||||
export type TableActionDefinition = PlatformInterfaceIdentityProps & {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
@@ -76,6 +77,10 @@ export default function TableActionGroup({
|
||||
type="button"
|
||||
variant={action.variant ?? "secondary"}
|
||||
className="table-action-button"
|
||||
interfaceId={action.interfaceId}
|
||||
helpContextId={action.helpContextId}
|
||||
helpModuleId={action.helpModuleId}
|
||||
helpTopicId={action.helpTopicId}
|
||||
aria-label={translatedLabel}
|
||||
title={translatedLabel}
|
||||
disabled={action.disabled || action.applicable === false}
|
||||
|
||||
@@ -37,7 +37,7 @@ assertEqual((emptyAction.match(/table-action-placeholder/g) ?? []).length, 3, "e
|
||||
const contextActions = renderToStaticMarkup(
|
||||
<TableActionGroup
|
||||
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: "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('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('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");
|
||||
|
||||
let propagationStopped = false;
|
||||
|
||||
Reference in New Issue
Block a user