708 lines
26 KiB
TypeScript
708 lines
26 KiB
TypeScript
import { FormGrid } from "./ContentGrid";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
|
import type {
|
|
ApiSettings,
|
|
CredentialEnvelopeSummary,
|
|
MailProfileScope
|
|
} from "../types";
|
|
import {
|
|
createCredentialEnvelope,
|
|
deleteCredentialEnvelope,
|
|
listCredentialEnvelopes,
|
|
updateCredentialEnvelope
|
|
} from "../api/credentials";
|
|
import Button from "./Button";
|
|
import Card from "./Card";
|
|
import ConfirmDialog from "./ConfirmDialog";
|
|
import ConnectionTree, { type ConnectionTreeColumn } from "./ConnectionTree";
|
|
import Dialog from "./Dialog";
|
|
import DismissibleAlert from "./DismissibleAlert";
|
|
import FormField from "./FormField";
|
|
import LoadingFrame from "./LoadingFrame";
|
|
import PasswordField from "./PasswordField";
|
|
import {
|
|
ReferenceMultiSelect,
|
|
combineReferenceOptionProviders,
|
|
customReferenceOption,
|
|
staticReferenceOptionProvider,
|
|
type ReferenceOption
|
|
} from "./ReferenceSelect";
|
|
import { platformModuleReferenceProvider } from "../platform/referenceProviders";
|
|
import StatusBadge from "./StatusBadge";
|
|
import TableActionGroup from "./table/TableActionGroup";
|
|
import ToggleSwitch from "./ToggleSwitch";
|
|
import { useUnsavedDraftGuard } from "./UnsavedChangesGuard";
|
|
import {
|
|
usePlatformModules,
|
|
usePlatformUiCapabilities
|
|
} from "../platform/ModuleContext";
|
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
|
import type {
|
|
CredentialReferenceSelectorsUiCapability
|
|
} from "./ReferenceSelect";
|
|
import ActionBlockerHint, { type ActionBlockerReason } from "./ActionBlockerHint";
|
|
import DocumentationHelpLink from "./help/DocumentationHelpLink";
|
|
|
|
export type CredentialEnvelopeTargetOption = {
|
|
id: string;
|
|
label: string;
|
|
secondary?: string | null;
|
|
};
|
|
|
|
export type CredentialEnvelopeServerOption = {
|
|
id: string;
|
|
label: string;
|
|
secondary?: string | null;
|
|
disabled?: boolean;
|
|
};
|
|
|
|
export type CredentialEnvelopeManagerProps = {
|
|
settings: ApiSettings;
|
|
scopeType: Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
|
|
scopeId?: string | null;
|
|
targetOptions?: CredentialEnvelopeTargetOption[];
|
|
serverOptions?: CredentialEnvelopeServerOption[];
|
|
targetLabel?: string;
|
|
title?: string;
|
|
canWrite: boolean;
|
|
};
|
|
|
|
type CredentialKind = "username_password" | "token" | "api_key";
|
|
|
|
type CredentialDraft = {
|
|
name: string;
|
|
description: string;
|
|
credentialKind: CredentialKind;
|
|
username: string;
|
|
secret: string;
|
|
allowedModules: string;
|
|
allowedServerRefs: string;
|
|
inheritToLowerScopes: boolean;
|
|
isActive: boolean;
|
|
clearSecret: boolean;
|
|
retainedPublicData: Record<string, unknown>;
|
|
};
|
|
|
|
const EMPTY_DRAFT: CredentialDraft = {
|
|
name: "",
|
|
description: "",
|
|
credentialKind: "username_password",
|
|
username: "",
|
|
secret: "",
|
|
allowedModules: "",
|
|
allowedServerRefs: "",
|
|
inheritToLowerScopes: false,
|
|
isActive: true,
|
|
clearSecret: false,
|
|
retainedPublicData: {}
|
|
};
|
|
|
|
const CREDENTIAL_DOCUMENTATION = {
|
|
moduleId: "access",
|
|
contextId: "access.credentials",
|
|
documentationType: "admin" as const
|
|
};
|
|
|
|
export default function CredentialEnvelopeManager({
|
|
settings,
|
|
scopeType,
|
|
scopeId,
|
|
targetOptions = [],
|
|
serverOptions = [],
|
|
targetLabel = "Target",
|
|
title = "Credential envelopes",
|
|
canWrite
|
|
}: CredentialEnvelopeManagerProps) {
|
|
const modules = usePlatformModules();
|
|
const { translateText } = usePlatformLanguage();
|
|
const [selectedTargetId, setSelectedTargetId] = useState(scopeId ?? targetOptions[0]?.id ?? "");
|
|
const [credentials, setCredentials] = useState<CredentialEnvelopeSummary[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [notice, setNotice] = useState("");
|
|
const [editing, setEditing] = useState<CredentialEnvelopeSummary | "new" | null>(null);
|
|
const [draft, setDraft] = useState<CredentialDraft>(EMPTY_DRAFT);
|
|
const [savedDraftKey, setSavedDraftKey] = useState("");
|
|
const [deleting, setDeleting] = useState<CredentialEnvelopeSummary | null>(null);
|
|
const activeScopeId = scopeType === "system" || scopeType === "tenant"
|
|
? scopeId ?? null
|
|
: selectedTargetId || null;
|
|
const scopeReady = scopeType === "system" || scopeType === "tenant" || Boolean(activeScopeId);
|
|
const draftDirty = Boolean(editing) && credentialDraftKey(draft) !== savedDraftKey;
|
|
const referenceCapabilities =
|
|
usePlatformUiCapabilities<CredentialReferenceSelectorsUiCapability>(
|
|
"core.credentialReferenceSelectors"
|
|
);
|
|
const moduleReferenceOptions = useMemo<ReferenceOption[]>(
|
|
() => modules.map((module) => ({
|
|
value: module.id,
|
|
label: translateText(module.label),
|
|
description: `${module.id} · version ${module.version}`,
|
|
kind: "module",
|
|
sourceModule: "core",
|
|
provenance: { version: module.version }
|
|
})),
|
|
[modules, translateText]
|
|
);
|
|
const serverReferenceOptions = useMemo<ReferenceOption[]>(
|
|
() => serverOptions.map((server) => ({
|
|
value: server.id,
|
|
label: server.label,
|
|
description: server.secondary ?? server.id,
|
|
kind: "server",
|
|
disabled: server.disabled,
|
|
availability: server.disabled ? "inactive" : "available",
|
|
provenance: { announcedByConsumer: true }
|
|
})),
|
|
[serverOptions]
|
|
);
|
|
const moduleReferenceProvider = useMemo(
|
|
() => platformModuleReferenceProvider(settings, moduleReferenceOptions),
|
|
[moduleReferenceOptions, settings]
|
|
);
|
|
const serverReferenceProvider = useMemo(
|
|
() =>
|
|
combineReferenceOptionProviders([
|
|
staticReferenceOptionProvider(serverReferenceOptions),
|
|
...referenceCapabilities.map((capability) =>
|
|
capability.serverProvider(settings, {
|
|
scopeType,
|
|
scopeId: activeScopeId
|
|
})
|
|
)
|
|
]),
|
|
[
|
|
activeScopeId,
|
|
referenceCapabilities,
|
|
scopeType,
|
|
serverReferenceOptions,
|
|
settings
|
|
]
|
|
);
|
|
|
|
useUnsavedDraftGuard({
|
|
dirty: draftDirty,
|
|
onSave: saveDraft,
|
|
onDiscard: closeEditor
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (scopeId) {
|
|
setSelectedTargetId(scopeId);
|
|
return;
|
|
}
|
|
if (
|
|
targetOptions.length > 0 &&
|
|
!targetOptions.some((option) => option.id === selectedTargetId)
|
|
) {
|
|
setSelectedTargetId(targetOptions[0].id);
|
|
}
|
|
}, [scopeId, selectedTargetId, targetOptions]);
|
|
|
|
useEffect(() => {
|
|
void loadCredentials();
|
|
}, [
|
|
activeScopeId,
|
|
scopeReady,
|
|
scopeType,
|
|
settings.accessToken,
|
|
settings.apiBaseUrl,
|
|
settings.apiKey
|
|
]);
|
|
|
|
async function loadCredentials() {
|
|
if (!scopeReady) {
|
|
setCredentials([]);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
setCredentials(
|
|
await listCredentialEnvelopes(settings, {
|
|
scope_type: scopeType,
|
|
scope_id: activeScopeId,
|
|
include_inactive: true
|
|
})
|
|
);
|
|
} catch (err) {
|
|
setCredentials([]);
|
|
setError(errorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function openCreate() {
|
|
const next = { ...EMPTY_DRAFT, retainedPublicData: {} };
|
|
setEditing("new");
|
|
setDraft(next);
|
|
setSavedDraftKey(credentialDraftKey(next));
|
|
setError("");
|
|
setNotice("");
|
|
}
|
|
|
|
function openEdit(credential: CredentialEnvelopeSummary) {
|
|
const next = credentialDraft(credential);
|
|
setEditing(credential);
|
|
setDraft(next);
|
|
setSavedDraftKey(credentialDraftKey(next));
|
|
setError("");
|
|
setNotice("");
|
|
}
|
|
|
|
function closeEditor() {
|
|
if (saving) return;
|
|
setEditing(null);
|
|
setDraft(EMPTY_DRAFT);
|
|
setSavedDraftKey("");
|
|
}
|
|
|
|
async function saveDraft(): Promise<boolean> {
|
|
if (!editing || !scopeReady || !draft.name.trim() || !canWrite) return false;
|
|
if (editing === "new" && !draft.secret.trim()) {
|
|
setError("Enter a secret before creating the credential.");
|
|
return false;
|
|
}
|
|
const kindChanged = editing !== "new" && draft.credentialKind !== editing.credential_kind;
|
|
if (kindChanged && !draft.secret.trim()) {
|
|
setError("Enter a replacement secret when changing the credential type.");
|
|
return false;
|
|
}
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
const publicData = {
|
|
...draft.retainedPublicData,
|
|
username: draft.username.trim() || undefined
|
|
};
|
|
if (!draft.username.trim()) delete publicData.username;
|
|
const shared = {
|
|
name: draft.name.trim(),
|
|
description: draft.description.trim() || null,
|
|
credential_kind: draft.credentialKind,
|
|
public_data: publicData,
|
|
allowed_modules: splitValues(draft.allowedModules),
|
|
allowed_server_refs: splitValues(draft.allowedServerRefs),
|
|
inherit_to_lower_scopes: draft.inheritToLowerScopes,
|
|
is_active: draft.isActive
|
|
};
|
|
if (editing === "new") {
|
|
await createCredentialEnvelope(settings, {
|
|
scope_type: scopeType,
|
|
scope_id: activeScopeId,
|
|
...shared,
|
|
secret_data: secretPayload(draft)
|
|
});
|
|
} else {
|
|
await updateCredentialEnvelope(settings, editing.id, {
|
|
...shared,
|
|
...(draft.secret.trim() ? { secret_data: secretPayload(draft) } : {}),
|
|
clear_secret: draft.clearSecret
|
|
});
|
|
}
|
|
setNotice(editing === "new" ? "Credential created." : "Credential saved.");
|
|
closeEditor();
|
|
await loadCredentials();
|
|
return true;
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
return false;
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (!deleting) return;
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
await deleteCredentialEnvelope(settings, deleting.id);
|
|
setNotice("Credential deleted. Connections using it will remain configured but cannot authenticate.");
|
|
setDeleting(null);
|
|
await loadCredentials();
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
const columns = useMemo<ConnectionTreeColumn<CredentialEnvelopeSummary>[]>(
|
|
() => [
|
|
{
|
|
id: "credential",
|
|
header: "Credential",
|
|
width: "minmax(240px, 1.4fr)",
|
|
render: (credential) => (
|
|
<div className="connection-tree-main">
|
|
<strong>{credential.name}</strong>
|
|
<div className="connection-tree-muted-line">
|
|
{credential.description || credential.public_data.username
|
|
? String(credential.description || credential.public_data.username)
|
|
: credential.id}
|
|
</div>
|
|
</div>
|
|
)
|
|
},
|
|
{
|
|
id: "kind",
|
|
header: "Type",
|
|
width: "minmax(150px, 0.7fr)",
|
|
render: (credential) => credentialKindLabel(credential.credential_kind)
|
|
},
|
|
{
|
|
id: "availability",
|
|
header: "Availability",
|
|
width: "minmax(220px, 1fr)",
|
|
render: (credential) => availabilityLabel(credential)
|
|
},
|
|
{
|
|
id: "status",
|
|
header: "Status",
|
|
width: "120px",
|
|
render: (credential) => (
|
|
<StatusBadge
|
|
status={credential.is_active && credential.secret_configured ? "success" : "inactive"}
|
|
label={
|
|
credential.is_active
|
|
? credential.secret_configured
|
|
? "Ready"
|
|
: "No secret"
|
|
: "Inactive"
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
],
|
|
[]
|
|
);
|
|
|
|
const kindChanged = editing !== null && editing !== "new" &&
|
|
draft.credentialKind !== editing.credential_kind;
|
|
const saveDisabled = saving || !canWrite || !draft.name.trim() ||
|
|
(editing === "new" && !draft.secret.trim()) ||
|
|
(kindChanged && !draft.secret.trim());
|
|
const managerBlocker = credentialManagerBlocker({ canWrite, scopeReady, scopeType });
|
|
const writeDisabledReason = saving
|
|
? "A credential change is already being saved."
|
|
: !scopeReady
|
|
? "Select a credential owner before changing credentials."
|
|
: !canWrite
|
|
? "Your account may inspect credentials at this scope but cannot change them."
|
|
: undefined;
|
|
const saveDisabledReason = saving
|
|
? "The credential is already being saved."
|
|
: !canWrite
|
|
? "Your account may inspect this credential but cannot change it."
|
|
: !scopeReady
|
|
? "Select a credential owner before saving."
|
|
: !draft.name.trim()
|
|
? "Enter a credential name before saving."
|
|
: editing === "new" && !draft.secret.trim()
|
|
? "Enter a secret before creating the credential."
|
|
: kindChanged && !draft.secret.trim()
|
|
? "Enter a replacement secret when changing the credential type."
|
|
: undefined;
|
|
|
|
return (
|
|
<div className="credential-envelope-manager">
|
|
{targetOptions.length > 0 && (
|
|
<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)}
|
|
>
|
|
{targetOptions.map((option) => (
|
|
<option key={option.id} value={option.id}>
|
|
{option.label}{option.secondary ? ` - ${option.secondary}` : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</FormField>
|
|
)}
|
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
|
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
|
{managerBlocker && (
|
|
<ActionBlockerHint
|
|
reason={managerBlocker}
|
|
documentation={CREDENTIAL_DOCUMENTATION}
|
|
/>
|
|
)}
|
|
<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" />
|
|
<Button
|
|
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}
|
|
>
|
|
<RefreshCw size={16} />
|
|
</Button>
|
|
<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}
|
|
>
|
|
<Plus size={16} /> Add credential
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<LoadingFrame loading={loading}>
|
|
<ConnectionTree
|
|
rows={credentials}
|
|
columns={columns}
|
|
getRowKey={(credential) => credential.id}
|
|
emptyText="No credentials are configured for this scope."
|
|
renderActions={(credential) => (
|
|
<TableActionGroup
|
|
actions={[
|
|
{
|
|
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
|
|
},
|
|
{
|
|
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,
|
|
variant: "danger"
|
|
}
|
|
]}
|
|
/>
|
|
)}
|
|
/>
|
|
</LoadingFrame>
|
|
</Card>
|
|
|
|
<Dialog variant="administration" size="wide"
|
|
open={Boolean(editing)}
|
|
title={editing === "new" ? "Add reusable credential" : "Edit reusable credential"}
|
|
helpContextId="access.credentials.editor"
|
|
helpModuleId="access"
|
|
onClose={closeEditor}
|
|
closeDisabled={saving}
|
|
footerClassName="button-row compact-actions"
|
|
footer={
|
|
<>
|
|
<Button onClick={closeEditor} disabled={saving} disabledReason={saving ? "Wait for the credential save to finish." : undefined}>Cancel</Button>
|
|
<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>
|
|
</>
|
|
}
|
|
>
|
|
<div className="adaptive-config-form">
|
|
<section className="adaptive-config-section">
|
|
<header>
|
|
<h3>Identity</h3>
|
|
<p>The name and type are visible; secret values are never returned by the API.</p>
|
|
</header>
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
<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" 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" 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"} 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 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"
|
|
/>
|
|
)}
|
|
</FormGrid>
|
|
</section>
|
|
<section className="adaptive-config-section">
|
|
<header>
|
|
<h3>Availability</h3>
|
|
<p>Empty module or server lists mean every module or server allowed by scope.</p>
|
|
</header>
|
|
<FormGrid columns={2} collapseAt="standard" className="">
|
|
<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(", ") })}
|
|
provider={moduleReferenceProvider}
|
|
aria-label="Credential modules"
|
|
placeholder="Add a module"
|
|
disabled={saving}
|
|
/>
|
|
</FormField>
|
|
<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(", ") })}
|
|
provider={serverReferenceProvider}
|
|
createCustomOption={(value) => customReferenceOption(value, "server")}
|
|
aria-label="Credential servers"
|
|
placeholder="Add a server reference"
|
|
disabled={saving}
|
|
/>
|
|
</FormField>
|
|
<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" />
|
|
</FormGrid>
|
|
</section>
|
|
</div>
|
|
</Dialog>
|
|
|
|
<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"
|
|
tone="danger"
|
|
busy={saving}
|
|
onCancel={() => setDeleting(null)}
|
|
onConfirm={() => void confirmDelete()}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function credentialManagerBlocker({
|
|
canWrite,
|
|
scopeReady,
|
|
scopeType
|
|
}: {
|
|
canWrite: boolean;
|
|
scopeReady: boolean;
|
|
scopeType: CredentialEnvelopeManagerProps["scopeType"];
|
|
}): ActionBlockerReason | null {
|
|
if (!scopeReady) {
|
|
return {
|
|
summary: "A credential owner is required.",
|
|
details: `No ${scopeType} target is currently selected.`,
|
|
requiredAction: "Select the user or group that will own the reusable credential.",
|
|
actor: "An administrator with access to this settings surface",
|
|
target: "The target selector above"
|
|
};
|
|
}
|
|
if (!canWrite) {
|
|
return {
|
|
summary: "You can inspect reusable credentials but cannot change them.",
|
|
requiredAction: "Ask for credential-management authority if a change is required.",
|
|
actor: "A scope or access administrator",
|
|
target: "Access and role administration"
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function credentialDraft(credential: CredentialEnvelopeSummary): CredentialDraft {
|
|
return {
|
|
...EMPTY_DRAFT,
|
|
name: credential.name,
|
|
description: credential.description ?? "",
|
|
credentialKind: supportedCredentialKind(credential.credential_kind),
|
|
username: String(credential.public_data.username ?? ""),
|
|
allowedModules: credential.allowed_modules.join(", "),
|
|
allowedServerRefs: credential.allowed_server_refs.join(", "),
|
|
inheritToLowerScopes: credential.inherit_to_lower_scopes,
|
|
isActive: credential.is_active,
|
|
retainedPublicData: { ...credential.public_data }
|
|
};
|
|
}
|
|
|
|
function credentialDraftKey(draft: CredentialDraft): string {
|
|
return JSON.stringify(draft);
|
|
}
|
|
|
|
function splitValues(value: string): string[] {
|
|
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
|
|
}
|
|
|
|
function secretPayload(draft: CredentialDraft): Record<string, string> {
|
|
const secret = draft.secret.trim();
|
|
if (!secret) return {};
|
|
if (draft.credentialKind === "token") return { token: secret };
|
|
if (draft.credentialKind === "api_key") return { api_key: secret };
|
|
return { password: secret };
|
|
}
|
|
|
|
function supportedCredentialKind(value: string): CredentialKind {
|
|
return value === "token" || value === "api_key" ? value : "username_password";
|
|
}
|
|
|
|
function secretFieldLabel(kind: CredentialKind): string {
|
|
if (kind === "token") return "Access token";
|
|
if (kind === "api_key") return "API key";
|
|
return "Password";
|
|
}
|
|
|
|
function credentialKindLabel(kind: string): string {
|
|
if (kind === "username_password") return "Username and password";
|
|
if (kind === "api_key") return "API key";
|
|
if (kind === "token") return "Access token";
|
|
return kind.split("_").join(" ");
|
|
}
|
|
|
|
function availabilityLabel(credential: CredentialEnvelopeSummary): string {
|
|
const modules = credential.allowed_modules.length
|
|
? credential.allowed_modules.join(", ")
|
|
: "All modules";
|
|
const servers = credential.allowed_server_refs.length
|
|
? `${credential.allowed_server_refs.length} server restriction${credential.allowed_server_refs.length === 1 ? "" : "s"}`
|
|
: "all servers";
|
|
return `${modules} · ${servers}${credential.inherit_to_lower_scopes ? " · inherited" : ""}`;
|
|
}
|
|
|
|
function errorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|