mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-03 21:08:13 +02:00
b041b5e97c
Reported by @tg12. Pre-fix, the Settings panel stored real third-party
Copernicus CDSE client_id + client_secret in browser localStorage /
sessionStorage via the privacy storage helper, and the proxy routes
required those values to come back in every tile/token request body.
Any same-origin script (XSS, malicious browser extension, dev-tools
HAR export) had read access to the credentials.
This change moves them server-side, behind the same .env-backed admin
flow every other third-party API key (OpenSky, AIS Stream, Finnhub,
Shodan, …) already uses.
Backend
-------
backend/services/api_settings.py
* Added SENTINEL_CLIENT_ID and SENTINEL_CLIENT_SECRET entries to
API_REGISTRY. The existing GET/PUT /api/settings/api-keys flow
(already require_local_operator-gated, .env-backed) now manages
them — no new route surface.
backend/routers/tools.py
* /api/sentinel/token and /api/sentinel/tile resolve credentials via
a new _resolve_sentinel_credentials() helper: body fields win for
back-compat with any legacy callers, otherwise the helper reads
SENTINEL_CLIENT_ID / SENTINEL_CLIENT_SECRET from os.environ.
* When neither source has a value, the route returns 400 with a
friendly pointer ("Set SENTINEL_CLIENT_ID and SENTINEL_CLIENT_SECRET
in the API Keys panel") instead of the curt "required" message.
The user's standing rule against hostile errors applies.
* Function bodies only — decorator lines untouched, so this PR does
not conflict with #303 (which adds Depends(require_local_operator)
to the same routes).
Frontend
--------
frontend/src/lib/sentinelHub.ts — rewritten
* Removed: getSentinelCredentials / setSentinelCredentials /
clearSentinelCredentials / getSentinelCredentialStorageMode.
These were the browser-storage read/write helpers; their existence
was the bug.
* Added: checkBackendSentinelStatus(), refreshSentinelStatus(),
getCachedSentinelStatus(), and a kept-for-back-compat
hasSentinelCredentials() shim. Status is sourced from
/api/settings/api-keys (the same endpoint the API Keys panel
already uses), so we don't add a new route just for this read.
* Added: migrateLegacySentinelBrowserKeys() — one-shot, idempotent
helper that clears sb_sentinel_client_id / _secret / _instance_id
from BOTH localStorage and sessionStorage. We deliberately do NOT
auto-POST those legacy browser values to the backend; doing so
would silently migrate a secret across a trust boundary without
operator consent. Operators re-enter once in the API Keys panel
and the legacy keys get wiped here.
* fetchSentinelTile and getSentinelToken no longer send client_id /
client_secret in the request body. The backend uses .env.
frontend/src/components/SettingsPanel.tsx
* Dropped sb_sentinel_client_id / _secret / _instance_id from
PRIVACY_SENSITIVE_BROWSER_KEYS — they're no longer written.
* SentinelTab rewritten: removed the inline Client ID / Client Secret
inputs + Save / Clear / Test buttons. Replaced with a status panel
that calls checkBackendSentinelStatus() on mount, a one-click
"Open API Keys Panel" button, and a migration banner that appears
only when migrateLegacySentinelBrowserKeys() actually cleared
something.
* Setup guide STEP 3 now points to the API Keys panel instead of
the local form.
frontend/src/app/page.tsx
* Added a one-time useEffect that fires checkBackendSentinelStatus()
on mount so the cached value (which the synchronous
hasSentinelCredentials() shim reads) is populated before
MaplibreViewer's tile-URL memo runs.
Tests
-----
backend/tests/test_sentinel_credentials_server_side.py (new)
* API_REGISTRY surface — sentinel_client_id / sentinel_client_secret
are registered with the right env_keys, ALLOWED_ENV_KEYS lets
/api/settings/api-keys PUT them.
* Resolution order — body wins, env is fallback, neither → 400 with
the friendly pointer message, and NO upstream HTTP call when
neither source has credentials (asserted via
MagicMock(side_effect=AssertionError)).
* /api/sentinel/tile same shape.
frontend/src/__tests__/utils/sentinelHub.test.ts (new)
* migrateLegacySentinelBrowserKeys clears localStorage AND
sessionStorage, reports what it cleared, idempotent.
* fetchSentinelTile + getSentinelToken POST WITHOUT client_id /
client_secret in the body (plants leaked credentials in browser
storage first to prove they are NOT picked up).
* checkBackendSentinelStatus parses /api/settings/api-keys correctly:
true only when both keys is_set, false on partial config or
network errors.
All 7 backend tests + 8 frontend tests pass locally. The
test_no_new_duplicate_routes guard and the api-settings test suite
still pass.
Credit: @tg12 for the audit report.
3058 lines
144 KiB
TypeScript
3058 lines
144 KiB
TypeScript
'use client';
|
|
|
|
import { API_BASE } from '@/lib/api';
|
|
import { clearAdminSession, hasAdminSession, primeAdminSession } from '@/lib/adminSession';
|
|
import { controlPlaneFetch, controlPlaneJson } from '@/lib/controlPlane';
|
|
import { isNativeProtectedSettingsReady } from '@/lib/nativeProtectedSettings';
|
|
import {
|
|
fetchPrivacyProfileSnapshot,
|
|
fetchRnsStatusSnapshot,
|
|
invalidatePrivacyProfileCache,
|
|
invalidateRnsStatusCache,
|
|
} from '@/mesh/controlPlaneStatusClient';
|
|
import {
|
|
clearBrowserIdentityState,
|
|
purgeBrowserContactGraph,
|
|
purgeBrowserSigningMaterial,
|
|
setSecureModeCached,
|
|
} from '@/mesh/meshIdentity';
|
|
import { purgeBrowserDmState } from '@/mesh/meshDmWorkerClient';
|
|
import {
|
|
connectWormhole,
|
|
disconnectWormhole,
|
|
fetchWormholeSettings,
|
|
fetchWormholeState,
|
|
invalidateWormholeRuntimeCache,
|
|
joinWormhole,
|
|
restartWormhole,
|
|
type WormholeState,
|
|
} from '@/mesh/wormholeClient';
|
|
import {
|
|
fetchWormholeDmRootHealth,
|
|
fetchWormholeIdentity,
|
|
type WormholeDmRootHealth,
|
|
} from '@/mesh/wormholeIdentityClient';
|
|
import {
|
|
formatLegacyCompatibilitySeenAt,
|
|
hasLegacyCompatibilityActivity,
|
|
summarizeLegacyCompatibility,
|
|
} from '@/mesh/wormholeCompatibility';
|
|
import {
|
|
formatGateCompatSeenAt,
|
|
getGateCompatTelemetryEventName,
|
|
getGateCompatTelemetrySnapshot,
|
|
summarizeGateCompatTelemetry,
|
|
type GateCompatTelemetrySnapshot,
|
|
} from '@/mesh/gateCompatTelemetry';
|
|
import {
|
|
describeBrowserGateLocalRuntimeStatus,
|
|
getBrowserGateLocalRuntimeEventName,
|
|
getBrowserGateLocalRuntimeStatus,
|
|
type BrowserGateLocalRuntimeStatus,
|
|
} from '@/mesh/meshGateWorkerClient';
|
|
import {
|
|
isNativeDesktop,
|
|
companionStatus as fetchCompanionStatus,
|
|
companionEnable,
|
|
companionDisable,
|
|
companionOpenBrowser,
|
|
type CompanionStatus,
|
|
} from '@/lib/desktopCompanion';
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
Settings,
|
|
ExternalLink,
|
|
Key,
|
|
Shield,
|
|
X,
|
|
Save,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Rss,
|
|
Plus,
|
|
Trash2,
|
|
RotateCcw,
|
|
Satellite,
|
|
Copy,
|
|
Check,
|
|
Radar,
|
|
} from 'lucide-react';
|
|
import {
|
|
// Issue #298: Sentinel credentials now live server-side. The legacy
|
|
// browser-storage helpers (getSentinelCredentials / setSentinelCredentials
|
|
// / clearSentinelCredentials / getSentinelCredentialStorageMode) have
|
|
// been removed from sentinelHub.ts. We use the new status check + the
|
|
// one-time migration helper instead.
|
|
checkBackendSentinelStatus,
|
|
migrateLegacySentinelBrowserKeys,
|
|
} from '@/lib/sentinelHub';
|
|
import {
|
|
getPrivacyProfilePreference,
|
|
getPrivacyStrictPreference,
|
|
getSessionModePreference,
|
|
migrateSensitiveBrowserItems,
|
|
setPrivacyProfilePreference,
|
|
setPrivacyStrictPreference,
|
|
setSessionModePreference,
|
|
} from '@/lib/privacyBrowserStorage';
|
|
import { useTranslation, LOCALES, type Locale } from '@/i18n';
|
|
|
|
interface ApiEntry {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
category: string;
|
|
url: string | null;
|
|
required: boolean;
|
|
has_key: boolean;
|
|
env_key: string | null;
|
|
is_set: boolean;
|
|
}
|
|
|
|
interface FeedEntry {
|
|
name: string;
|
|
url: string;
|
|
weight: number;
|
|
}
|
|
|
|
interface EnvMeta {
|
|
env_path: string;
|
|
env_path_exists: boolean;
|
|
env_path_writable: boolean;
|
|
env_example_path: string;
|
|
env_example_path_exists: boolean;
|
|
operator_keys_env_path?: string;
|
|
operator_keys_env_path_exists?: boolean;
|
|
operator_keys_env_path_writable?: boolean;
|
|
}
|
|
|
|
const WEIGHT_LABELS: Record<number, string> = {
|
|
1: 'LOW',
|
|
2: 'MED',
|
|
3: 'STD',
|
|
4: 'HIGH',
|
|
5: 'CRIT',
|
|
};
|
|
const WEIGHT_COLORS: Record<number, string> = {
|
|
1: 'text-gray-400 border-gray-600',
|
|
2: 'text-blue-400 border-blue-600',
|
|
3: 'text-cyan-400 border-cyan-600',
|
|
4: 'text-orange-400 border-orange-600',
|
|
5: 'text-red-400 border-red-600',
|
|
};
|
|
const SETTINGS_FOCUS_KEY = 'sb_settings_focus';
|
|
const WORMHOLE_RETURN_KEY = 'sb_wormhole_return_target';
|
|
const WORMHOLE_READY_EVENT = 'sb:wormhole-ready';
|
|
// Issue #298 (tg12): Sentinel credentials moved from browser storage to
|
|
// the backend ``.env`` (managed through the API Keys panel). The legacy
|
|
// keys (``sb_sentinel_client_id`` / ``sb_sentinel_client_secret`` /
|
|
// ``sb_sentinel_instance_id``) are no longer treated as sensitive
|
|
// browser state because they are no longer written. ``SentinelTab``
|
|
// runs ``migrateLegacySentinelBrowserKeys()`` once on mount to clear
|
|
// any leftover values from pre-#298 installs.
|
|
const PRIVACY_SENSITIVE_BROWSER_KEYS = [
|
|
'sb_infonet_head',
|
|
'sb_infonet_head_history',
|
|
'sb_infonet_peers',
|
|
] as const;
|
|
|
|
async function applySecureModeBoundary(enabled: boolean): Promise<void> {
|
|
setSecureModeCached(enabled);
|
|
if (!enabled) return;
|
|
purgeBrowserSigningMaterial();
|
|
purgeBrowserContactGraph();
|
|
await purgeBrowserDmState();
|
|
}
|
|
|
|
function migratePrivacySensitiveBrowserState(): void {
|
|
migrateSensitiveBrowserItems([...PRIVACY_SENSITIVE_BROWSER_KEYS]);
|
|
}
|
|
|
|
const MAX_FEEDS = 50;
|
|
|
|
// Category colors for the tactical UI
|
|
const CATEGORY_COLORS: Record<string, string> = {
|
|
Aviation: 'text-cyan-400 border-cyan-500/30 bg-cyan-950/20',
|
|
Maritime: 'text-blue-400 border-blue-500/30 bg-blue-950/20',
|
|
Geophysical: 'text-orange-400 border-orange-500/30 bg-orange-950/20',
|
|
Space: 'text-purple-400 border-purple-500/30 bg-purple-950/20',
|
|
Intelligence: 'text-red-400 border-red-500/30 bg-red-950/20',
|
|
Geolocation: 'text-green-400 border-green-500/30 bg-green-950/20',
|
|
Weather: 'text-yellow-400 border-yellow-500/30 bg-yellow-950/20',
|
|
Markets: 'text-emerald-400 border-emerald-500/30 bg-emerald-950/20',
|
|
SIGINT: 'text-rose-400 border-rose-500/30 bg-rose-950/20',
|
|
Reconnaissance: 'text-green-400 border-green-500/30 bg-green-950/20',
|
|
};
|
|
|
|
function dmRootMonitorTone(state: string | undefined): string {
|
|
switch (String(state || '').toLowerCase()) {
|
|
case 'ok':
|
|
return 'border-green-500/35 bg-green-950/16 text-green-300';
|
|
case 'warning':
|
|
return 'border-yellow-500/35 bg-yellow-950/16 text-yellow-200';
|
|
case 'critical':
|
|
return 'border-red-500/35 bg-red-950/16 text-red-200';
|
|
default:
|
|
return 'border-cyan-500/25 bg-cyan-950/10 text-cyan-200';
|
|
}
|
|
}
|
|
|
|
function dmRootMonitorLabel(state: string | undefined): string {
|
|
switch (String(state || '').toLowerCase()) {
|
|
case 'ok':
|
|
return 'HEALTHY';
|
|
case 'warning':
|
|
return 'ATTENTION';
|
|
case 'critical':
|
|
return 'BLOCKED';
|
|
default:
|
|
return 'UNKNOWN';
|
|
}
|
|
}
|
|
|
|
function dmRootUrgencyTone(urgency: string | undefined): string {
|
|
switch (String(urgency || '').toLowerCase()) {
|
|
case 'page':
|
|
return 'border-red-500/35 bg-red-950/18 text-red-200';
|
|
case 'ticket':
|
|
return 'border-yellow-500/35 bg-yellow-950/18 text-yellow-200';
|
|
case 'watch':
|
|
return 'border-cyan-500/35 bg-cyan-950/18 text-cyan-200';
|
|
default:
|
|
return 'border-slate-600/35 bg-slate-900/18 text-slate-300';
|
|
}
|
|
}
|
|
|
|
function formatAgeWindow(ageS?: number, maxS?: number): string {
|
|
const age = Math.max(0, Number(ageS || 0));
|
|
const max = Math.max(0, Number(maxS || 0));
|
|
const fmt = (value: number) => {
|
|
if (value <= 0) return '0s';
|
|
if (value < 60) return `${value}s`;
|
|
if (value < 3600) return `${Math.round(value / 60)}m`;
|
|
return `${Math.round(value / 3600)}h`;
|
|
};
|
|
return max > 0 ? `${fmt(age)} / ${fmt(max)} max` : fmt(age);
|
|
}
|
|
|
|
type Tab = 'api-keys' | 'news-feeds' | 'sentinel' | 'sar' | 'protocol';
|
|
|
|
const SettingsPanel = React.memo(function SettingsPanel({
|
|
isOpen,
|
|
onClose,
|
|
}: {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}) {
|
|
const [activeTab, setActiveTab] = useState<Tab>('api-keys');
|
|
|
|
// Native desktop bypass: when the native IPC bridge is present, protected
|
|
// settings are authenticated through Rust-side admin-key ownership. The
|
|
// browser admin-session flow is unnecessary and unavailable in packaged mode.
|
|
const nativeProtected = isNativeProtectedSettingsReady();
|
|
const { t, locale, setLocale } = useTranslation();
|
|
|
|
// --- Admin Key (for protected endpoints) ---
|
|
const [adminKey, setAdminKey] = useState('');
|
|
const [adminSessionReady, setAdminSessionReady] = useState(false);
|
|
const [adminSessionBusy, setAdminSessionBusy] = useState(false);
|
|
const [adminSessionMsg, setAdminSessionMsg] = useState<string | null>(null);
|
|
const [, setStrictPrivacy] = useState(() => getPrivacyStrictPreference());
|
|
const [privacyProfile, setPrivacyProfile] = useState(() => getPrivacyProfilePreference());
|
|
const [sessionMode, setSessionMode] = useState(() => getSessionModePreference());
|
|
const [browserWipeBusy, setBrowserWipeBusy] = useState(false);
|
|
const [browserWipeMsg, setBrowserWipeMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(
|
|
null,
|
|
);
|
|
const [wormholeEnabled, setWormholeEnabled] = useState(false);
|
|
const [wormholeSaving, setWormholeSaving] = useState(false);
|
|
const [wormholeMsg, setWormholeMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(
|
|
null,
|
|
);
|
|
const [wormholeTransport, setWormholeTransport] = useState('direct');
|
|
const [wormholeSocksProxy, setWormholeSocksProxy] = useState('');
|
|
const [wormholeSocksDns, setWormholeSocksDns] = useState(true);
|
|
const [wormholeAnonymousMode, setWormholeAnonymousMode] = useState(false);
|
|
const [wormholeDirty, setWormholeDirty] = useState(false);
|
|
const [wormholeStatus, setWormholeStatus] = useState<WormholeState | null>(null);
|
|
const [wormholeGuideNotice, setWormholeGuideNotice] = useState<string | null>(null);
|
|
const [showAdvancedWormhole, setShowAdvancedWormhole] = useState(false);
|
|
const [wormholeQuickState, setWormholeQuickState] = useState<'idle' | 'ready' | 'connecting' | 'active'>('idle');
|
|
const [showOperatorTools, setShowOperatorTools] = useState(false);
|
|
const [wormholeNodeId, setWormholeNodeId] = useState<string | null>(null);
|
|
const [wormholeKeyCopied, setWormholeKeyCopied] = useState(false);
|
|
const [gateCompatTelemetry, setGateCompatTelemetry] = useState<GateCompatTelemetrySnapshot>(
|
|
() => getGateCompatTelemetrySnapshot(),
|
|
);
|
|
const [gateLocalRuntimeStatus, setGateLocalRuntimeStatus] = useState<BrowserGateLocalRuntimeStatus>(
|
|
() => getBrowserGateLocalRuntimeStatus(),
|
|
);
|
|
const [dmRootHealth, setDmRootHealth] = useState<WormholeDmRootHealth | null>(null);
|
|
const [dmRootHealthBusy, setDmRootHealthBusy] = useState(false);
|
|
const [dmRootHealthMsg, setDmRootHealthMsg] = useState<string | null>(null);
|
|
|
|
// --- Time Machine ---
|
|
const [tmEnabled, setTmEnabled] = useState(false);
|
|
const [tmSaving, setTmSaving] = useState(false);
|
|
|
|
// Fetch Time Machine status when protocol tab opens
|
|
useEffect(() => {
|
|
if (!isOpen || activeTab !== 'protocol') return;
|
|
fetch(`${API_BASE}/api/settings/timemachine`)
|
|
.then((r) => r.json())
|
|
.then((d) => setTmEnabled(!!d.enabled))
|
|
.catch(() => {});
|
|
}, [isOpen, activeTab]);
|
|
|
|
const toggleTimeMachine = useCallback(async () => {
|
|
setTmSaving(true);
|
|
try {
|
|
const res = await controlPlaneFetch('/api/settings/timemachine', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled: !tmEnabled }),
|
|
requireAdminSession: false,
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setTmEnabled(!!data.enabled);
|
|
}
|
|
} catch {}
|
|
setTmSaving(false);
|
|
}, [tmEnabled]);
|
|
|
|
// --- Browser Companion (desktop-only) ---
|
|
const [companionAvailable] = useState(() => isNativeDesktop());
|
|
const [companion, setCompanion] = useState<CompanionStatus | null>(null);
|
|
const [companionBusy, setCompanionBusy] = useState(false);
|
|
const [companionError, setCompanionError] = useState<string | null>(null);
|
|
|
|
const [companionLoadFailed, setCompanionLoadFailed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || activeTab !== 'protocol' || !companionAvailable) return;
|
|
setCompanionLoadFailed(false);
|
|
fetchCompanionStatus()
|
|
.then((s) => setCompanion(s))
|
|
.catch(() => {
|
|
setCompanion(null);
|
|
setCompanionLoadFailed(true);
|
|
});
|
|
}, [isOpen, activeTab, companionAvailable]);
|
|
|
|
useEffect(() => {
|
|
const refreshTelemetry = () => setGateCompatTelemetry(getGateCompatTelemetrySnapshot());
|
|
refreshTelemetry();
|
|
if (typeof window === 'undefined') return;
|
|
const eventName = getGateCompatTelemetryEventName();
|
|
window.addEventListener(eventName, refreshTelemetry as EventListener);
|
|
return () => {
|
|
window.removeEventListener(eventName, refreshTelemetry as EventListener);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const refreshRuntimeStatus = () => setGateLocalRuntimeStatus(getBrowserGateLocalRuntimeStatus());
|
|
refreshRuntimeStatus();
|
|
if (typeof window === 'undefined') return;
|
|
const eventName = getBrowserGateLocalRuntimeEventName();
|
|
window.addEventListener(eventName, refreshRuntimeStatus as EventListener);
|
|
return () => {
|
|
window.removeEventListener(eventName, refreshRuntimeStatus as EventListener);
|
|
};
|
|
}, []);
|
|
|
|
const toggleCompanion = useCallback(async () => {
|
|
setCompanionBusy(true);
|
|
setCompanionError(null);
|
|
try {
|
|
const result = companion?.enabled
|
|
? await companionDisable()
|
|
: await companionEnable();
|
|
if (result) setCompanion(result);
|
|
} catch (e) {
|
|
setCompanionError(e instanceof Error ? e.message : String(e));
|
|
}
|
|
setCompanionBusy(false);
|
|
}, [companion?.enabled]);
|
|
|
|
const openCompanionBrowser = useCallback(async () => {
|
|
setCompanionBusy(true);
|
|
setCompanionError(null);
|
|
try {
|
|
const result = await companionOpenBrowser();
|
|
if (result) setCompanion(result);
|
|
} catch (e) {
|
|
setCompanionError(e instanceof Error ? e.message : String(e));
|
|
}
|
|
setCompanionBusy(false);
|
|
}, []);
|
|
|
|
const clearSessionIdentity = () => {
|
|
if (typeof window === 'undefined') return;
|
|
const keys = [
|
|
'sb_mesh_pubkey',
|
|
'sb_mesh_privkey',
|
|
'sb_mesh_node_id',
|
|
'sb_mesh_sovereignty_accepted',
|
|
'sb_mesh_dh_pubkey',
|
|
'sb_mesh_dh_privkey',
|
|
'sb_mesh_dh_algo',
|
|
'sb_mesh_dh_last_ts',
|
|
'sb_mesh_contacts',
|
|
'sb_mesh_dm_notify',
|
|
'sb_mesh_sequence',
|
|
'sb_mesh_algo',
|
|
];
|
|
for (const key of keys) {
|
|
try {
|
|
sessionStorage.removeItem(key);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
};
|
|
const [rnsStatus, setRnsStatus] = useState<{
|
|
enabled: boolean;
|
|
ready: boolean;
|
|
configured_peers: number;
|
|
active_peers: number;
|
|
} | null>(null);
|
|
const wipeLocalMeshTraces = useCallback(async () => {
|
|
setBrowserWipeBusy(true);
|
|
setBrowserWipeMsg(null);
|
|
try {
|
|
await clearBrowserIdentityState();
|
|
await purgeBrowserDmState();
|
|
for (const key of PRIVACY_SENSITIVE_BROWSER_KEYS) {
|
|
try {
|
|
localStorage.removeItem(key);
|
|
sessionStorage.removeItem(key);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
setSessionModePreference(true);
|
|
setSessionMode(true);
|
|
setBrowserWipeMsg({
|
|
type: 'ok',
|
|
text: wormholeEnabled
|
|
? 'Browser-held mesh traces cleared. The local Wormhole agent stays running, but this tab will need to reconnect to it.'
|
|
: 'Browser-held mesh traces cleared from this browser.',
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'unknown error';
|
|
setBrowserWipeMsg({
|
|
type: 'err',
|
|
text: `Could not clear browser-held mesh traces: ${message}`,
|
|
});
|
|
} finally {
|
|
setBrowserWipeBusy(false);
|
|
}
|
|
}, [wormholeEnabled]);
|
|
const refreshAdminSession = useCallback(async () => {
|
|
// In native desktop mode, protected settings are handled through Rust IPC
|
|
// with native admin-key ownership — no browser admin-session needed.
|
|
if (isNativeProtectedSettingsReady()) {
|
|
setAdminSessionReady(true);
|
|
setAdminSessionMsg(null);
|
|
return true;
|
|
}
|
|
const ready = await hasAdminSession();
|
|
setAdminSessionReady(ready);
|
|
if (!ready) {
|
|
setAdminSessionMsg((prev) => (prev === 'LOCAL SESSION PRIMED' ? null : prev));
|
|
}
|
|
return ready;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (activeTab !== 'protocol') {
|
|
setShowOperatorTools(true);
|
|
}
|
|
}, [activeTab]);
|
|
const ensureAdminSession = useCallback(async () => {
|
|
// Native desktop: already authenticated via Rust IPC admin-key ownership.
|
|
if (isNativeProtectedSettingsReady()) {
|
|
setAdminSessionReady(true);
|
|
setAdminSessionMsg(null);
|
|
return;
|
|
}
|
|
try {
|
|
await primeAdminSession(adminKey.trim() || undefined);
|
|
setAdminSessionReady(true);
|
|
if (adminKey.trim()) {
|
|
setAdminKey('');
|
|
setAdminSessionMsg('LOCAL SESSION PRIMED');
|
|
} else {
|
|
setAdminSessionMsg(null);
|
|
}
|
|
} catch (e) {
|
|
const ready = await refreshAdminSession();
|
|
setAdminSessionReady(ready);
|
|
const message =
|
|
e instanceof Error && e.message === 'admin_session_required'
|
|
? 'ADMIN SESSION REQUIRED'
|
|
: e instanceof Error
|
|
? e.message
|
|
: 'ADMIN SESSION FAILED';
|
|
setAdminSessionMsg(message);
|
|
throw e;
|
|
}
|
|
}, [adminKey, refreshAdminSession]);
|
|
|
|
// --- API Keys state ---
|
|
// API keys are write-only in-app. Values are sent once to the local backend,
|
|
// stored server-side, and never returned to the browser.
|
|
const [apis, setApis] = useState<ApiEntry[]>([]);
|
|
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
|
|
const [apiKeyEditing, setApiKeyEditing] = useState<Record<string, boolean>>({});
|
|
const [apiKeySaving, setApiKeySaving] = useState<string | null>(null);
|
|
const [apiKeyMsg, setApiKeyMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
|
new Set(['Aviation', 'Maritime']),
|
|
);
|
|
const [envMeta, setEnvMeta] = useState<EnvMeta | null>(null);
|
|
|
|
// --- News Feeds state ---
|
|
const [feeds, setFeeds] = useState<FeedEntry[]>([]);
|
|
const [feedsDirty, setFeedsDirty] = useState(false);
|
|
const [feedSaving, setFeedSaving] = useState(false);
|
|
const [feedMsg, setFeedMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
|
|
|
const handleProtectedSettingsError = useCallback(
|
|
async (error: unknown) => {
|
|
const message = error instanceof Error ? error.message : 'Protected settings request failed';
|
|
if (
|
|
message === 'Forbidden — admin key not configured' ||
|
|
message === 'Forbidden — invalid or missing admin key'
|
|
) {
|
|
await clearAdminSession();
|
|
setAdminSessionReady(false);
|
|
setAdminSessionMsg(
|
|
message === 'Forbidden — admin key not configured'
|
|
? 'BACKEND ADMIN KEY NOT CONFIGURED'
|
|
: 'ADMIN KEY INVALID OR EXPIRED',
|
|
);
|
|
setApis([]);
|
|
setFeeds([]);
|
|
setFeedsDirty(false);
|
|
setDmRootHealth(null);
|
|
setDmRootHealthMsg(message);
|
|
}
|
|
return message;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const fetchKeys = useCallback(async () => {
|
|
try {
|
|
setApis(await controlPlaneJson<ApiEntry[]>('/api/settings/api-keys', {
|
|
requireAdminSession: false,
|
|
}));
|
|
return true;
|
|
} catch (e) {
|
|
await handleProtectedSettingsError(e);
|
|
return false;
|
|
}
|
|
}, [handleProtectedSettingsError]);
|
|
|
|
const saveApiKey = useCallback(
|
|
async (envKey: string | null) => {
|
|
if (!envKey) return;
|
|
const value = String(apiKeyInputs[envKey] || '').trim();
|
|
if (!value) {
|
|
setApiKeyMsg({ type: 'err', text: `Enter a value for ${envKey}.` });
|
|
return;
|
|
}
|
|
setApiKeySaving(envKey);
|
|
setApiKeyMsg(null);
|
|
try {
|
|
const result = await controlPlaneJson<{
|
|
keys?: ApiEntry[];
|
|
env?: EnvMeta;
|
|
}>('/api/settings/api-keys', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ [envKey]: value }),
|
|
requireAdminSession: false,
|
|
});
|
|
if (result.keys) setApis(result.keys);
|
|
if (result.env) setEnvMeta(result.env);
|
|
setApiKeyInputs((prev) => ({ ...prev, [envKey]: '' }));
|
|
setApiKeyEditing((prev) => ({ ...prev, [envKey]: false }));
|
|
setApiKeyMsg({ type: 'ok', text: `${envKey} saved locally. Restart or refresh feeds to use it.` });
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : 'Could not save API key';
|
|
setApiKeyMsg({ type: 'err', text: message });
|
|
} finally {
|
|
setApiKeySaving(null);
|
|
}
|
|
},
|
|
[apiKeyInputs],
|
|
);
|
|
|
|
const fetchEnvMeta = useCallback(async () => {
|
|
try {
|
|
const res = await fetch('/api/settings/api-keys/meta');
|
|
if (!res.ok) return;
|
|
const data: EnvMeta = await res.json();
|
|
setEnvMeta(data);
|
|
} catch {
|
|
// Non-fatal: the panel still works without the path hint.
|
|
}
|
|
}, []);
|
|
|
|
const fetchFeeds = useCallback(async () => {
|
|
try {
|
|
setFeeds(await controlPlaneJson<FeedEntry[]>('/api/settings/news-feeds'));
|
|
setFeedsDirty(false);
|
|
return true;
|
|
} catch (e) {
|
|
await handleProtectedSettingsError(e);
|
|
return false;
|
|
}
|
|
}, [handleProtectedSettingsError]);
|
|
|
|
const fetchWormhole = useCallback(async () => {
|
|
try {
|
|
const data = await fetchWormholeSettings(true);
|
|
setWormholeEnabled(Boolean(data?.enabled));
|
|
await applySecureModeBoundary(Boolean(data?.enabled));
|
|
setWormholeTransport(String(data?.transport || 'direct'));
|
|
setWormholeSocksProxy(String(data?.socks_proxy || ''));
|
|
setWormholeSocksDns(Boolean(data?.socks_dns ?? true));
|
|
setWormholeAnonymousMode(Boolean(data?.anonymous_mode));
|
|
setWormholeDirty(false);
|
|
} catch (e) {
|
|
console.error('Failed to fetch wormhole settings', e);
|
|
}
|
|
}, []);
|
|
|
|
const fetchPrivacyProfile = useCallback(async () => {
|
|
try {
|
|
const data = await fetchPrivacyProfileSnapshot(true);
|
|
const profile = String(data?.profile || 'default');
|
|
setPrivacyProfile(profile);
|
|
if (typeof data?.wormhole_enabled === 'boolean') {
|
|
setWormholeEnabled(Boolean(data.wormhole_enabled));
|
|
await applySecureModeBoundary(Boolean(data.wormhole_enabled));
|
|
}
|
|
const high = profile === 'high';
|
|
setStrictPrivacy(high);
|
|
const nextSessionMode = high || getSessionModePreference();
|
|
setSessionMode(nextSessionMode);
|
|
setSessionModePreference(nextSessionMode);
|
|
setPrivacyStrictPreference(high, { sessionMode: nextSessionMode });
|
|
setPrivacyProfilePreference(profile, { sessionMode: nextSessionMode });
|
|
migratePrivacySensitiveBrowserState();
|
|
} catch (e) {
|
|
console.error('Failed to fetch privacy profile', e);
|
|
}
|
|
}, []);
|
|
|
|
const fetchRnsStatus = useCallback(async () => {
|
|
try {
|
|
setRnsStatus(await fetchRnsStatusSnapshot(true));
|
|
} catch (e) {
|
|
console.error('Failed to fetch RNS status', e);
|
|
}
|
|
}, []);
|
|
|
|
const fetchWormholeStatus = useCallback(async () => {
|
|
try {
|
|
const state = await fetchWormholeState(true);
|
|
setWormholeStatus(state);
|
|
if (state.ready && !wormholeNodeId) {
|
|
try {
|
|
const id = await fetchWormholeIdentity();
|
|
if (id?.node_id) setWormholeNodeId(id.node_id);
|
|
} catch { /* identity fetch is best-effort */ }
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch wormhole status', e);
|
|
}
|
|
}, [wormholeNodeId]);
|
|
|
|
const fetchDmRootHealth = useCallback(async () => {
|
|
if (!nativeProtected && !adminSessionReady) {
|
|
setDmRootHealth(null);
|
|
setDmRootHealthMsg(null);
|
|
return false;
|
|
}
|
|
setDmRootHealthBusy(true);
|
|
setDmRootHealthMsg(null);
|
|
try {
|
|
const data = await fetchWormholeDmRootHealth();
|
|
setDmRootHealth(data);
|
|
return true;
|
|
} catch (e) {
|
|
const message = await handleProtectedSettingsError(e);
|
|
setDmRootHealth(null);
|
|
setDmRootHealthMsg(message);
|
|
return false;
|
|
} finally {
|
|
setDmRootHealthBusy(false);
|
|
}
|
|
}, [adminSessionReady, handleProtectedSettingsError, nativeProtected]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
if (typeof window !== 'undefined') {
|
|
const focusTarget = sessionStorage.getItem(SETTINGS_FOCUS_KEY);
|
|
if (focusTarget === 'wormhole-gates') {
|
|
setActiveTab('protocol');
|
|
setWormholeGuideNotice(
|
|
'Gates use the Wormhole-backed experimental obfuscation lane. Press GET WORMHOLE KEY and we will walk the rest from here.',
|
|
);
|
|
sessionStorage.removeItem(SETTINGS_FOCUS_KEY);
|
|
} else {
|
|
setWormholeGuideNotice(null);
|
|
}
|
|
}
|
|
void (async () => {
|
|
const ready = await refreshAdminSession();
|
|
await fetchKeys();
|
|
if (ready) {
|
|
await fetchFeeds();
|
|
} else {
|
|
setFeeds([]);
|
|
setFeedsDirty(false);
|
|
}
|
|
void fetchWormhole();
|
|
void fetchRnsStatus();
|
|
void fetchPrivacyProfile();
|
|
void fetchWormholeStatus();
|
|
})();
|
|
}
|
|
}, [
|
|
isOpen,
|
|
fetchKeys,
|
|
fetchFeeds,
|
|
fetchWormhole,
|
|
fetchRnsStatus,
|
|
fetchPrivacyProfile,
|
|
fetchWormholeStatus,
|
|
refreshAdminSession,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!wormholeEnabled) {
|
|
setWormholeQuickState('idle');
|
|
return;
|
|
}
|
|
if (wormholeStatus?.ready) {
|
|
setWormholeQuickState('active');
|
|
if (typeof window !== 'undefined') {
|
|
const returnTarget = sessionStorage.getItem(WORMHOLE_RETURN_KEY);
|
|
if (returnTarget) {
|
|
sessionStorage.removeItem(WORMHOLE_RETURN_KEY);
|
|
sessionStorage.removeItem(SETTINGS_FOCUS_KEY);
|
|
window.dispatchEvent(new CustomEvent(WORMHOLE_READY_EVENT, { detail: { target: returnTarget } }));
|
|
onClose();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (wormholeSaving || wormholeStatus?.running) {
|
|
setWormholeQuickState('connecting');
|
|
return;
|
|
}
|
|
setWormholeQuickState('ready');
|
|
}, [onClose, wormholeEnabled, wormholeSaving, wormholeStatus]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
if (activeTab === 'api-keys') {
|
|
void fetchKeys();
|
|
void fetchEnvMeta();
|
|
return;
|
|
}
|
|
if (!adminSessionReady) return;
|
|
if (activeTab === 'news-feeds') {
|
|
void fetchFeeds();
|
|
}
|
|
}, [isOpen, adminSessionReady, activeTab, fetchKeys, fetchEnvMeta, fetchFeeds]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || activeTab !== 'protocol' || !showOperatorTools) return;
|
|
if (!nativeProtected && !adminSessionReady) {
|
|
setDmRootHealth(null);
|
|
setDmRootHealthMsg(null);
|
|
return;
|
|
}
|
|
void fetchDmRootHealth();
|
|
}, [
|
|
isOpen,
|
|
activeTab,
|
|
showOperatorTools,
|
|
nativeProtected,
|
|
adminSessionReady,
|
|
fetchDmRootHealth,
|
|
]);
|
|
|
|
const toggleCategory = (cat: string) => {
|
|
setExpandedCategories((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(cat)) next.delete(cat);
|
|
else next.add(cat);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => {
|
|
if (!acc[api.category]) acc[api.category] = [];
|
|
acc[api.category].push(api);
|
|
return acc;
|
|
}, {});
|
|
|
|
// News Feeds handlers
|
|
const updateFeed = (idx: number, field: keyof FeedEntry, value: string | number) => {
|
|
setFeeds((prev) => prev.map((f, i) => (i === idx ? { ...f, [field]: value } : f)));
|
|
setFeedsDirty(true);
|
|
setFeedMsg(null);
|
|
};
|
|
|
|
const removeFeed = (idx: number) => {
|
|
setFeeds((prev) => prev.filter((_, i) => i !== idx));
|
|
setFeedsDirty(true);
|
|
setFeedMsg(null);
|
|
};
|
|
|
|
const addFeed = () => {
|
|
if (feeds.length >= MAX_FEEDS) return;
|
|
setFeeds((prev) => [...prev, { name: '', url: '', weight: 3 }]);
|
|
setFeedsDirty(true);
|
|
setFeedMsg(null);
|
|
};
|
|
|
|
const saveFeeds = async () => {
|
|
setFeedSaving(true);
|
|
setFeedMsg(null);
|
|
try {
|
|
const res = await controlPlaneFetch('/api/settings/news-feeds', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(feeds),
|
|
});
|
|
if (res.ok) {
|
|
setFeedsDirty(false);
|
|
setFeedMsg({
|
|
type: 'ok',
|
|
text: 'Feeds saved. Changes take effect on next news refresh (~30min) or manual /api/refresh.',
|
|
});
|
|
} else {
|
|
const d = await res.json().catch(() => ({}));
|
|
setFeedMsg({ type: 'err', text: d.message || 'Save failed' });
|
|
}
|
|
} catch {
|
|
setFeedMsg({ type: 'err', text: 'Network error' });
|
|
} finally {
|
|
setFeedSaving(false);
|
|
}
|
|
};
|
|
|
|
const resetFeeds = async () => {
|
|
try {
|
|
const res = await controlPlaneFetch('/api/settings/news-feeds/reset', {
|
|
method: 'POST',
|
|
});
|
|
if (res.ok) {
|
|
const d = await res.json();
|
|
setFeeds(d.feeds || []);
|
|
setFeedsDirty(false);
|
|
setFeedMsg({ type: 'ok', text: 'Reset to defaults' });
|
|
}
|
|
} catch {
|
|
setFeedMsg({ type: 'err', text: 'Reset failed' });
|
|
}
|
|
};
|
|
|
|
const saveWormholeSettings = async (enabledOverride?: boolean) => {
|
|
setWormholeSaving(true);
|
|
setWormholeMsg(null);
|
|
try {
|
|
invalidateWormholeRuntimeCache();
|
|
const next = typeof enabledOverride === 'boolean' ? enabledOverride : wormholeEnabled;
|
|
const res = await controlPlaneFetch('/api/settings/wormhole', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
enabled: next,
|
|
transport: wormholeTransport,
|
|
socks_proxy: wormholeSocksProxy,
|
|
socks_dns: wormholeSocksDns,
|
|
anonymous_mode: wormholeAnonymousMode,
|
|
}),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
invalidateWormholeRuntimeCache();
|
|
setWormholeEnabled(Boolean(data?.enabled));
|
|
await applySecureModeBoundary(Boolean(data?.enabled));
|
|
setWormholeTransport(String(data?.transport || wormholeTransport));
|
|
setWormholeSocksProxy(String(data?.socks_proxy || wormholeSocksProxy));
|
|
setWormholeSocksDns(Boolean(data?.socks_dns ?? wormholeSocksDns));
|
|
setWormholeAnonymousMode(Boolean(data?.anonymous_mode ?? wormholeAnonymousMode));
|
|
setWormholeDirty(false);
|
|
if (data?.runtime) setWormholeStatus(data.runtime as WormholeState);
|
|
setWormholeMsg({
|
|
type: 'ok',
|
|
text: next
|
|
? data?.runtime?.ready
|
|
? 'Local agent connected with the updated settings.'
|
|
: 'Settings saved. Local agent is starting.'
|
|
: 'Local agent disabled and disconnected.',
|
|
});
|
|
} else {
|
|
setWormholeMsg({ type: 'err', text: 'Failed to update local agent settings' });
|
|
}
|
|
} catch {
|
|
setWormholeMsg({ type: 'err', text: 'Network error updating local agent settings' });
|
|
} finally {
|
|
setWormholeSaving(false);
|
|
}
|
|
};
|
|
|
|
const toggleWormhole = async () => {
|
|
await saveWormholeSettings(!wormholeEnabled);
|
|
};
|
|
|
|
const quickStartWormhole = async () => {
|
|
setWormholeSaving(true);
|
|
setWormholeQuickState('ready');
|
|
setWormholeMsg(null);
|
|
try {
|
|
const data = await joinWormhole();
|
|
invalidateWormholeRuntimeCache();
|
|
if (data?.identity?.node_id) {
|
|
setWormholeNodeId(data.identity.node_id);
|
|
}
|
|
setWormholeEnabled(Boolean(data?.settings?.enabled ?? data?.runtime?.configured ?? true));
|
|
setWormholeTransport(String(data?.settings?.transport || 'direct'));
|
|
setWormholeSocksProxy(String(data?.settings?.socks_proxy || ''));
|
|
setWormholeSocksDns(Boolean(data?.settings?.socks_dns ?? true));
|
|
setWormholeAnonymousMode(Boolean(data?.settings?.anonymous_mode ?? false));
|
|
setWormholeDirty(false);
|
|
await applySecureModeBoundary(true);
|
|
setWormholeQuickState('connecting');
|
|
const runtime = (data?.runtime as WormholeState | undefined) ?? (await fetchWormholeState(true));
|
|
invalidateWormholeRuntimeCache();
|
|
setWormholeStatus(runtime);
|
|
setWormholeEnabled(Boolean(runtime.configured));
|
|
setWormholeQuickState(runtime.ready ? 'active' : 'connecting');
|
|
setWormholeMsg({
|
|
type: 'ok',
|
|
text: runtime.ready
|
|
? 'Wormhole key ready. Gates and the obfuscated inbox can open now.'
|
|
: 'Wormhole key is provisioning. Wait for LOCAL AGENT ACTIVE.',
|
|
});
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : 'Wormhole quick start failed';
|
|
setWormholeMsg({ type: 'err', text: message });
|
|
setWormholeQuickState('idle');
|
|
} finally {
|
|
setWormholeSaving(false);
|
|
}
|
|
};
|
|
|
|
const controlWormhole = async (action: 'connect' | 'disconnect' | 'restart') => {
|
|
setWormholeSaving(true);
|
|
setWormholeMsg(null);
|
|
try {
|
|
await ensureAdminSession();
|
|
const runtime =
|
|
action === 'connect'
|
|
? await connectWormhole()
|
|
: action === 'disconnect'
|
|
? await disconnectWormhole()
|
|
: await restartWormhole();
|
|
invalidateWormholeRuntimeCache();
|
|
setWormholeStatus(runtime);
|
|
setWormholeEnabled(Boolean(runtime.configured));
|
|
await applySecureModeBoundary(Boolean(runtime.configured));
|
|
setWormholeMsg({
|
|
type: 'ok',
|
|
text:
|
|
action === 'disconnect'
|
|
? 'Local agent disconnected.'
|
|
: runtime.ready
|
|
? `Local agent ${action === 'restart' ? 'restarted' : 'connected'}.`
|
|
: 'Local agent is starting. Mesh actions will unlock when ready.',
|
|
});
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : 'Local agent request failed';
|
|
setWormholeMsg({ type: 'err', text: message });
|
|
} finally {
|
|
setWormholeSaving(false);
|
|
}
|
|
};
|
|
|
|
const setHighPrivacy = async (enabled: boolean) => {
|
|
const profile = enabled ? 'high' : 'default';
|
|
const nextSessionMode = enabled || getSessionModePreference();
|
|
setSessionModePreference(nextSessionMode);
|
|
setPrivacyStrictPreference(enabled, { sessionMode: nextSessionMode });
|
|
setPrivacyProfilePreference(profile, { sessionMode: nextSessionMode });
|
|
setPrivacyProfile(profile);
|
|
setStrictPrivacy(enabled);
|
|
setSessionMode(nextSessionMode);
|
|
migratePrivacySensitiveBrowserState();
|
|
if (nextSessionMode) clearSessionIdentity();
|
|
try {
|
|
const res = await controlPlaneFetch('/api/settings/privacy-profile', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ profile }),
|
|
});
|
|
if (!res.ok) {
|
|
setWormholeMsg({ type: 'err', text: 'Failed to save privacy profile' });
|
|
} else {
|
|
invalidatePrivacyProfileCache();
|
|
invalidateRnsStatusCache();
|
|
const data = await res.json().catch(() => ({}));
|
|
const forcedWormhole = Boolean(data?.wormhole_enabled);
|
|
if (forcedWormhole) {
|
|
setWormholeEnabled(true);
|
|
await applySecureModeBoundary(true);
|
|
}
|
|
setWormholeMsg({
|
|
type: 'ok',
|
|
text: forcedWormhole
|
|
? 'High Privacy requires the local agent. It was enabled for this device.'
|
|
: 'Privacy profile saved.',
|
|
});
|
|
}
|
|
} catch {
|
|
setWormholeMsg({ type: 'err', text: 'Failed to save privacy profile' });
|
|
}
|
|
};
|
|
|
|
const unlockAdminSession = async () => {
|
|
setAdminSessionBusy(true);
|
|
setAdminSessionMsg(null);
|
|
try {
|
|
await ensureAdminSession();
|
|
await Promise.all([fetchKeys(), fetchFeeds()]);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : 'ADMIN SESSION FAILED';
|
|
if (message === 'Forbidden — admin key not configured') {
|
|
await clearAdminSession();
|
|
setAdminSessionReady(false);
|
|
setAdminSessionMsg('BACKEND ADMIN KEY NOT CONFIGURED');
|
|
return;
|
|
}
|
|
setAdminSessionMsg(message.toUpperCase());
|
|
} finally {
|
|
setAdminSessionBusy(false);
|
|
}
|
|
};
|
|
|
|
const lockAdminSession = async () => {
|
|
setAdminSessionBusy(true);
|
|
setAdminSessionMsg(null);
|
|
try {
|
|
await clearAdminSession();
|
|
setAdminKey('');
|
|
setAdminSessionReady(false);
|
|
setDmRootHealth(null);
|
|
setDmRootHealthMsg(null);
|
|
setAdminSessionMsg('LOCAL SESSION CLEARED');
|
|
} finally {
|
|
setAdminSessionBusy(false);
|
|
}
|
|
};
|
|
|
|
const configuredTransport = (wormholeStatus?.transport || wormholeTransport || '').toLowerCase();
|
|
const activeTransport = (wormholeStatus?.transport_active || '').toLowerCase();
|
|
const effectiveTransport = activeTransport || configuredTransport || 'direct';
|
|
const anonModeReady =
|
|
Boolean(wormholeEnabled) &&
|
|
Boolean(wormholeStatus?.ready) &&
|
|
['tor', 'tor_arti', 'i2p', 'mixnet'].includes(effectiveTransport) &&
|
|
wormholeAnonymousMode;
|
|
const rnsReady = Boolean(wormholeStatus?.rns_ready ?? rnsStatus?.ready);
|
|
const recentPrivateFallback = Boolean(wormholeStatus?.recent_private_clearnet_fallback);
|
|
const recentPrivateFallbackReason =
|
|
wormholeStatus?.recent_private_clearnet_fallback_reason ||
|
|
'An obfuscated-tier payload recently fell back to clearnet relay.';
|
|
const legacyCompatibilityItems = summarizeLegacyCompatibility(wormholeStatus?.legacy_compatibility);
|
|
const legacyCompatibilityActivity = hasLegacyCompatibilityActivity(
|
|
wormholeStatus?.legacy_compatibility,
|
|
);
|
|
const legacyCompatibilityAllBlocked =
|
|
legacyCompatibilityItems.length > 0 && legacyCompatibilityItems.every((item) => item.blocked);
|
|
const gateCompatTopReasons = summarizeGateCompatTelemetry(gateCompatTelemetry, 3);
|
|
const trustModeLabel = !wormholeEnabled
|
|
? 'PUBLIC / DEGRADED'
|
|
: wormholeStatus?.ready && rnsReady
|
|
? 'EXPERIMENTAL / OBFUSCATED+'
|
|
: 'EXPERIMENTAL / OBFUSCATED';
|
|
const transportMismatch =
|
|
Boolean(activeTransport) && Boolean(configuredTransport) && activeTransport !== configuredTransport;
|
|
const wormholeQuickButtonLabel =
|
|
wormholeQuickState === 'active'
|
|
? 'ACTIVE'
|
|
: wormholeQuickState === 'connecting'
|
|
? 'CONNECTING'
|
|
: wormholeQuickState === 'ready'
|
|
? 'READY'
|
|
: 'GET WORMHOLE KEY';
|
|
const dmRootCardTone = dmRootMonitorTone(
|
|
showOperatorTools
|
|
? dmRootHealth?.monitoring?.state || (dmRootHealthMsg ? 'critical' : 'warning')
|
|
: 'warning',
|
|
);
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<>
|
|
{/* Backdrop */}
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9998]"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Settings Panel */}
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -300 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -300 }}
|
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
|
className="fixed left-0 top-0 bottom-0 w-[480px] bg-[var(--bg-secondary)]/95 backdrop-blur-sm border-r border-cyan-900/50 z-[9999] flex flex-col shadow-[4px_0_40px_rgba(0,0,0,0.3)]"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]/80">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
|
|
<Settings size={16} className="text-cyan-400" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">
|
|
{t('settings.title').toUpperCase()}
|
|
</h2>
|
|
<span className="text-[13px] text-[var(--text-muted)] font-mono tracking-widest">
|
|
SETTINGS & DATA SOURCES
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/*
|
|
UI language toggle. Locale change is purely client-side
|
|
(persists to localStorage('sb_locale')) — no network call,
|
|
no telemetry. See frontend/src/i18n/index.ts for the list
|
|
of available locales and CONTRIBUTING.md for the
|
|
translation-neutrality policy.
|
|
*/}
|
|
<label
|
|
htmlFor="sb-locale-select"
|
|
className="text-[11px] tracking-[0.18em] uppercase text-[var(--text-muted)] font-mono"
|
|
>
|
|
LANG
|
|
</label>
|
|
<select
|
|
id="sb-locale-select"
|
|
value={locale}
|
|
onChange={(e) => setLocale(e.target.value as Locale)}
|
|
aria-label="UI language"
|
|
className="h-8 px-2 border border-[var(--border-primary)] bg-[var(--bg-primary)]/60 text-[12px] font-mono text-[var(--text-secondary)] tracking-wider hover:border-cyan-500/50 focus:outline-none focus:border-cyan-500/80 transition-colors"
|
|
>
|
|
{LOCALES.map((entry) => (
|
|
<option key={entry.code} value={entry.code}>
|
|
{entry.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-8 h-8 border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Operator Tools */}
|
|
{activeTab === 'protocol' && !showOperatorTools ? (
|
|
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-[var(--border-primary)]/40 bg-[var(--bg-primary)]/30">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<Shield size={12} className="text-cyan-400" />
|
|
<div className="min-w-0">
|
|
<div className="text-[13px] font-mono tracking-widest text-cyan-300">WORMHOLE FIRST-RUN</div>
|
|
<div className="text-[12px] font-mono text-[var(--text-muted)] mt-0.5">
|
|
Wormhole join below does not need operator tools. API/news tabs do.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowOperatorTools(true)}
|
|
className="px-2 py-1 border border-cyan-500/30 text-[12px] font-mono text-cyan-300/80 tracking-widest hover:text-cyan-200 hover:border-cyan-400/40"
|
|
>
|
|
OPERATOR TOOLS
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-[var(--border-primary)]/40 bg-[var(--bg-primary)]/30">
|
|
<Shield
|
|
size={12}
|
|
className={adminSessionReady ? 'text-green-400' : 'text-yellow-500'}
|
|
/>
|
|
<span className="text-[13px] font-mono tracking-widest text-[var(--text-muted)] whitespace-nowrap">
|
|
OPERATOR TOOLS
|
|
</span>
|
|
<input
|
|
type="password"
|
|
value={adminKey}
|
|
onChange={(e) => setAdminKey(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && adminKey.trim() && !adminSessionBusy) {
|
|
void unlockAdminSession();
|
|
}
|
|
}}
|
|
disabled={nativeProtected}
|
|
placeholder={
|
|
nativeProtected
|
|
? 'Protected via native desktop bridge'
|
|
: adminSessionReady
|
|
? 'Operator tools unlocked. Enter key only to reseed or recover...'
|
|
: 'Enter operator key for protected settings tabs...'
|
|
}
|
|
className="flex-1 bg-[var(--bg-primary)]/60 border border-[var(--border-primary)] px-2 py-1 text-sm font-mono text-[var(--text-secondary)] outline-none focus:border-cyan-700 placeholder:text-[var(--text-muted)]/50"
|
|
/>
|
|
{nativeProtected ? null : adminSessionReady ? (
|
|
<button
|
|
onClick={() => void lockAdminSession()}
|
|
disabled={adminSessionBusy}
|
|
className="px-2 py-1 border border-red-500/30 text-[12px] font-mono text-red-300/80 tracking-widest hover:text-red-200 hover:border-red-400/40 disabled:opacity-50"
|
|
>
|
|
LOCK
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => void unlockAdminSession()}
|
|
disabled={adminSessionBusy || !adminKey.trim()}
|
|
className="px-2 py-1 border border-cyan-500/30 text-[12px] font-mono text-cyan-300/80 tracking-widest hover:text-cyan-200 hover:border-cyan-400/40 disabled:opacity-50"
|
|
>
|
|
UNLOCK
|
|
</button>
|
|
)}
|
|
{activeTab === 'protocol' && (
|
|
<button
|
|
onClick={() => setShowOperatorTools(false)}
|
|
className="px-2 py-1 border border-[var(--border-primary)] text-[12px] font-mono text-[var(--text-muted)] tracking-widest hover:text-cyan-300 hover:border-cyan-500/40"
|
|
>
|
|
HIDE
|
|
</button>
|
|
)}
|
|
<span
|
|
className={`text-[12px] font-mono tracking-widest ${
|
|
adminSessionReady ? 'text-green-400/70' : 'text-yellow-400/70'
|
|
}`}
|
|
>
|
|
{nativeProtected ? 'NATIVE' : adminSessionReady ? 'ACTIVE' : 'LOCKED'}
|
|
</span>
|
|
</div>
|
|
{adminSessionMsg && (
|
|
<div className="px-4 py-1.5 border-b border-[var(--border-primary)]/20 bg-[var(--bg-primary)]/20">
|
|
<span
|
|
className={`text-[12px] font-mono tracking-widest ${
|
|
adminSessionReady ? 'text-green-300/80' : 'text-yellow-300/80'
|
|
}`}
|
|
>
|
|
{adminSessionMsg}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
{adminSessionMsg === 'BACKEND ADMIN KEY NOT CONFIGURED' && activeTab !== 'protocol' && (
|
|
<div className="mx-4 mt-3 border border-yellow-500/25 bg-yellow-950/10 px-3 py-3 text-sm font-mono text-yellow-200/90 leading-relaxed">
|
|
<div>
|
|
This is not an old market/API key problem. The backend admin secret itself is
|
|
not configured, so protected Settings tabs cannot load.
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<button
|
|
onClick={() => {
|
|
const el = document.querySelector<HTMLInputElement>('input[type="password"]');
|
|
el?.focus();
|
|
}}
|
|
className="px-3 py-1.5 border border-yellow-400/40 bg-yellow-950/20 text-[13px] font-mono tracking-[0.18em] text-yellow-200 hover:bg-yellow-950/30"
|
|
>
|
|
PASTE ADMIN KEY
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('protocol')}
|
|
className="px-3 py-1.5 border border-cyan-500/35 bg-cyan-950/18 text-[13px] font-mono tracking-[0.18em] text-cyan-200 hover:bg-cyan-950/28"
|
|
>
|
|
BACK TO WORMHOLE
|
|
</button>
|
|
</div>
|
|
<div className="mt-3 text-[13px] text-yellow-100/70">
|
|
Add <span className="text-cyan-300">ADMIN_KEY</span> to{' '}
|
|
<span className="text-cyan-300">backend/.env</span>, restart the backend, then
|
|
paste that same key above and unlock.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex border-b border-[var(--border-primary)]/60">
|
|
<button
|
|
onClick={() => setActiveTab('api-keys')}
|
|
className={`flex-1 px-4 py-2.5 text-sm font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === 'api-keys' ? 'text-cyan-400 border-b-2 border-cyan-500 bg-cyan-950/10' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
|
>
|
|
<Key size={10} />
|
|
{t('settings.general').toUpperCase()}
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('news-feeds')}
|
|
className={`flex-1 px-4 py-2.5 text-sm font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === 'news-feeds' ? 'text-orange-400 border-b-2 border-orange-500 bg-orange-950/10' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
|
>
|
|
<Rss size={10} />
|
|
{t('settings.feeds').toUpperCase()}
|
|
{feedsDirty && (
|
|
<span className="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('sentinel')}
|
|
className={`flex-1 px-4 py-2.5 text-sm font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === 'sentinel' ? 'text-purple-400 border-b-2 border-purple-500 bg-purple-950/10' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
|
>
|
|
<Satellite size={10} />
|
|
{t('settings.shodan').toUpperCase()}
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('sar')}
|
|
className={`flex-1 px-4 py-2.5 text-sm font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === 'sar' ? 'text-amber-400 border-b-2 border-amber-500 bg-amber-950/10' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
|
>
|
|
<Radar size={10} />
|
|
{t('settings.sar').toUpperCase()}
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('protocol')}
|
|
className={`flex-1 px-4 py-2.5 text-sm font-mono tracking-widest font-bold transition-colors flex items-center justify-center gap-1.5 ${activeTab === 'protocol' ? 'text-green-400 border-b-2 border-green-500 bg-green-950/10' : 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
|
>
|
|
<Shield size={10} />
|
|
{t('settings.infonet').toUpperCase()}
|
|
</button>
|
|
</div>
|
|
|
|
{/* ==================== API KEYS TAB ==================== */}
|
|
{/* ==================== MESH PROTOCOL TAB ==================== */}
|
|
{activeTab === 'protocol' && (
|
|
<div className="flex-1 flex flex-col overflow-y-auto styled-scrollbar">
|
|
<div className="mx-4 mt-4 p-3 border border-cyan-900/30 bg-cyan-950/12">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm text-cyan-300 font-mono tracking-[0.18em]">
|
|
WORMHOLE KEY SETUP
|
|
</div>
|
|
<div className="mt-2 text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
|
One click enters Wormhole on the recommended path for gates and the obfuscated
|
|
inbox. Manual transport tuning stays hidden unless you ask for it.
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-[12px] text-[var(--text-muted)] font-mono tracking-[0.2em]">
|
|
STATUS
|
|
</div>
|
|
<div className="mt-1 text-[11px] font-mono text-cyan-200">
|
|
{wormholeStatus?.ready
|
|
? 'ACTIVE'
|
|
: wormholeEnabled
|
|
? 'TURN ON CONNECT'
|
|
: 'OFF'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 grid gap-2 text-[13px] font-mono text-[var(--text-muted)] leading-relaxed">
|
|
<div>1. Press <span className="text-green-300">GET WORMHOLE KEY</span>.</div>
|
|
<div>2. We handle the recommended setup path in the background.</div>
|
|
<div>3. Wait for <span className="text-green-300">ACTIVE</span>.</div>
|
|
<div>4. We send you straight back into gates.</div>
|
|
</div>
|
|
{wormholeGuideNotice && (
|
|
<div className="mt-3 border border-fuchsia-500/25 bg-fuchsia-950/12 px-3 py-2 text-sm font-mono text-fuchsia-200/90 leading-relaxed">
|
|
{wormholeGuideNotice}
|
|
</div>
|
|
)}
|
|
{adminSessionMsg === 'BACKEND ADMIN KEY NOT CONFIGURED' && (
|
|
<div className="mt-3 border border-cyan-500/20 bg-cyan-950/10 px-3 py-2 text-sm font-mono text-cyan-200/85 leading-relaxed">
|
|
Operator key is only needed for protected Settings tabs. Wormhole join below now
|
|
works without it.
|
|
</div>
|
|
)}
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<button
|
|
onClick={quickStartWormhole}
|
|
disabled={wormholeSaving || wormholeQuickState === 'active'}
|
|
className="px-3 py-1.5 border border-green-500/40 bg-green-950/20 text-[13px] font-mono tracking-[0.18em] text-green-300 hover:bg-green-950/30 disabled:opacity-40"
|
|
>
|
|
{wormholeQuickButtonLabel}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowAdvancedWormhole((prev) => !prev)}
|
|
className="px-3 py-1.5 border border-cyan-500/35 bg-cyan-950/18 text-[13px] font-mono tracking-[0.18em] text-cyan-200 hover:bg-cyan-950/28"
|
|
>
|
|
{showAdvancedWormhole ? 'HIDE MANUAL SETUP' : 'MANUAL SETUP'}
|
|
</button>
|
|
</div>
|
|
{wormholeMsg && (
|
|
<div
|
|
className={`mt-3 px-3 py-2 text-sm font-mono leading-relaxed ${wormholeMsg.type === 'ok' ? 'text-green-300 bg-green-950/18 border border-green-900/30' : 'text-red-300 bg-red-950/18 border border-red-900/30'}`}
|
|
>
|
|
{wormholeMsg.text}
|
|
</div>
|
|
)}
|
|
{wormholeNodeId && (
|
|
<div className="mt-3 border border-cyan-500/20 bg-black/30 px-3 py-2">
|
|
<div className="text-[13px] font-mono tracking-[0.18em] text-[var(--text-muted)] mb-1">
|
|
YOUR WORMHOLE IDENTITY
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 text-[11px] font-mono text-cyan-300 break-all select-all">
|
|
{wormholeNodeId}
|
|
</code>
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(wormholeNodeId);
|
|
setWormholeKeyCopied(true);
|
|
setTimeout(() => setWormholeKeyCopied(false), 2000);
|
|
} catch { /* clipboard not available */ }
|
|
}}
|
|
className="shrink-0 px-2 py-1 border border-cyan-500/30 text-cyan-400 hover:bg-cyan-950/30 transition-colors text-[13px] font-mono flex items-center gap-1"
|
|
title="Copy identity to clipboard"
|
|
>
|
|
{wormholeKeyCopied ? <Check size={10} /> : <Copy size={10} />}
|
|
{wormholeKeyCopied ? 'COPIED' : 'COPY'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={`mx-4 mt-4 p-3 border ${dmRootCardTone}`}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm font-mono tracking-[0.18em]">DM ROOT HEALTH</div>
|
|
<div className="mt-2 text-sm font-mono leading-relaxed text-[var(--text-secondary)]">
|
|
External witness freshness, transparency readback, and the next operator
|
|
action for strong DM trust.
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`px-2 py-1 border text-[11px] font-mono tracking-[0.18em] ${dmRootCardTone}`}
|
|
>
|
|
{!showOperatorTools
|
|
? 'HIDDEN'
|
|
: dmRootHealth
|
|
? dmRootMonitorLabel(dmRootHealth.monitoring?.state)
|
|
: dmRootHealthBusy
|
|
? 'LOADING'
|
|
: dmRootHealthMsg
|
|
? 'BLOCKED'
|
|
: 'UNKNOWN'}
|
|
</span>
|
|
{showOperatorTools && (nativeProtected || adminSessionReady) && (
|
|
<button
|
|
onClick={() => void fetchDmRootHealth()}
|
|
disabled={dmRootHealthBusy}
|
|
className="px-2 py-1 border border-cyan-500/30 text-[12px] font-mono tracking-[0.18em] text-cyan-200 hover:bg-cyan-950/20 disabled:opacity-50"
|
|
>
|
|
<span className="inline-flex items-center gap-1">
|
|
<RotateCcw size={11} />
|
|
REFRESH
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{!showOperatorTools ? (
|
|
<div className="mt-3 border border-cyan-500/20 bg-black/20 px-3 py-3 text-sm font-mono text-[var(--text-muted)] leading-relaxed">
|
|
Wormhole join stays visible without operator tools. Open operator tools to see
|
|
external witness freshness, transparency readback, and remediation guidance.
|
|
<div className="mt-3">
|
|
<button
|
|
onClick={() => setShowOperatorTools(true)}
|
|
className="px-3 py-1.5 border border-cyan-500/35 bg-cyan-950/18 text-[13px] font-mono tracking-[0.18em] text-cyan-200 hover:bg-cyan-950/28"
|
|
>
|
|
SHOW TOOLS
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : !nativeProtected && !adminSessionReady ? (
|
|
<div className="mt-3 border border-yellow-500/25 bg-yellow-950/12 px-3 py-3 text-sm font-mono text-yellow-200/90 leading-relaxed">
|
|
Unlock operator tools above to load live DM root health, external witness
|
|
freshness, and transparency monitoring status.
|
|
</div>
|
|
) : dmRootHealthBusy && !dmRootHealth ? (
|
|
<div className="mt-3 border border-cyan-500/20 bg-black/20 px-3 py-3 text-sm font-mono text-cyan-200/80">
|
|
Loading current DM root health...
|
|
</div>
|
|
) : dmRootHealth ? (
|
|
<div className="mt-3 grid gap-2">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="border border-[var(--border-primary)]/50 bg-black/20 px-3 py-2">
|
|
<div className="text-[12px] font-mono tracking-[0.18em] text-[var(--text-muted)]">
|
|
SUMMARY
|
|
</div>
|
|
<div className="mt-1 text-[13px] font-mono text-[var(--text-secondary)]">
|
|
{String(dmRootHealth.state || '').replaceAll('_', ' ').toUpperCase()}
|
|
</div>
|
|
<div className="mt-1 text-[12px] font-mono text-[var(--text-muted)] leading-relaxed">
|
|
{dmRootHealth.detail}
|
|
</div>
|
|
</div>
|
|
<div className="border border-[var(--border-primary)]/50 bg-black/20 px-3 py-2">
|
|
<div className="text-[12px] font-mono tracking-[0.18em] text-[var(--text-muted)]">
|
|
STRONG TRUST
|
|
</div>
|
|
<div
|
|
className={`mt-1 text-[13px] font-mono ${
|
|
dmRootHealth.strong_trust_blocked ? 'text-red-300' : 'text-green-300'
|
|
}`}
|
|
>
|
|
{dmRootHealth.strong_trust_blocked ? 'BLOCKED' : 'CURRENT'}
|
|
</div>
|
|
<div className="mt-1 text-[12px] font-mono text-[var(--text-muted)]">
|
|
{dmRootHealth.monitoring?.status_line || 'Operator monitoring active.'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="border border-[var(--border-primary)]/50 bg-black/20 px-3 py-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-[12px] font-mono tracking-[0.18em] text-[var(--text-muted)]">
|
|
WITNESS
|
|
</div>
|
|
<span
|
|
className={`px-1.5 py-0.5 border text-[11px] font-mono tracking-widest ${dmRootUrgencyTone(
|
|
dmRootHealth.witness.health_state === 'error'
|
|
? 'page'
|
|
: dmRootHealth.witness.health_state === 'warning'
|
|
? 'ticket'
|
|
: 'watch',
|
|
)}`}
|
|
>
|
|
{String(dmRootHealth.witness.state || '').replaceAll('_', ' ').toUpperCase()}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 text-[12px] font-mono text-[var(--text-muted)]">
|
|
Age {formatAgeWindow(dmRootHealth.witness.age_s, dmRootHealth.witness.freshness_window_s)}
|
|
</div>
|
|
<div className="mt-1 text-[12px] font-mono text-[var(--text-muted)]">
|
|
{dmRootHealth.witness.source_label ||
|
|
dmRootHealth.witness.source_ref ||
|
|
dmRootHealth.witness.detail ||
|
|
'Configured witness source unavailable.'}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border border-[var(--border-primary)]/50 bg-black/20 px-3 py-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-[12px] font-mono tracking-[0.18em] text-[var(--text-muted)]">
|
|
TRANSPARENCY
|
|
</div>
|
|
<span
|
|
className={`px-1.5 py-0.5 border text-[11px] font-mono tracking-widest ${dmRootUrgencyTone(
|
|
dmRootHealth.transparency.health_state === 'error'
|
|
? 'page'
|
|
: dmRootHealth.transparency.health_state === 'warning'
|
|
? 'ticket'
|
|
: 'watch',
|
|
)}`}
|
|
>
|
|
{String(dmRootHealth.transparency.state || '').replaceAll('_', ' ').toUpperCase()}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 text-[12px] font-mono text-[var(--text-muted)]">
|
|
Age{' '}
|
|
{formatAgeWindow(
|
|
dmRootHealth.transparency.age_s,
|
|
dmRootHealth.transparency.freshness_window_s,
|
|
)}
|
|
</div>
|
|
<div className="mt-1 text-[12px] font-mono text-[var(--text-muted)]">
|
|
{dmRootHealth.transparency.source_ref ||
|
|
dmRootHealth.transparency.export_path ||
|
|
dmRootHealth.transparency.detail ||
|
|
'Configured ledger readback unavailable.'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{dmRootHealth.alerts.length > 0 && (
|
|
<div className="border border-[var(--border-primary)]/50 bg-black/20 px-3 py-2">
|
|
<div className="text-[12px] font-mono tracking-[0.18em] text-[var(--text-muted)]">
|
|
ACTIVE ALERTS
|
|
</div>
|
|
<div className="mt-2 grid gap-2">
|
|
{dmRootHealth.alerts.slice(0, 2).map((alert) => (
|
|
<div
|
|
key={`${alert.code}-${alert.target}`}
|
|
className={`border px-2 py-2 text-[12px] font-mono leading-relaxed ${
|
|
alert.blocking
|
|
? 'border-red-500/30 bg-red-950/12 text-red-200'
|
|
: 'border-yellow-500/30 bg-yellow-950/12 text-yellow-200'
|
|
}`}
|
|
>
|
|
<div className="tracking-[0.16em]">
|
|
{alert.code.replaceAll('_', ' ').toUpperCase()}
|
|
</div>
|
|
<div className="mt-1 text-[var(--text-secondary)]">{alert.detail}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="border border-[var(--border-primary)]/50 bg-black/20 px-3 py-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-[12px] font-mono tracking-[0.18em] text-[var(--text-muted)]">
|
|
NEXT ACTION
|
|
</div>
|
|
<span
|
|
className={`px-1.5 py-0.5 border text-[11px] font-mono tracking-widest ${dmRootUrgencyTone(
|
|
dmRootHealth.runbook?.urgency,
|
|
)}`}
|
|
>
|
|
{String(dmRootHealth.runbook?.urgency || 'none').toUpperCase()}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 text-[13px] font-mono text-[var(--text-secondary)]">
|
|
{(
|
|
dmRootHealth.runbook?.next_action_detail &&
|
|
'title' in dmRootHealth.runbook.next_action_detail &&
|
|
dmRootHealth.runbook.next_action_detail.title
|
|
) ||
|
|
dmRootHealth.runbook?.next_action ||
|
|
'No action required.'}
|
|
</div>
|
|
<div className="mt-1 text-[12px] font-mono text-[var(--text-muted)] leading-relaxed">
|
|
{(
|
|
dmRootHealth.runbook?.next_action_detail &&
|
|
'summary' in dmRootHealth.runbook.next_action_detail &&
|
|
dmRootHealth.runbook.next_action_detail.summary
|
|
) ||
|
|
dmRootHealth.monitoring?.status_line ||
|
|
'Current external assurance is within policy.'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="mt-3 border border-red-500/25 bg-red-950/12 px-3 py-3 text-sm font-mono text-red-200/90 leading-relaxed">
|
|
{dmRootHealthMsg || 'Could not load DM root health.'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showAdvancedWormhole && (
|
|
<>
|
|
{/* Privacy Mode */}
|
|
<div className="mx-4 mt-4 p-3 border border-green-900/30 bg-green-950/10">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<Shield size={12} className="text-green-500 mt-0.5 flex-shrink-0" />
|
|
<span className="text-sm text-[var(--text-secondary)] font-mono tracking-widest">
|
|
HIGH PRIVACY MODE (OPT-IN)
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
const next = privacyProfile !== 'high';
|
|
setHighPrivacy(next);
|
|
}}
|
|
className={`px-2 py-1 border text-[13px] font-mono tracking-widest transition-colors ${privacyProfile === 'high' ? 'border-green-500/40 text-green-400 bg-green-950/20' : 'border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
|
>
|
|
{privacyProfile === 'high' ? 'ON' : 'OFF'}
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-[var(--text-muted)] font-mono leading-relaxed mt-2">
|
|
Enables High Privacy profile: session-only identity, stronger jitter, sharded
|
|
transport (when available), and stricter sync behavior. High Privacy requires
|
|
the local agent for mesh traffic and refuses clearnet fallback for obfuscated
|
|
sends. This does not make you anonymous or fully hidden.
|
|
</p>
|
|
{privacyProfile === 'high' && (
|
|
<div className="mt-2 p-2 border border-yellow-500/30 bg-yellow-950/10 text-sm text-yellow-200/90 font-mono leading-relaxed">
|
|
Recommendation: use a reputable VPN or hidden transport. A VPN can help hide
|
|
your IP from the backend and peers, but it does not eliminate metadata,
|
|
endpoint compromise, or traffic analysis risks.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Session Identity Mode */}
|
|
<div className="mx-4 mt-3 p-3 border border-green-900/30 bg-green-950/10">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<Shield size={12} className="text-green-500 mt-0.5 flex-shrink-0" />
|
|
<span className="text-sm text-[var(--text-secondary)] font-mono tracking-widest">
|
|
EPHEMERAL SESSION ID (RECOMMENDED)
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
const next = !sessionMode;
|
|
setSessionMode(next);
|
|
setSessionModePreference(next);
|
|
migratePrivacySensitiveBrowserState();
|
|
if (next) clearSessionIdentity();
|
|
}}
|
|
className={`px-2 py-1 border text-[13px] font-mono tracking-widest transition-colors ${sessionMode ? 'border-green-500/40 text-green-400 bg-green-950/20' : 'border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
|
>
|
|
{sessionMode ? 'ON' : 'OFF'}
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-[var(--text-muted)] font-mono leading-relaxed mt-2">
|
|
When enabled, agent keys are stored in session storage and reset on browser
|
|
close. Your identity will not persist across restarts.
|
|
</p>
|
|
<div className="mt-3 flex items-center justify-between gap-3 border border-[var(--border-primary)] bg-black/20 px-3 py-2">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-mono tracking-widest text-[var(--text-secondary)]">
|
|
WIPE LOCAL MESH TRACES
|
|
</div>
|
|
<p className="mt-1 text-sm font-mono leading-relaxed text-[var(--text-muted)]">
|
|
Clears browser-held mesh identities, DM ratchet state, cached contacts, and
|
|
privacy-sensitive browser storage. The local agent is not shut down.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
void wipeLocalMeshTraces();
|
|
}}
|
|
disabled={browserWipeBusy}
|
|
className={`shrink-0 px-2 py-1 border text-[13px] font-mono tracking-widest transition-colors ${
|
|
browserWipeBusy
|
|
? 'border-[var(--border-primary)] text-[var(--text-muted)] opacity-60 cursor-not-allowed'
|
|
: 'border-yellow-500/40 text-yellow-300 bg-yellow-950/20 hover:text-yellow-200'
|
|
}`}
|
|
>
|
|
{browserWipeBusy ? 'WIPING' : 'WIPE NOW'}
|
|
</button>
|
|
</div>
|
|
{browserWipeMsg && (
|
|
<div
|
|
className={`mt-2 text-sm font-mono leading-relaxed ${
|
|
browserWipeMsg.type === 'ok' ? 'text-green-300' : 'text-red-300'
|
|
}`}
|
|
>
|
|
{browserWipeMsg.text}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Wormhole Mode */}
|
|
<div className="mx-4 mt-3 p-3 border border-green-900/30 bg-green-950/10">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<Shield size={12} className="text-green-500 mt-0.5 flex-shrink-0" />
|
|
<span className="text-sm text-[var(--text-secondary)] font-mono tracking-widest">
|
|
LOCAL MESH AGENT (OPT-IN)
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={toggleWormhole}
|
|
disabled={wormholeSaving}
|
|
className={`px-2 py-1 border text-[13px] font-mono tracking-widest transition-colors ${wormholeEnabled ? 'border-green-500/40 text-green-400 bg-green-950/20' : 'border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-secondary)]'} ${wormholeSaving ? 'opacity-60 cursor-not-allowed' : ''}`}
|
|
>
|
|
{wormholeEnabled ? 'ON' : 'OFF'}
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-[var(--text-muted)] font-mono leading-relaxed mt-2">
|
|
Runs a local mesh agent that handles traffic directly, removing the backend
|
|
as a central observer. Experimental — does not guarantee privacy or anonymity.
|
|
</p>
|
|
<div className="mt-2 grid grid-cols-1 gap-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-[13px] font-mono text-[var(--text-muted)] tracking-widest">
|
|
TRANSPORT
|
|
</span>
|
|
<select
|
|
value={wormholeTransport}
|
|
onChange={(e) => {
|
|
setWormholeTransport(e.target.value);
|
|
setWormholeDirty(true);
|
|
}}
|
|
className="bg-[var(--bg-primary)]/60 border border-[var(--border-primary)] px-2 py-1 text-[13px] font-mono text-[var(--text-secondary)]"
|
|
>
|
|
<option value="direct">DIRECT</option>
|
|
<option value="tor">TOR (SOCKS5)</option>
|
|
<option value="i2p">I2P (SOCKS5)</option>
|
|
<option value="mixnet">MIXNET (SOCKS5)</option>
|
|
</select>
|
|
</div>
|
|
{(wormholeTransport === 'tor' ||
|
|
wormholeTransport === 'i2p' ||
|
|
wormholeTransport === 'mixnet') && (
|
|
<>
|
|
<input
|
|
type="text"
|
|
value={wormholeSocksProxy}
|
|
onChange={(e) => {
|
|
setWormholeSocksProxy(e.target.value);
|
|
setWormholeDirty(true);
|
|
}}
|
|
placeholder="SOCKS5 proxy (e.g. 127.0.0.1:9050)"
|
|
className="w-full bg-black/30 border border-[var(--border-primary)]/40 px-2 py-1 text-sm font-mono text-[var(--text-muted)] outline-none focus:border-cyan-500/50"
|
|
/>
|
|
<div className="flex flex-wrap gap-1">
|
|
<button
|
|
onClick={() => {
|
|
setWormholeTransport('tor');
|
|
setWormholeSocksProxy('127.0.0.1:9050');
|
|
setWormholeDirty(true);
|
|
}}
|
|
className="px-2 py-1 border border-purple-500/30 text-purple-300 text-[12px] font-mono tracking-widest hover:bg-purple-950/20"
|
|
>
|
|
TOR 9050
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setWormholeTransport('tor');
|
|
setWormholeSocksProxy('127.0.0.1:9150');
|
|
setWormholeDirty(true);
|
|
}}
|
|
className="px-2 py-1 border border-purple-500/30 text-purple-300 text-[12px] font-mono tracking-widest hover:bg-purple-950/20"
|
|
>
|
|
TOR 9150
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setWormholeTransport('i2p');
|
|
setWormholeSocksProxy('127.0.0.1:4447');
|
|
setWormholeDirty(true);
|
|
}}
|
|
className="px-2 py-1 border border-blue-500/30 text-blue-300 text-[12px] font-mono tracking-widest hover:bg-blue-950/20"
|
|
>
|
|
I2P 4447
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setWormholeTransport('mixnet');
|
|
setWormholeSocksProxy('127.0.0.1:1080');
|
|
setWormholeDirty(true);
|
|
}}
|
|
className="px-2 py-1 border border-cyan-500/30 text-cyan-300 text-[12px] font-mono tracking-widest hover:bg-cyan-950/20"
|
|
>
|
|
MIXNET 1080
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[13px] font-mono text-[var(--text-muted)] tracking-widest">
|
|
PROXY DNS
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
setWormholeSocksDns((prev) => !prev);
|
|
setWormholeDirty(true);
|
|
}}
|
|
className={`px-2 py-1 border text-[13px] font-mono tracking-widest transition-colors ${wormholeSocksDns ? 'border-green-500/40 text-green-400 bg-green-950/20' : 'border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
|
>
|
|
{wormholeSocksDns ? 'ON' : 'OFF'}
|
|
</button>
|
|
</div>
|
|
<div className="text-[13px] font-mono text-[var(--text-muted)] leading-relaxed">
|
|
Hidden transport requires a local SOCKS5 proxy (Tor/I2P/Mixnet) already
|
|
running. Save applies the new transport immediately.
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="flex items-center justify-between gap-2 border border-green-900/20 bg-black/20 px-2 py-2">
|
|
<div>
|
|
<div className="text-[13px] font-mono text-[var(--text-secondary)] tracking-widest">
|
|
HIDDEN TRANSPORT MODE
|
|
</div>
|
|
<div className="mt-1 text-[13px] font-mono text-[var(--text-muted)] leading-relaxed">
|
|
Public mesh writes fail closed unless the local agent is active on
|
|
Tor/I2P/Mixnet. Direct transport is blocked while this is on.
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setWormholeAnonymousMode((prev) => !prev);
|
|
setWormholeDirty(true);
|
|
}}
|
|
className={`px-2 py-1 border text-[13px] font-mono tracking-widest transition-colors ${wormholeAnonymousMode ? 'border-green-500/40 text-green-400 bg-green-950/20' : 'border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-secondary)]'}`}
|
|
>
|
|
{wormholeAnonymousMode ? 'ON' : 'OFF'}
|
|
</button>
|
|
</div>
|
|
{wormholeAnonymousMode && (
|
|
<div className="flex flex-col gap-1 text-[13px] font-mono">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`px-1.5 py-0.5 border ${anonModeReady ? 'border-green-500/40 text-green-400 bg-green-950/20' : 'border-yellow-500/40 text-yellow-300 bg-yellow-950/10'}`}
|
|
>
|
|
{trustModeLabel}
|
|
</span>
|
|
<span className="text-[var(--text-muted)] leading-relaxed">
|
|
{anonModeReady
|
|
? 'Hidden transport is active. Public gate posting routes through the local agent.'
|
|
: 'Connect the local agent over Tor, I2P, or Mixnet before posting publicly.'}
|
|
</span>
|
|
</div>
|
|
<div className="text-[var(--text-muted)] leading-relaxed">
|
|
Mesh Terminal stays read-only for sensitive posting and DM actions while
|
|
the hidden transport policy is active. Use MeshChat for the hardened path.
|
|
</div>
|
|
<div className="text-[var(--text-muted)] leading-relaxed">
|
|
Relay fallback reduces metadata protection compared with direct obfuscated
|
|
transport. Meshtastic/APRS remain degraded, integrity-only channels in
|
|
this phase.
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!wormholeAnonymousMode && (
|
|
<div className="flex flex-col gap-1 text-[13px] font-mono">
|
|
<div className="flex items-center gap-2">
|
|
<span className="px-1.5 py-0.5 border border-orange-500/40 text-orange-300 bg-orange-950/20">
|
|
{trustModeLabel}
|
|
</span>
|
|
<span className="text-[var(--text-muted)] leading-relaxed">
|
|
Hidden transport is off. Public posting may use public or degraded
|
|
transports until you require Tor, I2P, or Mixnet.
|
|
</span>
|
|
</div>
|
|
<div className="text-[var(--text-muted)] leading-relaxed">
|
|
Meshtastic/APRS/JS8 remain public or degraded in this phase unless a
|
|
separate obfuscated transport is explicitly enabled.
|
|
</div>
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={() => saveWormholeSettings()}
|
|
disabled={!wormholeDirty || wormholeSaving}
|
|
className="px-2 py-1 border border-green-500/40 text-green-400 bg-green-950/20 hover:bg-green-950/30 transition-colors text-[13px] font-mono tracking-widest disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{wormholeSaving ? 'SAVING...' : 'SAVE LOCAL AGENT SETTINGS'}
|
|
</button>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<button
|
|
onClick={() => controlWormhole('connect')}
|
|
disabled={wormholeSaving}
|
|
className="px-2 py-1 border border-green-500/40 text-green-400 bg-green-950/20 hover:bg-green-950/30 transition-colors text-[13px] font-mono tracking-widest disabled:opacity-40"
|
|
>
|
|
CONNECT
|
|
</button>
|
|
<button
|
|
onClick={() => controlWormhole('restart')}
|
|
disabled={wormholeSaving || !wormholeEnabled}
|
|
className="px-2 py-1 border border-yellow-500/40 text-yellow-300 bg-yellow-950/10 hover:bg-yellow-950/20 transition-colors text-[13px] font-mono tracking-widest disabled:opacity-40"
|
|
>
|
|
RESTART
|
|
</button>
|
|
<button
|
|
onClick={() => controlWormhole('disconnect')}
|
|
disabled={wormholeSaving || !wormholeEnabled}
|
|
className="px-2 py-1 border border-red-500/40 text-red-300 bg-red-950/10 hover:bg-red-950/20 transition-colors text-[13px] font-mono tracking-widest disabled:opacity-40"
|
|
>
|
|
DISCONNECT
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{rnsStatus && (
|
|
<div className="mt-2 text-[13px] font-mono text-[var(--text-muted)] flex items-center gap-2">
|
|
<span
|
|
className={`px-1.5 py-0.5 border ${rnsStatus.ready ? 'border-green-500/40 text-green-400 bg-green-950/20' : 'border-yellow-500/40 text-yellow-400 bg-yellow-950/20'}`}
|
|
>
|
|
RNS {rnsStatus.ready ? 'READY' : rnsStatus.enabled ? 'STARTING' : 'OFF'}
|
|
</span>
|
|
<span>
|
|
peers {rnsStatus.active_peers}/{rnsStatus.configured_peers}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{wormholeStatus && (
|
|
<div className="mt-1 space-y-2 text-[13px] font-mono text-[var(--text-muted)]">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`px-1.5 py-0.5 border ${
|
|
wormholeStatus.ready
|
|
? 'border-green-500/40 text-green-400 bg-green-950/20'
|
|
: wormholeStatus.running
|
|
? 'border-yellow-500/40 text-yellow-300 bg-yellow-950/10'
|
|
: 'border-slate-600/40 text-slate-300 bg-slate-900/20'
|
|
}`}
|
|
>
|
|
{wormholeStatus.ready
|
|
? 'LOCAL AGENT ACTIVE'
|
|
: wormholeStatus.running
|
|
? 'LOCAL AGENT STARTING'
|
|
: wormholeStatus.configured
|
|
? 'LOCAL AGENT IDLE'
|
|
: 'LOCAL AGENT OFF'}
|
|
</span>
|
|
{wormholeStatus.pid > 0 && <span>pid {wormholeStatus.pid}</span>}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`px-1.5 py-0.5 border ${
|
|
effectiveTransport !== 'direct'
|
|
? 'border-purple-500/40 text-purple-300 bg-purple-950/20'
|
|
: 'border-slate-600/40 text-slate-300 bg-slate-900/20'
|
|
}`}
|
|
>
|
|
ACTIVE {effectiveTransport.toUpperCase()}
|
|
</span>
|
|
{transportMismatch && (
|
|
<span className="px-1.5 py-0.5 border border-yellow-500/40 text-yellow-300 bg-yellow-950/10">
|
|
FALLBACK
|
|
</span>
|
|
)}
|
|
{recentPrivateFallback && (
|
|
<span className="px-1.5 py-0.5 border border-red-500/40 text-red-300 bg-red-950/20">
|
|
PRIVACY DOWNGRADE
|
|
</span>
|
|
)}
|
|
{wormholeStatus.proxy_active && (
|
|
<span className="text-[12px] text-[var(--text-muted)]">
|
|
proxy {wormholeStatus.proxy_active}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-[13px] leading-relaxed">
|
|
Public transport identity, gate personas, and the obfuscated DM alias are
|
|
compartmentalized inside the local agent.
|
|
</div>
|
|
{recentPrivateFallback && (
|
|
<div className="text-[13px] text-red-300/90 leading-relaxed">
|
|
{recentPrivateFallbackReason}
|
|
</div>
|
|
)}
|
|
{wormholeStatus.last_error && (
|
|
<div className="text-[13px] text-red-300/90 leading-relaxed">
|
|
{wormholeStatus.last_error}
|
|
</div>
|
|
)}
|
|
{legacyCompatibilityItems.length > 0 && (
|
|
<div className="border border-cyan-900/25 bg-black/20 px-3 py-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-[12px] font-mono tracking-[0.18em] text-cyan-300">
|
|
LEGACY SUNSET
|
|
</div>
|
|
<div className="text-[11px] font-mono text-[var(--text-muted)]">
|
|
{legacyCompatibilityAllBlocked
|
|
? 'DESKTOP DEFAULT: BLOCKING'
|
|
: 'COMPATIBILITY STILL OPEN'}
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 space-y-2">
|
|
{legacyCompatibilityItems.map((item) => (
|
|
<div key={item.key} className="space-y-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span
|
|
className={`px-1.5 py-0.5 border ${
|
|
item.blocked
|
|
? 'border-green-500/40 text-green-300 bg-green-950/20'
|
|
: 'border-yellow-500/40 text-yellow-300 bg-yellow-950/15'
|
|
}`}
|
|
>
|
|
{item.blocked ? 'BLOCKED' : 'ALLOWING'}
|
|
</span>
|
|
<span className="text-[var(--text-secondary)]">{item.label}</span>
|
|
<span className="text-[var(--text-muted)]">
|
|
seen {item.count}
|
|
{item.blockedCount > 0 ? ` • blocked ${item.blockedCount}` : ''}
|
|
</span>
|
|
</div>
|
|
<div className="text-[12px] leading-relaxed text-[var(--text-muted)]">
|
|
{item.blocked ? 'remove after' : 'target'} {item.targetVersion} / {item.targetDate}
|
|
{item.lastSeenAt > 0
|
|
? ` • last seen ${formatLegacyCompatibilitySeenAt(item.lastSeenAt)}`
|
|
: ' • never observed'}
|
|
</div>
|
|
{item.recentTargets.length > 0 && (
|
|
<div className="text-[12px] leading-relaxed text-yellow-200/80">
|
|
recent {item.recentTargets.join(' • ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{!legacyCompatibilityActivity && (
|
|
<div className="mt-2 text-[12px] leading-relaxed text-green-300/80">
|
|
No live legacy traffic observed in this runtime. When this stays at
|
|
zero, the final hard cutoff is low risk.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="border border-amber-900/25 bg-black/20 px-3 py-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-[12px] font-mono tracking-[0.18em] text-amber-300">
|
|
GATE COMPAT
|
|
</div>
|
|
<div className="text-[11px] font-mono text-[var(--text-muted)]">
|
|
required {gateCompatTelemetry.totalRequired} • used {gateCompatTelemetry.totalUsed}
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 text-[12px] leading-relaxed text-[var(--text-muted)]">
|
|
{describeBrowserGateLocalRuntimeStatus(gateLocalRuntimeStatus)}
|
|
</div>
|
|
{gateCompatTopReasons.length > 0 ? (
|
|
<div className="mt-2 space-y-2">
|
|
{gateCompatTopReasons.map((item) => (
|
|
<div key={item.reason} className="space-y-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-[var(--text-secondary)]">{item.label}</span>
|
|
<span className="text-[var(--text-muted)]">
|
|
need {item.requiredCount}
|
|
{item.usedCount > 0 ? ` • used ${item.usedCount}` : ''}
|
|
</span>
|
|
</div>
|
|
<div className="text-[12px] leading-relaxed text-[var(--text-muted)]">
|
|
{item.lastAt > 0
|
|
? `last seen ${formatGateCompatSeenAt(item.lastAt)}`
|
|
: 'never observed'}
|
|
{item.recentGates.length > 0
|
|
? ` • rooms ${item.recentGates.join(' • ')}`
|
|
: ''}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="mt-2 text-[12px] leading-relaxed text-green-300/80">
|
|
No browser gate compat issues recorded for this profile yet.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ── Time Machine ────────────────────────── */}
|
|
<div className="mx-4 mt-4 mb-4 p-3 border border-amber-900/30 bg-amber-950/8">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm text-amber-300 font-mono tracking-[0.18em]">
|
|
TIME MACHINE
|
|
</div>
|
|
<div className="mt-1.5 text-[12px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
|
Records hourly snapshots of all entity positions (flights, ships, satellites)
|
|
for historical playback via the timeline scrubber.
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={toggleTimeMachine}
|
|
disabled={tmSaving}
|
|
className={`px-4 py-1.5 text-[12px] font-mono tracking-[0.18em] border rounded-sm transition-colors whitespace-nowrap ${
|
|
tmEnabled
|
|
? 'text-amber-300 border-amber-500/40 bg-amber-950/30 hover:bg-amber-950/50'
|
|
: 'text-[var(--text-muted)] border-slate-600/40 bg-slate-900/20 hover:bg-slate-900/40'
|
|
} disabled:opacity-40`}
|
|
>
|
|
{tmSaving ? '...' : tmEnabled ? 'ON' : 'OFF'}
|
|
</button>
|
|
</div>
|
|
<div className="mt-2 p-2 border border-amber-500/15 bg-black/20 text-[11px] font-mono text-amber-200/70 leading-relaxed">
|
|
<span className="text-amber-400">STORAGE:</span> ~5-8 MB/day · ~200 MB/month (gzip compressed).
|
|
Snapshots are stored locally and never leave your machine.
|
|
{tmEnabled && (
|
|
<span className="text-amber-300"> Auto-snapshots are running.</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Browser Companion (desktop-only) ───── */}
|
|
{companionAvailable && (companion || companionLoadFailed) && (
|
|
<div className="mx-4 mt-4 mb-4 p-3 border border-violet-900/30 bg-violet-950/8">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm text-violet-300 font-mono tracking-[0.18em]">
|
|
BROWSER COMPANION
|
|
</div>
|
|
<div className="mt-1.5 text-[12px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
|
{companionLoadFailed
|
|
? 'Could not load companion status from the native bridge.'
|
|
: <>
|
|
Open this app in a regular browser on localhost.
|
|
{companion?.enabled && companion.url && (
|
|
<span className="text-violet-300"> Active at {companion.url}</span>
|
|
)}
|
|
</>
|
|
}
|
|
</div>
|
|
</div>
|
|
{companion && (
|
|
<div className="flex gap-2">
|
|
{companion.enabled && (
|
|
<button
|
|
type="button"
|
|
onClick={openCompanionBrowser}
|
|
disabled={companionBusy}
|
|
className="px-3 py-1.5 text-[12px] font-mono tracking-[0.18em] border text-violet-300 border-violet-500/40 bg-violet-950/30 hover:bg-violet-950/50 rounded-sm transition-colors whitespace-nowrap disabled:opacity-40"
|
|
>
|
|
{companionBusy ? '...' : 'OPEN'}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={toggleCompanion}
|
|
disabled={companionBusy}
|
|
className={`px-4 py-1.5 text-[12px] font-mono tracking-[0.18em] border rounded-sm transition-colors whitespace-nowrap ${
|
|
companion.enabled
|
|
? 'text-violet-300 border-violet-500/40 bg-violet-950/30 hover:bg-violet-950/50'
|
|
: 'text-[var(--text-muted)] border-slate-600/40 bg-slate-900/20 hover:bg-slate-900/40'
|
|
} disabled:opacity-40`}
|
|
>
|
|
{companionBusy ? '...' : companion.enabled ? 'ON' : 'OFF'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{companion?.warning && (
|
|
<div className="mt-2 p-2 border border-violet-500/15 bg-black/20 text-[11px] font-mono text-violet-200/70 leading-relaxed">
|
|
<span className="text-violet-400">REDUCED TRUST:</span>{' '}
|
|
{companion.warning}
|
|
</div>
|
|
)}
|
|
{companionLoadFailed && (
|
|
<div className="mt-2 p-2 border border-amber-500/20 bg-amber-950/15 text-[11px] font-mono text-amber-300/90 leading-relaxed">
|
|
Companion service unavailable. The native bridge did not respond. Try reopening Settings or restarting the app.
|
|
</div>
|
|
)}
|
|
{companionError && (
|
|
<div className="mt-2 p-2 border border-red-500/20 bg-red-950/15 text-[11px] font-mono text-red-300/90 leading-relaxed">
|
|
{companionError}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'api-keys' && (
|
|
<>
|
|
{/* Info Banner */}
|
|
<div className="mx-4 mt-4 p-3 border border-cyan-900/30 bg-cyan-950/10 space-y-2">
|
|
<div className="flex items-start gap-2">
|
|
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
|
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
|
API keys are saved locally by this backend. Values are write-only: the app
|
|
stores the key and shows CONFIGURED, but it never reads the secret back into
|
|
the browser. Keys marked with{' '}
|
|
<Key size={8} className="inline text-yellow-500" /> unlock the richest live
|
|
aircraft and vessel feeds.
|
|
</p>
|
|
</div>
|
|
<div className="pl-5 text-[12px] font-mono text-cyan-200/80 leading-relaxed">
|
|
Configured keys stay hidden for shared dashboards. Unlock operator tools, then
|
|
use ROTATE only when you intentionally want to replace a working credential.
|
|
</div>
|
|
{envMeta && (
|
|
<div className="pl-5 text-[12px] font-mono text-[var(--text-muted)] leading-relaxed space-y-0.5">
|
|
<div>
|
|
<span className="text-cyan-500/70">local key store:</span>{' '}
|
|
<span className="text-cyan-300 break-all select-all">
|
|
{envMeta.operator_keys_env_path || envMeta.env_path}
|
|
</span>{' '}
|
|
{envMeta.operator_keys_env_path_exists || envMeta.env_path_exists ? (
|
|
<span className="text-green-400/80">[exists]</span>
|
|
) : (
|
|
<span className="text-amber-400/80">[will be created on first save]</span>
|
|
)}
|
|
{envMeta.env_path_exists && !envMeta.env_path_writable && (
|
|
<span className="text-red-400/90"> [NOT WRITABLE — edit by hand]</span>
|
|
)}
|
|
</div>
|
|
{envMeta.env_example_path_exists && (
|
|
<div>
|
|
<span className="text-cyan-500/70">template:</span>{' '}
|
|
<span className="text-cyan-300/80 break-all select-all">
|
|
{envMeta.env_example_path}
|
|
</span>{' '}
|
|
<span className="text-[var(--text-muted)]">
|
|
(copy to .env and fill in your keys; comments above each entry list the registration URL)
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{apiKeyMsg && (
|
|
<div
|
|
className={`pl-5 text-sm font-mono ${
|
|
apiKeyMsg.type === 'ok' ? 'text-green-300' : 'text-red-300'
|
|
}`}
|
|
>
|
|
{apiKeyMsg.text}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* API List */}
|
|
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
|
|
{Object.entries(grouped).map(([category, categoryApis]) => {
|
|
const colorClass =
|
|
CATEGORY_COLORS[category] || 'text-gray-400 border-gray-700 bg-gray-900/20';
|
|
const isExpanded = expandedCategories.has(category);
|
|
return (
|
|
<div
|
|
key={category}
|
|
className="border border-[var(--border-primary)]/60 overflow-hidden"
|
|
>
|
|
<button
|
|
onClick={() => toggleCategory(category)}
|
|
className="w-full flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`text-[13px] font-mono tracking-widest font-bold px-2 py-0.5 border ${colorClass}`}
|
|
>
|
|
{category.toUpperCase()}
|
|
</span>
|
|
<span className="text-sm text-[var(--text-muted)] font-mono">
|
|
{categoryApis.length}{' '}
|
|
{categoryApis.length === 1 ? 'service' : 'services'}
|
|
</span>
|
|
</div>
|
|
{isExpanded ? (
|
|
<ChevronUp size={12} className="text-[var(--text-muted)]" />
|
|
) : (
|
|
<ChevronDown size={12} className="text-[var(--text-muted)]" />
|
|
)}
|
|
</button>
|
|
<AnimatePresence>
|
|
{isExpanded && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{categoryApis.map((api) => (
|
|
<div
|
|
key={api.id}
|
|
className="border-t border-[var(--border-primary)]/40 px-4 py-3 hover:bg-[var(--bg-secondary)]/30 transition-colors"
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-2">
|
|
{api.required && (
|
|
<Key size={10} className="text-yellow-500" />
|
|
)}
|
|
<span className="text-xs font-mono text-[var(--text-primary)] font-medium">
|
|
{api.name}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{api.has_key ? (
|
|
api.is_set ? (
|
|
<span className="text-[12px] font-mono px-1.5 py-0.5 border border-green-500/30 text-green-400 bg-green-950/20">
|
|
KEY SET
|
|
</span>
|
|
) : (
|
|
<span className="text-[12px] font-mono px-1.5 py-0.5 border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">
|
|
MISSING
|
|
</span>
|
|
)
|
|
) : (
|
|
<span className="text-[12px] font-mono px-1.5 py-0.5 border border-[var(--border-primary)] text-[var(--text-muted)]">
|
|
PUBLIC
|
|
</span>
|
|
)}
|
|
{api.url && (
|
|
<a
|
|
href={api.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<ExternalLink size={10} />
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-[var(--text-muted)] font-mono leading-relaxed mb-2">
|
|
{api.description}
|
|
</p>
|
|
{api.has_key && (
|
|
<div className="mt-2 space-y-2 text-[12px] font-mono">
|
|
{api.is_set ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex items-center gap-2">
|
|
<span className="px-2 py-0.5 border border-green-500/40 bg-green-950/20 text-green-300 tracking-wider">
|
|
CONFIGURED
|
|
</span>
|
|
<span className="text-[var(--text-muted)] leading-relaxed">
|
|
Secret hidden. Stored write-only on this backend as{' '}
|
|
<span className="text-cyan-300 select-all break-all">
|
|
{api.env_key}
|
|
</span>
|
|
.
|
|
</span>
|
|
</div>
|
|
{api.env_key && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!(nativeProtected || adminSessionReady)) {
|
|
setApiKeyMsg({
|
|
type: 'err',
|
|
text: 'Unlock operator tools before rotating a configured key.',
|
|
});
|
|
return;
|
|
}
|
|
setApiKeyMsg(null);
|
|
setApiKeyEditing((prev) => ({
|
|
...prev,
|
|
[api.env_key as string]: !prev[api.env_key as string],
|
|
}));
|
|
}}
|
|
className={`shrink-0 px-2 py-1 border text-[11px] tracking-widest transition-colors ${
|
|
nativeProtected || adminSessionReady
|
|
? 'border-yellow-500/40 text-yellow-300 hover:bg-yellow-500/10'
|
|
: 'border-[var(--border-primary)] text-[var(--text-muted)] hover:border-yellow-500/30 hover:text-yellow-300/80'
|
|
}`}
|
|
>
|
|
{apiKeyEditing[api.env_key] ? 'CANCEL' : 'ROTATE'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{!(nativeProtected || adminSessionReady) && (
|
|
<div className="text-[11px] text-yellow-300/70 leading-relaxed">
|
|
Operator tools are locked. Viewers can see source status
|
|
but cannot replace saved credentials.
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<span className="px-2 py-0.5 border border-amber-500/40 bg-amber-950/20 text-amber-300 tracking-wider">
|
|
NOT CONFIGURED
|
|
</span>
|
|
<span className="text-[var(--text-muted)]">
|
|
Save {api.env_key} here to enable this source.
|
|
</span>
|
|
</div>
|
|
)}
|
|
{(!api.is_set || (api.env_key && apiKeyEditing[api.env_key])) && (
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="password"
|
|
value={api.env_key ? apiKeyInputs[api.env_key] || '' : ''}
|
|
onChange={(event) => {
|
|
if (!api.env_key) return;
|
|
setApiKeyInputs((prev) => ({
|
|
...prev,
|
|
[api.env_key as string]: event.target.value,
|
|
}));
|
|
}}
|
|
placeholder={
|
|
api.is_set
|
|
? 'Enter replacement key...'
|
|
: `Enter ${api.env_key}...`
|
|
}
|
|
className="min-w-0 flex-1 bg-[var(--bg-primary)] border border-[var(--border-primary)] px-2 py-1.5 text-sm text-[var(--text-primary)] outline-none focus:border-cyan-500/70 placeholder:text-[var(--text-muted)]/50"
|
|
autoComplete="off"
|
|
/>
|
|
<button
|
|
onClick={() => void saveApiKey(api.env_key)}
|
|
disabled={
|
|
!api.env_key ||
|
|
apiKeySaving === api.env_key ||
|
|
!String(
|
|
api.env_key ? apiKeyInputs[api.env_key] || '' : '',
|
|
).trim()
|
|
}
|
|
className="h-8 px-3 border border-cyan-500/40 bg-cyan-950/20 text-cyan-300 hover:bg-cyan-500/15 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5 tracking-widest"
|
|
>
|
|
<Save size={12} />
|
|
{apiKeySaving === api.env_key ? 'SAVING' : 'SAVE'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
|
<div className="flex items-center justify-between text-[13px] text-[var(--text-muted)] font-mono">
|
|
<span>{apis.length} REGISTERED APIs</span>
|
|
<span>{apis.filter((a) => a.has_key && a.is_set).length} KEYS CONFIGURED</span>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ==================== NEWS FEEDS TAB ==================== */}
|
|
{activeTab === 'news-feeds' && (
|
|
<>
|
|
{/* Info Banner */}
|
|
<div className="mx-4 mt-4 p-3 border border-orange-900/30 bg-orange-950/10">
|
|
<div className="flex items-start gap-2">
|
|
<Rss size={12} className="text-orange-500 mt-0.5 flex-shrink-0" />
|
|
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
|
Configure RSS/Atom feeds for the Threat Intel news panel. Each feed is scored
|
|
by keyword heuristics and weighted by the priority you set. Up to{' '}
|
|
<span className="text-orange-400">{MAX_FEEDS}</span> sources.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Feed List */}
|
|
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-2">
|
|
{feeds.map((feed, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="border border-[var(--border-primary)]/60 p-3 hover:border-[var(--border-secondary)]/60 transition-colors group"
|
|
>
|
|
{/* Row 1: Name + Weight + Delete */}
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<input
|
|
type="text"
|
|
value={feed.name}
|
|
onChange={(e) => updateFeed(idx, 'name', e.target.value)}
|
|
className="flex-1 bg-transparent border-b border-[var(--border-primary)] text-xs font-mono text-[var(--text-primary)] outline-none focus:border-cyan-500/70 transition-colors px-1 py-0.5"
|
|
placeholder="Source name..."
|
|
/>
|
|
{/* Weight selector */}
|
|
<div className="flex items-center gap-1">
|
|
{[1, 2, 3, 4, 5].map((w) => (
|
|
<button
|
|
key={w}
|
|
onClick={() => updateFeed(idx, 'weight', w)}
|
|
className={`w-5 h-5 text-[12px] font-mono font-bold border transition-all ${feed.weight === w ? WEIGHT_COLORS[w] + ' bg-black/40' : 'border-[var(--border-primary)]/40 text-[var(--text-muted)]/50 hover:border-[var(--border-secondary)]'}`}
|
|
title={WEIGHT_LABELS[w]}
|
|
>
|
|
{w}
|
|
</button>
|
|
))}
|
|
<span
|
|
className={`text-[12px] font-mono ml-1 w-7 ${WEIGHT_COLORS[feed.weight]?.split(' ')[0] || 'text-gray-400'}`}
|
|
>
|
|
{WEIGHT_LABELS[feed.weight] || 'STD'}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => removeFeed(idx)}
|
|
className="w-6 h-6 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 hover:bg-red-950/20 transition-all opacity-0 group-hover:opacity-100"
|
|
title="Remove feed"
|
|
>
|
|
<Trash2 size={11} />
|
|
</button>
|
|
</div>
|
|
{/* Row 2: URL */}
|
|
<input
|
|
type="text"
|
|
value={feed.url}
|
|
onChange={(e) => updateFeed(idx, 'url', e.target.value)}
|
|
className="w-full bg-black/30 border border-[var(--border-primary)]/40 px-2 py-1 text-sm font-mono text-[var(--text-muted)] outline-none focus:border-cyan-500/50 focus:text-cyan-300 transition-colors"
|
|
placeholder="https://example.com/rss.xml"
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{/* Add Feed Button */}
|
|
<button
|
|
onClick={addFeed}
|
|
disabled={feeds.length >= MAX_FEEDS}
|
|
className="w-full py-2.5 border border-dashed border-[var(--border-primary)]/60 text-[var(--text-muted)] hover:border-orange-500/50 hover:text-orange-400 hover:bg-orange-950/10 transition-all text-sm font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<Plus size={10} />
|
|
ADD FEED ({feeds.length}/{MAX_FEEDS})
|
|
</button>
|
|
</div>
|
|
|
|
{/* Status message */}
|
|
{feedMsg && (
|
|
<div
|
|
className={`mx-4 mb-2 px-3 py-2 text-sm font-mono ${feedMsg.type === 'ok' ? 'text-green-400 bg-green-950/20 border border-green-900/30' : 'text-red-400 bg-red-950/20 border border-red-900/30'}`}
|
|
>
|
|
{feedMsg.text}
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={saveFeeds}
|
|
disabled={!feedsDirty || feedSaving}
|
|
className="flex-1 px-4 py-2 bg-orange-500/20 border border-orange-500/40 text-orange-400 hover:bg-orange-500/30 transition-colors text-sm font-mono flex items-center justify-center gap-1.5 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<Save size={10} />
|
|
{feedSaving ? 'SAVING...' : 'SAVE FEEDS'}
|
|
</button>
|
|
<button
|
|
onClick={resetFeeds}
|
|
className="px-3 py-2 border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-all text-sm font-mono flex items-center gap-1.5"
|
|
title="Reset to defaults"
|
|
>
|
|
<RotateCcw size={10} />
|
|
RESET
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center justify-between text-[13px] text-[var(--text-muted)] font-mono mt-2">
|
|
<span>
|
|
{feeds.length}/{MAX_FEEDS} SOURCES
|
|
</span>
|
|
<span>WEIGHT: 1=LOW 5=CRITICAL</span>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ==================== SENTINEL HUB TAB ==================== */}
|
|
{activeTab === 'sentinel' && (
|
|
<SentinelTab onGoToApiKeys={() => setActiveTab('api-keys')} />
|
|
)}
|
|
{activeTab === 'sar' && <SarSettingsTab />}
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
});
|
|
|
|
// ─── Sentinel Hub Settings Tab ─────────────────────────────────────────────
|
|
// Issue #298 (tg12): Sentinel credentials now live in the backend ``.env``
|
|
// and are managed through the existing API Keys panel — same flow as every
|
|
// other third-party API key (OpenSky, AIS Stream, Finnhub, …). This tab no
|
|
// longer collects credentials. It does three things:
|
|
// 1. Runs migrateLegacySentinelBrowserKeys() once to wipe pre-#298
|
|
// values out of localStorage / sessionStorage.
|
|
// 2. Shows the operator whether the backend has the credentials.
|
|
// 3. Offers a one-click jump to the API Keys panel where they enter them.
|
|
function SentinelTab({ onGoToApiKeys }: { onGoToApiKeys: () => void }) {
|
|
const [backendConfigured, setBackendConfigured] = useState<boolean | null>(null);
|
|
const [migrationResult, setMigrationResult] = useState<{ cleared: string[] } | null>(null);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// One-time legacy browser-key wipe. Idempotent — does nothing on a
|
|
// fresh install. We do NOT silently POST any browser-stored values
|
|
// to the backend; operators who relied on them re-enter once in the
|
|
// API Keys panel. Doing the wipe regardless ensures pre-#298 secrets
|
|
// don't linger in localStorage indefinitely.
|
|
setMigrationResult(migrateLegacySentinelBrowserKeys());
|
|
|
|
// Check whether the backend has SENTINEL_CLIENT_ID/SECRET set.
|
|
void checkBackendSentinelStatus().then(setBackendConfigured);
|
|
}, []);
|
|
|
|
const refresh = async () => {
|
|
setRefreshing(true);
|
|
try {
|
|
// refreshSentinelStatus() invalidates the module-level cache so the
|
|
// next check actually hits the backend instead of returning the
|
|
// memoized value. Lazy-imported so SSR/tests don't choke.
|
|
const { refreshSentinelStatus } = await import('@/lib/sentinelHub');
|
|
refreshSentinelStatus();
|
|
const ok = await checkBackendSentinelStatus();
|
|
setBackendConfigured(ok);
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
|
|
const statusColor =
|
|
backendConfigured === null
|
|
? 'text-[var(--text-muted)]'
|
|
: backendConfigured
|
|
? 'text-green-400'
|
|
: 'text-yellow-400';
|
|
const statusLabel =
|
|
backendConfigured === null
|
|
? 'CHECKING…'
|
|
: backendConfigured
|
|
? 'CONFIGURED ON BACKEND'
|
|
: 'NOT CONFIGURED';
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col overflow-y-auto styled-scrollbar">
|
|
{/* Setup Guide */}
|
|
<div className="mx-4 mt-4 p-3 border border-purple-900/30 bg-purple-950/10">
|
|
<div className="flex items-start gap-2">
|
|
<Satellite size={12} className="text-purple-400 mt-0.5 flex-shrink-0" />
|
|
<div className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed space-y-2">
|
|
<p className="text-purple-300 font-bold">COPERNICUS SENTINEL HUB SETUP</p>
|
|
<p className="text-[var(--text-muted)]">
|
|
Sentinel Hub gives you access to ESA satellite imagery (Sentinel-2 true color,
|
|
NDVI vegetation, false color IR, moisture index). Free tier: 10,000 processing
|
|
units/month. Follow each step below:
|
|
</p>
|
|
<div className="space-y-1.5 mt-1">
|
|
<p>
|
|
<span className="text-purple-400 font-bold">STEP 1:</span>{' '}
|
|
Go to{' '}
|
|
<a
|
|
href="https://dataspace.copernicus.eu"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-purple-400 underline hover:text-purple-300"
|
|
>
|
|
dataspace.copernicus.eu
|
|
</a>
|
|
{' '}→ click <span className="text-white">Register</span> (top right) →
|
|
create a free account. Pick <span className="text-white">Public</span> for
|
|
User Category.
|
|
</p>
|
|
<p>
|
|
<span className="text-purple-400 font-bold">STEP 2:</span>{' '}
|
|
Once logged in, go to{' '}
|
|
<a
|
|
href="https://shapps.dataspace.copernicus.eu/dashboard/"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-purple-400 underline hover:text-purple-300"
|
|
>
|
|
Sentinel Hub Dashboard
|
|
</a>
|
|
{' '}→ click your <span className="text-white">user icon</span> (top right)
|
|
{' '}→ <span className="text-white">User Settings</span>
|
|
{' '}→ <span className="text-white">OAuth clients</span> tab →{' '}
|
|
click <span className="text-cyan-400">"+ Create new"</span>.
|
|
Give it any name (e.g. "ShadowBroker"). Copy the{' '}
|
|
<span className="text-white">Client ID</span> and{' '}
|
|
<span className="text-white">Client Secret</span> it shows you.
|
|
</p>
|
|
<p>
|
|
<span className="text-purple-400 font-bold">STEP 3:</span>{' '}
|
|
Paste both values into the <span className="text-cyan-400">API Keys</span> panel
|
|
under <span className="text-white">SENTINEL_CLIENT_ID</span> and{' '}
|
|
<span className="text-white">SENTINEL_CLIENT_SECRET</span>, then hit Save.
|
|
The backend uses them to mint short-lived tokens — your browser never sees
|
|
the secret again.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Backend status */}
|
|
<div className="mx-4 mt-3 p-3 border border-[var(--border-primary)] bg-[var(--bg-primary)]/30">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-[13px] font-mono text-[var(--text-muted)] tracking-widest">
|
|
BACKEND STATUS
|
|
</span>
|
|
<span className={`text-[11px] font-mono font-bold ${statusColor}`}>
|
|
{statusLabel}
|
|
</span>
|
|
</div>
|
|
<p className="text-[13px] text-[var(--text-muted)] font-mono leading-relaxed">
|
|
{backendConfigured === false
|
|
? 'Sentinel credentials are not yet set in the backend .env. Open the API Keys panel to enter them — the tile overlay and Sentinel-2 Intel Card will work as soon as both fields are saved.'
|
|
: backendConfigured === true
|
|
? 'Sentinel credentials are configured on the backend. The dashboard fetches tokens automatically; your browser does not handle the secret.'
|
|
: 'Checking backend configuration…'}
|
|
</p>
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<button
|
|
onClick={onGoToApiKeys}
|
|
className="flex-1 px-4 py-2 bg-purple-500/20 border border-purple-500/40 text-purple-400 hover:bg-purple-500/30 transition-colors text-sm font-mono flex items-center justify-center gap-1.5"
|
|
>
|
|
OPEN API KEYS PANEL
|
|
</button>
|
|
<button
|
|
onClick={refresh}
|
|
disabled={refreshing}
|
|
className="px-3 py-2 border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-cyan-400 hover:border-cyan-500/50 transition-all text-sm font-mono disabled:opacity-40"
|
|
title="Re-check backend status"
|
|
>
|
|
{refreshing ? 'CHECKING…' : 'REFRESH'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Migration notice (only if we actually cleared anything) */}
|
|
{migrationResult && migrationResult.cleared.length > 0 && (
|
|
<div className="mx-4 mt-3 px-3 py-2 text-sm font-mono text-cyan-400 bg-cyan-950/20 border border-cyan-900/30">
|
|
<p className="font-bold mb-1">LEGACY BROWSER CREDENTIALS CLEARED</p>
|
|
<p className="text-[13px] leading-relaxed text-[var(--text-muted)]">
|
|
Found and removed pre-#298 Sentinel credentials from browser storage
|
|
({migrationResult.cleared.join(', ')}). Re-enter them in the API Keys panel
|
|
above; they'll be stored server-side from now on and never sent back to
|
|
the browser.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer + Usage Meter */}
|
|
<div className="p-4 border-t border-[var(--border-primary)]/80 mt-auto">
|
|
<UsageMeter />
|
|
<div className="mt-2 p-2 border border-[var(--border-primary)]/40 bg-[var(--bg-primary)]/30">
|
|
<p className="text-[13px] text-[var(--text-muted)] font-mono leading-relaxed">
|
|
Credentials are stored in the backend <span className="text-cyan-400">.env</span>{' '}
|
|
and never sent to the browser. The tile proxy mints short-lived OAuth tokens
|
|
on demand using those values.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UsageMeter() {
|
|
const [usage, setUsage] = useState({ month: '', tiles: 0, pu: 0 });
|
|
|
|
useEffect(() => {
|
|
// Import dynamically to avoid SSR issues
|
|
import('@/lib/sentinelHub').then(({ getSentinelUsage }) => {
|
|
setUsage(getSentinelUsage());
|
|
});
|
|
// Refresh every 10s when tab is active
|
|
const id = setInterval(() => {
|
|
import('@/lib/sentinelHub').then(({ getSentinelUsage }) => {
|
|
setUsage(getSentinelUsage());
|
|
});
|
|
}, 10_000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
const maxRequests = 10_000;
|
|
const maxPU = 10_000;
|
|
const pct = Math.min(100, (usage.tiles / maxRequests) * 100);
|
|
const barColor =
|
|
pct < 50 ? 'bg-purple-500' : pct < 80 ? 'bg-yellow-500' : 'bg-red-500';
|
|
const textColor =
|
|
pct < 50 ? 'text-purple-400' : pct < 80 ? 'text-yellow-400' : 'text-red-400';
|
|
|
|
return (
|
|
<div className="mt-3 p-3 border border-purple-900/30 bg-purple-950/10">
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-[13px] font-mono text-purple-400 tracking-widest">
|
|
MONTHLY USAGE
|
|
</span>
|
|
<span className="text-[13px] font-mono text-[var(--text-muted)]">
|
|
{usage.month || '—'}
|
|
</span>
|
|
</div>
|
|
{/* Progress bar */}
|
|
<div className="w-full h-1.5 rounded-full bg-[var(--bg-primary)] mb-2">
|
|
<div
|
|
className={`h-full rounded-full ${barColor} transition-all`}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div>
|
|
<div className={`text-[11px] font-mono font-bold ${textColor}`}>
|
|
{usage.tiles.toLocaleString()}
|
|
</div>
|
|
<div className="text-[12px] font-mono text-[var(--text-muted)]">
|
|
/ {maxRequests.toLocaleString()} tiles
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className={`text-[11px] font-mono font-bold ${textColor}`}>
|
|
{usage.pu.toLocaleString()}
|
|
</div>
|
|
<div className="text-[12px] font-mono text-[var(--text-muted)]">
|
|
/ {maxPU.toLocaleString()} PU
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-[11px] font-mono font-bold text-[var(--text-secondary)]">
|
|
{Math.round(100 - pct)}%
|
|
</div>
|
|
<div className="text-[12px] font-mono text-[var(--text-muted)]">remaining</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── SAR Ground-Change Settings Tab ───────────────────────────────────────────
|
|
function SarSettingsTab() {
|
|
const [status, setStatus] = useState<Record<string, unknown> | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [actionMsg, setActionMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
|
const [disabling, setDisabling] = useState(false);
|
|
|
|
const fetchStatus = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/sar/status`, { credentials: 'include' });
|
|
if (res.ok) {
|
|
const body = await res.json();
|
|
setStatus(body);
|
|
}
|
|
} catch { /* silent */ }
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => { fetchStatus(); }, [fetchStatus]);
|
|
|
|
const products = (status?.products ?? {}) as Record<string, unknown>;
|
|
const modeBEnabled = !!products.enabled;
|
|
const catalogEnabled = !!(status?.catalog as Record<string, unknown>)?.enabled;
|
|
const openclawEnabled = !!status?.openclaw_enabled;
|
|
|
|
const handleDisable = async () => {
|
|
setDisabling(true);
|
|
setActionMsg(null);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/sar/mode-b/disable`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(typeof body?.detail === 'string' ? body.detail : `HTTP ${res.status}`);
|
|
}
|
|
setActionMsg({ type: 'ok', text: 'Mode B disabled. Credentials wiped.' });
|
|
await fetchStatus();
|
|
} catch (e) {
|
|
setActionMsg({
|
|
type: 'err',
|
|
text: e instanceof Error ? e.message : 'Failed to disable Mode B',
|
|
});
|
|
} finally {
|
|
setDisabling(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center p-8">
|
|
<span className="text-xs font-mono text-[var(--text-muted)] animate-pulse">
|
|
Loading SAR status...
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col overflow-y-auto styled-scrollbar">
|
|
{/* Status Overview */}
|
|
<div className="mx-4 mt-4 p-3 border border-amber-900/30 bg-amber-950/10">
|
|
<div className="flex items-start gap-2">
|
|
<Radar size={12} className="text-amber-400 mt-0.5 flex-shrink-0" />
|
|
<div className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed space-y-2">
|
|
<p className="text-amber-300 font-bold">SAR GROUND-CHANGE STATUS</p>
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full ${catalogEnabled ? 'bg-green-400' : 'bg-red-400'}`} />
|
|
<span className="text-[11px]">
|
|
<span className="text-amber-300 font-bold">Mode A</span> (Catalog):{' '}
|
|
{catalogEnabled ? 'Active' : 'Disabled'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full ${modeBEnabled ? 'bg-green-400' : 'bg-yellow-400'}`} />
|
|
<span className="text-[11px]">
|
|
<span className="text-amber-300 font-bold">Mode B</span> (Anomalies):{' '}
|
|
{modeBEnabled ? 'Active — credentials stored' : 'Not configured'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full ${openclawEnabled ? 'bg-green-400' : 'bg-gray-500'}`} />
|
|
<span className="text-[11px]">
|
|
OpenClaw SAR integration:{' '}
|
|
{openclawEnabled ? 'Enabled' : 'Disabled'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mode B Controls */}
|
|
{modeBEnabled && (
|
|
<div className="mx-4 mt-3 p-3 border border-amber-900/20 bg-amber-950/5 space-y-3">
|
|
<p className="text-[11px] font-mono text-amber-300 font-bold tracking-wide">
|
|
MODE B CREDENTIALS
|
|
</p>
|
|
<p className="text-[11px] font-mono text-[var(--text-muted)]">
|
|
Earthdata credentials are stored server-side in{' '}
|
|
<span className="text-amber-400/80">backend/data/sar_runtime.json</span>.
|
|
Disabling Mode B wipes them from disk.
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleDisable}
|
|
disabled={disabling}
|
|
className="px-3 py-1.5 text-[10px] font-mono font-bold tracking-wide border border-red-500/40 text-red-400 hover:bg-red-500/10 transition disabled:opacity-50"
|
|
>
|
|
{disabling ? 'DISABLING...' : 'REVOKE & DISABLE MODE B'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Setup Guide (when Mode B not active) */}
|
|
{!modeBEnabled && (
|
|
<div className="mx-4 mt-3 p-3 border border-amber-900/20 bg-amber-950/5 space-y-3">
|
|
<p className="text-[11px] font-mono text-amber-300 font-bold tracking-wide">
|
|
ENABLE MODE B
|
|
</p>
|
|
<p className="text-[11px] font-mono text-[var(--text-muted)]">
|
|
Mode B requires a free NASA Earthdata account. To set up:
|
|
</p>
|
|
<ol className="list-decimal list-inside space-y-1 text-[11px] font-mono text-[var(--text-secondary)]">
|
|
<li>
|
|
Register at{' '}
|
|
<a
|
|
href="https://urs.earthdata.nasa.gov/users/new"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-amber-400 underline hover:text-amber-300"
|
|
>
|
|
urs.earthdata.nasa.gov
|
|
</a>
|
|
</li>
|
|
<li>Generate a user token from your Earthdata profile page</li>
|
|
<li>
|
|
Toggle the <span className="text-white">SAR Ground-Change</span> layer ON
|
|
in the left panel — the first-run wizard will prompt for your token
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action feedback */}
|
|
{actionMsg && (
|
|
<div
|
|
className={`mx-4 mt-3 p-2 text-[11px] font-mono border ${
|
|
actionMsg.type === 'ok'
|
|
? 'text-green-400 border-green-500/30 bg-green-950/10'
|
|
: 'text-red-400 border-red-500/30 bg-red-950/10'
|
|
}`}
|
|
>
|
|
{actionMsg.text}
|
|
</div>
|
|
)}
|
|
|
|
{/* Info blurb */}
|
|
<div className="mx-4 mt-3 mb-4 p-3 border border-[var(--border-primary)]/30">
|
|
<p className="text-[11px] font-mono text-[var(--text-muted)] leading-relaxed">
|
|
SAR (Synthetic Aperture Radar) detects ground changes through cloud cover, at
|
|
night, anywhere on Earth. Mode A is a free Sentinel-1 scene catalog from
|
|
Alaska Satellite Facility. Mode B adds real-time anomaly detection via NASA
|
|
OPERA DISP, DSWx, DIST-ALERT, and Copernicus EGMS — all free with an
|
|
Earthdata account.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default SettingsPanel;
|