You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

224 lines
7.1 KiB
TypeScript

// frontend/components/settings/DataTab.tsx — 데이터 내보내기 + API 토큰 + 시스템 정보(읽기전용)
"use client";
import { useState } from "react";
import { Button } from "@/components/Button";
import { Icon } from "@/components/Icon";
import { settingsApi } from "@/lib/settings/api";
import { useSystemConfig, useTokens } from "@/lib/hooks/useSettings";
import type { ToastFn } from "./SettingsClient";
const MODE_LABEL: Record<string, string> = {
mock: "데모(mock)",
real: "실연동",
csv: "CSV",
ics: "iCal",
healthkit: "HealthKit",
clipper: "클리퍼",
imap: "IMAP",
};
const DOMAIN_LABEL: Record<string, string> = {
calendar: "일정",
mail: "메일",
chat: "채팅",
finance: "금융",
health: "건강",
knowledge: "지식",
};
export function DataTab({ toast }: { toast: ToastFn }) {
const { data: sys } = useSystemConfig();
const { data: tokens, refresh } = useTokens();
const [exporting, setExporting] = useState(false);
const [tokenName, setTokenName] = useState("");
const [created, setCreated] = useState<{ name: string; token: string } | null>(null);
const onExport = async () => {
setExporting(true);
try {
const blob = await settingsApi.exportData();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "ari-export.zip";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast("내보내기를 시작했어요", "green");
} catch {
toast("내보내기에 실패했어요", "coral");
} finally {
setExporting(false);
}
};
const onCreate = async () => {
const name = tokenName.trim() || "API 토큰";
try {
const t = await settingsApi.createToken(name);
if (t.token) setCreated({ name: t.name, token: t.token });
setTokenName("");
await refresh();
toast("토큰을 발급했어요", "green");
} catch {
toast("토큰 발급에 실패했어요", "coral");
}
};
const onRevoke = async (id: string) => {
try {
await settingsApi.revokeToken(id);
await refresh();
toast("토큰을 폐기했어요", "faint");
} catch {
toast("폐기에 실패했어요", "coral");
}
};
const onCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast("클립보드에 복사했어요", "blue");
} catch {
toast("복사에 실패했어요", "amber");
}
};
const activeTokens = (tokens ?? []).filter((t) => !t.revoked);
return (
<section className="set-sec" aria-label="데이터 · 보안">
<header className="set-sechead">
<h2> · </h2>
<p> , API , .</p>
</header>
{/* 데이터 내보내기 */}
<div className="set-card set-row-between">
<div className="sp-meta">
<b> </b>
<span className="set-hint">··· ZIP .</span>
</div>
<Button variant="glass" icon="upload" onClick={onExport} disabled={exporting}>
{exporting ? "준비 중…" : "ZIP 내보내기"}
</Button>
</div>
{/* API 토큰 */}
<div className="set-card">
<header className="set-cardhead">
<h3>
<Icon name="key" /> API
</h3>
<span className="set-hint"> API .</span>
</header>
<div className="set-tokenform">
<input
className="set-input"
value={tokenName}
maxLength={40}
placeholder="토큰 이름 (예: CLI, 자동화)"
aria-label="토큰 이름"
onChange={(e) => setTokenName(e.target.value)}
/>
<Button variant="lime" icon="plus" onClick={onCreate}>
</Button>
</div>
{created && (
<div className="set-reveal" role="status">
<div className="sr-head">
<Icon name="lock" />
<b> </b>
</div>
<code className="sr-token">{created.token}</code>
<div className="sr-actions">
<button type="button" className="set-chip" onClick={() => onCopy(created.token)}>
<Icon name="layers" />
</button>
<button type="button" className="set-chip" onClick={() => setCreated(null)}>
<Icon name="tick" />
</button>
</div>
</div>
)}
{activeTokens.length === 0 ? (
<div className="set-empty-row"> .</div>
) : (
<ul className="set-tokenlist">
{activeTokens.map((t) => (
<li key={t.id}>
<span className="tk-ic" aria-hidden>
<Icon name="key" />
</span>
<span className="tk-meta">
<b>{t.name}</b>
<small>
{t.id} · {new Date(t.created_at).toLocaleDateString("ko-KR")}
</small>
</span>
<button
type="button"
className="set-chip danger"
onClick={() => onRevoke(t.id)}
aria-label={`${t.name} 토큰 폐기`}
>
<Icon name="trash" />
</button>
</li>
))}
</ul>
)}
</div>
{/* 시스템 정보 (읽기 전용) */}
<div className="set-card">
<header className="set-cardhead">
<h3></h3>
<span className="set-hint">(.env) · </span>
</header>
<dl className="set-kv">
<div>
<dt></dt>
<dd>{sys?.env ?? "—"}</dd>
</div>
<div>
<dt></dt>
<dd>{sys ? (sys.auth_enabled ? "켜짐" : "꺼짐(데모)") : "—"}</dd>
</div>
<div>
<dt> </dt>
<dd>{sys ? (sys.worker_enabled ? "켜짐" : "꺼짐(수동)") : "—"}</dd>
</div>
<div>
<dt></dt>
<dd>{sys?.database ?? "—"}</dd>
</div>
<div>
<dt> </dt>
<dd>{sys ? `${sys.sync_interval_minutes}` : "—"}</dd>
</div>
<div>
<dt> </dt>
<dd>{sys?.web_search_provider ?? "—"}</dd>
</div>
</dl>
{sys && (
<div className="set-modes" aria-label="커넥터 기본 모드">
{Object.entries(sys.connector_modes).map(([domain, mode]) => (
<span key={domain} className="set-mode">
{DOMAIN_LABEL[domain] ?? domain}
<b>{MODE_LABEL[mode] ?? mode}</b>
</span>
))}
</div>
)}
</div>
</section>
);
}