mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-06-03 21:08:13 +02:00
2dc1fcc778
What this release does
----------------------
1. Establishes a fresh Tauri updater signing keypair. The previous keypair
(pubkey baked into v0.9.79 / v0.9.8) had no matching private key on
any maintainer-controlled machine — every prior release shipped
without signatures, so auto-update has never actually worked. v0.9.81
rotates to a new pubkey and ships signed installers + latest.json so
every release from here is a one-click upgrade.
2. Fixes the ``admin_session_required`` race in TopRightControls.tsx.
The updateAction state used to default to ``auto_apply`` at React-init
time. A click on the Update button before the async runtime probe
completed went down the auto_apply path (POST /api/system/update),
which throws ``admin_session_required`` on fresh sessions. Desktop
installs now default to ``manual_download`` based on synchronous
``window.__TAURI__`` detection at useState init.
One-time cost for current installs
----------------------------------
Anyone on v0.9.79 or v0.9.8 will see the in-app Update button still
trigger the broken path on their existing install (the fix only takes
effect once they're ON v0.9.81). The MANUAL DOWNLOAD button in the
update dialog opens the GitHub release page, where they grab the .msi
and run it. After that one manual hop, all future updates are seamless.
Release artifacts
-----------------
ShadowBroker_v0.9.81.zip 6.06 MB
42f8a51f9a5690d1e7349d90d8ecf2d163c9061d6cf90c69ee03647a785437ff
ShadowBroker_0.9.81_x64_en-US.msi 122.4 MB
a45b177c26c95d2b28d71592d7147e88ff4e104865f214fde11249d311ec9e25
ShadowBroker_0.9.81_x64-setup.exe 76.5 MB
eca884b9d37eeccd0f11c91dcc6f6ae1b3609d9dee72bd73c37c9a427babfef2
Plus .sig files for the .msi and .exe, plus a signed latest.json for
the Tauri updater endpoint.
Sizes match the v0.9.79 / v0.9.8 reference shape within drift for
the new TopRightControls patch.
release_digests.json keeps v0.9.79 + v0.9.8 blocks alongside v0.9.81
so operators still on those versions continue to validate cleanly
during the rollout transition.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
745 lines
33 KiB
TypeScript
745 lines
33 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { X, ExternalLink, Key, Shield, Radar, Globe, Satellite, Ship, Radio, Bot, Copy, Check, Network } from 'lucide-react';
|
|
|
|
const CURRENT_ONBOARDING_VERSION = '0.9.81-agentic-onboarding-1';
|
|
const STORAGE_KEY = `shadowbroker_onboarding_complete_v${CURRENT_ONBOARDING_VERSION}`;
|
|
const LEGACY_STORAGE_KEY = 'shadowbroker_onboarding_complete';
|
|
|
|
const API_GUIDES = [
|
|
{
|
|
name: 'OpenSky Network',
|
|
icon: <Radar size={14} className="text-cyan-400" />,
|
|
required: true,
|
|
description:
|
|
'Flight tracking with global ADS-B coverage. Provides real-time aircraft positions.',
|
|
steps: [
|
|
'Create a free account at opensky-network.org',
|
|
'Go to Dashboard → OAuth → Create Client',
|
|
'Copy your Client ID and Client Secret',
|
|
'Paste both into Quick Local Setup above or Settings → API Keys',
|
|
],
|
|
url: 'https://opensky-network.org/index.php?option=com_users&view=registration',
|
|
color: 'cyan',
|
|
},
|
|
{
|
|
name: 'AIS Stream',
|
|
icon: <Ship size={14} className="text-blue-400" />,
|
|
required: true,
|
|
description: 'Real-time vessel tracking via AIS (Automatic Identification System).',
|
|
steps: [
|
|
'Register at aisstream.io',
|
|
'Navigate to your API Keys page',
|
|
'Generate a new API key',
|
|
'Paste it into Quick Local Setup above or Settings → API Keys',
|
|
],
|
|
url: 'https://aisstream.io/authenticate',
|
|
color: 'blue',
|
|
},
|
|
];
|
|
|
|
const FREE_SOURCES = [
|
|
{ name: 'ADS-B Exchange', desc: 'Military & general aviation', icon: <Radar size={12} /> },
|
|
{ name: 'USGS Earthquakes', desc: 'Global seismic data', icon: <Globe size={12} /> },
|
|
{ name: 'CelesTrak', desc: '2,000+ satellite orbits', icon: <Satellite size={12} /> },
|
|
{ name: 'GDELT Project', desc: 'Global conflict events', icon: <Globe size={12} /> },
|
|
{ name: 'RainViewer', desc: 'Weather radar overlay', icon: <Globe size={12} /> },
|
|
{ name: 'OpenMHz', desc: 'Radio scanner feeds', icon: <Radio size={12} /> },
|
|
{ name: 'RSS Feeds', desc: 'NPR, BBC, Reuters, AP', icon: <Globe size={12} /> },
|
|
{ name: 'Yahoo Finance', desc: 'Defense stocks & oil', icon: <Globe size={12} /> },
|
|
];
|
|
|
|
interface OnboardingModalProps {
|
|
onClose: () => void;
|
|
onOpenSettings: () => void;
|
|
}
|
|
|
|
const OnboardingModal = React.memo(function OnboardingModal({
|
|
onClose,
|
|
onOpenSettings,
|
|
}: OnboardingModalProps) {
|
|
const [step, setStep] = useState(0);
|
|
const [setupKeys, setSetupKeys] = useState({
|
|
OPENSKY_CLIENT_ID: '',
|
|
OPENSKY_CLIENT_SECRET: '',
|
|
AIS_API_KEY: '',
|
|
});
|
|
const [setupSaving, setSetupSaving] = useState(false);
|
|
const [setupMsg, setSetupMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
|
const [agentSecret, setAgentSecret] = useState('');
|
|
const [agentTier, setAgentTier] = useState<'restricted' | 'full'>('restricted');
|
|
const [agentMode, setAgentMode] = useState<'local' | 'remote'>('local');
|
|
const [agentLoading, setAgentLoading] = useState(false);
|
|
const [agentMsg, setAgentMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
|
const [agentCopied, setAgentCopied] = useState(false);
|
|
const [torStarting, setTorStarting] = useState(false);
|
|
const [torAddress, setTorAddress] = useState('');
|
|
|
|
const handleDismiss = () => {
|
|
localStorage.setItem(STORAGE_KEY, 'true');
|
|
localStorage.setItem(LEGACY_STORAGE_KEY, 'true');
|
|
onClose();
|
|
};
|
|
|
|
const handleOpenSettings = () => {
|
|
localStorage.setItem(STORAGE_KEY, 'true');
|
|
localStorage.setItem(LEGACY_STORAGE_KEY, 'true');
|
|
onClose();
|
|
onOpenSettings();
|
|
};
|
|
|
|
const saveSetupKeys = async () => {
|
|
const payload = Object.fromEntries(
|
|
Object.entries(setupKeys).filter(([, value]) => value.trim()),
|
|
);
|
|
if (!Object.keys(payload).length) {
|
|
setSetupMsg({ type: 'err', text: 'Enter at least one API key first.' });
|
|
return;
|
|
}
|
|
setSetupSaving(true);
|
|
setSetupMsg(null);
|
|
try {
|
|
const res = await fetch('/api/settings/api-keys', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok || data?.ok === false) {
|
|
throw new Error(data?.detail || 'Could not save API keys.');
|
|
}
|
|
setSetupKeys({ OPENSKY_CLIENT_ID: '', OPENSKY_CLIENT_SECRET: '', AIS_API_KEY: '' });
|
|
setSetupMsg({ type: 'ok', text: 'Keys saved locally. Restart or refresh feeds to use them.' });
|
|
} catch (error) {
|
|
setSetupMsg({
|
|
type: 'err',
|
|
text: error instanceof Error ? error.message : 'Could not save API keys.',
|
|
});
|
|
} finally {
|
|
setSetupSaving(false);
|
|
}
|
|
};
|
|
|
|
const agentEndpoint =
|
|
agentMode === 'local'
|
|
? 'http://localhost:8000'
|
|
: torAddress || '<prepare remote .onion link>';
|
|
|
|
const agentSnippet = [
|
|
`SHADOWBROKER_URL=${agentEndpoint}`,
|
|
agentSecret ? `SHADOWBROKER_KEY=${agentSecret}` : 'SHADOWBROKER_KEY=<generate in ShadowBroker>',
|
|
`SHADOWBROKER_ACCESS=${agentTier}`,
|
|
'',
|
|
'# FIRST: load available tools',
|
|
`GET ${agentEndpoint}/api/ai/tools`,
|
|
'',
|
|
'# Auth: HMAC-SHA256 signed requests.',
|
|
'# Restricted = read-only telemetry. Full = can write when asked.',
|
|
].join('\n');
|
|
const remoteAgentNeedsTor = agentMode === 'remote' && !torAddress;
|
|
|
|
// Issue #302 (tg12): the full HMAC secret no longer comes back from
|
|
// GET /api/ai/connect-info. We fetch metadata + the masked fingerprint
|
|
// first; if the operator has explicitly asked to see the key (the
|
|
// ``reveal`` flag), we follow up with POST /api/ai/connect-info/reveal
|
|
// (after a transparent POST /bootstrap if the secret hasn't been
|
|
// minted yet) which carries the secret with strict no-store headers.
|
|
const fetchAgentConnectInfo = async (reveal = true) => {
|
|
setAgentLoading(true);
|
|
setAgentMsg(null);
|
|
try {
|
|
// 1) GET metadata + masked fingerprint.
|
|
const metaRes = await fetch('/api/ai/connect-info');
|
|
const metaData = await metaRes.json().catch(() => ({}));
|
|
if (!metaRes.ok || metaData?.ok === false) {
|
|
throw new Error(metaData?.detail || 'Could not prepare agent credentials.');
|
|
}
|
|
setAgentTier(metaData.access_tier === 'full' ? 'full' : 'restricted');
|
|
|
|
// 2) Mint the secret if it isn't set yet — transparent, idempotent.
|
|
let secretSet = !!metaData.hmac_secret_set;
|
|
if (!secretSet) {
|
|
const bootRes = await fetch('/api/ai/connect-info/bootstrap', {
|
|
method: 'POST',
|
|
});
|
|
const bootData = await bootRes.json().catch(() => ({}));
|
|
if (!bootRes.ok || bootData?.ok === false) {
|
|
throw new Error(bootData?.detail || 'Could not generate agent credentials.');
|
|
}
|
|
secretSet = !!bootData.hmac_secret_set;
|
|
}
|
|
|
|
// 3) If the caller asked to see the secret, fetch it explicitly.
|
|
// Otherwise the masked fingerprint is enough for the UI.
|
|
if (reveal && secretSet) {
|
|
const revealRes = await fetch('/api/ai/connect-info/reveal', {
|
|
method: 'POST',
|
|
});
|
|
const revealData = await revealRes.json().catch(() => ({}));
|
|
if (!revealRes.ok || revealData?.ok === false) {
|
|
throw new Error(revealData?.detail || 'Could not reveal agent credentials.');
|
|
}
|
|
setAgentSecret(revealData.hmac_secret || '');
|
|
} else {
|
|
setAgentSecret(metaData.masked_hmac_secret || '');
|
|
}
|
|
setAgentMsg({ type: 'ok', text: 'Agent key is ready. Copy it into your local or remote agent runtime.' });
|
|
} catch (error) {
|
|
setAgentMsg({
|
|
type: 'err',
|
|
text: error instanceof Error ? error.message : 'Could not prepare agent credentials.',
|
|
});
|
|
} finally {
|
|
setAgentLoading(false);
|
|
}
|
|
};
|
|
|
|
const saveAgentTier = async (tier: 'restricted' | 'full') => {
|
|
setAgentTier(tier);
|
|
setAgentMsg(null);
|
|
try {
|
|
const res = await fetch('/api/ai/connect-info/access-tier', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ tier }),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok || data?.ok === false) {
|
|
throw new Error(data?.detail || 'Could not update agent access tier.');
|
|
}
|
|
setAgentMsg({
|
|
type: 'ok',
|
|
text: tier === 'full'
|
|
? 'Full access saved. The agent can write to the dashboard when authenticated.'
|
|
: 'Restricted access saved. The agent can read telemetry but cannot write.',
|
|
});
|
|
} catch (error) {
|
|
setAgentMsg({
|
|
type: 'err',
|
|
text: error instanceof Error ? error.message : 'Could not update agent access tier.',
|
|
});
|
|
}
|
|
};
|
|
|
|
const prepareTorAgentAddress = async () => {
|
|
setTorStarting(true);
|
|
setAgentMsg(null);
|
|
try {
|
|
const res = await fetch('/api/settings/tor/start', { method: 'POST' });
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok || data?.ok === false || !data?.onion_address) {
|
|
throw new Error(data?.detail || 'Could not start Tor hidden service.');
|
|
}
|
|
setTorAddress(data.onion_address);
|
|
setAgentMsg({
|
|
type: 'ok',
|
|
text: 'Tor is ready. The remote agent link is private to your local ShadowBroker node.',
|
|
});
|
|
} catch (error) {
|
|
setAgentMsg({
|
|
type: 'err',
|
|
text:
|
|
error instanceof Error
|
|
? error.message
|
|
: 'ShadowBroker could not install or start Tor automatically. Check network access and try again.',
|
|
});
|
|
} finally {
|
|
setTorStarting(false);
|
|
}
|
|
};
|
|
|
|
const copyAgentSnippet = async () => {
|
|
if (remoteAgentNeedsTor) {
|
|
setAgentMsg({ type: 'err', text: 'Install Tor and create the remote link first, then copy the agent config.' });
|
|
return;
|
|
}
|
|
await navigator.clipboard.writeText(agentSnippet);
|
|
setAgentCopied(true);
|
|
setTimeout(() => setAgentCopied(false), 1600);
|
|
};
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{/* Backdrop */}
|
|
<motion.div
|
|
key="onboarding-backdrop"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[10000]"
|
|
onClick={handleDismiss}
|
|
/>
|
|
|
|
{/* Modal */}
|
|
<motion.div
|
|
key="onboarding-modal"
|
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
|
className="fixed inset-0 z-[10001] flex items-center justify-center pointer-events-none"
|
|
>
|
|
<div
|
|
className="w-[580px] max-h-[85vh] bg-[var(--bg-secondary)]/98 border border-cyan-900/50 pointer-events-auto flex flex-col overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="p-6 pb-4 border-b border-[var(--border-primary)]/80">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
|
|
<Shield size={20} className="text-cyan-400" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">
|
|
MISSION BRIEFING
|
|
</h2>
|
|
<span className="text-[13px] text-[var(--text-muted)] font-mono tracking-widest">
|
|
FIRST-TIME SETUP
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleDismiss}
|
|
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>
|
|
|
|
{/* Step Indicators */}
|
|
<div className="flex gap-2 px-6 pt-4">
|
|
{['API Keys', 'AI Agent', 'Trust Modes', 'Free Sources'].map((label, i) => (
|
|
<button
|
|
key={label}
|
|
onClick={() => setStep(i)}
|
|
className={`flex-1 py-1.5 text-[13px] font-mono tracking-widest border transition-all ${
|
|
step === i
|
|
? 'border-cyan-500/50 text-cyan-400 bg-cyan-950/20'
|
|
: 'border-[var(--border-primary)] text-[var(--text-muted)] hover:border-[var(--border-secondary)] hover:text-[var(--text-secondary)]'
|
|
}`}
|
|
>
|
|
{label.toUpperCase()}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto styled-scrollbar p-6">
|
|
{step === 2 && (
|
|
<div className="space-y-4">
|
|
<div className="text-center py-4">
|
|
<div className="text-lg font-bold tracking-[0.3em] text-[var(--text-primary)] font-mono mb-2">
|
|
T R U S T <span className="text-cyan-400">M O D E S</span>
|
|
</div>
|
|
<p className="hidden">
|
|
Real-time OSINT dashboard aggregating 12+ live intelligence sources. Flights,
|
|
ships, satellites, earthquakes, conflicts, and more — all on one map.
|
|
</p>
|
|
<p className="text-[11px] text-[var(--text-secondary)] font-mono leading-relaxed max-w-md mx-auto">
|
|
These modes explain what lane the network is using. Set up the API keys first,
|
|
then use this screen to understand public mesh versus private Wormhole paths.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="hidden">
|
|
<div className="flex items-start gap-2">
|
|
<Globe size={14} className="text-green-500 mt-0.5 flex-shrink-0" />
|
|
<div>
|
|
<p className="text-[11px] text-green-400 font-mono font-bold mb-1">
|
|
8 Sources Work Immediately
|
|
</p>
|
|
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
|
Military aircraft, satellites, earthquakes, global conflicts, weather radar,
|
|
radio scanners, news, and market data all work out of the box — no keys
|
|
needed.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-cyan-950/20 border border-cyan-500/20 p-4">
|
|
<div className="flex items-start gap-2">
|
|
<Shield size={14} className="text-cyan-400 mt-0.5 flex-shrink-0" />
|
|
<div>
|
|
<p className="text-[11px] text-cyan-300 font-mono font-bold mb-1">
|
|
TRUST MODES
|
|
</p>
|
|
<div className="space-y-1 text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
|
<div>
|
|
<span className="text-orange-300">PUBLIC / DEGRADED</span> — Meshtastic,
|
|
APRS, and perimeter feeds. Observable and linkable.
|
|
</div>
|
|
<div>
|
|
<span className="text-yellow-300">PRIVATE / TRANSITIONAL</span> —
|
|
Wormhole lane is active. Gate chat runs on this lane, but metadata resistance is reduced until Reticulum is ready.
|
|
</div>
|
|
<div>
|
|
<span className="text-green-300">PRIVATE / STRONG</span> — Wormhole and
|
|
Reticulum are both ready. Dead Drop / DM requires this tier for the strongest privacy posture.
|
|
</div>
|
|
</div>
|
|
<p className="mt-2 text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
|
Public mesh is not private just because Wormhole exists. Use Wormhole when
|
|
you want the private lane, and treat public mesh as public.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step === 1 && (
|
|
<div className="space-y-5">
|
|
<div>
|
|
<p className="text-[11px] text-violet-300 font-mono font-bold tracking-widest mb-2">
|
|
STEP 1 - WHERE IS YOUR AGENT?
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
onClick={() => setAgentMode('local')}
|
|
className={`border px-4 py-3 text-left transition-all ${
|
|
agentMode === 'local'
|
|
? 'border-cyan-500/50 bg-cyan-950/40'
|
|
: 'border-[var(--border-primary)] hover:border-cyan-500/30'
|
|
}`}
|
|
>
|
|
<p className={`text-sm font-mono font-bold ${agentMode === 'local' ? 'text-cyan-300' : 'text-[var(--text-secondary)]'}`}>
|
|
Local
|
|
</p>
|
|
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-1">
|
|
Same machine as ShadowBroker
|
|
</p>
|
|
</button>
|
|
<button
|
|
onClick={() => setAgentMode('remote')}
|
|
className={`border px-4 py-3 text-left transition-all ${
|
|
agentMode === 'remote'
|
|
? 'border-violet-500/50 bg-violet-950/40'
|
|
: 'border-[var(--border-primary)] hover:border-violet-500/30'
|
|
}`}
|
|
>
|
|
<p className={`text-sm font-mono font-bold ${agentMode === 'remote' ? 'text-violet-300' : 'text-[var(--text-secondary)]'}`}>
|
|
Remote
|
|
</p>
|
|
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-1">
|
|
Different machine over Tor
|
|
</p>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-[11px] text-violet-300 font-mono font-bold tracking-widest mb-2">
|
|
STEP 2 - WHAT CAN IT DO?
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
onClick={() => void saveAgentTier('restricted')}
|
|
className={`border px-4 py-3 text-left transition-all ${
|
|
agentTier === 'restricted'
|
|
? 'border-green-500/50 bg-green-950/30'
|
|
: 'border-[var(--border-primary)] hover:border-green-500/30'
|
|
}`}
|
|
>
|
|
<p className="text-sm text-green-300 font-mono font-bold flex items-center gap-2">
|
|
<Shield size={14} /> Read Only
|
|
</p>
|
|
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-2">
|
|
Can see live telemetry but cannot change anything
|
|
</p>
|
|
</button>
|
|
<button
|
|
onClick={() => void saveAgentTier('full')}
|
|
className={`border px-4 py-3 text-left transition-all ${
|
|
agentTier === 'full'
|
|
? 'border-amber-500/50 bg-amber-950/30'
|
|
: 'border-[var(--border-primary)] hover:border-amber-500/30'
|
|
}`}
|
|
>
|
|
<p className="text-sm text-amber-300 font-mono font-bold flex items-center gap-2">
|
|
<Network size={14} /> Full Access
|
|
</p>
|
|
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-2">
|
|
Can place pins, create layers, and trigger display actions
|
|
</p>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between gap-3 mb-2">
|
|
<div>
|
|
<p className="text-[11px] text-violet-300 font-mono font-bold tracking-widest">
|
|
STEP 3 - COPY THIS INTO YOUR AGENT
|
|
</p>
|
|
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-1">
|
|
Generate a local key, then copy these variables into OpenClaw, Hermes, or another HMAC agent.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => void fetchAgentConnectInfo(true)}
|
|
disabled={agentLoading}
|
|
className="px-3 py-2 border border-violet-500/40 text-violet-300 hover:bg-violet-500/10 disabled:opacity-50 text-[11px] font-mono tracking-widest"
|
|
>
|
|
{agentLoading ? 'GENERATING...' : 'GENERATE'}
|
|
</button>
|
|
</div>
|
|
|
|
{remoteAgentNeedsTor && (
|
|
<div className="mb-2 border border-violet-500/30 bg-violet-950/20 p-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] text-violet-200 font-mono font-bold tracking-widest">
|
|
TOR REQUIRED FOR REMOTE AGENTS
|
|
</p>
|
|
<p className="text-[10px] text-[var(--text-muted)] font-mono mt-1 leading-relaxed">
|
|
ShadowBroker will install or use Tor locally, then create a private .onion link for this backend.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => void prepareTorAgentAddress()}
|
|
disabled={torStarting}
|
|
className="shrink-0 px-3 py-2 border border-violet-500/40 text-violet-200 hover:bg-violet-500/10 disabled:opacity-50 text-[10px] font-mono tracking-widest flex items-center gap-2"
|
|
>
|
|
<Network size={13} />
|
|
{torStarting ? 'INSTALLING...' : 'INSTALL TOR'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="relative">
|
|
<pre className="min-h-40 max-h-56 overflow-auto styled-scrollbar bg-[var(--bg-primary)] border border-violet-500/30 p-4 pr-24 text-[12px] text-violet-100 font-mono whitespace-pre-wrap leading-relaxed">
|
|
{agentSnippet}
|
|
</pre>
|
|
<button
|
|
onClick={() => void copyAgentSnippet()}
|
|
disabled={remoteAgentNeedsTor}
|
|
className="absolute top-3 right-3 px-3 py-2 border border-violet-500/50 bg-violet-950/50 text-violet-200 hover:bg-violet-800/30 disabled:opacity-45 disabled:hover:bg-violet-950/50 text-[11px] font-mono tracking-widest flex items-center gap-2"
|
|
>
|
|
{agentCopied ? <Check size={13} /> : <Copy size={13} />}
|
|
{agentCopied ? 'COPIED' : 'COPY'}
|
|
</button>
|
|
</div>
|
|
|
|
{agentMsg && (
|
|
<p
|
|
className={`mt-2 text-sm font-mono ${
|
|
agentMsg.type === 'ok' ? 'text-green-300' : 'text-red-300'
|
|
}`}
|
|
>
|
|
{agentMsg.text}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-[11px] text-orange-300/80 font-mono leading-relaxed">
|
|
Remote agent access uses the signed HTTP API over Tor. Wormhole uses the same Tor/Arti transport lane when it is available; MLS-native agent transport is still planned.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{step === 0 && (
|
|
<div className="space-y-4">
|
|
<div className="bg-yellow-950/20 border border-yellow-500/20 p-4">
|
|
<div className="flex items-start gap-2">
|
|
<Key size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
|
<div>
|
|
<p className="text-[11px] text-yellow-400 font-mono font-bold mb-1">
|
|
START HERE
|
|
</p>
|
|
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
|
OpenSky Network and AIS Stream are the free keys that make ShadowBroker
|
|
useful immediately: live aircraft and vessel tracking. Paste them below or
|
|
use Settings later; secrets stay on the local backend.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border border-cyan-900/40 bg-cyan-950/10 p-4 space-y-3">
|
|
<div>
|
|
<p className="text-[11px] text-cyan-300 font-mono font-bold tracking-widest">
|
|
QUICK LOCAL SETUP
|
|
</p>
|
|
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed mt-1">
|
|
Paste keys here once. ShadowBroker stores them server-side only and never
|
|
displays the secret back in the browser.
|
|
</p>
|
|
</div>
|
|
{[
|
|
['OPENSKY_CLIENT_ID', 'OpenSky Client ID'],
|
|
['OPENSKY_CLIENT_SECRET', 'OpenSky Client Secret'],
|
|
['AIS_API_KEY', 'AIS Stream API Key'],
|
|
].map(([key, label]) => (
|
|
<input
|
|
key={key}
|
|
type="password"
|
|
value={setupKeys[key as keyof typeof setupKeys]}
|
|
onChange={(event) =>
|
|
setSetupKeys((prev) => ({ ...prev, [key]: event.target.value }))
|
|
}
|
|
placeholder={label}
|
|
className="w-full bg-[var(--bg-primary)] border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-primary)] font-mono outline-none focus:border-cyan-500/70 placeholder:text-[var(--text-muted)]/60"
|
|
autoComplete="off"
|
|
/>
|
|
))}
|
|
{setupMsg && (
|
|
<p
|
|
className={`text-sm font-mono ${
|
|
setupMsg.type === 'ok' ? 'text-green-300' : 'text-red-300'
|
|
}`}
|
|
>
|
|
{setupMsg.text}
|
|
</p>
|
|
)}
|
|
<button
|
|
onClick={() => void saveSetupKeys()}
|
|
disabled={setupSaving}
|
|
className="w-full py-2 bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-[11px] font-mono tracking-widest"
|
|
>
|
|
{setupSaving ? 'SAVING...' : 'SAVE KEYS LOCALLY'}
|
|
</button>
|
|
</div>
|
|
|
|
{API_GUIDES.map((api) => (
|
|
<div
|
|
key={api.name}
|
|
className={`border border-${api.color}-900/30 bg-${api.color}-950/10 p-4`}
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
{api.icon}
|
|
<span className="text-xs font-mono text-white font-bold">{api.name}</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">
|
|
REQUIRED
|
|
</span>
|
|
</div>
|
|
<a
|
|
href={api.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={`text-sm font-mono text-${api.color}-400 hover:text-${api.color}-300 flex items-center gap-1 transition-colors`}
|
|
>
|
|
GET KEY <ExternalLink size={10} />
|
|
</a>
|
|
</div>
|
|
<p className="text-sm text-[var(--text-secondary)] font-mono mb-3">
|
|
{api.description}
|
|
</p>
|
|
<ol className="space-y-1.5">
|
|
{api.steps.map((s, i) => (
|
|
<li key={i} className="flex items-start gap-2">
|
|
<span
|
|
className={`text-[13px] font-mono text-${api.color}-500 font-bold mt-0.5 w-3 flex-shrink-0`}
|
|
>
|
|
{i + 1}.
|
|
</span>
|
|
<span className="text-sm text-gray-300 font-mono">{s}</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
))}
|
|
|
|
<button
|
|
onClick={handleOpenSettings}
|
|
className="w-full py-3 bg-cyan-500/10 border border-cyan-500/30 text-cyan-400 hover:bg-cyan-500/20 transition-colors text-[11px] font-mono tracking-widest flex items-center justify-center gap-2"
|
|
>
|
|
<Key size={14} />
|
|
OPEN SETTINGS TO ENTER KEYS
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{step === 3 && (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-[var(--text-secondary)] font-mono mb-3">
|
|
These data sources are completely free and require no API keys. They activate
|
|
automatically on launch, while OpenSky and AIS Stream unlock the richer live
|
|
aviation and maritime experience.
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{FREE_SOURCES.map((src) => (
|
|
<div
|
|
key={src.name}
|
|
className="border border-[var(--border-primary)]/60 bg-[var(--bg-secondary)]/30 p-3 hover:border-[var(--border-secondary)] transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-green-500">{src.icon}</span>
|
|
<span className="text-sm font-mono text-[var(--text-primary)] font-medium">
|
|
{src.name}
|
|
</span>
|
|
</div>
|
|
<p className="text-[13px] text-[var(--text-muted)] font-mono">{src.desc}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 border-t border-[var(--border-primary)]/80 flex items-center justify-between">
|
|
<button
|
|
onClick={() => setStep(Math.max(0, step - 1))}
|
|
className={`px-4 py-2 border text-sm font-mono tracking-widest transition-all ${
|
|
step === 0
|
|
? 'border-[var(--border-primary)] text-[var(--text-muted)] cursor-not-allowed'
|
|
: 'border-[var(--border-primary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)]'
|
|
}`}
|
|
disabled={step === 0}
|
|
>
|
|
PREV
|
|
</button>
|
|
|
|
<div className="flex gap-1.5">
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<div
|
|
key={i}
|
|
className={`w-1.5 h-1.5 rounded-full transition-colors ${step === i ? 'bg-cyan-400' : 'bg-[var(--border-primary)]'}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{step < 3 ? (
|
|
<button
|
|
onClick={() => setStep(step + 1)}
|
|
className="px-4 py-2 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/10 text-sm font-mono tracking-widest transition-all"
|
|
>
|
|
NEXT
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleDismiss}
|
|
className="px-4 py-2 bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 text-sm font-mono tracking-widest transition-all"
|
|
>
|
|
LAUNCH
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
});
|
|
|
|
export function useOnboarding() {
|
|
const [showOnboarding, setShowOnboarding] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const done = localStorage.getItem(STORAGE_KEY);
|
|
if (!done) {
|
|
setShowOnboarding(true);
|
|
}
|
|
}, []);
|
|
|
|
return { showOnboarding, setShowOnboarding };
|
|
}
|
|
|
|
export default OnboardingModal;
|