Files
govoplan-core/webui/src/components/CredentialEnvelopeManager.tsx
T

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);
}