638 lines
18 KiB
TypeScript
638 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import { Trans, useTranslation } from "react-i18next";
|
|
import type { TFunction } from "i18next";
|
|
import { IconEdit, IconTrash, IconEye, IconEyeOff, IconKey } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Badge } from "@kandev/ui/badge";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@kandev/ui/dialog";
|
|
import { Input } from "@kandev/ui/input";
|
|
import { Textarea } from "@kandev/ui/textarea";
|
|
import { SettingsPageTemplate } from "@/components/settings/settings-page-template";
|
|
import { useSecrets } from "@/hooks/domains/settings/use-secrets";
|
|
import {
|
|
createSecret,
|
|
updateSecret,
|
|
deleteSecret,
|
|
revealSecret,
|
|
} from "@/lib/api/domains/secrets-api";
|
|
import { useRequest } from "@/lib/http/use-request";
|
|
import type { SecretListItem, SecretScope, UpdateSecretRequest } from "@/lib/types/http-secrets";
|
|
|
|
export type SecretFormState = {
|
|
name: string;
|
|
value: string;
|
|
};
|
|
|
|
const defaultFormState: SecretFormState = {
|
|
name: "",
|
|
value: "",
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Create / Edit form */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
type SecretFormProps = {
|
|
title: string;
|
|
formState: SecretFormState;
|
|
onFormChange: (patch: Partial<SecretFormState>) => void;
|
|
onSubmit: () => void;
|
|
onCancel: () => void;
|
|
isValid: boolean;
|
|
isBusy: boolean;
|
|
submitLabel: string;
|
|
showSubmit?: boolean;
|
|
baselineState?: SecretFormState;
|
|
};
|
|
|
|
function SecretForm({
|
|
title,
|
|
formState,
|
|
onFormChange,
|
|
onSubmit,
|
|
onCancel,
|
|
isValid,
|
|
isBusy,
|
|
submitLabel,
|
|
showSubmit = true,
|
|
baselineState,
|
|
}: SecretFormProps) {
|
|
const { t } = useTranslation();
|
|
const nameIsDirty = Boolean(baselineState) && formState.name.trim() !== baselineState?.name;
|
|
const valueIsDirty = Boolean(baselineState) && formState.value !== baselineState?.value;
|
|
return (
|
|
<div
|
|
className="rounded-lg border border-border/70 bg-background p-4 space-y-3"
|
|
data-settings-dirty={nameIsDirty || valueIsDirty}
|
|
>
|
|
<div className="text-sm font-medium text-foreground">{title}</div>
|
|
<div className="space-y-2">
|
|
<Input
|
|
value={formState.name}
|
|
onChange={(e) => onFormChange({ name: e.target.value })}
|
|
placeholder={t("settings:nameEGOpenaiProductionKey")}
|
|
disabled={isBusy}
|
|
data-settings-dirty={nameIsDirty}
|
|
/>
|
|
<Textarea
|
|
value={formState.value}
|
|
onChange={(e) => onFormChange({ value: e.target.value })}
|
|
placeholder={t("settings:secretValue")}
|
|
rows={2}
|
|
className="resize-y font-mono text-sm"
|
|
disabled={isBusy}
|
|
data-settings-dirty={valueIsDirty}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{showSubmit && (
|
|
<Button onClick={onSubmit} disabled={!isValid || isBusy} className="cursor-pointer">
|
|
{submitLabel}
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" onClick={onCancel} disabled={isBusy} className="cursor-pointer">
|
|
{t("settings:cancel")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* List item */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
type SecretListItemRowProps = {
|
|
secret: SecretListItem;
|
|
workspaceId?: string;
|
|
onEdit: (secret: SecretListItem) => void;
|
|
onDelete: (secret: SecretListItem) => void;
|
|
isBusy: boolean;
|
|
showCreate: boolean;
|
|
isEditing: boolean;
|
|
};
|
|
|
|
function SecretListItemRow({
|
|
secret,
|
|
workspaceId,
|
|
onEdit,
|
|
onDelete,
|
|
isBusy,
|
|
showCreate,
|
|
isEditing,
|
|
}: SecretListItemRowProps) {
|
|
const { t } = useTranslation();
|
|
const [revealed, setRevealed] = useState(false);
|
|
const [revealedValue, setRevealedValue] = useState<string | null>(null);
|
|
const [revealing, setRevealing] = useState(false);
|
|
|
|
const handleReveal = async () => {
|
|
if (revealed) {
|
|
setRevealed(false);
|
|
setRevealedValue(null);
|
|
return;
|
|
}
|
|
setRevealing(true);
|
|
try {
|
|
const resp = await revealSecret(secret.id, {
|
|
cache: "no-store",
|
|
...(workspaceId ? { workspaceId } : {}),
|
|
});
|
|
setRevealedValue(resp.value);
|
|
setRevealed(true);
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setRevealing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="rounded-lg border border-border/70 bg-background p-4 space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<IconKey className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
<div className="text-sm font-medium text-foreground truncate">{secret.name}</div>
|
|
<Badge variant="outline" className="shrink-0 text-[10px]">
|
|
{secret.scope === "workspace"
|
|
? t("settings:workspaceScope")
|
|
: t("settings:globalScope")}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleReveal}
|
|
disabled={revealing || isBusy}
|
|
className="min-h-11 min-w-11 cursor-pointer"
|
|
aria-label={
|
|
revealed
|
|
? t("settings:hideSecretNamed", { name: secret.name })
|
|
: t("settings:revealSecretNamed", { name: secret.name })
|
|
}
|
|
>
|
|
{revealed ? <IconEyeOff className="h-4 w-4" /> : <IconEye className="h-4 w-4" />}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => onEdit(secret)}
|
|
disabled={isBusy || showCreate || isEditing}
|
|
className="min-h-11 min-w-11 cursor-pointer"
|
|
aria-label={t("settings:editSecretNamed", { name: secret.name })}
|
|
>
|
|
<IconEdit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => onDelete(secret)}
|
|
disabled={isBusy}
|
|
className="min-h-11 min-w-11 cursor-pointer"
|
|
aria-label={t("settings:deleteSecretNamed", { name: secret.name })}
|
|
>
|
|
<IconTrash className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{revealed && revealedValue !== null && (
|
|
<div className="text-xs font-mono bg-muted/50 rounded px-2 py-1 break-all">
|
|
{revealedValue}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Delete dialog */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
type DeleteSecretDialogProps = {
|
|
target: SecretListItem | null;
|
|
onClose: () => void;
|
|
onConfirm: () => void;
|
|
isBusy: boolean;
|
|
};
|
|
|
|
export function DeleteSecretDialog({
|
|
target,
|
|
onClose,
|
|
onConfirm,
|
|
isBusy,
|
|
}: DeleteSecretDialogProps) {
|
|
const { t } = useTranslation();
|
|
// The secret's own name is user data: it is interpolated as a value and never
|
|
// routed through a catalog key.
|
|
const name = target?.name ?? t("settings:thisSecret");
|
|
return (
|
|
<Dialog
|
|
open={Boolean(target)}
|
|
onOpenChange={(open) => {
|
|
if (!open) onClose();
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("settings:deleteSecret")}</DialogTitle>
|
|
<DialogDescription>
|
|
<Trans i18nKey="settings:thisWillPermanentlyRemoveSecret" values={{ name }}>
|
|
This will permanently remove{" "}
|
|
<span className="font-medium text-foreground">{name}</span>. This action cannot be
|
|
undone.
|
|
</Trans>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={onClose} className="cursor-pointer">
|
|
{t("settings:cancel")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={onConfirm}
|
|
disabled={isBusy}
|
|
className="cursor-pointer"
|
|
>
|
|
{t("settings:deleteSecret")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* State + actions hooks */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function useSecretsState(
|
|
scope: SecretScope,
|
|
workspaceId?: string,
|
|
initialItems?: SecretListItem[],
|
|
) {
|
|
const { loaded, items, addSecret, updateSecret, removeSecret } = useSecrets(
|
|
scope,
|
|
workspaceId,
|
|
initialItems,
|
|
);
|
|
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [formState, setFormState] = useState<SecretFormState>(defaultFormState);
|
|
const [deleteTarget, setDeleteTarget] = useState<SecretListItem | null>(null);
|
|
|
|
return {
|
|
loaded,
|
|
items,
|
|
addSecret,
|
|
updateSecretInStore: updateSecret,
|
|
removeSecret,
|
|
scope,
|
|
workspaceId,
|
|
editingId,
|
|
setEditingId,
|
|
showCreate,
|
|
setShowCreate,
|
|
formState,
|
|
setFormState,
|
|
deleteTarget,
|
|
setDeleteTarget,
|
|
};
|
|
}
|
|
|
|
function useSecretsActions(state: ReturnType<typeof useSecretsState>) {
|
|
const {
|
|
addSecret: addToStore,
|
|
updateSecretInStore,
|
|
removeSecret: removeFromStore,
|
|
editingId,
|
|
setEditingId,
|
|
setShowCreate,
|
|
setFormState,
|
|
setDeleteTarget,
|
|
deleteTarget,
|
|
formState,
|
|
scope,
|
|
workspaceId,
|
|
} = state;
|
|
|
|
const requestOptions = {
|
|
cache: "no-store" as const,
|
|
...(scope === "workspace" && workspaceId ? { workspaceId } : {}),
|
|
};
|
|
|
|
const resetForm = useCallback(() => {
|
|
setEditingId(null);
|
|
setShowCreate(false);
|
|
setFormState(defaultFormState);
|
|
}, [setEditingId, setShowCreate, setFormState]);
|
|
|
|
const isValid = useMemo(() => {
|
|
const nameOk = formState.name.trim().length > 0;
|
|
const valueOk = editingId ? true : formState.value.trim().length > 0;
|
|
return nameOk && valueOk;
|
|
}, [formState, editingId]);
|
|
|
|
const createRequest = useRequest(async (s: SecretFormState) => {
|
|
const item = await createSecret(
|
|
{
|
|
name: s.name.trim(),
|
|
value: s.value,
|
|
...(scope === "workspace" && workspaceId
|
|
? { scope: "workspace" as const, workspace_id: workspaceId }
|
|
: { scope: "global" as const }),
|
|
},
|
|
requestOptions,
|
|
);
|
|
addToStore(item);
|
|
resetForm();
|
|
});
|
|
|
|
const updateRequest = useRequest(async (id: string, s: SecretFormState) => {
|
|
const payload: UpdateSecretRequest = {
|
|
name: s.name.trim(),
|
|
};
|
|
if (s.value.trim()) payload.value = s.value;
|
|
const item = await updateSecret(id, payload, requestOptions);
|
|
updateSecretInStore(item);
|
|
resetForm();
|
|
});
|
|
|
|
const deleteRequest = useRequest(async (id: string) => {
|
|
await deleteSecret(id, requestOptions);
|
|
removeFromStore(id);
|
|
if (editingId === id) resetForm();
|
|
});
|
|
|
|
const isBusy = createRequest.isLoading || updateRequest.isLoading || deleteRequest.isLoading;
|
|
|
|
return {
|
|
resetForm,
|
|
isValid,
|
|
isBusy,
|
|
handleCreate: async () => {
|
|
if (!isValid || isBusy) return;
|
|
await createRequest.run(formState);
|
|
},
|
|
handleUpdate: async () => {
|
|
if (!isValid || isBusy || !editingId) return;
|
|
await updateRequest.run(editingId, formState);
|
|
},
|
|
startEditing: (secret: SecretListItem) => {
|
|
setEditingId(secret.id);
|
|
setShowCreate(false);
|
|
setFormState({
|
|
name: secret.name,
|
|
value: "",
|
|
});
|
|
},
|
|
startCreate: () => {
|
|
setEditingId(null);
|
|
setShowCreate(true);
|
|
setFormState(defaultFormState);
|
|
},
|
|
openDelete: (secret: SecretListItem) => setDeleteTarget(secret),
|
|
closeDelete: () => setDeleteTarget(null),
|
|
confirmDelete: () => {
|
|
if (!deleteTarget) return;
|
|
deleteRequest.run(deleteTarget.id).catch(() => undefined);
|
|
setDeleteTarget(null);
|
|
},
|
|
items: state.items,
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Main component */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function getSecretEditState(
|
|
items: SecretListItem[],
|
|
editingId: string | null,
|
|
formState: SecretFormState,
|
|
) {
|
|
const editingSecret = items.find((secret) => secret.id === editingId);
|
|
const revision = JSON.stringify({ ...formState, name: formState.name.trim() });
|
|
const savedRevision = JSON.stringify({ name: editingSecret?.name ?? "", value: "" });
|
|
return {
|
|
revision,
|
|
isDirty: Boolean(editingId) && revision !== savedRevision,
|
|
baseline: { name: editingSecret?.name ?? "", value: "" },
|
|
};
|
|
}
|
|
|
|
export function getSecretDraftMeta(
|
|
items: SecretListItem[],
|
|
editingId: string | null,
|
|
showCreate: boolean,
|
|
formState: SecretFormState,
|
|
) {
|
|
const edit = getSecretEditState(items, editingId, formState);
|
|
return {
|
|
edit,
|
|
isDirty: showCreate || edit.isDirty,
|
|
revision: `${showCreate ? "new" : (editingId ?? "none")}:${edit.revision}`,
|
|
};
|
|
}
|
|
|
|
type SecretsSettingsProps = {
|
|
scope?: SecretScope;
|
|
workspaceId?: string;
|
|
initialItems?: SecretListItem[];
|
|
};
|
|
|
|
type SecretsSettingsState = ReturnType<typeof useSecretsState>;
|
|
type SecretsSettingsActions = ReturnType<typeof useSecretsActions>;
|
|
|
|
function secretScopeTitle(t: TFunction) {
|
|
return t("settings:secrets");
|
|
}
|
|
|
|
function secretScopeDescription(t: TFunction, scope: SecretScope) {
|
|
return scope === "workspace"
|
|
? t("settings:workspaceSecretsDescription")
|
|
: t("settings:manageApiKeysAndCredentialsSecrets");
|
|
}
|
|
|
|
function secretDraftInvalidReason(
|
|
t: TFunction,
|
|
showCreate: boolean,
|
|
isDirty: boolean,
|
|
isValid: boolean,
|
|
) {
|
|
if (!isDirty || isValid) return undefined;
|
|
return showCreate
|
|
? t("settings:aSecretNameAndValueAreRequired")
|
|
: t("settings:aSecretNameIsRequired");
|
|
}
|
|
|
|
type SecretsSettingsBodyProps = {
|
|
workspaceId?: string;
|
|
state: SecretsSettingsState;
|
|
actions: SecretsSettingsActions;
|
|
edit: ReturnType<typeof getSecretEditState>;
|
|
onFormChange: (patch: Partial<SecretFormState>) => void;
|
|
};
|
|
|
|
function SecretsSettingsBody({
|
|
workspaceId,
|
|
state,
|
|
actions,
|
|
edit,
|
|
onFormChange,
|
|
}: SecretsSettingsBodyProps) {
|
|
const { t } = useTranslation();
|
|
const { loaded, editingId, showCreate, formState, deleteTarget, items } = state;
|
|
const { isValid, isBusy } = actions;
|
|
return (
|
|
<>
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm font-medium text-foreground">{secretScopeTitle(t)}</div>
|
|
<Button
|
|
onClick={actions.startCreate}
|
|
disabled={isBusy || Boolean(editingId) || showCreate}
|
|
className="cursor-pointer"
|
|
>
|
|
{t("settings:addSecret")}
|
|
</Button>
|
|
</div>
|
|
|
|
{showCreate && (
|
|
<SecretForm
|
|
title={t("settings:newSecret")}
|
|
formState={formState}
|
|
onFormChange={onFormChange}
|
|
onSubmit={actions.handleCreate}
|
|
onCancel={actions.resetForm}
|
|
isValid={isValid}
|
|
isBusy={isBusy}
|
|
submitLabel={t("settings:addSecret")}
|
|
showSubmit={false}
|
|
baselineState={defaultFormState}
|
|
/>
|
|
)}
|
|
|
|
{editingId && (
|
|
<SecretForm
|
|
title={t("settings:editSecret")}
|
|
formState={formState}
|
|
onFormChange={onFormChange}
|
|
onSubmit={actions.handleUpdate}
|
|
onCancel={actions.resetForm}
|
|
isValid={isValid}
|
|
isBusy={isBusy}
|
|
submitLabel={t("settings:saveChanges")}
|
|
showSubmit={false}
|
|
baselineState={edit.baseline}
|
|
/>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
{!loaded && (
|
|
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
|
{t("settings:loadingSecrets")}
|
|
</div>
|
|
)}
|
|
{loaded && items.length === 0 && !showCreate && (
|
|
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
|
{t("settings:noSecretsYetAddYourFirst")}
|
|
</div>
|
|
)}
|
|
{items.map((secret) => (
|
|
<SecretListItemRow
|
|
key={secret.id}
|
|
secret={secret}
|
|
workspaceId={workspaceId}
|
|
onEdit={actions.startEditing}
|
|
onDelete={actions.openDelete}
|
|
isBusy={isBusy}
|
|
showCreate={showCreate}
|
|
isEditing={editingId === secret.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<DeleteSecretDialog
|
|
target={deleteTarget}
|
|
onClose={actions.closeDelete}
|
|
onConfirm={actions.confirmDelete}
|
|
isBusy={isBusy}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SecretsSettingsContent({
|
|
scope,
|
|
workspaceId,
|
|
state,
|
|
actions,
|
|
}: {
|
|
scope: SecretScope;
|
|
workspaceId?: string;
|
|
state: SecretsSettingsState;
|
|
actions: SecretsSettingsActions;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { editingId, showCreate, formState, setFormState, items } = state;
|
|
const { isValid } = actions;
|
|
const { edit, isDirty, revision } = getSecretDraftMeta(items, editingId, showCreate, formState);
|
|
const invalidReason = secretDraftInvalidReason(t, showCreate, isDirty, isValid);
|
|
|
|
const onFormChange = (patch: Partial<SecretFormState>) =>
|
|
setFormState((prev) => ({ ...prev, ...patch }));
|
|
|
|
return (
|
|
<SettingsPageTemplate
|
|
title={secretScopeTitle(t)}
|
|
description={secretScopeDescription(t, scope)}
|
|
isDirty={isDirty}
|
|
saveStatus="idle"
|
|
saveId={`secrets-${scope}-item-draft`}
|
|
saveRevision={revision}
|
|
canSave={!isDirty || isValid}
|
|
invalidReason={invalidReason}
|
|
onSave={showCreate ? actions.handleCreate : actions.handleUpdate}
|
|
onDiscard={actions.resetForm}
|
|
>
|
|
<SecretsSettingsBody
|
|
workspaceId={workspaceId}
|
|
state={state}
|
|
actions={actions}
|
|
edit={edit}
|
|
onFormChange={onFormChange}
|
|
/>
|
|
</SettingsPageTemplate>
|
|
);
|
|
}
|
|
|
|
export function SecretsSettings({
|
|
scope = "global",
|
|
workspaceId,
|
|
initialItems,
|
|
}: SecretsSettingsProps) {
|
|
const state = useSecretsState(scope, workspaceId, initialItems);
|
|
const actions = useSecretsActions(state);
|
|
return (
|
|
<SecretsSettingsContent
|
|
scope={scope}
|
|
workspaceId={workspaceId}
|
|
state={state}
|
|
actions={actions}
|
|
/>
|
|
);
|
|
}
|