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

527 lines
18 KiB
TypeScript

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 StatusBadge from "./StatusBadge";
import TableActionGroup from "./table/TableActionGroup";
import ToggleSwitch from "./ToggleSwitch";
import { useUnsavedDraftGuard } from "./UnsavedChangesGuard";
export type CredentialEnvelopeTargetOption = {
id: string;
label: string;
secondary?: string | null;
};
export type CredentialEnvelopeManagerProps = {
settings: ApiSettings;
scopeType: Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
scopeId?: string | null;
targetOptions?: CredentialEnvelopeTargetOption[];
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: {}
};
export default function CredentialEnvelopeManager({
settings,
scopeType,
scopeId,
targetOptions = [],
targetLabel = "Target",
title = "Credential envelopes",
canWrite
}: CredentialEnvelopeManagerProps) {
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;
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());
return (
<div className="credential-envelope-manager">
{targetOptions.length > 0 && (
<FormField label={targetLabel}>
<select
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>}
<Card
title={title}
actions={
<div className="button-row compact-actions">
<Button
type="button"
title="Reload credentials"
aria-label="Reload credentials"
onClick={() => void loadCredentials()}
disabled={loading}
>
<RefreshCw size={16} />
</Button>
<Button
type="button"
variant="primary"
onClick={openCreate}
disabled={!canWrite || !scopeReady || loading}
>
<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} />,
onClick: () => openEdit(credential),
disabled: !canWrite || saving
},
{
id: "delete",
label: `Delete ${credential.name}`,
icon: <Trash2 size={15} />,
onClick: () => setDeleting(credential),
disabled: !canWrite || saving,
variant: "danger"
}
]}
/>
)}
/>
</LoadingFrame>
</Card>
<Dialog
open={Boolean(editing)}
title={editing === "new" ? "Add reusable credential" : "Edit reusable credential"}
onClose={closeEditor}
closeDisabled={saving}
className="admin-dialog admin-dialog-wide adaptive-config-dialog"
footerClassName="button-row compact-actions"
footer={
<>
<Button onClick={closeEditor} disabled={saving}>Cancel</Button>
<Button variant="primary" onClick={() => void saveDraft()} disabled={saveDisabled}>
<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>
<div className="form-grid two">
<FormField label="Name">
<input 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 })}>
<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>
<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>
<FormField
label={secretFieldLabel(draft.credentialKind)}
help={editing !== "new" ? "Leave blank to retain the configured secret." : undefined}
>
<PasswordField value={draft.secret} onValueChange={(secret) => setDraft({ ...draft, secret, clearSecret: false })} disabled={saving} autoComplete="new-password" />
</FormField>
{editing !== "new" && (
<ToggleSwitch
checked={draft.clearSecret}
disabled={saving || Boolean(draft.secret)}
onChange={(clearSecret) => setDraft({ ...draft, clearSecret })}
label="Remove configured secret"
/>
)}
</div>
</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>
<div className="form-grid two">
<FormField label="Modules" help="Comma-separated module IDs, for example mail, files, calendar, addresses.">
<input value={draft.allowedModules} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedModules: event.target.value })} />
</FormField>
<FormField label="Servers" help="Optional references such as mail:server-id, files:connection-id, calendar:source-id, or addresses:source-id.">
<input value={draft.allowedServerRefs} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedServerRefs: event.target.value })} />
</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" />
</div>
</section>
</div>
</Dialog>
<ConfirmDialog
open={Boolean(deleting)}
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 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);
}