feat: wire role-scoped device creds
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { loadChatHistory } from "./controllers/chat";
|
||||
import { loadDevices } from "./controllers/devices";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway";
|
||||
import { GatewayBrowserClient } from "./gateway";
|
||||
@@ -106,6 +107,7 @@ export function connectGateway(host: GatewayHost) {
|
||||
host.hello = hello;
|
||||
applySnapshot(host, hello);
|
||||
void loadNodes(host as unknown as ClawdbotApp, { quiet: true });
|
||||
void loadDevices(host as unknown as ClawdbotApp, { quiet: true });
|
||||
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
||||
},
|
||||
onClose: ({ code, reason }) => {
|
||||
@@ -169,6 +171,10 @@ export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
|
||||
if (evt.event === "cron" && host.tab === "cron") {
|
||||
void loadCron(host as unknown as Parameters<typeof loadCron>[0]);
|
||||
}
|
||||
|
||||
if (evt.event === "device.pair.requested" || evt.event === "device.pair.resolved") {
|
||||
void loadDevices(host as unknown as ClawdbotApp, { quiet: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
|
||||
|
||||
@@ -38,6 +38,13 @@ import { renderLogs } from "./views/logs";
|
||||
import { renderNodes } from "./views/nodes";
|
||||
import { renderOverview } from "./views/overview";
|
||||
import { renderSessions } from "./views/sessions";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
loadDevices,
|
||||
rejectDevicePairing,
|
||||
revokeDeviceToken,
|
||||
rotateDeviceToken,
|
||||
} from "./controllers/devices";
|
||||
import { renderSkills } from "./views/skills";
|
||||
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
|
||||
import { loadChannels } from "./controllers/channels";
|
||||
@@ -301,6 +308,9 @@ export function renderApp(state: AppViewState) {
|
||||
? renderNodes({
|
||||
loading: state.nodesLoading,
|
||||
nodes: state.nodes,
|
||||
devicesLoading: state.devicesLoading,
|
||||
devicesError: state.devicesError,
|
||||
devicesList: state.devicesList,
|
||||
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
|
||||
configLoading: state.configLoading,
|
||||
configSaving: state.configSaving,
|
||||
@@ -315,6 +325,13 @@ export function renderApp(state: AppViewState) {
|
||||
execApprovalsTarget: state.execApprovalsTarget,
|
||||
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
|
||||
onRefresh: () => loadNodes(state),
|
||||
onDevicesRefresh: () => loadDevices(state),
|
||||
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
|
||||
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
|
||||
onDeviceRotate: (deviceId, role, scopes) =>
|
||||
rotateDeviceToken(state, { deviceId, role, scopes }),
|
||||
onDeviceRevoke: (deviceId, role) =>
|
||||
revokeDeviceToken(state, { deviceId, role }),
|
||||
onLoadConfig: () => loadConfig(state),
|
||||
onLoadExecApprovals: () => {
|
||||
const target =
|
||||
|
||||
@@ -3,6 +3,7 @@ import { loadCronJobs, loadCronStatus } from "./controllers/cron";
|
||||
import { loadChannels } from "./controllers/channels";
|
||||
import { loadDebug } from "./controllers/debug";
|
||||
import { loadLogs } from "./controllers/logs";
|
||||
import { loadDevices } from "./controllers/devices";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
import { loadExecApprovals } from "./controllers/exec-approvals";
|
||||
import { loadPresence } from "./controllers/presence";
|
||||
@@ -136,6 +137,7 @@ export async function refreshActiveTab(host: SettingsHost) {
|
||||
if (host.tab === "skills") await loadSkills(host as unknown as ClawdbotApp);
|
||||
if (host.tab === "nodes") {
|
||||
await loadNodes(host as unknown as ClawdbotApp);
|
||||
await loadDevices(host as unknown as ClawdbotApp);
|
||||
await loadConfig(host as unknown as ClawdbotApp);
|
||||
await loadExecApprovals(host as unknown as ClawdbotApp);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
ExecApprovalsFile,
|
||||
ExecApprovalsSnapshot,
|
||||
} from "./controllers/exec-approvals";
|
||||
import type { DevicePairingList } from "./controllers/devices";
|
||||
|
||||
export type AppViewState = {
|
||||
settings: UiSettings;
|
||||
@@ -48,6 +49,9 @@ export type AppViewState = {
|
||||
chatQueue: ChatQueueItem[];
|
||||
nodesLoading: boolean;
|
||||
nodes: Array<Record<string, unknown>>;
|
||||
devicesLoading: boolean;
|
||||
devicesError: string | null;
|
||||
devicesList: DevicePairingList | null;
|
||||
execApprovalsLoading: boolean;
|
||||
execApprovalsSaving: boolean;
|
||||
execApprovalsDirty: boolean;
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
ExecApprovalsFile,
|
||||
ExecApprovalsSnapshot,
|
||||
} from "./controllers/exec-approvals";
|
||||
import type { DevicePairingList } from "./controllers/devices";
|
||||
import {
|
||||
resetToolStream as resetToolStreamInternal,
|
||||
toggleToolOutput as toggleToolOutputInternal,
|
||||
@@ -108,6 +109,9 @@ export class ClawdbotApp extends LitElement {
|
||||
|
||||
@state() nodesLoading = false;
|
||||
@state() nodes: Array<Record<string, unknown>> = [];
|
||||
@state() devicesLoading = false;
|
||||
@state() devicesError: string | null = null;
|
||||
@state() devicesList: DevicePairingList | null = null;
|
||||
@state() execApprovalsLoading = false;
|
||||
@state() execApprovalsSaving = false;
|
||||
@state() execApprovalsDirty = false;
|
||||
|
||||
135
ui/src/ui/controllers/devices.ts
Normal file
135
ui/src/ui/controllers/devices.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import { loadOrCreateDeviceIdentity } from "../device-identity";
|
||||
import { clearDeviceAuthToken, storeDeviceAuthToken } from "../device-auth";
|
||||
|
||||
export type DeviceTokenSummary = {
|
||||
role: string;
|
||||
scopes?: string[];
|
||||
createdAtMs?: number;
|
||||
rotatedAtMs?: number;
|
||||
revokedAtMs?: number;
|
||||
lastUsedAtMs?: number;
|
||||
};
|
||||
|
||||
export type PendingDevice = {
|
||||
requestId: string;
|
||||
deviceId: string;
|
||||
displayName?: string;
|
||||
role?: string;
|
||||
remoteIp?: string;
|
||||
isRepair?: boolean;
|
||||
ts?: number;
|
||||
};
|
||||
|
||||
export type PairedDevice = {
|
||||
deviceId: string;
|
||||
displayName?: string;
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
remoteIp?: string;
|
||||
tokens?: DeviceTokenSummary[];
|
||||
createdAtMs?: number;
|
||||
approvedAtMs?: number;
|
||||
};
|
||||
|
||||
export type DevicePairingList = {
|
||||
pending: PendingDevice[];
|
||||
paired: PairedDevice[];
|
||||
};
|
||||
|
||||
export type DevicesState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
devicesLoading: boolean;
|
||||
devicesError: string | null;
|
||||
devicesList: DevicePairingList | null;
|
||||
};
|
||||
|
||||
export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean }) {
|
||||
if (!state.client || !state.connected) return;
|
||||
if (state.devicesLoading) return;
|
||||
state.devicesLoading = true;
|
||||
if (!opts?.quiet) state.devicesError = null;
|
||||
try {
|
||||
const res = (await state.client.request("device.pair.list", {})) as DevicePairingList | null;
|
||||
state.devicesList = {
|
||||
pending: Array.isArray(res?.pending) ? res!.pending : [],
|
||||
paired: Array.isArray(res?.paired) ? res!.paired : [],
|
||||
};
|
||||
} catch (err) {
|
||||
if (!opts?.quiet) state.devicesError = String(err);
|
||||
} finally {
|
||||
state.devicesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function approveDevicePairing(state: DevicesState, requestId: string) {
|
||||
if (!state.client || !state.connected) return;
|
||||
try {
|
||||
await state.client.request("device.pair.approve", { requestId });
|
||||
await loadDevices(state);
|
||||
} catch (err) {
|
||||
state.devicesError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rejectDevicePairing(state: DevicesState, requestId: string) {
|
||||
if (!state.client || !state.connected) return;
|
||||
const confirmed = window.confirm("Reject this device pairing request?");
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await state.client.request("device.pair.reject", { requestId });
|
||||
await loadDevices(state);
|
||||
} catch (err) {
|
||||
state.devicesError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rotateDeviceToken(
|
||||
state: DevicesState,
|
||||
params: { deviceId: string; role: string; scopes?: string[] },
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
try {
|
||||
const res = (await state.client.request("device.token.rotate", params)) as
|
||||
| { token?: string; role?: string; deviceId?: string; scopes?: string[] }
|
||||
| undefined;
|
||||
if (res?.token) {
|
||||
const identity = await loadOrCreateDeviceIdentity();
|
||||
const role = res.role ?? params.role;
|
||||
if (res.deviceId === identity.deviceId || params.deviceId === identity.deviceId) {
|
||||
storeDeviceAuthToken({
|
||||
deviceId: identity.deviceId,
|
||||
role,
|
||||
token: res.token,
|
||||
scopes: res.scopes ?? params.scopes ?? [],
|
||||
});
|
||||
}
|
||||
window.prompt("New device token (copy and store securely):", res.token);
|
||||
}
|
||||
await loadDevices(state);
|
||||
} catch (err) {
|
||||
state.devicesError = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeDeviceToken(
|
||||
state: DevicesState,
|
||||
params: { deviceId: string; role: string },
|
||||
) {
|
||||
if (!state.client || !state.connected) return;
|
||||
const confirmed = window.confirm(
|
||||
`Revoke token for ${params.deviceId} (${params.role})?`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await state.client.request("device.token.revoke", params);
|
||||
const identity = await loadOrCreateDeviceIdentity();
|
||||
if (params.deviceId === identity.deviceId) {
|
||||
clearDeviceAuthToken({ deviceId: identity.deviceId, role: params.role });
|
||||
}
|
||||
await loadDevices(state);
|
||||
} catch (err) {
|
||||
state.devicesError = String(err);
|
||||
}
|
||||
}
|
||||
99
ui/src/ui/device-auth.ts
Normal file
99
ui/src/ui/device-auth.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
export type DeviceAuthEntry = {
|
||||
token: string;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
updatedAtMs: number;
|
||||
};
|
||||
|
||||
type DeviceAuthStore = {
|
||||
version: 1;
|
||||
deviceId: string;
|
||||
tokens: Record<string, DeviceAuthEntry>;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "clawdbot.device.auth.v1";
|
||||
|
||||
function normalizeRole(role: string): string {
|
||||
return role.trim();
|
||||
}
|
||||
|
||||
function normalizeScopes(scopes: string[] | undefined): string[] {
|
||||
if (!Array.isArray(scopes)) return [];
|
||||
const out = new Set<string>();
|
||||
for (const scope of scopes) {
|
||||
const trimmed = scope.trim();
|
||||
if (trimmed) out.add(trimmed);
|
||||
}
|
||||
return [...out].sort();
|
||||
}
|
||||
|
||||
function readStore(): DeviceAuthStore | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as DeviceAuthStore;
|
||||
if (!parsed || parsed.version !== 1) return null;
|
||||
if (!parsed.deviceId || typeof parsed.deviceId !== "string") return null;
|
||||
if (!parsed.tokens || typeof parsed.tokens !== "object") return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStore(store: DeviceAuthStore) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function loadDeviceAuthToken(params: {
|
||||
deviceId: string;
|
||||
role: string;
|
||||
}): DeviceAuthEntry | null {
|
||||
const store = readStore();
|
||||
if (!store || store.deviceId !== params.deviceId) return null;
|
||||
const role = normalizeRole(params.role);
|
||||
const entry = store.tokens[role];
|
||||
if (!entry || typeof entry.token !== "string") return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function storeDeviceAuthToken(params: {
|
||||
deviceId: string;
|
||||
role: string;
|
||||
token: string;
|
||||
scopes?: string[];
|
||||
}): DeviceAuthEntry {
|
||||
const role = normalizeRole(params.role);
|
||||
const next: DeviceAuthStore = {
|
||||
version: 1,
|
||||
deviceId: params.deviceId,
|
||||
tokens: {},
|
||||
};
|
||||
const existing = readStore();
|
||||
if (existing && existing.deviceId === params.deviceId) {
|
||||
next.tokens = { ...existing.tokens };
|
||||
}
|
||||
const entry: DeviceAuthEntry = {
|
||||
token: params.token,
|
||||
role,
|
||||
scopes: normalizeScopes(params.scopes),
|
||||
updatedAtMs: Date.now(),
|
||||
};
|
||||
next.tokens[role] = entry;
|
||||
writeStore(next);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function clearDeviceAuthToken(params: { deviceId: string; role: string }) {
|
||||
const store = readStore();
|
||||
if (!store || store.deviceId !== params.deviceId) return;
|
||||
const role = normalizeRole(params.role);
|
||||
if (!store.tokens[role]) return;
|
||||
const next = { ...store, tokens: { ...store.tokens } };
|
||||
delete next.tokens[role];
|
||||
writeStore(next);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "../../../src/gateway/protocol/client-info.js";
|
||||
import { buildDeviceAuthPayload } from "../../../src/gateway/device-auth.js";
|
||||
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity";
|
||||
import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth";
|
||||
|
||||
export type GatewayEventFrame = {
|
||||
type: "event";
|
||||
@@ -29,6 +30,12 @@ export type GatewayHelloOk = {
|
||||
protocol: number;
|
||||
features?: { methods?: string[]; events?: string[] };
|
||||
snapshot?: unknown;
|
||||
auth?: {
|
||||
deviceToken?: string;
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
issuedAtMs?: number;
|
||||
};
|
||||
policy?: { tickIntervalMs?: number };
|
||||
};
|
||||
|
||||
@@ -120,15 +127,21 @@ export class GatewayBrowserClient {
|
||||
this.connectTimer = null;
|
||||
}
|
||||
const deviceIdentity = await loadOrCreateDeviceIdentity();
|
||||
const scopes = ["operator.admin"];
|
||||
const role = "operator";
|
||||
const storedToken = loadDeviceAuthToken({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
role,
|
||||
})?.token;
|
||||
const authToken = storedToken ?? this.opts.token;
|
||||
const canFallbackToShared = Boolean(storedToken && this.opts.token);
|
||||
const auth =
|
||||
this.opts.token || this.opts.password
|
||||
authToken || this.opts.password
|
||||
? {
|
||||
token: this.opts.token,
|
||||
token: authToken,
|
||||
password: this.opts.password,
|
||||
}
|
||||
: undefined;
|
||||
const scopes = ["operator.admin"];
|
||||
const role = "operator";
|
||||
const signedAtMs = Date.now();
|
||||
const nonce = this.connectNonce ?? undefined;
|
||||
const payload = buildDeviceAuthPayload({
|
||||
@@ -138,7 +151,7 @@ export class GatewayBrowserClient {
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: this.opts.token ?? null,
|
||||
token: authToken ?? null,
|
||||
nonce,
|
||||
});
|
||||
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
|
||||
@@ -169,10 +182,21 @@ export class GatewayBrowserClient {
|
||||
|
||||
void this.request<GatewayHelloOk>("connect", params)
|
||||
.then((hello) => {
|
||||
if (hello?.auth?.deviceToken) {
|
||||
storeDeviceAuthToken({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
role: hello.auth.role ?? role,
|
||||
token: hello.auth.deviceToken,
|
||||
scopes: hello.auth.scopes ?? [],
|
||||
});
|
||||
}
|
||||
this.backoffMs = 800;
|
||||
this.opts.onHello?.(hello);
|
||||
})
|
||||
.catch(() => {
|
||||
if (canFallbackToShared) {
|
||||
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
|
||||
}
|
||||
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
import { clampText, formatAgo } from "../format";
|
||||
import { clampText, formatAgo, formatList } from "../format";
|
||||
import type {
|
||||
ExecApprovalsAllowlistEntry,
|
||||
ExecApprovalsFile,
|
||||
ExecApprovalsSnapshot,
|
||||
} from "../controllers/exec-approvals";
|
||||
import type {
|
||||
DevicePairingList,
|
||||
DeviceTokenSummary,
|
||||
PairedDevice,
|
||||
PendingDevice,
|
||||
} from "../controllers/devices";
|
||||
|
||||
export type NodesProps = {
|
||||
loading: boolean;
|
||||
nodes: Array<Record<string, unknown>>;
|
||||
devicesLoading: boolean;
|
||||
devicesError: string | null;
|
||||
devicesList: DevicePairingList | null;
|
||||
configForm: Record<string, unknown> | null;
|
||||
configLoading: boolean;
|
||||
configSaving: boolean;
|
||||
@@ -24,6 +33,11 @@ export type NodesProps = {
|
||||
execApprovalsTarget: "gateway" | "node";
|
||||
execApprovalsTargetNodeId: string | null;
|
||||
onRefresh: () => void;
|
||||
onDevicesRefresh: () => void;
|
||||
onDeviceApprove: (requestId: string) => void;
|
||||
onDeviceReject: (requestId: string) => void;
|
||||
onDeviceRotate: (deviceId: string, role: string, scopes?: string[]) => void;
|
||||
onDeviceRevoke: (deviceId: string, role: string) => void;
|
||||
onLoadConfig: () => void;
|
||||
onLoadExecApprovals: () => void;
|
||||
onBindDefault: (nodeId: string | null) => void;
|
||||
@@ -42,6 +56,7 @@ export function renderNodes(props: NodesProps) {
|
||||
return html`
|
||||
${renderExecApprovals(approvalsState)}
|
||||
${renderBindings(bindingState)}
|
||||
${renderDevices(props)}
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
@@ -61,6 +76,128 @@ export function renderNodes(props: NodesProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDevices(props: NodesProps) {
|
||||
const list = props.devicesList ?? { pending: [], paired: [] };
|
||||
const pending = Array.isArray(list.pending) ? list.pending : [];
|
||||
const paired = Array.isArray(list.paired) ? list.paired : [];
|
||||
return html`
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
<div class="card-title">Devices</div>
|
||||
<div class="card-sub">Pairing requests + role tokens.</div>
|
||||
</div>
|
||||
<button class="btn" ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
|
||||
${props.devicesLoading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
${props.devicesError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>`
|
||||
: nothing}
|
||||
<div class="list" style="margin-top: 16px;">
|
||||
${pending.length > 0
|
||||
? html`
|
||||
<div class="muted" style="margin-bottom: 8px;">Pending</div>
|
||||
${pending.map((req) => renderPendingDevice(req, props))}
|
||||
`
|
||||
: nothing}
|
||||
${paired.length > 0
|
||||
? html`
|
||||
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
|
||||
${paired.map((device) => renderPairedDevice(device, props))}
|
||||
`
|
||||
: nothing}
|
||||
${pending.length === 0 && paired.length === 0
|
||||
? html`<div class="muted">No paired devices.</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPendingDevice(req: PendingDevice, props: NodesProps) {
|
||||
const name = req.displayName?.trim() || req.deviceId;
|
||||
const age = typeof req.ts === "number" ? formatAgo(req.ts) : "n/a";
|
||||
const role = req.role?.trim() ? `role: ${req.role}` : "role: -";
|
||||
const repair = req.isRepair ? " · repair" : "";
|
||||
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
|
||||
return html`
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="list-title">${name}</div>
|
||||
<div class="list-sub">${req.deviceId}${ip}</div>
|
||||
<div class="muted" style="margin-top: 6px;">
|
||||
${role} · requested ${age}${repair}
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
<div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;">
|
||||
<button class="btn btn--sm primary" @click=${() => props.onDeviceApprove(req.requestId)}>
|
||||
Approve
|
||||
</button>
|
||||
<button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPairedDevice(device: PairedDevice, props: NodesProps) {
|
||||
const name = device.displayName?.trim() || device.deviceId;
|
||||
const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
|
||||
const roles = `roles: ${formatList(device.roles)}`;
|
||||
const scopes = `scopes: ${formatList(device.scopes)}`;
|
||||
const tokens = Array.isArray(device.tokens) ? device.tokens : [];
|
||||
return html`
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="list-title">${name}</div>
|
||||
<div class="list-sub">${device.deviceId}${ip}</div>
|
||||
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
|
||||
${tokens.length === 0
|
||||
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>`
|
||||
: html`
|
||||
<div class="muted" style="margin-top: 10px;">Tokens</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
|
||||
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
|
||||
const status = token.revokedAtMs ? "revoked" : "active";
|
||||
const scopes = `scopes: ${formatList(token.scopes)}`;
|
||||
const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null);
|
||||
return html`
|
||||
<div class="row" style="justify-content: space-between; gap: 8px;">
|
||||
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
|
||||
<div class="row" style="justify-content: flex-end; gap: 6px; flex-wrap: wrap;">
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
|
||||
>
|
||||
Rotate
|
||||
</button>
|
||||
${token.revokedAtMs
|
||||
? nothing
|
||||
: html`
|
||||
<button
|
||||
class="btn btn--sm danger"
|
||||
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
type BindingAgent = {
|
||||
id: string;
|
||||
name?: string;
|
||||
|
||||
Reference in New Issue
Block a user