f21fa5b5e8
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
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<IssueResp>("/api/shortcut/issue", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ label }),
|
|
});
|
|
|
|
const revokeToken = (jti: string) =>
|
|
apiJSON<void>(`/api/shortcut/${encodeURIComponent(jti)}`, { method: "DELETE" });
|
|
|
|
// baseURL 是本服务器的对外地址,快捷指令请求剪贴板端点时用它拼 URL。桌面注入了
|
|
// loopback 代理地址,但快捷指令跑在 iPhone 上,应当用真正的对外 origin。
|
|
function baseURL(): string
|
|
{
|
|
return window.location.origin;
|
|
}
|
|
|
|
async function copyText(value: string): Promise<void>
|
|
{
|
|
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<ShortcutTokenView[] | null>(null);
|
|
const [ loadError, setLoadError ] = useState(false);
|
|
const [ label, setLabel ] = useState("");
|
|
const [ issuing, setIssuing ] = useState(false);
|
|
const [ revoking, setRevoking ] = useState<Set<string>>(new Set());
|
|
const [ issued, setIssued ] = useState<IssueResp | null>(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 (
|
|
<Panel title={t("settings.shortcut.title")}>
|
|
<Stack gap="sm">
|
|
<Text size="sm" c="dimmed">{t("settings.shortcut.intro")}</Text>
|
|
|
|
<Group align="flex-end" gap="sm" wrap="nowrap">
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<TextField
|
|
label={t("settings.shortcut.labelField")}
|
|
placeholder={t("settings.shortcut.labelPlaceholder")}
|
|
value={label}
|
|
onChange={(e) => setLabel(e.currentTarget.value)}
|
|
onKeyDown={(e) =>
|
|
{
|
|
if (e.key === "Enter" && !issuing)
|
|
{
|
|
e.preventDefault();
|
|
void handleIssue();
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="primary"
|
|
leftIcon={<Plus size={14} />}
|
|
onClick={handleIssue}
|
|
loading={issuing}
|
|
>
|
|
{t("settings.shortcut.issue")}
|
|
</Button>
|
|
</Group>
|
|
|
|
{issued && (
|
|
<IssuedReveal
|
|
issued={issued}
|
|
base={baseURL()}
|
|
onDismiss={() => setIssued(null)}
|
|
/>
|
|
)}
|
|
|
|
{loadError && (
|
|
<Group gap="sm">
|
|
<Text size="sm" c="dimmed">{t("settings.shortcut.loadError")}</Text>
|
|
<Button size="sm" variant="ghost" onClick={() => void reload()}>
|
|
{t("settings.shortcut.retry")}
|
|
</Button>
|
|
</Group>
|
|
)}
|
|
|
|
{tokens && tokens.length === 0 && !loadError && (
|
|
<Text size="sm" c="dimmed">{t("settings.shortcut.empty")}</Text>
|
|
)}
|
|
|
|
{tokens && tokens.length > 0 && (
|
|
<Stack gap="xs">
|
|
{tokens.map((tok) => (
|
|
<TokenRow
|
|
key={tok.jti}
|
|
token={tok}
|
|
revoking={revoking.has(tok.jti)}
|
|
onRevoke={() => handleRevoke(tok)}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Panel variant="sunken" padding="tight">
|
|
<Group justify="space-between" wrap="nowrap" align="center">
|
|
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
|
|
<Group gap={6} wrap="nowrap">
|
|
<Text fw={500} size="sm" truncate>{token.label}</Text>
|
|
{token.revoked && <Badge tone="neutral">{t("settings.shortcut.revoked")}</Badge>}
|
|
{!token.revoked && expired && (
|
|
<Badge tone="neutral">{t("settings.shortcut.expired")}</Badge>
|
|
)}
|
|
</Group>
|
|
<Text size="xs" c="dimmed" truncate>
|
|
{t("settings.shortcut.expiresAt", {
|
|
date: new Date(token.expires_at * 1000).toLocaleDateString(),
|
|
})}
|
|
{" · "}
|
|
{lastUsed}
|
|
</Text>
|
|
</Stack>
|
|
{!token.revoked && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
loading={revoking}
|
|
onClick={onRevoke}
|
|
style={{ color: "var(--state-error)" }}
|
|
>
|
|
{revoking ? t("settings.shortcut.revoking") : t("settings.shortcut.revoke")}
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
// IssuedReveal 内联展示刚签发的令牌(仅此一次)+ 服务器地址 + 手动搭建指引。
|
|
// 沿用设置页一贯的「下沉面板 + Callout + Button」语汇,不引入弹窗。
|
|
function IssuedReveal(props: { issued: IssueResp; base: string; onDismiss: () => void })
|
|
{
|
|
const { issued, base, onDismiss } = props;
|
|
return (
|
|
<Panel variant="sunken" padding="tight">
|
|
<Stack gap="sm">
|
|
<Callout
|
|
tone="warn"
|
|
icon={<KeyRound size={16} />}
|
|
title={t("settings.shortcut.issuedTitle")}
|
|
>
|
|
{t("settings.shortcut.onceWarning")}
|
|
</Callout>
|
|
|
|
<Field label={t("settings.shortcut.tokenLabel")} value={issued.token} mono />
|
|
<Field label={t("settings.shortcut.baseLabel")} value={base} />
|
|
|
|
<Stack gap={6}>
|
|
<Text size="sm" fw={600}>{t("settings.shortcut.guideTitle")}</Text>
|
|
<Text size="sm" c="dimmed">{t("settings.shortcut.guideIntro")}</Text>
|
|
<Text size="sm" c="dimmed">{t("settings.shortcut.guideDeviceName")}</Text>
|
|
|
|
<Text size="sm" fw={500}>{t("settings.shortcut.guidePullTitle")}</Text>
|
|
<Stack gap={2}>
|
|
<Text size="sm">1. {t("settings.shortcut.guidePull1")}</Text>
|
|
<Text size="sm">2. {t("settings.shortcut.guidePull2")}</Text>
|
|
<Text size="sm">3. {t("settings.shortcut.guidePull3")}</Text>
|
|
</Stack>
|
|
|
|
<Text size="sm" fw={500}>{t("settings.shortcut.guidePushTitle")}</Text>
|
|
<Stack gap={2}>
|
|
<Text size="sm">1. {t("settings.shortcut.guidePush1")}</Text>
|
|
<Text size="sm">2. {t("settings.shortcut.guidePush2")}</Text>
|
|
<Text size="sm">3. {t("settings.shortcut.guidePush3")}</Text>
|
|
</Stack>
|
|
|
|
<Text size="xs" c="dimmed">{t("settings.shortcut.guideToleranceNote")}</Text>
|
|
</Stack>
|
|
|
|
<Group justify="flex-end">
|
|
<Button variant="ghost" onClick={onDismiss}>
|
|
{t("settings.shortcut.done")}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
// Field 是一行「标签 + 单行只读值 + 复制按钮」,值用等宽字体、可断行。
|
|
function Field(props: { label: string; value: string; mono?: boolean })
|
|
{
|
|
const { label, value, mono } = props;
|
|
return (
|
|
<Stack gap={4}>
|
|
<Text size="xs" c="dimmed">{label}</Text>
|
|
<Group gap="xs" wrap="nowrap" align="flex-start">
|
|
<Text
|
|
size="sm"
|
|
style={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
wordBreak: "break-all",
|
|
fontFamily: mono ? "var(--font-mono)" : undefined,
|
|
}}
|
|
>
|
|
{value}
|
|
</Text>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
leftIcon={<Copy size={14} />}
|
|
onClick={() => void copyText(value)}
|
|
>
|
|
{t("settings.shortcut.copy")}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
);
|
|
}
|