Add shared credential envelope infrastructure
This commit is contained in:
76
webui/src/api/credentials.ts
Normal file
76
webui/src/api/credentials.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { ApiSettings, CredentialEnvelopeSummary, MailProfileScope } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type CredentialEnvelopePayload = {
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
credential_kind: string;
|
||||
public_data?: Record<string, unknown>;
|
||||
secret_data?: Record<string, string>;
|
||||
allowed_modules?: string[];
|
||||
allowed_server_refs?: string[];
|
||||
inherit_to_lower_scopes?: boolean;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export type CredentialEnvelopeUpdatePayload = Partial<
|
||||
Omit<CredentialEnvelopePayload, "scope_type" | "scope_id">
|
||||
> & {
|
||||
clear_secret?: boolean;
|
||||
};
|
||||
|
||||
export async function listCredentialEnvelopes(
|
||||
settings: ApiSettings,
|
||||
params: {
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
include_inactive?: boolean;
|
||||
}
|
||||
): Promise<CredentialEnvelopeSummary[]> {
|
||||
const search = new URLSearchParams({ scope_type: params.scope_type });
|
||||
if (params.scope_id) search.set("scope_id", params.scope_id);
|
||||
if (params.include_inactive) search.set("include_inactive", "true");
|
||||
const response = await apiFetch<{ credentials: CredentialEnvelopeSummary[] }>(
|
||||
settings,
|
||||
`/api/v1/credentials?${search.toString()}`
|
||||
);
|
||||
return response.credentials;
|
||||
}
|
||||
|
||||
export function createCredentialEnvelope(
|
||||
settings: ApiSettings,
|
||||
payload: CredentialEnvelopePayload
|
||||
): Promise<CredentialEnvelopeSummary> {
|
||||
return apiFetch<CredentialEnvelopeSummary>(settings, "/api/v1/credentials", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCredentialEnvelope(
|
||||
settings: ApiSettings,
|
||||
credentialId: string,
|
||||
payload: CredentialEnvelopeUpdatePayload
|
||||
): Promise<CredentialEnvelopeSummary> {
|
||||
return apiFetch<CredentialEnvelopeSummary>(
|
||||
settings,
|
||||
`/api/v1/credentials/${encodeURIComponent(credentialId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCredentialEnvelope(
|
||||
settings: ApiSettings,
|
||||
credentialId: string
|
||||
): Promise<CredentialEnvelopeSummary> {
|
||||
return apiFetch<CredentialEnvelopeSummary>(
|
||||
settings,
|
||||
`/api/v1/credentials/${encodeURIComponent(credentialId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
MailCredentialEnvelope,
|
||||
MailImapTransportSettings,
|
||||
MailProfilePatternKey,
|
||||
MailProfilePolicy,
|
||||
@@ -11,11 +12,13 @@ import type {
|
||||
} from "../types";
|
||||
|
||||
export type {
|
||||
MailCredentialEnvelope,
|
||||
MailCredentialPolicy,
|
||||
MailProfilePatternKey,
|
||||
MailProfilePolicy,
|
||||
MailProfileScope,
|
||||
MailSecurity,
|
||||
MailServerEndpoint,
|
||||
MailServerProfile
|
||||
} from "../types";
|
||||
|
||||
@@ -24,6 +27,7 @@ export type MailImapTestPayload = MailImapTransportSettings;
|
||||
export type MailTransportCredentialsPayload = MailTransportCredentials;
|
||||
export type MailServerProfileCredentialsPayload = MailServerProfileCredentials;
|
||||
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
|
||||
export type MailCredentialListResponse = { credentials: MailCredentialEnvelope[] };
|
||||
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
||||
|
||||
export type MailConnectionTestResponse = {
|
||||
@@ -110,6 +114,7 @@ export type MailServerProfilePayload = {
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
is_active?: boolean;
|
||||
inherit_to_lower_scopes?: boolean;
|
||||
scope_type?: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
smtp: MailSmtpTestPayload;
|
||||
|
||||
526
webui/src/components/CredentialEnvelopeManager.tsx
Normal file
526
webui/src/components/CredentialEnvelopeManager.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
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);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/Unsave
|
||||
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager";
|
||||
|
||||
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
|
||||
|
||||
@@ -33,14 +34,15 @@ const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
|
||||
{ value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" }
|
||||
];
|
||||
|
||||
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
|
||||
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, canUseCredentials: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
|
||||
const groups: ModuleSubnavGroup<SettingsSection>[] = [
|
||||
{
|
||||
title: "i18n:govoplan-core.account.f967543b",
|
||||
items: [
|
||||
{ id: "profile", label: "i18n:govoplan-core.my_profile.2f3df0a9" },
|
||||
...(canUseMailProfiles ? [{ id: "mail-profiles" as const, label: "i18n:govoplan-core.mail_profiles.8a8018b7" }] : []),
|
||||
...(canUseFileConnectors ? [{ id: "file-connectors" as const, label: "i18n:govoplan-core.file_connections.1e362326" }] : [])]
|
||||
...(canUseFileConnectors ? [{ id: "file-connectors" as const, label: "i18n:govoplan-core.file_connections.1e362326" }] : []),
|
||||
...(canUseCredentials ? [{ id: "credentials" as const, label: "i18n:govoplan-core.credentials.dd097a22" }] : [])]
|
||||
|
||||
},
|
||||
{
|
||||
@@ -102,11 +104,19 @@ export default function SettingsPage({
|
||||
"admin:policies:write"
|
||||
]);
|
||||
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||
const canUseCredentials = hasAnyScope(auth, [
|
||||
"access:credential:read",
|
||||
"access:credential:write",
|
||||
"access:credential:manage_own",
|
||||
"mail:secret:manage_own",
|
||||
"admin:settings:read",
|
||||
"admin:settings:write"
|
||||
]);
|
||||
const contributedSections = useMemo(
|
||||
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
||||
[auth, settingsSectionCapabilities]
|
||||
);
|
||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, contributedSections), [canUseFileConnectors, canUseMailProfiles, contributedSections]);
|
||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, canUseCredentials, contributedSections), [canUseCredentials, canUseFileConnectors, canUseMailProfiles, contributedSections]);
|
||||
const availableSectionIds = useMemo(() => new Set(settingsSubnav.flatMap((group) => group.items.flatMap((item) => "id" in item ? [item.id] : []))), [settingsSubnav]);
|
||||
const requestedSection = searchParams.get("section");
|
||||
const active: SettingsSection = settingsSectionAvailable(availableSectionIds, requestedSection) ? requestedSection : "interface";
|
||||
@@ -343,6 +353,21 @@ export default function SettingsPage({
|
||||
|
||||
}
|
||||
|
||||
{active === "credentials" &&
|
||||
<CredentialEnvelopeManager
|
||||
settings={settings}
|
||||
scopeType="user"
|
||||
scopeId={auth.user.id}
|
||||
title="My reusable credentials"
|
||||
canWrite={hasAnyScope(auth, [
|
||||
"access:credential:write",
|
||||
"access:credential:manage_own",
|
||||
"mail:secret:manage_own",
|
||||
"admin:settings:write"
|
||||
])} />
|
||||
|
||||
}
|
||||
|
||||
{active === "interface" &&
|
||||
<div className="dashboard-grid settings-dashboard-grid">
|
||||
<Card title="i18n:govoplan-core.interface_preferences.b82b39a7">
|
||||
@@ -505,6 +530,7 @@ function sectionOrder(sectionId: string, contributedSections: SettingsSectionCon
|
||||
profile: 10,
|
||||
"mail-profiles": 20,
|
||||
"file-connectors": 30,
|
||||
"credentials": 40,
|
||||
interface: 10,
|
||||
workspace: 20,
|
||||
"local-connection": 30
|
||||
|
||||
@@ -57,6 +57,15 @@ export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/Con
|
||||
export { default as ColorPickerField } from "./components/ColorPickerField";
|
||||
export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel";
|
||||
export type { CredentialFieldsProps, CredentialPanelProps, CredentialValues } from "./components/CredentialPanel";
|
||||
export { default as CredentialEnvelopeManager } from "./components/CredentialEnvelopeManager";
|
||||
export type { CredentialEnvelopeManagerProps, CredentialEnvelopeTargetOption } from "./components/CredentialEnvelopeManager";
|
||||
export {
|
||||
createCredentialEnvelope,
|
||||
deleteCredentialEnvelope,
|
||||
listCredentialEnvelopes,
|
||||
updateCredentialEnvelope
|
||||
} from "./api/credentials";
|
||||
export type { CredentialEnvelopePayload, CredentialEnvelopeUpdatePayload } from "./api/credentials";
|
||||
export { default as DateField, TimeField, DateTimeField } from "./components/DateTimeField";
|
||||
export { default as Dialog } from "./components/Dialog";
|
||||
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
|
||||
|
||||
@@ -464,6 +464,51 @@ export type MailServerProfileCredentials = {
|
||||
imap?: MailTransportCredentials | null;
|
||||
};
|
||||
|
||||
export type CredentialEnvelopeSummary = {
|
||||
id: string;
|
||||
tenant_id?: string | null;
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
credential_kind: string;
|
||||
public_data: Record<string, unknown>;
|
||||
secret_keys: string[];
|
||||
secret_configured: boolean;
|
||||
allowed_modules: string[];
|
||||
allowed_server_refs: string[];
|
||||
inherit_to_lower_scopes: boolean;
|
||||
is_active: boolean;
|
||||
revision: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
};
|
||||
|
||||
export type MailCredentialEnvelope = CredentialEnvelopeSummary & {
|
||||
binding_id?: string | null;
|
||||
server_id?: string | null;
|
||||
is_default: boolean;
|
||||
};
|
||||
|
||||
export type MailServerEndpoint = {
|
||||
id: string;
|
||||
profile_id: string;
|
||||
tenant_id?: string | null;
|
||||
protocol: "smtp" | "imap";
|
||||
name: string;
|
||||
config: MailTransportSettings | MailImapTransportSettings;
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
inherit_to_lower_scopes: boolean;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
transport_revision: string;
|
||||
credentials: MailCredentialEnvelope[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type MailServerProfile = {
|
||||
id: string;
|
||||
tenant_id?: string | null;
|
||||
@@ -473,11 +518,13 @@ export type MailServerProfile = {
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
is_active: boolean;
|
||||
inherit_to_lower_scopes?: boolean;
|
||||
smtp: MailTransportSettings;
|
||||
imap?: MailImapTransportSettings | null;
|
||||
credentials?: MailServerProfileCredentials | null;
|
||||
smtp_password_configured?: boolean;
|
||||
imap_password_configured?: boolean;
|
||||
servers?: MailServerEndpoint[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
@@ -161,5 +161,7 @@ export const adminReadScopes = [
|
||||
"access:system_role:read",
|
||||
"access:audit:read",
|
||||
"access:system_setting:read",
|
||||
"access:system_credential:read",
|
||||
"access:credential:read",
|
||||
"access:governance:read"
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user