mirror of
https://github.com/dyzulk/trustlab.git
synced 2026-01-26 05:25:36 +07:00
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import useSWR, { mutate } from "swr";
|
|
import axios from "@/lib/axios";
|
|
import ComponentCard from "../common/ComponentCard";
|
|
import Button from "../ui/button/Button";
|
|
import Badge from "../ui/badge/Badge";
|
|
import { Trash2, Copy, Check, Plus, Lock, RefreshCw } from "lucide-react";
|
|
import { useToast } from "@/context/ToastContext";
|
|
import ConfirmationModal from "../common/ConfirmationModal";
|
|
import { useTranslations } from "next-intl";
|
|
|
|
interface ApiKey {
|
|
id: string;
|
|
name: string;
|
|
is_active: boolean;
|
|
created_at: string;
|
|
last_used_at: string | null;
|
|
}
|
|
|
|
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
|
|
|
|
export default function ApiKeyManagement() {
|
|
const t = useTranslations("ApiKeys");
|
|
const { data, error, isLoading } = useSWR("/api/api-keys", fetcher);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [newKeyName, setNewKeyName] = useState("");
|
|
const [generatedKey, setGeneratedKey] = useState<string | null>(null);
|
|
const [isRevoking, setIsRevoking] = useState<string | null>(null);
|
|
const [isToggling, setIsToggling] = useState<string | null>(null);
|
|
const [isRegenerating, setIsRegenerating] = useState<string | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
const { addToast } = useToast();
|
|
|
|
// Confirmation states
|
|
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
|
|
const [confirmRegenerateId, setConfirmRegenerateId] = useState<string | null>(null);
|
|
|
|
const handleCreate = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newKeyName.trim()) return;
|
|
|
|
setIsCreating(true);
|
|
try {
|
|
const response = await axios.post("/api/api-keys", { name: newKeyName });
|
|
setGeneratedKey(response.data.token);
|
|
setNewKeyName("");
|
|
mutate("/api/api-keys");
|
|
addToast(t("toast_gen_success"), "success");
|
|
} catch (err) {
|
|
addToast(t("toast_gen_failed"), "error");
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
};
|
|
|
|
const handleRevoke = async (id: string) => {
|
|
setIsRevoking(id);
|
|
try {
|
|
await axios.delete(`/api/api-keys/${id}`);
|
|
mutate("/api/api-keys");
|
|
addToast(t("toast_revoke_success"), "success");
|
|
setConfirmRevokeId(null);
|
|
} catch (err) {
|
|
addToast(t("toast_revoke_failed"), "error");
|
|
} finally {
|
|
setIsRevoking(null);
|
|
}
|
|
};
|
|
|
|
const handleToggle = async (id: string) => {
|
|
setIsToggling(id);
|
|
try {
|
|
await axios.patch(`/api/api-keys/${id}/toggle`);
|
|
mutate("/api/api-keys");
|
|
addToast(t("toast_status_updated"), "success");
|
|
} catch (err) {
|
|
addToast(t("toast_status_failed"), "error");
|
|
} finally {
|
|
setIsToggling(null);
|
|
}
|
|
};
|
|
|
|
const handleRegenerate = async (id: string) => {
|
|
setIsRegenerating(id);
|
|
try {
|
|
const response = await axios.post(`/api/api-keys/${id}/regenerate`);
|
|
setGeneratedKey(response.data.token);
|
|
mutate("/api/api-keys");
|
|
addToast(t("toast_regen_success"), "success");
|
|
setConfirmRegenerateId(null);
|
|
} catch (err) {
|
|
addToast(t("toast_regen_failed"), "error");
|
|
} finally {
|
|
setIsRegenerating(null);
|
|
}
|
|
};
|
|
|
|
const copyToClipboard = () => {
|
|
if (generatedKey) {
|
|
navigator.clipboard.writeText(generatedKey);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
};
|
|
|
|
const keys = data?.data || [];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<ComponentCard
|
|
title={t("gen_title")}
|
|
desc={t("gen_desc")}
|
|
>
|
|
{!generatedKey ? (
|
|
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-4">
|
|
<div className="flex-1">
|
|
<input
|
|
type="text"
|
|
placeholder={t("input_placeholder")}
|
|
value={newKeyName}
|
|
onChange={(e) => setNewKeyName(e.target.value)}
|
|
className="w-full px-4 py-2 rounded-lg border border-gray-200 dark:border-gray-800 bg-transparent dark:bg-white/[0.03] text-gray-800 dark:text-white/90 focus:ring-brand-500 focus:border-brand-500"
|
|
required
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
loading={isCreating}
|
|
disabled={!newKeyName.trim()}
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
{t("btn_generate")}
|
|
</Button>
|
|
</form>
|
|
) : (
|
|
<div className="p-4 rounded-xl bg-brand-50 dark:bg-brand-500/10 border border-brand-100 dark:border-brand-500/20">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium text-brand-700 dark:text-brand-400">{t("your_key")}</span>
|
|
<span className="text-xs text-brand-600 dark:text-brand-500 font-normal italic">{t("copy_warning")}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 bg-white dark:bg-gray-900 px-4 py-3 rounded-lg border border-brand-200 dark:border-brand-500/30 text-gray-800 dark:text-white font-mono text-sm break-all">
|
|
{generatedKey}
|
|
</div>
|
|
<button
|
|
onClick={copyToClipboard}
|
|
className="p-3 bg-white dark:bg-gray-800 border border-brand-200 dark:border-brand-500/30 rounded-lg text-brand-500 hover:bg-brand-50 dark:hover:bg-brand-500/10 transition-colors"
|
|
title={t("copy_tooltip")}
|
|
>
|
|
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
|
|
</button>
|
|
</div>
|
|
<div className="mt-4 flex justify-end">
|
|
<Button variant="outline" onClick={() => setGeneratedKey(null)}>
|
|
{t("btn_done")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</ComponentCard>
|
|
|
|
<ComponentCard title={t("active_title")} desc={t("active_desc")}>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left">
|
|
<thead>
|
|
<tr className="border-b border-gray-100 dark:border-gray-800">
|
|
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">{t("th_name")}</th>
|
|
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">{t("th_status")}</th>
|
|
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">{t("th_created")}</th>
|
|
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">{t("th_last_used")}</th>
|
|
<th className="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider text-right">{t("th_actions")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
{isLoading ? (
|
|
<tr>
|
|
<td colSpan={5} className="px-4 py-10 text-center text-gray-500">{t("loading_keys")}</td>
|
|
</tr>
|
|
) : keys.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="px-4 py-10 text-center text-gray-500 italic">{t("no_keys")}</td>
|
|
</tr>
|
|
) : (
|
|
keys.map((key: ApiKey) => (
|
|
<tr key={key.id} className="hover:bg-gray-50 dark:hover:bg-white/[0.02] transition-colors">
|
|
<td className="px-4 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-gray-500">
|
|
<Lock className="w-4 h-4" />
|
|
</div>
|
|
<span className="text-sm font-medium text-gray-800 dark:text-white/90">{key.name}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-4">
|
|
<button
|
|
onClick={() => handleToggle(key.id)}
|
|
disabled={isToggling === key.id}
|
|
title={key.is_active ? t("tooltip_deactivate") : t("tooltip_activate")}
|
|
>
|
|
<Badge color={key.is_active ? "success" : "warning"}>
|
|
{isToggling === key.id ? "..." : (key.is_active ? t("status_active") : t("status_inactive"))}
|
|
</Badge>
|
|
</button>
|
|
</td>
|
|
<td className="px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
|
|
{new Date(key.created_at).toLocaleDateString()}
|
|
</td>
|
|
<td className="px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
|
|
{key.last_used_at ? new Date(key.last_used_at).toLocaleString() : t("never_used")}
|
|
</td>
|
|
<td className="px-4 py-4 text-right">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
onClick={() => setConfirmRegenerateId(key.id)}
|
|
disabled={isRegenerating === key.id}
|
|
className="p-2 text-gray-400 hover:text-brand-500 transition-colors disabled:opacity-50"
|
|
title={t("tooltip_regenerate")}
|
|
>
|
|
<RefreshCw className={`w-5 h-5 ${isRegenerating === key.id ? "animate-spin" : ""}`} />
|
|
</button>
|
|
<button
|
|
onClick={() => setConfirmRevokeId(key.id)}
|
|
disabled={isRevoking === key.id}
|
|
className="p-2 text-gray-400 hover:text-error-500 transition-colors disabled:opacity-50"
|
|
title={t("tooltip_revoke")}
|
|
>
|
|
<Trash2 className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</ComponentCard>
|
|
|
|
<ConfirmationModal
|
|
isOpen={confirmRevokeId !== null}
|
|
onClose={() => setConfirmRevokeId(null)}
|
|
onConfirm={() => confirmRevokeId && handleRevoke(confirmRevokeId)}
|
|
title={t("revoke_title")}
|
|
message={t("revoke_msg")}
|
|
isLoading={isRevoking !== null}
|
|
confirmLabel={t("revoke_confirm")}
|
|
requiredInput="REVOKE"
|
|
/>
|
|
|
|
<ConfirmationModal
|
|
isOpen={confirmRegenerateId !== null}
|
|
onClose={() => setConfirmRegenerateId(null)}
|
|
onConfirm={() => confirmRegenerateId && handleRegenerate(confirmRegenerateId)}
|
|
title={t("regen_title")}
|
|
message={t("regen_msg")}
|
|
isLoading={isRegenerating !== null}
|
|
confirmLabel={t("regen_confirm")}
|
|
variant="warning"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|