feat(gateway): add paired devices API and dashboard tab
This commit is contained in:
parent
0253752bc9
commit
3f70cbbf9b
@ -529,6 +529,48 @@ pub async fn handle_api_health(
|
||||
Json(serde_json::json!({"health": snapshot})).into_response()
|
||||
}
|
||||
|
||||
/// GET /api/pairing/devices — list paired devices
|
||||
pub async fn handle_api_pairing_devices(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
let devices = state.pairing.paired_devices();
|
||||
Json(serde_json::json!({ "devices": devices })).into_response()
|
||||
}
|
||||
|
||||
/// DELETE /api/pairing/devices/:id — revoke paired device
|
||||
pub async fn handle_api_pairing_device_revoke(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
if !state.pairing.revoke_device(&id) {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Paired device not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = super::persist_pairing_tokens(state.config.clone(), &state.pairing).await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("Failed to persist pairing state: {e}")})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
Json(serde_json::json!({"status": "ok", "revoked": true, "id": id})).into_response()
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
fn normalize_dashboard_config_toml(root: &mut toml::Value) {
|
||||
|
||||
@ -758,6 +758,11 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
.route("/api/memory", get(api::handle_api_memory_list))
|
||||
.route("/api/memory", post(api::handle_api_memory_store))
|
||||
.route("/api/memory/{key}", delete(api::handle_api_memory_delete))
|
||||
.route("/api/pairing/devices", get(api::handle_api_pairing_devices))
|
||||
.route(
|
||||
"/api/pairing/devices/{id}",
|
||||
delete(api::handle_api_pairing_device_revoke),
|
||||
)
|
||||
.route("/api/cost", get(api::handle_api_cost))
|
||||
.route("/api/cli-tools", get(api::handle_api_cli_tools))
|
||||
.route("/api/health", get(api::handle_api_health))
|
||||
|
||||
@ -24,6 +24,8 @@ const MAX_TRACKED_CLIENTS: usize = 10_000;
|
||||
const FAILED_ATTEMPT_RETENTION_SECS: u64 = 900; // 15 min
|
||||
/// Minimum interval between full sweeps of the failed-attempt map.
|
||||
const FAILED_ATTEMPT_SWEEP_INTERVAL_SECS: u64 = 300; // 5 min
|
||||
/// Display length for stable paired-device IDs derived from token hash prefix.
|
||||
const DEVICE_ID_PREFIX_LEN: usize = 16;
|
||||
|
||||
/// Per-client failed attempt state with optional absolute lockout deadline.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@ -33,6 +35,41 @@ struct FailedAttemptState {
|
||||
last_attempt: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PairedDeviceMeta {
|
||||
created_at: Option<String>,
|
||||
last_seen_at: Option<String>,
|
||||
paired_by: Option<String>,
|
||||
}
|
||||
|
||||
impl PairedDeviceMeta {
|
||||
fn legacy() -> Self {
|
||||
Self {
|
||||
created_at: None,
|
||||
last_seen_at: None,
|
||||
paired_by: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fresh(paired_by: Option<String>) -> Self {
|
||||
let now = now_rfc3339();
|
||||
Self {
|
||||
created_at: Some(now.clone()),
|
||||
last_seen_at: Some(now),
|
||||
paired_by,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct PairedDevice {
|
||||
pub id: String,
|
||||
pub token_fingerprint: String,
|
||||
pub created_at: Option<String>,
|
||||
pub last_seen_at: Option<String>,
|
||||
pub paired_by: Option<String>,
|
||||
}
|
||||
|
||||
/// Manages pairing state for the gateway.
|
||||
///
|
||||
/// Bearer tokens are stored as SHA-256 hashes to prevent plaintext exposure
|
||||
@ -47,6 +84,8 @@ pub struct PairingGuard {
|
||||
pairing_code: Arc<Mutex<Option<String>>>,
|
||||
/// Set of SHA-256 hashed bearer tokens (persisted across restarts).
|
||||
paired_tokens: Arc<Mutex<HashSet<String>>>,
|
||||
/// Non-secret per-device metadata keyed by token hash.
|
||||
paired_device_meta: Arc<Mutex<HashMap<String, PairedDeviceMeta>>>,
|
||||
/// Brute-force protection: per-client failed attempt state + last sweep timestamp.
|
||||
failed_attempts: Arc<Mutex<(HashMap<String, FailedAttemptState>, Instant)>>,
|
||||
}
|
||||
@ -71,6 +110,10 @@ impl PairingGuard {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let paired_device_meta: HashMap<String, PairedDeviceMeta> = tokens
|
||||
.iter()
|
||||
.map(|hash| (hash.clone(), PairedDeviceMeta::legacy()))
|
||||
.collect();
|
||||
let code = if require_pairing && tokens.is_empty() {
|
||||
Some(generate_code())
|
||||
} else {
|
||||
@ -80,6 +123,7 @@ impl PairingGuard {
|
||||
require_pairing,
|
||||
pairing_code: Arc::new(Mutex::new(code)),
|
||||
paired_tokens: Arc::new(Mutex::new(tokens)),
|
||||
paired_device_meta: Arc::new(Mutex::new(paired_device_meta)),
|
||||
failed_attempts: Arc::new(Mutex::new((HashMap::new(), Instant::now()))),
|
||||
}
|
||||
}
|
||||
@ -132,8 +176,16 @@ impl PairingGuard {
|
||||
guard.0.remove(&client_id);
|
||||
}
|
||||
let token = generate_token();
|
||||
let hashed_token = hash_token(&token);
|
||||
let mut tokens = self.paired_tokens.lock();
|
||||
tokens.insert(hash_token(&token));
|
||||
tokens.insert(hashed_token.clone());
|
||||
drop(tokens);
|
||||
|
||||
let mut metadata = self.paired_device_meta.lock();
|
||||
metadata.insert(
|
||||
hashed_token,
|
||||
PairedDeviceMeta::fresh(Some(client_id.clone())),
|
||||
);
|
||||
|
||||
// Consume the pairing code so it cannot be reused
|
||||
*pairing_code = None;
|
||||
@ -205,8 +257,21 @@ impl PairingGuard {
|
||||
return true;
|
||||
}
|
||||
let hashed = hash_token(token);
|
||||
let tokens = self.paired_tokens.lock();
|
||||
tokens.contains(&hashed)
|
||||
let is_valid = {
|
||||
let tokens = self.paired_tokens.lock();
|
||||
tokens.contains(&hashed)
|
||||
};
|
||||
|
||||
if is_valid {
|
||||
let mut metadata = self.paired_device_meta.lock();
|
||||
let now = now_rfc3339();
|
||||
let entry = metadata
|
||||
.entry(hashed)
|
||||
.or_insert_with(PairedDeviceMeta::legacy);
|
||||
entry.last_seen_at = Some(now);
|
||||
}
|
||||
|
||||
is_valid
|
||||
}
|
||||
|
||||
/// Returns true if the gateway is already paired (has at least one token).
|
||||
@ -220,6 +285,80 @@ impl PairingGuard {
|
||||
let tokens = self.paired_tokens.lock();
|
||||
tokens.iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// List paired devices with non-secret metadata for dashboard management.
|
||||
pub fn paired_devices(&self) -> Vec<PairedDevice> {
|
||||
let token_hashes: Vec<String> = {
|
||||
let tokens = self.paired_tokens.lock();
|
||||
tokens.iter().cloned().collect()
|
||||
};
|
||||
let metadata = self.paired_device_meta.lock();
|
||||
|
||||
let mut devices: Vec<PairedDevice> = token_hashes
|
||||
.into_iter()
|
||||
.map(|hash| {
|
||||
let meta = metadata
|
||||
.get(&hash)
|
||||
.cloned()
|
||||
.unwrap_or_else(PairedDeviceMeta::legacy);
|
||||
let id = device_id_from_hash(&hash);
|
||||
PairedDevice {
|
||||
id: id.clone(),
|
||||
token_fingerprint: id,
|
||||
created_at: meta.created_at,
|
||||
last_seen_at: meta.last_seen_at,
|
||||
paired_by: meta.paired_by,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
devices.sort_by(|a, b| {
|
||||
b.last_seen_at
|
||||
.cmp(&a.last_seen_at)
|
||||
.then_with(|| b.created_at.cmp(&a.created_at))
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
devices
|
||||
}
|
||||
|
||||
/// Revoke a paired device by short ID (hash prefix) or full token hash.
|
||||
///
|
||||
/// Returns true when a device token was removed.
|
||||
pub fn revoke_device(&self, device_id: &str) -> bool {
|
||||
let requested = device_id.trim();
|
||||
if requested.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut tokens = self.paired_tokens.lock();
|
||||
let token_hash = tokens
|
||||
.iter()
|
||||
.find(|hash| {
|
||||
let hash = hash.as_str();
|
||||
hash == requested || device_id_from_hash(hash) == requested
|
||||
})
|
||||
.cloned();
|
||||
|
||||
let Some(token_hash) = token_hash else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let removed = tokens.remove(&token_hash);
|
||||
let tokens_empty = tokens.is_empty();
|
||||
drop(tokens);
|
||||
|
||||
if removed {
|
||||
self.paired_device_meta.lock().remove(&token_hash);
|
||||
if self.require_pairing && tokens_empty {
|
||||
let mut code = self.pairing_code.lock();
|
||||
if code.is_none() {
|
||||
*code = Some(generate_code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removed
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a client identifier: trim whitespace, map empty to `"unknown"`.
|
||||
@ -232,6 +371,14 @@ fn normalize_client_key(key: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn now_rfc3339() -> String {
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
fn device_id_from_hash(hash: &str) -> String {
|
||||
hash.chars().take(DEVICE_ID_PREFIX_LEN).collect()
|
||||
}
|
||||
|
||||
/// Remove failed-attempt entries whose `last_attempt` is older than the retention window.
|
||||
fn prune_failed_attempts(map: &mut HashMap<String, FailedAttemptState>, now: Instant) {
|
||||
map.retain(|_, state| {
|
||||
@ -418,6 +565,44 @@ mod tests {
|
||||
assert!(!guard.is_authenticated("wrong"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn paired_devices_and_revoke_device_roundtrip() {
|
||||
let guard = PairingGuard::new(true, &[]);
|
||||
let code = guard.pairing_code().unwrap().to_string();
|
||||
let token = guard.try_pair(&code, "test_client").await.unwrap().unwrap();
|
||||
assert!(guard.is_authenticated(&token));
|
||||
|
||||
let devices = guard.paired_devices();
|
||||
assert_eq!(devices.len(), 1);
|
||||
assert_eq!(devices[0].paired_by.as_deref(), Some("test_client"));
|
||||
assert!(devices[0].created_at.is_some());
|
||||
assert!(devices[0].last_seen_at.is_some());
|
||||
|
||||
let revoked = guard.revoke_device(&devices[0].id);
|
||||
assert!(revoked, "revoke should remove the paired token");
|
||||
assert!(!guard.is_authenticated(&token));
|
||||
assert!(!guard.is_paired());
|
||||
assert!(
|
||||
guard.pairing_code().is_some(),
|
||||
"revoke of final device should regenerate one-time pairing code"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn authenticate_updates_legacy_device_last_seen() {
|
||||
let token = "zc_valid";
|
||||
let token_hash = hash_token(token);
|
||||
let guard = PairingGuard::new(true, &[token_hash]);
|
||||
let before = guard.paired_devices();
|
||||
assert_eq!(before.len(), 1);
|
||||
assert!(before[0].last_seen_at.is_none());
|
||||
|
||||
assert!(guard.is_authenticated(token));
|
||||
|
||||
let after = guard.paired_devices();
|
||||
assert!(after[0].last_seen_at.is_some());
|
||||
}
|
||||
|
||||
// ── Token hashing ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
1
web/dist/assets/index-C70eaW2F.css
vendored
Normal file
1
web/dist/assets/index-C70eaW2F.css
vendored
Normal file
File diff suppressed because one or more lines are too long
320
web/dist/assets/index-CJ6bGkAt.js
vendored
Normal file
320
web/dist/assets/index-CJ6bGkAt.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/index-DEhGL4Jw.css
vendored
1
web/dist/assets/index-DEhGL4Jw.css
vendored
File diff suppressed because one or more lines are too long
295
web/dist/assets/index-Dam-egf7.js
vendored
295
web/dist/assets/index-Dam-egf7.js
vendored
File diff suppressed because one or more lines are too long
8
web/dist/index.html
vendored
8
web/dist/index.html
vendored
@ -3,10 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' ws: wss: https:; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
|
||||
/>
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>ZeroClaw</title>
|
||||
<script type="module" crossorigin src="/_app/assets/index-Dam-egf7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/_app/assets/index-DEhGL4Jw.css">
|
||||
<script type="module" crossorigin src="/_app/assets/index-CJ6bGkAt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/_app/assets/index-C70eaW2F.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -7,6 +7,7 @@ import Tools from './pages/Tools';
|
||||
import Cron from './pages/Cron';
|
||||
import Integrations from './pages/Integrations';
|
||||
import Memory from './pages/Memory';
|
||||
import Devices from './pages/Devices';
|
||||
import Config from './pages/Config';
|
||||
import Cost from './pages/Cost';
|
||||
import Logs from './pages/Logs';
|
||||
@ -119,6 +120,7 @@ function AppContent() {
|
||||
<Route path="/cron" element={<Cron />} />
|
||||
<Route path="/integrations" element={<Integrations />} />
|
||||
<Route path="/memory" element={<Memory />} />
|
||||
<Route path="/devices" element={<Devices />} />
|
||||
<Route path="/config" element={<Config />} />
|
||||
<Route path="/cost" element={<Cost />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
Clock,
|
||||
Puzzle,
|
||||
Brain,
|
||||
Smartphone,
|
||||
Settings,
|
||||
DollarSign,
|
||||
Activity,
|
||||
@ -21,6 +22,7 @@ const navItems = [
|
||||
{ to: '/cron', icon: Clock, labelKey: 'nav.cron' },
|
||||
{ to: '/integrations', icon: Puzzle, labelKey: 'nav.integrations' },
|
||||
{ to: '/memory', icon: Brain, labelKey: 'nav.memory' },
|
||||
{ to: '/devices', icon: Smartphone, labelKey: 'nav.devices' },
|
||||
{ to: '/config', icon: Settings, labelKey: 'nav.config' },
|
||||
{ to: '/cost', icon: DollarSign, labelKey: 'nav.cost' },
|
||||
{ to: '/logs', icon: Activity, labelKey: 'nav.logs' },
|
||||
|
||||
@ -6,6 +6,7 @@ import type {
|
||||
IntegrationSettingsPayload,
|
||||
DiagResult,
|
||||
MemoryEntry,
|
||||
PairedDevice,
|
||||
CostSummary,
|
||||
CliTool,
|
||||
HealthSnapshot,
|
||||
@ -247,6 +248,22 @@ export function deleteMemory(key: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paired Devices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getPairedDevices(): Promise<PairedDevice[]> {
|
||||
return apiFetch<PairedDevice[] | { devices: PairedDevice[] }>('/api/pairing/devices').then(
|
||||
(data) => unwrapField(data, 'devices'),
|
||||
);
|
||||
}
|
||||
|
||||
export function revokePairedDevice(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/pairing/devices/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cost
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -16,6 +16,7 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'nav.cron': 'Scheduled Jobs',
|
||||
'nav.integrations': 'Integrations',
|
||||
'nav.memory': 'Memory',
|
||||
'nav.devices': 'Devices',
|
||||
'nav.config': 'Configuration',
|
||||
'nav.cost': 'Cost Tracker',
|
||||
'nav.logs': 'Logs',
|
||||
@ -199,6 +200,7 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'nav.cron': 'Zamanlanmis Gorevler',
|
||||
'nav.integrations': 'Entegrasyonlar',
|
||||
'nav.memory': 'Hafiza',
|
||||
'nav.devices': 'Cihazlar',
|
||||
'nav.config': 'Yapilandirma',
|
||||
'nav.cost': 'Maliyet Takibi',
|
||||
'nav.logs': 'Kayitlar',
|
||||
@ -382,6 +384,7 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
'nav.cron': '定时任务',
|
||||
'nav.integrations': '集成',
|
||||
'nav.memory': '记忆',
|
||||
'nav.devices': '设备',
|
||||
'nav.config': '配置',
|
||||
'nav.cost': '成本追踪',
|
||||
'nav.logs': '日志',
|
||||
|
||||
170
web/src/pages/Devices.tsx
Normal file
170
web/src/pages/Devices.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Smartphone, RefreshCw, ShieldX } from 'lucide-react';
|
||||
import type { PairedDevice } from '@/types/api';
|
||||
import { getPairedDevices, revokePairedDevice } from '@/lib/api';
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return 'Unknown';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export default function Devices() {
|
||||
const [devices, setDevices] = useState<PairedDevice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingRevoke, setPendingRevoke] = useState<string | null>(null);
|
||||
|
||||
const loadDevices = async (isRefresh = false) => {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getPairedDevices();
|
||||
setDevices(data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load paired devices');
|
||||
} finally {
|
||||
if (isRefresh) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadDevices(false);
|
||||
}, []);
|
||||
|
||||
const handleRevoke = async (id: string) => {
|
||||
try {
|
||||
await revokePairedDevice(id);
|
||||
setDevices((prev) => prev.filter((device) => device.id !== id));
|
||||
setPendingRevoke(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to revoke paired device');
|
||||
setPendingRevoke(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="h-5 w-5 text-blue-400" />
|
||||
<h2 className="text-base font-semibold text-white">
|
||||
Paired Devices ({devices.length})
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
void loadDevices(true);
|
||||
}}
|
||||
disabled={refreshing}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-700 bg-red-900/30 p-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-8 text-center">
|
||||
<ShieldX className="mx-auto mb-3 h-10 w-10 text-gray-600" />
|
||||
<p className="text-gray-400">No paired devices found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-gray-800 bg-gray-900">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-400">
|
||||
Device ID
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-400">
|
||||
Paired By
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-400">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-400">
|
||||
Last Seen
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-400">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((device) => (
|
||||
<tr
|
||||
key={device.id}
|
||||
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-xs text-white">
|
||||
{device.token_fingerprint}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-300">
|
||||
{device.paired_by ?? 'Unknown'}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-400">
|
||||
{formatDate(device.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-gray-400">
|
||||
{formatDate(device.last_seen_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{pendingRevoke === device.id ? (
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="text-xs text-red-400">Revoke?</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
void handleRevoke(device.id);
|
||||
}}
|
||||
className="text-xs font-medium text-red-400 hover:text-red-300"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPendingRevoke(null)}
|
||||
className="text-xs font-medium text-gray-400 hover:text-white"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setPendingRevoke(device.id)}
|
||||
className="text-xs font-medium text-red-400 hover:text-red-300"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -93,6 +93,14 @@ export interface MemoryEntry {
|
||||
score: number | null;
|
||||
}
|
||||
|
||||
export interface PairedDevice {
|
||||
id: string;
|
||||
token_fingerprint: string;
|
||||
created_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
paired_by: string | null;
|
||||
}
|
||||
|
||||
export interface CostSummary {
|
||||
session_cost_usd: number;
|
||||
daily_cost_usd: number;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user