527 lines
18 KiB
TypeScript
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);
|
|
}
|