feat: admin agent switcher - view any agent's config from WebUI
- Add agent selector dropdown in header (admin only) - Return role in GET /config response - Collect agents from both auth: and agent_tokens: KV prefixes - Show 'viewing <agent>' badge when viewing another agent - Non-admin users see only their own config (unchanged)
This commit is contained in:
parent
eadaa97caa
commit
a3b3c598ab
@ -18,18 +18,38 @@ export default function Dashboard({ onLogout, addToast }: Props) {
|
|||||||
const [modal, setModal] = useState<{ key?: string; entry?: ConfigEntry } | null>(null);
|
const [modal, setModal] = useState<{ key?: string; entry?: ConfigEntry } | null>(null);
|
||||||
const [tab, setTab] = useState<"config" | "admin">("config");
|
const [tab, setTab] = useState<"config" | "admin">("config");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [agentList, setAgentList] = useState<string[]>([]);
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<string>("");
|
||||||
|
const [ownAgentId, setOwnAgentId] = useState<string>("");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async (agentId?: string) => {
|
||||||
try {
|
try {
|
||||||
const d = await api.getConfig();
|
if (agentId && agentId !== ownAgentId) {
|
||||||
setData(d);
|
const d = await api.getAgent(agentId);
|
||||||
|
setData(d);
|
||||||
|
} else {
|
||||||
|
const d = await api.getConfig();
|
||||||
|
setData(d);
|
||||||
|
if (!ownAgentId && d.agent_id) {
|
||||||
|
setOwnAgentId(d.agent_id);
|
||||||
|
setSelectedAgent(d.agent_id);
|
||||||
|
}
|
||||||
|
// Load agent list for admins
|
||||||
|
if (d.role === "admin" && agentList.length === 0) {
|
||||||
|
try {
|
||||||
|
const agents = await api.getAgents();
|
||||||
|
const list = Array.isArray(agents) ? agents : agents.agents || [];
|
||||||
|
setAgentList(list);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast("Failed to load: " + e.message, "error");
|
addToast("Failed to load: " + e.message, "error");
|
||||||
if (e.message.includes("401") || e.message.includes("403")) onLogout();
|
if (e.message.includes("401") || e.message.includes("403")) onLogout();
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [ownAgentId, agentList.length]);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
@ -45,7 +65,7 @@ export default function Dashboard({ onLogout, addToast }: Props) {
|
|||||||
await api.putKey(key, scope, { value, env, secret });
|
await api.putKey(key, scope, { value, env, secret });
|
||||||
addToast(`Key "${key}" saved`, "success");
|
addToast(`Key "${key}" saved`, "success");
|
||||||
setModal(null);
|
setModal(null);
|
||||||
load();
|
load(selectedAgent);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast("Failed: " + e.message, "error");
|
addToast("Failed: " + e.message, "error");
|
||||||
}
|
}
|
||||||
@ -56,13 +76,20 @@ export default function Dashboard({ onLogout, addToast }: Props) {
|
|||||||
try {
|
try {
|
||||||
await api.deleteKey(key, scope);
|
await api.deleteKey(key, scope);
|
||||||
addToast(`Key "${key}" deleted`, "success");
|
addToast(`Key "${key}" deleted`, "success");
|
||||||
load();
|
load(selectedAgent);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast("Failed: " + e.message, "error");
|
addToast("Failed: " + e.message, "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAgentSwitch = (agentId: string) => {
|
||||||
|
setSelectedAgent(agentId);
|
||||||
|
setLoading(true);
|
||||||
|
load(agentId);
|
||||||
|
};
|
||||||
|
|
||||||
const isAdmin = data?.role === "admin";
|
const isAdmin = data?.role === "admin";
|
||||||
|
const isViewingOther = selectedAgent && selectedAgent !== ownAgentId;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -88,6 +115,18 @@ export default function Dashboard({ onLogout, addToast }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm text-[#64748b]" style={{ fontFamily: "var(--font-mono)" }}>{data?.agent_id}</span>
|
<span className="text-sm text-[#64748b]" style={{ fontFamily: "var(--font-mono)" }}>{data?.agent_id}</span>
|
||||||
|
{isAdmin && agentList.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={selectedAgent}
|
||||||
|
onChange={(e) => handleAgentSwitch(e.target.value)}
|
||||||
|
className="px-2 py-1 bg-[#0a0a0c] border border-[#1e2030] rounded-lg text-sm text-white focus:outline-none focus:border-[#3b82f6] transition-colors"
|
||||||
|
>
|
||||||
|
{agentList.map((id) => (
|
||||||
|
<option key={id} value={id}>{id}{id === ownAgentId ? " (me)" : ""}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{isViewingOther && <span className="text-xs px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-400 border border-amber-500/30 font-medium">viewing {selectedAgent}</span>}
|
||||||
{isAdmin && <span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 border border-purple-500/30 font-medium">admin</span>}
|
{isAdmin && <span className="text-xs px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 border border-purple-500/30 font-medium">admin</span>}
|
||||||
<button onClick={onLogout} className="text-sm text-[#64748b] hover:text-white transition-colors">Logout</button>
|
<button onClick={onLogout} className="text-sm text-[#64748b] hover:text-white transition-colors">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -195,7 +195,7 @@ async function handleSync(auth: AuthInfo, kv: KVNamespace): Promise<Response> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ agent_id: auth.agent_id, secrets: resolved });
|
return json({ agent_id: auth.agent_id, role: auth.role, secrets: resolved });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Admin endpoints ────────────────────────────────────────────────────
|
// ── Admin endpoints ────────────────────────────────────────────────────
|
||||||
@ -232,9 +232,22 @@ async function handleAdminRevokeTokens(auth: AuthInfo, kv: KVNamespace, agentId:
|
|||||||
|
|
||||||
async function handleAdminListAgents(auth: AuthInfo, kv: KVNamespace): Promise<Response> {
|
async function handleAdminListAgents(auth: AuthInfo, kv: KVNamespace): Promise<Response> {
|
||||||
if (auth.role !== "admin") return err("admin required", 403);
|
if (auth.role !== "admin") return err("admin required", 403);
|
||||||
const list = await kv.list({ prefix: "agent_tokens:" });
|
// Collect agents from agent_tokens: prefix
|
||||||
const agents = list.keys.map((k) => k.name.replace("agent_tokens:", ""));
|
const tokenList = await kv.list({ prefix: "agent_tokens:" });
|
||||||
return json({ agents });
|
const fromTokens = tokenList.keys.map((k) => k.name.replace("agent_tokens:", ""));
|
||||||
|
// Also collect agents from auth: entries
|
||||||
|
const authList = await kv.list({ prefix: "auth:" });
|
||||||
|
const agentIds = new Set<string>(fromTokens);
|
||||||
|
for (const key of authList.keys) {
|
||||||
|
const val = await kv.get(key.name);
|
||||||
|
if (val) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(val);
|
||||||
|
if (parsed.agent_id) agentIds.add(parsed.agent_id);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json({ agents: [...agentIds].sort() });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAdminInspect(auth: AuthInfo, kv: KVNamespace, agentId: string): Promise<Response> {
|
async function handleAdminInspect(auth: AuthInfo, kv: KVNamespace, agentId: string): Promise<Response> {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user