import { useEffect, useState } from "react"; import { Group, Stack, Text } from "@mantine/core"; import { Copy, KeyRound, Plus } from "lucide-react"; import { apiJSON } from "../../net/api"; import { t } from "../../i18n"; import { formatRelative } from "../../utils/format"; import { Badge, Button, Callout, Panel, TextField } from "../../ui/primitives"; import { toast } from "../../ui/feedback"; type ShortcutTokenView = { jti: string; label: string; scopes: string[]; created_at: number; expires_at: number; last_used_at: number | null; revoked: boolean; }; type IssueResp = { token: string; jti: string; label: string; scopes: string[]; expires_at: number; }; const listTokens = () => apiJSON<{ tokens: ShortcutTokenView[] }>("/api/shortcut"); const issueToken = (label: string) => apiJSON("/api/shortcut/issue", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label }), }); const revokeToken = (jti: string) => apiJSON(`/api/shortcut/${encodeURIComponent(jti)}`, { method: "DELETE" }); // baseURL 是本服务器的对外地址,快捷指令请求剪贴板端点时用它拼 URL。桌面注入了 // loopback 代理地址,但快捷指令跑在 iPhone 上,应当用真正的对外 origin。 function baseURL(): string { return window.location.origin; } async function copyText(value: string): Promise { try { await navigator.clipboard.writeText(value); toast.ok(t("settings.shortcut.copied")); } catch { toast.error(t("settings.shortcut.copyFailed")); } } // ShortcutTokens 管理 iOS 快捷指令的长效、仅剪贴板、可吊销令牌:列表 / 签发 // (仅展示一次)/ 吊销 + 安装指引。挂在设置页,需登录会话(用令牌本身不能管理令牌)。 export function ShortcutTokens() { const [ tokens, setTokens ] = useState(null); const [ loadError, setLoadError ] = useState(false); const [ label, setLabel ] = useState(""); const [ issuing, setIssuing ] = useState(false); const [ revoking, setRevoking ] = useState>(new Set()); const [ issued, setIssued ] = useState(null); const reload = async () => { try { const r = await listTokens(); setTokens(r.tokens ?? []); setLoadError(false); } catch { // 网络波动不该让整页报错;显示重试入口即可(容错要求)。 setLoadError(true); } }; useEffect(() => { void reload(); }, []); const handleIssue = async () => { setIssuing(true); try { const res = await issueToken(label.trim()); setIssued(res); // 内联展示一次性令牌 + 指引 setLabel(""); await reload(); } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (msg.startsWith("503")) { toast.error(t("settings.shortcut.unavailable")); } else if (msg.startsWith("409")) { toast.error(t("settings.shortcut.tooMany")); } else { toast.error(t("settings.shortcut.issueError", { message: msg })); } } finally { setIssuing(false); } }; const handleRevoke = async (token: ShortcutTokenView) => { if (!window.confirm(t("settings.shortcut.revokeConfirm"))) { return; } setRevoking((prev) => new Set(prev).add(token.jti)); try { await revokeToken(token.jti); toast.ok(t("settings.shortcut.revokedToast")); await reload(); } catch (e) { toast.error(t("settings.shortcut.issueError", { message: e instanceof Error ? e.message : String(e), })); } finally { setRevoking((prev) => { const next = new Set(prev); next.delete(token.jti); return next; }); } }; return ( {t("settings.shortcut.intro")}
setLabel(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === "Enter" && !issuing) { e.preventDefault(); void handleIssue(); } }} />
{issued && ( setIssued(null)} /> )} {loadError && ( {t("settings.shortcut.loadError")} )} {tokens && tokens.length === 0 && !loadError && ( {t("settings.shortcut.empty")} )} {tokens && tokens.length > 0 && ( {tokens.map((tok) => ( handleRevoke(tok)} /> ))} )}
); } function TokenRow(props: { token: ShortcutTokenView; revoking: boolean; onRevoke: () => void }) { const { token, revoking, onRevoke } = props; const expired = token.expires_at * 1000 < Date.now(); const lastUsed = token.last_used_at ? t("settings.shortcut.lastUsed", { time: formatRelative(token.last_used_at) }) : t("settings.shortcut.neverUsed"); return ( {token.label} {token.revoked && {t("settings.shortcut.revoked")}} {!token.revoked && expired && ( {t("settings.shortcut.expired")} )} {t("settings.shortcut.expiresAt", { date: new Date(token.expires_at * 1000).toLocaleDateString(), })} {" · "} {lastUsed} {!token.revoked && ( )} ); } // IssuedReveal 内联展示刚签发的令牌(仅此一次)+ 服务器地址 + 手动搭建指引。 // 沿用设置页一贯的「下沉面板 + Callout + Button」语汇,不引入弹窗。 function IssuedReveal(props: { issued: IssueResp; base: string; onDismiss: () => void }) { const { issued, base, onDismiss } = props; return ( } title={t("settings.shortcut.issuedTitle")} > {t("settings.shortcut.onceWarning")} {t("settings.shortcut.guideTitle")} {t("settings.shortcut.guideIntro")} {t("settings.shortcut.guideDeviceName")} {t("settings.shortcut.guidePullTitle")} 1. {t("settings.shortcut.guidePull1")} 2. {t("settings.shortcut.guidePull2")} 3. {t("settings.shortcut.guidePull3")} {t("settings.shortcut.guidePushTitle")} 1. {t("settings.shortcut.guidePush1")} 2. {t("settings.shortcut.guidePush2")} 3. {t("settings.shortcut.guidePush3")} {t("settings.shortcut.guideToleranceNote")} ); } // Field 是一行「标签 + 单行只读值 + 复制按钮」,值用等宽字体、可断行。 function Field(props: { label: string; value: string; mono?: boolean }) { const { label, value, mono } = props; return ( {label} {value} ); }