Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
234 lines
8.4 KiB
TypeScript
234 lines
8.4 KiB
TypeScript
import { useEffect, useState, type ReactNode } from 'react';
|
|
import type { PublicBuildDTO, PublicBuildsResponse } from '../types';
|
|
import {
|
|
AETHERFORGE_CLIENT_HEADER,
|
|
AETHERFORGE_CLIENT_VALUE,
|
|
authHeaders,
|
|
clearStoredAuth,
|
|
consumeAuthExpiredFlag,
|
|
encodeBasicToken,
|
|
getStoredAuth,
|
|
setStoredAuth,
|
|
} from '../api/auth';
|
|
import { useSound } from '../context/SoundContext';
|
|
import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
|
|
import DocsEntryCard from './DocsEntryCard';
|
|
|
|
export default function SessionGate({ children }: { children: ReactNode }) {
|
|
const { play } = useSound();
|
|
const [ready, setReady] = useState(false);
|
|
const [authed, setAuthed] = useState(!!getStoredAuth());
|
|
const [degraded, setDegraded] = useState(false);
|
|
const [user, setUser] = useState('');
|
|
const [pass, setPass] = useState('');
|
|
const [err, setErr] = useState('');
|
|
const [sessionExpired, setSessionExpired] = useState(false);
|
|
const [publicOpen, setPublicOpen] = useState(false);
|
|
const [publicBuilds, setPublicBuilds] = useState<PublicBuildDTO[]>([]);
|
|
const [publicBuildsEnabled, setPublicBuildsEnabled] = useState(false);
|
|
const [publicLatestN, setPublicLatestN] = useState(3);
|
|
const [publicLoading, setPublicLoading] = useState(false);
|
|
const [publicErr, setPublicErr] = useState('');
|
|
|
|
useEffect(() => {
|
|
const sync = () => {
|
|
const hasAuth = !!getStoredAuth();
|
|
setAuthed(hasAuth);
|
|
if (!hasAuth) {
|
|
setSessionExpired(consumeAuthExpiredFlag());
|
|
}
|
|
};
|
|
window.addEventListener('aetherforge-auth', sync);
|
|
return () => window.removeEventListener('aetherforge-auth', sync);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const token = getStoredAuth();
|
|
if (!token) {
|
|
setAuthed(false);
|
|
setSessionExpired(consumeAuthExpiredFlag());
|
|
setReady(true);
|
|
return;
|
|
}
|
|
fetch('/api/v1/config', { headers: authHeaders() })
|
|
.then((r) => {
|
|
if (r.status === 401) {
|
|
clearStoredAuth({ silent: true, expired: true });
|
|
setAuthed(false);
|
|
setSessionExpired(true);
|
|
} else if (!r.ok) {
|
|
// Server reachable but unhappy — keep saved credentials (degraded mode).
|
|
setAuthed(true);
|
|
setDegraded(true);
|
|
} else {
|
|
setAuthed(true);
|
|
setDegraded(false);
|
|
}
|
|
setReady(true);
|
|
})
|
|
.catch(() => {
|
|
// Network blip — trust stored credentials until the server responds.
|
|
setAuthed(true);
|
|
setDegraded(true);
|
|
setReady(true);
|
|
});
|
|
}, []);
|
|
|
|
const handleLogin = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setErr('');
|
|
setSessionExpired(false);
|
|
const headers: Record<string, string> = {
|
|
[AETHERFORGE_CLIENT_HEADER]: AETHERFORGE_CLIENT_VALUE,
|
|
Authorization: `Basic ${encodeBasicToken(user, pass)}`,
|
|
};
|
|
try {
|
|
const res = await fetch('/api/v1/config', { headers });
|
|
if (!res.ok) {
|
|
setErr('Login failed — check username and password.');
|
|
play('error');
|
|
return;
|
|
}
|
|
setStoredAuth(user, pass);
|
|
setAuthed(true);
|
|
setDegraded(false);
|
|
play('success');
|
|
} catch {
|
|
setErr('Cannot reach server — check that miner-server is running.');
|
|
}
|
|
};
|
|
|
|
if (!ready) {
|
|
return (
|
|
<div className="session-gate">
|
|
<div className="session-gate-sacred-ring" aria-hidden>
|
|
<FlowerOfLifeWatermark opacity={0.5} />
|
|
</div>
|
|
<p className="font-tech">Starting AetherForge…</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const loadPublicBuilds = async () => {
|
|
setPublicLoading(true);
|
|
setPublicErr('');
|
|
try {
|
|
const res = await fetch('/api/v1/public/builds');
|
|
if (!res.ok) throw new Error('unavailable');
|
|
const data = (await res.json()) as PublicBuildsResponse;
|
|
setPublicBuilds(data.builds ?? []);
|
|
setPublicBuildsEnabled(!!data.public_builds_enabled);
|
|
setPublicLatestN(data.latest_n ?? 3);
|
|
setPublicOpen(true);
|
|
} catch {
|
|
setPublicErr('Public builds are not available yet — forge an installer first.');
|
|
setPublicOpen(true);
|
|
} finally {
|
|
setPublicLoading(false);
|
|
}
|
|
};
|
|
|
|
if (!authed) {
|
|
return (
|
|
<div className="session-gate">
|
|
<div className="session-gate-sacred-ring" aria-hidden>
|
|
<FlowerOfLifeWatermark opacity={0.5} />
|
|
</div>
|
|
<div className="session-gate-keys" aria-hidden>
|
|
<div className="session-gate-key session-gate-key--tl">
|
|
<KnowledgeKey opacity={0.55} />
|
|
</div>
|
|
<div className="session-gate-key session-gate-key--br">
|
|
<KnowledgeKey opacity={0.45} />
|
|
</div>
|
|
</div>
|
|
<form className="session-gate-card card" onSubmit={handleLogin}>
|
|
<h1 className="font-display">AetherForge</h1>
|
|
<p className="form-hint">Sign in to open the command deck.</p>
|
|
{sessionExpired && (
|
|
<p className="form-hint" style={{ color: 'var(--accent-red)' }}>
|
|
Your session expired — please sign in again.
|
|
</p>
|
|
)}
|
|
<label className="label" htmlFor="session-user">Username</label>
|
|
<input id="session-user" className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
|
|
<label className="label" htmlFor="session-pass">Password</label>
|
|
<input
|
|
id="session-pass"
|
|
className="input"
|
|
type="password"
|
|
value={pass}
|
|
onChange={(e) => setPass(e.target.value)}
|
|
autoComplete="current-password"
|
|
/>
|
|
{err && <p className="form-hint" style={{ color: 'var(--accent-red)' }}>{err}</p>}
|
|
<button type="submit" className="btn btn-primary btn-lg">
|
|
Enter Command Deck
|
|
</button>
|
|
<p className="session-gate-whisper" aria-hidden>
|
|
ψ · the deck remembers every key
|
|
</p>
|
|
<DocsEntryCard variant="featured" />
|
|
<div className="session-public-drawer" style={{ marginTop: '1rem', width: '100%' }}>
|
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline btn-sm"
|
|
style={{ flex: 1, minWidth: '10rem' }}
|
|
onClick={() => void loadPublicBuilds()}
|
|
disabled={publicLoading}
|
|
>
|
|
{publicLoading ? 'Loading…' : 'Public builds (no login)'}
|
|
</button>
|
|
<a
|
|
href="/spread/"
|
|
className="btn btn-outline btn-sm"
|
|
style={{ flex: 1, minWidth: '10rem', textAlign: 'center' }}
|
|
>
|
|
Spread Kit
|
|
</a>
|
|
</div>
|
|
{publicOpen && (
|
|
<div className="card" style={{ marginTop: '0.75rem', textAlign: 'left' }}>
|
|
<p className="form-hint" style={{ marginTop: 0 }}>
|
|
{publicBuildsEnabled
|
|
? 'All forged installers exposed — no credentials required.'
|
|
: `Pinned + marked-public + latest ${publicLatestN} forged installers — no credentials required.`}
|
|
</p>
|
|
{publicErr && <p className="form-hint" style={{ color: 'var(--accent-red)' }}>{publicErr}</p>}
|
|
{publicBuilds.length === 0 && !publicErr && (
|
|
<p className="form-hint">No public builds yet.</p>
|
|
)}
|
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
|
{publicBuilds.map((b) => (
|
|
<li key={b.id} style={{ marginBottom: '0.5rem', fontSize: '0.85rem' }}>
|
|
<strong>{b.worker_name}</strong>
|
|
<span className="form-hint"> · {b.platform}</span>
|
|
{b.pinned && <span> 📌</span>}
|
|
<br />
|
|
<a href={b.download_url} className="mono" style={{ fontSize: '0.75rem' }}>
|
|
Download
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{degraded && (
|
|
<div className="session-degraded-banner" role="status">
|
|
Cannot reach server — using saved credentials. Some data may be stale until connectivity returns.
|
|
</div>
|
|
)}
|
|
{children}
|
|
</>
|
|
);
|
|
}
|