Skip to content

Commit 8595e83

Browse files
feat: Web UI Token Manager — 多 token 切换与 session 隔离
- 新增 useTokens hook 管理 localStorage token CRUD - 新增 TokenManagerDialog 弹窗组件(添加/编辑/删除/切换 token) - api client 支持Bearer token 认证,UUID 跟随 token 变化 - Navbar 添加 token 切换按钮 - 切换 token 时自动 reload,实现 session 数据隔离 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e302cd2 commit 8595e83

6 files changed

Lines changed: 412 additions & 6 deletions

File tree

packages/remote-control-server/web/src/App.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
11
import { useState, useEffect, useCallback, lazy, Suspense } from "react";
22
import { Navbar } from "./components/Navbar";
33
import { IdentityPanel } from "./components/IdentityPanel";
4+
import { TokenManagerDialog } from "./components/TokenManagerDialog";
45
import { ThemeProvider } from "./lib/theme";
5-
import { getUuid, setUuid, apiBind } from "./api/client";
6+
import { getUuid, setUuid, apiBind, setActiveApiToken } from "./api/client";
67
import { ACPDirectView } from "./components/ACPDirectView";
8+
import { useTokens } from "./hooks/useTokens";
79

810
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
911
const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail })));
1012

1113
export default function App() {
1214
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
1315
const [identityOpen, setIdentityOpen] = useState(false);
16+
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
1417
const [acpDirect, setAcpDirect] = useState<{ url: string; token: string } | null>(null);
18+
const { tokens, activeTokenId, activeLabel, activeTokenValue, setActiveTokenId, addToken, removeToken, updateToken } = useTokens();
19+
20+
// Sync active token to API client
21+
useEffect(() => {
22+
setActiveApiToken(activeTokenValue);
23+
}, [activeTokenValue]);
24+
25+
const handleSetActiveToken = useCallback((id: string) => {
26+
setActiveTokenId(id);
27+
}, [setActiveTokenId]);
1528

1629
// Simple hash-based router
1730
const parseRoute = useCallback(() => {
@@ -97,6 +110,8 @@ export default function App() {
97110
<div className="flex h-screen flex-col bg-surface-0 text-text-primary">
98111
<Navbar
99112
onIdentityClick={() => setIdentityOpen(true)}
113+
onTokenClick={() => setTokenDialogOpen(true)}
114+
activeTokenLabel={currentSessionId ? undefined : activeLabel}
100115
sessionTitle={currentSessionId || (acpDirect ? "ACP" : undefined)}
101116
onBack={(currentSessionId || acpDirect) ? navigateToDashboard : undefined}
102117
/>
@@ -114,6 +129,17 @@ export default function App() {
114129
</Suspense>
115130

116131
<IdentityPanel open={identityOpen} onClose={() => setIdentityOpen(false)} />
132+
133+
<TokenManagerDialog
134+
open={tokenDialogOpen}
135+
onClose={() => setTokenDialogOpen(false)}
136+
tokens={tokens}
137+
activeTokenId={activeTokenId}
138+
onSetActive={handleSetActiveToken}
139+
onAdd={addToken}
140+
onRemove={removeToken}
141+
onUpdate={updateToken}
142+
/>
117143
</div>
118144
</ThemeProvider>
119145
);

packages/remote-control-server/web/src/api/client.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,35 @@ export function setUuid(uuid: string): void {
2424
localStorage.setItem("rcs_uuid", uuid);
2525
}
2626

27+
/** Active API token for Authorization header (set by useTokens) */
28+
let _activeToken: string | null = null;
29+
30+
export function setActiveApiToken(token: string | null): void {
31+
_activeToken = token;
32+
}
33+
34+
export function getActiveApiToken(): string | null {
35+
return _activeToken;
36+
}
37+
2738
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
2839
const headers: Record<string, string> = { "Content-Type": "application/json" };
29-
const uuid = getUuid();
30-
const sep = path.includes("?") ? "&" : "?";
31-
const url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
40+
41+
if (_activeToken) {
42+
headers["Authorization"] = `Bearer ${_activeToken}`;
43+
}
44+
45+
// When using Bearer token auth, backend derives UUID from the token — no need to send query param.
46+
// Otherwise fall back to UUID auth via query param.
47+
let url: string;
48+
if (_activeToken) {
49+
const sep = path.includes("?") ? "&" : "?";
50+
url = `${BASE}${path}${sep}uuid=${encodeURIComponent(_activeToken)}`;
51+
} else {
52+
const uuid = getUuid();
53+
const sep = path.includes("?") ? "&" : "?";
54+
url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
55+
}
3256
const opts: RequestInit = { method, headers };
3357
if (body !== undefined) opts.body = JSON.stringify(body);
3458

packages/remote-control-server/web/src/components/Navbar.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { cn } from "../lib/utils";
22
import { ThemeToggle } from "../../components/ui/theme-toggle";
3-
import { ChevronLeft, LayoutGrid, UserPlus } from "lucide-react";
3+
import { ChevronLeft, LayoutGrid, UserPlus, KeyRound } from "lucide-react";
44

55
interface NavbarProps {
66
onIdentityClick: () => void;
7+
onTokenClick: () => void;
8+
activeTokenLabel?: string | null;
79
sessionTitle?: string;
810
onBack?: () => void;
911
}
1012

11-
export function Navbar({ onIdentityClick, sessionTitle, onBack }: NavbarProps) {
13+
export function Navbar({ onIdentityClick, onTokenClick, activeTokenLabel, sessionTitle, onBack }: NavbarProps) {
1214
return (
1315
<nav className="sticky top-0 z-40 border-b border-border bg-surface-1/80 backdrop-blur-md">
1416
<div className="mx-auto flex h-11 sm:h-12 max-w-5xl items-center justify-between px-3 sm:px-4">
@@ -51,6 +53,19 @@ export function Navbar({ onIdentityClick, sessionTitle, onBack }: NavbarProps) {
5153
</a>
5254
)}
5355
<ThemeToggle />
56+
<button
57+
onClick={onTokenClick}
58+
className={cn(
59+
"flex items-center gap-1 rounded-md px-2 sm:px-3 py-1.5 text-sm transition-colors",
60+
activeTokenLabel
61+
? "bg-brand/10 text-brand hover:bg-brand/20"
62+
: "text-text-secondary hover:bg-surface-2 hover:text-text-primary"
63+
)}
64+
title="Token Manager"
65+
>
66+
<KeyRound className="h-4 w-4" />
67+
<span className="hidden sm:inline max-w-24 truncate">{activeTokenLabel || "No Token"}</span>
68+
</button>
5469
<button
5570
onClick={onIdentityClick}
5671
className="flex items-center gap-1 rounded-md px-2 sm:px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-2 hover:text-text-primary transition-colors"
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { useState } from "react";
2+
import type { TokenEntry } from "../hooks/useTokens";
3+
import {
4+
Dialog,
5+
DialogContent,
6+
DialogHeader,
7+
DialogTitle,
8+
DialogDescription,
9+
} from "../../components/ui/dialog";
10+
import { Check, Copy, Eye, EyeOff, Pencil, Plus, Trash2, X } from "lucide-react";
11+
12+
interface TokenManagerDialogProps {
13+
open: boolean;
14+
onClose: () => void;
15+
tokens: TokenEntry[];
16+
activeTokenId: string | null;
17+
onSetActive: (id: string) => void;
18+
onAdd: (token: string, label: string) => string | null;
19+
onRemove: (id: string) => void;
20+
onUpdate: (id: string, label: string) => void;
21+
}
22+
23+
export function TokenManagerDialog({
24+
open,
25+
onClose,
26+
tokens,
27+
activeTokenId,
28+
onSetActive,
29+
onAdd,
30+
onRemove,
31+
onUpdate,
32+
}: TokenManagerDialogProps) {
33+
const [newToken, setNewToken] = useState("");
34+
const [newLabel, setNewLabel] = useState("");
35+
const [addError, setAddError] = useState("");
36+
const [editingId, setEditingId] = useState<string | null>(null);
37+
const [editLabel, setEditLabel] = useState("");
38+
const [visibleTokenId, setVisibleTokenId] = useState<string | null>(null);
39+
const [copiedId, setCopiedId] = useState<string | null>(null);
40+
41+
const handleCopy = (id: string, token: string) => {
42+
navigator.clipboard.writeText(token).then(() => {
43+
setCopiedId(id);
44+
setTimeout(() => setCopiedId(null), 1500);
45+
});
46+
};
47+
48+
const handleAdd = () => {
49+
const error = onAdd(newToken, newLabel);
50+
if (error) {
51+
setAddError(error);
52+
return;
53+
}
54+
setNewToken("");
55+
setNewLabel("");
56+
setAddError("");
57+
};
58+
59+
const handleStartEdit = (entry: TokenEntry) => {
60+
setEditingId(entry.id);
61+
setEditLabel(entry.label);
62+
};
63+
64+
const handleSaveEdit = (id: string) => {
65+
onUpdate(id, editLabel.trim() || "Unnamed");
66+
setEditingId(null);
67+
};
68+
69+
const handleSwitch = (id: string) => {
70+
onSetActive(id);
71+
onClose();
72+
};
73+
74+
return (
75+
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
76+
<DialogContent className="max-w-md rounded-2xl border-border bg-surface-1 p-6 shadow-2xl">
77+
<DialogHeader>
78+
<DialogTitle className="font-display text-lg font-semibold text-text-primary">
79+
Token Manager
80+
</DialogTitle>
81+
<DialogDescription className="text-sm text-text-muted">
82+
Manage API tokens for RCS authentication.
83+
</DialogDescription>
84+
</DialogHeader>
85+
86+
{/* Token list */}
87+
<div className="space-y-1 max-h-64 overflow-y-auto">
88+
{tokens.map((entry) => (
89+
<div key={entry.id} className="group flex items-center gap-1">
90+
{editingId === entry.id ? (
91+
<div className="flex flex-1 items-center gap-2 rounded-lg bg-surface-2 px-3 py-1.5">
92+
<input
93+
value={editLabel}
94+
onChange={(e) => setEditLabel(e.target.value)}
95+
onKeyDown={(e) => {
96+
if (e.key === "Enter") handleSaveEdit(entry.id);
97+
if (e.key === "Escape") setEditingId(null);
98+
}}
99+
className="flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-sm text-text-primary focus:border-brand focus:outline-none"
100+
autoFocus
101+
/>
102+
<button
103+
onClick={() => handleSaveEdit(entry.id)}
104+
className="text-brand hover:text-brand-light transition-colors"
105+
>
106+
<Check className="h-4 w-4" />
107+
</button>
108+
<button
109+
onClick={() => setEditingId(null)}
110+
className="text-text-muted hover:text-text-primary transition-colors"
111+
>
112+
<X className="h-4 w-4" />
113+
</button>
114+
</div>
115+
) : (
116+
<>
117+
<button
118+
onClick={() => handleSwitch(entry.id)}
119+
className={`flex flex-1 items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors ${
120+
activeTokenId === entry.id
121+
? "bg-brand/10 text-brand"
122+
: "text-text-secondary hover:bg-surface-2"
123+
}`}
124+
>
125+
<div className="flex flex-col items-start min-w-0">
126+
<span className="font-medium truncate w-full">{entry.label}</span>
127+
<span className="text-xs text-text-muted font-mono">
128+
{visibleTokenId === entry.id
129+
? entry.token
130+
: `${entry.token.slice(0, 6)}${"\u2022".repeat(6)}`}
131+
</span>
132+
</div>
133+
{activeTokenId === entry.id && <Check className="h-4 w-4 flex-shrink-0" />}
134+
</button>
135+
<button
136+
onClick={() => setVisibleTokenId(visibleTokenId === entry.id ? null : entry.id)}
137+
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
138+
title="Toggle token visibility"
139+
>
140+
{visibleTokenId === entry.id ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
141+
</button>
142+
<button
143+
onClick={() => handleCopy(entry.id, entry.token)}
144+
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
145+
title="Copy token"
146+
>
147+
{copiedId === entry.id ? <Check className="h-3.5 w-3.5 text-status-active" /> : <Copy className="h-3.5 w-3.5" />}
148+
</button>
149+
<button
150+
onClick={() => handleStartEdit(entry)}
151+
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
152+
title="Edit label"
153+
>
154+
<Pencil className="h-3.5 w-3.5" />
155+
</button>
156+
<button
157+
onClick={() => onRemove(entry.id)}
158+
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-status-error transition-all"
159+
title="Delete token"
160+
>
161+
<Trash2 className="h-3.5 w-3.5" />
162+
</button>
163+
</>
164+
)}
165+
</div>
166+
))}
167+
168+
{tokens.length === 0 && (
169+
<div className="py-4 text-center text-sm text-text-muted">
170+
No tokens saved yet. Add one below.
171+
</div>
172+
)}
173+
</div>
174+
175+
{/* Add form */}
176+
<div className="border-t border-border pt-4 space-y-3">
177+
<div className="text-sm font-medium text-text-secondary">Add Token</div>
178+
<div className="space-y-2">
179+
<input
180+
type="text"
181+
value={newToken}
182+
onChange={(e) => {
183+
setNewToken(e.target.value);
184+
setAddError("");
185+
}}
186+
placeholder="API Token"
187+
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none font-mono"
188+
onKeyDown={(e) => {
189+
if (e.key === "Enter") handleAdd();
190+
}}
191+
/>
192+
<div className="flex gap-2">
193+
<input
194+
type="text"
195+
value={newLabel}
196+
onChange={(e) => setNewLabel(e.target.value)}
197+
placeholder="Label (optional)"
198+
className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none"
199+
onKeyDown={(e) => {
200+
if (e.key === "Enter") handleAdd();
201+
}}
202+
/>
203+
<button
204+
onClick={handleAdd}
205+
disabled={!newToken.trim()}
206+
className="rounded-lg bg-brand px-3 py-2 text-white hover:bg-brand-light disabled:opacity-50 transition-colors"
207+
>
208+
<Plus className="h-4 w-4" />
209+
</button>
210+
</div>
211+
</div>
212+
{addError && <div className="text-xs text-status-error">{addError}</div>}
213+
</div>
214+
</DialogContent>
215+
</Dialog>
216+
);
217+
}

0 commit comments

Comments
 (0)