mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
import { useConfirm } from "@/components/surface/surface-scope";
|
|
import type { Dispatch, SetStateAction } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { copyToClipboard } from "@/lib/clipboard";
|
|
import { deleteApiKey, type ApiKey } from "@/main-axios";
|
|
import { Button } from "@/components/button";
|
|
import { Input } from "@/components/input";
|
|
import { Copy, Network, Plus, RefreshCw, Trash2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { AccordionSection } from "./AdminSettingsShared";
|
|
import type { AdminUser } from "./AdminManagementSections";
|
|
import { Select2 } from "@/components/select2";
|
|
|
|
type AdminApiKeysSectionProps = {
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
apiKeys: ApiKey[];
|
|
setApiKeys: Dispatch<SetStateAction<ApiKey[]>>;
|
|
loadApiKeys: () => void;
|
|
showCreateKey: boolean;
|
|
setShowCreateKey: Dispatch<SetStateAction<boolean>>;
|
|
createdKeyToken: string | null;
|
|
setCreatedKeyToken: Dispatch<SetStateAction<string | null>>;
|
|
newKeyName: string;
|
|
setNewKeyName: Dispatch<SetStateAction<string>>;
|
|
newKeyUserId: string;
|
|
setNewKeyUserId: Dispatch<SetStateAction<string>>;
|
|
newKeyExpiry: string;
|
|
setNewKeyExpiry: Dispatch<SetStateAction<string>>;
|
|
users: AdminUser[];
|
|
handleCreateApiKey: () => void;
|
|
newKeyLoading: boolean;
|
|
};
|
|
|
|
export function AdminApiKeysSection({
|
|
open,
|
|
onToggle,
|
|
apiKeys,
|
|
setApiKeys,
|
|
loadApiKeys,
|
|
showCreateKey,
|
|
setShowCreateKey,
|
|
createdKeyToken,
|
|
setCreatedKeyToken,
|
|
newKeyName,
|
|
setNewKeyName,
|
|
newKeyUserId,
|
|
setNewKeyUserId,
|
|
newKeyExpiry,
|
|
setNewKeyExpiry,
|
|
users,
|
|
handleCreateApiKey,
|
|
newKeyLoading,
|
|
}: AdminApiKeysSectionProps) {
|
|
const { t } = useTranslation();
|
|
const confirm = useConfirm();
|
|
|
|
return (
|
|
<AccordionSection
|
|
label={t("admin.sectionApiKeys")}
|
|
icon={<Network className="size-3.5" />}
|
|
open={open}
|
|
onToggle={onToggle}
|
|
>
|
|
<div className="flex flex-col pt-2">
|
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t("admin.apiKeysCount", { count: apiKeys.length })}{" "}
|
|
<a
|
|
href="https://docs.termix.site/features/api/api-keys"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-accent-brand hover:underline"
|
|
>
|
|
{t("admin.apiKeysDocsLink")}
|
|
</a>
|
|
<br />
|
|
{t("admin.apiKeysCliHint")}{" "}
|
|
<a
|
|
href="https://docs.termix.site/cli"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-accent-brand hover:underline"
|
|
>
|
|
{t("admin.apiKeysCliLink")}
|
|
</a>
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-6 text-muted-foreground hover:text-foreground"
|
|
onClick={loadApiKeys}
|
|
>
|
|
<RefreshCw className="size-3" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
onClick={() => {
|
|
setShowCreateKey((o) => !o);
|
|
setCreatedKeyToken(null);
|
|
}}
|
|
>
|
|
<Plus className="size-3" />
|
|
{t("admin.createRole")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{showCreateKey && (
|
|
<div className="flex flex-col gap-2.5 py-3 border-b border-border">
|
|
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
{t("admin.newApiKey")}
|
|
</span>
|
|
{createdKeyToken ? (
|
|
<div className="flex flex-col gap-2">
|
|
<span className="text-[10px] text-accent-brand font-semibold">
|
|
{t("admin.apiKeyCreatedWarning")}
|
|
</span>
|
|
<div className="flex items-center gap-2 bg-muted/30 border border-border px-2 py-1.5">
|
|
<span className="text-[10px] font-mono flex-1 truncate">
|
|
{createdKeyToken}
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
copyToClipboard(createdKeyToken);
|
|
toast.info(t("admin.copiedToClipboard"));
|
|
}}
|
|
className="text-muted-foreground hover:text-accent-brand shrink-0"
|
|
>
|
|
<Copy className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-xs self-end"
|
|
onClick={() => {
|
|
setShowCreateKey(false);
|
|
setCreatedKeyToken(null);
|
|
}}
|
|
>
|
|
{t("admin.done")}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
{t("admin.apiKeyName")}{" "}
|
|
<span className="text-accent-brand">*</span>
|
|
</label>
|
|
<Input
|
|
placeholder={t("placeholders.apiKeyName")}
|
|
value={newKeyName}
|
|
onChange={(e) => setNewKeyName(e.target.value)}
|
|
className="text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
{t("admin.apiKeyUser")}{" "}
|
|
<span className="text-accent-brand">*</span>
|
|
</label>
|
|
<Select2
|
|
className="px-2 py-1.5 text-xs bg-background border border-border text-foreground outline-none"
|
|
value={newKeyUserId}
|
|
onChange={(e) => setNewKeyUserId(e.target.value)}
|
|
>
|
|
<option value="">{t("admin.apiKeySelectUser")}</option>
|
|
{users.map((u) => (
|
|
<option key={u.id} value={u.id}>
|
|
{u.username}
|
|
</option>
|
|
))}
|
|
</Select2>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
|
{t("admin.apiKeyExpiresAt")}
|
|
</label>
|
|
<Input
|
|
type="date"
|
|
value={newKeyExpiry}
|
|
onChange={(e) => setNewKeyExpiry(e.target.value)}
|
|
className="text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-xs"
|
|
onClick={() => setShowCreateKey(false)}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
onClick={handleCreateApiKey}
|
|
disabled={newKeyLoading}
|
|
>
|
|
{newKeyLoading ? t("admin.creating") : t("admin.createKey")}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
{apiKeys.map((key) => (
|
|
<div
|
|
key={key.id}
|
|
className="flex items-start justify-between py-2.5 border-b border-border last:border-0 gap-2"
|
|
>
|
|
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<span className="text-xs font-semibold truncate">
|
|
{key.name}
|
|
</span>
|
|
{!key.isActive && (
|
|
<span className="text-[9px] font-semibold px-1 py-px border border-destructive/40 bg-destructive/10 text-destructive">
|
|
{t("admin.revokedBadge")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t("admin.apiKeyUser")}: {key.username}
|
|
</span>
|
|
<span className="text-[10px] font-mono text-muted-foreground truncate">
|
|
{key.tokenPrefix}…
|
|
</span>
|
|
<span className="flex gap-2.5 text-[10px] text-muted-foreground">
|
|
<span>{key.createdAt.split("T")[0]}</span>
|
|
<span>
|
|
{key.expiresAt
|
|
? key.expiresAt.split("T")[0]
|
|
: t("admin.apiKeyNoExpiry")}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
|
|
onClick={async () => {
|
|
const ok = await confirm({
|
|
title: t("admin.revokeKeyConfirm", { name: key.name }),
|
|
confirmLabel: t("admin.revokeKey"),
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await deleteApiKey(key.id);
|
|
setApiKeys((prev) => prev.filter((k) => k.id !== key.id));
|
|
toast.success(
|
|
t("admin.revokeKeySuccess", { name: key.name }),
|
|
);
|
|
} catch {
|
|
toast.error(t("admin.revokeKeyFailed"));
|
|
}
|
|
}}
|
|
>
|
|
<Trash2 className="size-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</AccordionSection>
|
|
);
|
|
}
|