Files
goclaw/ui/web/src/pages/cli-credentials/cli-user-credentials-dialog.tsx
T

301 lines
11 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound, Loader2, Plus, Trash2, Pencil } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { UserPickerCombobox } from "@/components/shared/user-picker-combobox";
import { toast } from "@/stores/use-toast-store";
import { useHttp } from "@/hooks/use-ws";
import i18next from "i18next";
import { CliCredentialEnvVarsSection, type ManualEnvEntry } from "./cli-credential-env-vars-section";
import type { SecureCLIBinary } from "./hooks/use-cli-credentials";
import type { CLIEnvEntryResponse, CLIEnvPayload } from "@/types/cli-credential";
interface UserCredEntry {
id: string;
binary_id: string;
user_id: string;
has_env: boolean;
/** Env variable names (no values) for display */
env_keys?: string[];
created_at: string;
updated_at: string;
}
interface CLIUserCredentialsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
binary: SecureCLIBinary;
}
type ViewState = "list" | "form";
function entriesFromEnv(env: Record<string, CLIEnvEntryResponse> | null | undefined): ManualEnvEntry[] {
if (!env || Object.keys(env).length === 0) return [];
return Object.entries(env).map(([key, entry]) => ({
key,
value: entry.value ?? "",
kind: entry.kind ?? "sensitive",
}));
}
function envPayloadFromEntries(entries: ManualEnvEntry[]): CLIEnvPayload {
const env: CLIEnvPayload = {};
for (const entry of entries) {
const key = entry.key.trim();
if (key) env[key] = { kind: entry.kind, value: entry.value };
}
return env;
}
export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUserCredentialsDialogProps) {
const { t } = useTranslation("cli-credentials");
const http = useHttp();
const [view, setView] = useState<ViewState>("list");
const [entries, setEntries] = useState<UserCredEntry[]>([]);
const [loadingList, setLoadingList] = useState(false);
// Form state
const [editEntry, setEditEntry] = useState<UserCredEntry | null>(null);
const [userId, setUserId] = useState("");
// Separate search text from selected value (onChange fires on every keystroke)
const [userSearchText, setUserSearchText] = useState("");
const [envEntries, setEnvEntries] = useState<ManualEnvEntry[]>([]);
const [saving, setSaving] = useState(false);
const [deleting, setDeletingId] = useState<string | null>(null);
// User picker for the form
const loadList = useCallback(async () => {
setLoadingList(true);
try {
const res = await http.get<{ user_credentials: UserCredEntry[] }>(
`/v1/cli-credentials/${binary.id}/user-credentials`,
);
setEntries(res.user_credentials ?? []);
} catch {
// silently ignore
} finally {
setLoadingList(false);
}
}, [http, binary.id]);
useEffect(() => {
if (!open) return;
setView("list");
setEditEntry(null);
setUserId("");
setUserSearchText("");
setEnvEntries([]);
loadList();
}, [open, loadList]);
const openAdd = () => {
setEditEntry(null);
setUserId("");
setUserSearchText("");
setEnvEntries([]);
setView("form");
};
const openEdit = async (entry: UserCredEntry) => {
setEditEntry(entry);
setUserId(entry.user_id);
setUserSearchText(entry.user_id);
setEnvEntries([]);
setView("form");
// Load existing env for edit
try {
const res = await http.get<{ user_id: string; env: Record<string, CLIEnvEntryResponse> | null }>(
`/v1/cli-credentials/${binary.id}/user-credentials/${entry.user_id}`,
);
setEnvEntries(entriesFromEnv(res.env));
} catch {
// leave env empty — user can re-enter
}
};
const handleSave = async () => {
const uid = userId.trim();
if (!uid) return;
const env = envPayloadFromEntries(envEntries);
// New entry needs at least one variable; edits may clear all keys (empty object).
if (!editEntry && Object.keys(env).length === 0) {
toast.error(i18next.t("cli-credentials:userCredentials.envRequired"));
return;
}
setSaving(true);
try {
await http.put(`/v1/cli-credentials/${binary.id}/user-credentials/${uid}`, { env });
toast.success(i18next.t("cli-credentials:userCredentials.saved"));
await loadList();
setView("list");
} catch (err) {
toast.error(
i18next.t("cli-credentials:userCredentials.saveFailed"),
err instanceof Error ? err.message : "",
);
} finally {
setSaving(false);
}
};
const handleDelete = async (entry: UserCredEntry) => {
setDeletingId(entry.id);
try {
await http.delete(`/v1/cli-credentials/${binary.id}/user-credentials/${entry.user_id}`);
toast.success(i18next.t("cli-credentials:userCredentials.deleted"));
await loadList();
} catch (err) {
toast.error(
i18next.t("cli-credentials:userCredentials.deleteFailed"),
err instanceof Error ? err.message : "",
);
} finally {
setDeletingId(null);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="h-4 w-4" />
{t("userCredentials.title")}
</DialogTitle>
<DialogDescription>
{t("userCredentials.description", { name: binary.binary_name })}
</DialogDescription>
</DialogHeader>
{view === "list" ? (
<>
{loadingList ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : entries.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("userCredentials.empty")}
</p>
) : (
<div className="flex flex-col gap-2 max-h-[50vh] overflow-y-auto pr-1">
{entries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between rounded-md border px-3 py-2"
>
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono text-sm truncate">{entry.user_id}</span>
{entry.has_env && (
<Badge variant="secondary" className="shrink-0 text-xs">
env
</Badge>
)}
</div>
{entry.env_keys && entry.env_keys.length > 0 && (
<p className="text-xs text-muted-foreground font-mono truncate" title={entry.env_keys.join(", ")}>
{entry.env_keys.join(", ")}
</p>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(entry)}
title={t("userCredentials.edit")}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleDelete(entry)}
disabled={deleting === entry.id}
title={t("userCredentials.delete")}
>
{deleting === entry.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
))}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("userCredentials.close")}
</Button>
<Button onClick={openAdd} className="gap-1">
<Plus className="h-3.5 w-3.5" />
{t("userCredentials.add")}
</Button>
</DialogFooter>
</>
) : (
<>
<div className="flex flex-col gap-4 max-h-[60vh] overflow-y-auto pr-1">
<div className="flex flex-col gap-1.5">
<Label>{t("userCredentials.userId")}</Label>
<UserPickerCombobox
value={userSearchText}
onChange={setUserSearchText}
onSelect={(val) => { setUserId(val); setUserSearchText(val); }}
placeholder={t("userCredentials.userIdPlaceholder")}
source="tenant_user"
allowCustom
/>
<p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 rounded-md px-2.5 py-1.5 border border-amber-200 dark:border-amber-800">{t("userCredentials.mergeHint")}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t("userCredentials.env")}</Label>
<CliCredentialEnvVarsSection
isManualMode
activePreset={null}
envValues={{}}
setEnvValues={() => undefined}
manualEnvEntries={envEntries}
setManualEnvEntries={setEnvEntries}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setView("list")}
disabled={saving}
>
{t("userCredentials.back")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
{t("userCredentials.save")}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}