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
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>
|
|
);
|
|
}
|