Files
DECNET/decnet_web/src/components/CanaryTokens/CreateTokenModal.tsx
anti f2b3393669 chore: relicense to AGPL-3.0-or-later and add SPDX headers
Replaces LICENSE (GPLv3 -> AGPLv3) and prepends
`SPDX-License-Identifier: AGPL-3.0-or-later` to every source file
across decnet/, decnet_web/, tests/, scripts/, and tools/.

Rationale: closes the GPLv3 ASP loophole so any party operating a
modified DECNET as a network service must offer their modified
source. Personal copyright (Samuel Paschuan) + inbound=outbound
contributions make a future unilateral relicense infeasible.

- LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt)
- COPYRIGHT: project copyright notice
- tools/add_spdx_headers.py: idempotent header injector
  (shebang- and PEP 263-aware)

Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh).
No behavior change; comments only.
2026-05-22 21:04:16 -04:00

300 lines
11 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import React, { useEffect, useRef, useState } from 'react';
import { X } from '../../icons';
import api from '../../utils/api';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import type { CanaryTokenRow } from '../CanaryTokenDrawer';
import {
KNOWN_GENERATORS, KIND_OPTIONS,
type BlobRow, type DeckyOption, type TopologyOption, type Scope, type GeneratorName,
} from './types';
import { extractError, fmtBytes } from './helpers';
import { INPUT_STYLE, BTN_PRIMARY, BTN_GHOST, Field } from './ui';
interface Props {
blobs: BlobRow[];
deckies: DeckyOption[];
topologies: TopologyOption[];
onClose: () => void;
onCreated: (token: CanaryTokenRow) => void;
}
/** Modal for planting a new canary token. Lets the operator pick
* fleet vs. topology scope, target decky, callback kind, placement
* path, and either a built-in template generator or a previously
* uploaded blob as the artifact source. */
export const CreateTokenModal: React.FC<Props> = ({ blobs, deckies, topologies, onClose, onCreated }) => {
const panelRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(onClose, true);
useFocusTrap(panelRef, true);
const [scope, setScope] = useState<Scope>('fleet');
const [topologyId, setTopologyId] = useState<string>(topologies[0]?.id ?? '');
const [topoDeckies, setTopoDeckies] = useState<DeckyOption[]>([]);
const [topoLoading, setTopoLoading] = useState(false);
// When scope flips to topology (or topology selection changes) we
// hydrate the chosen topology's decky list — different shape than the
// /deckies endpoint, so the picker must repopulate.
useEffect(() => {
if (scope !== 'topology' || !topologyId) {
setTopoDeckies([]);
return;
}
let cancelled = false;
setTopoLoading(true);
api.get(`/topologies/${encodeURIComponent(topologyId)}`)
.then((res) => {
if (cancelled) return;
const list: DeckyOption[] = (res.data?.deckies ?? []).map(
(d: { name: string; ip?: string }) => ({ name: d.name, ip: d.ip }),
);
setTopoDeckies(list);
})
.catch(() => { if (!cancelled) setTopoDeckies([]); })
.finally(() => { if (!cancelled) setTopoLoading(false); });
return () => { cancelled = true; };
}, [scope, topologyId]);
const activeDeckies = scope === 'topology' ? topoDeckies : deckies;
const [decky, setDecky] = useState(deckies[0]?.name ?? '');
// Reset the decky selection when the active list changes — otherwise
// a fleet decky name lingers as a stale value when the user flips to
// a topology that doesn't have that decky.
useEffect(() => {
if (activeDeckies.length === 0) {
setDecky('');
} else if (!activeDeckies.some((d) => d.name === decky)) {
setDecky(activeDeckies[0].name);
}
}, [activeDeckies]); // eslint-disable-line react-hooks/exhaustive-deps
const [kind, setKind] = useState<'http' | 'dns' | 'aws_passive'>('http');
const [path, setPath] = useState('/home/admin/.aws/credentials');
const [source, setSource] = useState<'generator' | 'blob'>('generator');
const [generator, setGenerator] = useState<GeneratorName>('aws_creds');
const [blobUuid, setBlobUuid] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
setError(null);
if (scope === 'topology' && !topologyId) return setError('Pick a topology.');
if (!decky.trim()) return setError('Pick a decky.');
if (!path.trim().startsWith('/')) return setError('placement_path must be absolute.');
if (source === 'blob' && !blobUuid) return setError('Pick a blob or switch to Generator.');
setSubmitting(true);
try {
const body: Record<string, unknown> = {
decky_name: decky.trim(),
kind,
placement_path: path.trim(),
};
if (scope === 'topology') body.topology_id = topologyId;
if (source === 'generator') body.generator = generator;
else body.blob_uuid = blobUuid;
const res = await api.post('/canary/tokens', body);
onCreated(res.data);
} catch (err) {
setError(extractError(err, 'Create failed.'));
} finally {
setSubmitting(false);
}
};
return (
<div
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: 'fixed', inset: 0,
backgroundColor: 'rgba(0,0,0,0.6)',
display: 'flex', justifyContent: 'center', alignItems: 'center',
zIndex: 1000,
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
style={{
width: 'min(560px, 100%)', maxHeight: '90vh', overflowY: 'auto',
backgroundColor: 'var(--bg-color, #0d1117)',
border: '1px solid var(--border-color, #30363d)',
padding: '24px', color: 'var(--text-color)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<div style={{ fontSize: '1rem', fontWeight: 'bold' }}>NEW CANARY TOKEN</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-color)', cursor: 'pointer' }}>
<X size={20} />
</button>
</div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
{(['fleet', 'topology'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
style={{
flex: 1,
padding: '8px',
background: scope === s ? 'var(--accent-color, #00ff88)' : 'transparent',
color: scope === s ? 'var(--bg-color, #0d1117)' : 'var(--text-color)',
border: '1px solid var(--border-color, #30363d)',
cursor: 'pointer', fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
{s === 'fleet' ? 'Fleet' : 'MazeNET topology'}
</button>
))}
</div>
{scope === 'topology' && (
<Field label="Topology">
{topologies.length === 0 ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
No active topologies. Deploy one from MazeNET first.
</div>
) : (
<select
value={topologyId}
onChange={(e) => setTopologyId(e.target.value)}
style={INPUT_STYLE}
>
{topologies.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.status})
</option>
))}
</select>
)}
</Field>
)}
<Field label="Decky">
{topoLoading ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
loading topology deckies
</div>
) : activeDeckies.length === 0 ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
{scope === 'topology'
? 'This topology has no deckies.'
: 'No fleet deckies running. Deploy a fleet first.'}
</div>
) : (
<select
value={decky}
onChange={(e) => setDecky(e.target.value)}
autoFocus
style={INPUT_STYLE}
>
{activeDeckies.map((d) => (
<option key={d.name} value={d.name}>
{d.name}{d.ip ? ` (${d.ip})` : ''}
</option>
))}
</select>
)}
</Field>
<Field label="Kind">
<select
value={kind}
onChange={(e) => setKind(e.target.value as typeof kind)}
style={INPUT_STYLE}
>
{KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Field>
<Field label="Placement path (inside the container)">
<input
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/home/admin/.aws/credentials"
style={{ ...INPUT_STYLE, fontFamily: 'monospace' }}
/>
</Field>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
{(['generator', 'blob'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setSource(s)}
style={{
flex: 1,
padding: '8px',
background: source === s ? 'var(--accent-color, #00ff88)' : 'transparent',
color: source === s ? 'var(--bg-color, #0d1117)' : 'var(--text-color)',
border: '1px solid var(--border-color, #30363d)',
cursor: 'pointer', fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em',
}}
>
{s === 'generator' ? 'Built-in template' : 'Operator upload'}
</button>
))}
</div>
{source === 'generator' && (
<Field label="Generator">
<select
value={generator}
onChange={(e) => setGenerator(e.target.value as GeneratorName)}
style={INPUT_STYLE}
>
{KNOWN_GENERATORS.map((g) => (
<option key={g} value={g}>{g}</option>
))}
</select>
</Field>
)}
{source === 'blob' && (
<Field label="Uploaded artifact">
{blobs.length === 0 ? (
<div style={{ fontSize: '0.8rem', opacity: 0.6, padding: '8px 0' }}>
No blobs uploaded yet. Use "Upload artifact" on the main page first.
</div>
) : (
<select
value={blobUuid}
onChange={(e) => setBlobUuid(e.target.value)}
style={INPUT_STYLE}
>
<option value=""> select </option>
{blobs.map((b) => (
<option key={b.uuid} value={b.uuid}>
{b.filename} ({b.content_type}, {fmtBytes(b.size_bytes)})
</option>
))}
</select>
)}
</Field>
)}
{error && (
<div style={{ color: '#ff5555', fontSize: '0.8rem', marginBottom: '12px' }}>{error}</div>
)}
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '20px' }}>
<button onClick={onClose} style={BTN_GHOST}>CANCEL</button>
<button
onClick={handleSubmit}
disabled={submitting}
style={{ ...BTN_PRIMARY, opacity: submitting ? 0.5 : 1, cursor: submitting ? 'wait' : 'pointer' }}
>
{submitting ? 'PLANTING…' : 'PLANT TOKEN'}
</button>
</div>
</div>
</div>
);
};