mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
feat(admin): serve Operator Console SPA at /admin (rust-embed)
Preact + Vite standalone admin-ui bundled into the server binary via rust-embed. Login is NIP-07 (Smirk wallet signs the NIP-98 admin challenge); tabs cover instance status, admin keys, invite codes, and the effective-config editor (live vs restart-required per field). admin-ui/dist is committed because rust-embed reads it at compile time; node_modules is gitignored (npm ci reproduces it). A lib test asserts index.html and the /admin/ asset base are embedded so an unbuilt console fails the build instead of an operator. The console is served on the admin plane only (loopback bind), behind the existing NIP-98 admin_guard + MAC allowlist.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Node deps — reproducible from package-lock.json (`npm ci`).
|
||||
node_modules/
|
||||
|
||||
# NOTE: dist/ is intentionally NOT ignored. rust-embed bakes admin-ui/dist/ into
|
||||
# the server binary at compile time, so the built console is committed. Rebuild it
|
||||
# with `npm run build` in this directory whenever src/ changes (CI checks freshness).
|
||||
+5
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
||||
:root{--smirk-bg: #2c1e12;--smirk-bg-elevated: #3a2a1b;--smirk-bg-sunken: #241810;--smirk-fg: #f3e9dd;--smirk-fg-muted: #b9a891;--smirk-accent: #f5c542;--smirk-accent-fg: #2c1e12;--smirk-border: rgba(255, 255, 255, .12);--smirk-border-strong: rgba(255, 255, 255, .22);--smirk-danger: #ef4444;--smirk-ok: #22c55e;--smirk-warn: #f59e0b;--smirk-radius: 10px;--smirk-font: system-ui, -apple-system, Segoe UI, Roboto, sans-serif}*{box-sizing:border-box}body{margin:0;background:var(--smirk-bg);color:var(--smirk-fg);font-family:var(--smirk-font);font-size:14px;line-height:1.5}h1,h2,h3{font-weight:700}button{font:inherit;cursor:pointer;border-radius:8px;border:1px solid var(--smirk-border);background:var(--smirk-bg-elevated);color:inherit;padding:8px 14px}button.primary{background:var(--smirk-accent);color:var(--smirk-accent-fg);border:none;font-weight:700}button.danger{color:var(--smirk-danger);border-color:var(--smirk-danger)}button:disabled{opacity:.5;cursor:default}input,textarea,select{font:inherit;color:inherit;background:var(--smirk-bg-sunken);border:1px solid var(--smirk-border);border-radius:8px;padding:8px 10px}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}a{color:var(--smirk-accent)}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Smirk Operator Console</title>
|
||||
<script type="module" crossorigin src="/admin/assets/index-DLOlKNyc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-uKG4VNAS.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Smirk Operator Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1023
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "smirk-admin-ui",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"description": "Operator console SPA, embedded into smirk-backend-core and served at /admin.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"preact": "^10.24.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Fetch layer for the /admin JSON API. Same-origin (the SPA is served from the admin
|
||||
// plane), bearer access token held in memory only (never localStorage). Admin routes
|
||||
// are outside the public OpenAPI, so this is hand-written.
|
||||
|
||||
let accessToken: string | null = null;
|
||||
|
||||
export function setToken(t: string | null): void {
|
||||
accessToken = t;
|
||||
}
|
||||
export function hasToken(): boolean {
|
||||
return accessToken !== null;
|
||||
}
|
||||
|
||||
async function req(method: string, path: string, body?: unknown): Promise<unknown> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
const data: unknown = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
(data as { error?: string; message?: string })?.error ??
|
||||
(data as { message?: string })?.message ??
|
||||
`${res.status} ${res.statusText}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface ChallengeResp {
|
||||
challenge: string;
|
||||
url: string;
|
||||
instance_id: string;
|
||||
expires_in: number;
|
||||
}
|
||||
export interface TokenResp {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
}
|
||||
export interface KeyRow {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
status: string;
|
||||
}
|
||||
export interface InviteView {
|
||||
code_prefix: string;
|
||||
label: string | null;
|
||||
created_at: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
challenge: () => req('GET', '/admin/auth/challenge') as Promise<ChallengeResp>,
|
||||
verify: (admin_token: string, challenge: string) =>
|
||||
req('POST', '/admin/auth/verify', { admin_token, challenge }) as Promise<TokenResp>,
|
||||
logout: () => req('POST', '/admin/auth/logout', {}),
|
||||
me: () => req('GET', '/admin/me'),
|
||||
features: () => req('GET', '/admin/features'),
|
||||
keys: () => req('GET', '/admin/keys') as Promise<KeyRow[]>,
|
||||
addKey: (pubkey: string, label?: string) =>
|
||||
req('POST', '/admin/keys', label ? { pubkey, label } : { pubkey }),
|
||||
revokeKey: (id: string) => req('DELETE', `/admin/keys/${id}`),
|
||||
config: () => req('GET', '/admin/config'),
|
||||
putConfig: (patch: unknown, expected_versions: Record<string, number>) =>
|
||||
req('PUT', '/admin/config', { patch, expected_versions }),
|
||||
invites: () =>
|
||||
req('GET', '/admin/invites') as Promise<{ invites: InviteView[]; unused_count: number }>,
|
||||
mintInvites: (count: number, label?: string) =>
|
||||
req('POST', '/admin/invites', label ? { count, label } : { count }) as Promise<{
|
||||
codes: string[];
|
||||
}>,
|
||||
};
|
||||
@@ -0,0 +1,363 @@
|
||||
import { render } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import './tokens.css';
|
||||
import { api, setToken, type KeyRow, type InviteView } from './api';
|
||||
import { buildAdminToken, nip07 } from './nip98';
|
||||
|
||||
// ── login ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Login({ onDone }: { onDone: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const signIn = async () => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const signer = nip07();
|
||||
if (!signer) {
|
||||
throw new Error(
|
||||
'No NIP-07 signer found. Enable your Smirk wallet (or nos2x/Alby) on this origin, then reload.',
|
||||
);
|
||||
}
|
||||
const c = await api.challenge();
|
||||
const token = await buildAdminToken(c, signer);
|
||||
const { access_token } = await api.verify(token, c.challenge);
|
||||
setToken(access_token);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Sign-in failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 420, margin: '12vh auto', padding: 24, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 40 }}>🐸</div>
|
||||
<h1>Smirk Operator Console</h1>
|
||||
<p style={{ color: 'var(--smirk-fg-muted)' }}>
|
||||
Sign in with an admin Nostr key on your instance's allowlist.
|
||||
</p>
|
||||
<button class="primary" style={{ padding: '12px 20px', fontSize: 16 }} disabled={busy} onClick={signIn}>
|
||||
{busy ? 'Signing in…' : 'Sign in with Smirk'}
|
||||
</button>
|
||||
{err && <p style={{ color: 'var(--smirk-danger)', marginTop: 16 }}>{err}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── shared ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Section({ title, children }: { title: string; children: unknown }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--smirk-bg-elevated)',
|
||||
border: '1px solid var(--smirk-border)',
|
||||
borderRadius: 'var(--smirk-radius)',
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const reload = () => {
|
||||
setErr(null);
|
||||
fn()
|
||||
.then(setData)
|
||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)));
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(reload, deps);
|
||||
return { data, err, reload };
|
||||
}
|
||||
|
||||
// ── status ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusTab() {
|
||||
const { data, err } = useAsync(() => api.features());
|
||||
return (
|
||||
<Section title="Instance features">
|
||||
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
|
||||
<pre style={{ overflow: 'auto', fontSize: 12 }}>{JSON.stringify(data, null, 2)}</pre>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── admin keys ────────────────────────────────────────────────────────────────
|
||||
|
||||
function KeysTab() {
|
||||
const { data, err, reload } = useAsync(() => api.keys());
|
||||
const [pubkey, setPubkey] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const add = async () => {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.addKey(pubkey.trim().toLowerCase());
|
||||
setPubkey('');
|
||||
reload();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const revoke = async (id: string) => {
|
||||
if (!confirm('Revoke this admin key?')) return;
|
||||
await api.revokeKey(id);
|
||||
reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Admin keys">
|
||||
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<tbody>
|
||||
{(data ?? []).map((k: KeyRow) => (
|
||||
<tr style={{ borderTop: '1px solid var(--smirk-border)' }}>
|
||||
<td style={{ fontFamily: 'monospace', padding: '6px 4px' }}>{k.pubkey.slice(0, 16)}…</td>
|
||||
<td style={{ padding: '6px 4px' }}>{k.status}</td>
|
||||
<td style={{ padding: '6px 4px', textAlign: 'right' }}>
|
||||
{k.status !== 'revoked' && (
|
||||
<button class="danger" onClick={() => revoke(k.id)}>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<input
|
||||
style={{ flex: 1 }}
|
||||
placeholder="new admin pubkey (64 hex)"
|
||||
value={pubkey}
|
||||
onInput={(e) => setPubkey((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button class="primary" disabled={busy || pubkey.trim().length !== 64} onClick={add}>
|
||||
Add key
|
||||
</button>
|
||||
</div>
|
||||
{msg && <p style={{ color: 'var(--smirk-danger)' }}>{msg}</p>}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── invites ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function InvitesTab() {
|
||||
const { data, err, reload } = useAsync(() => api.invites());
|
||||
const [count, setCount] = useState(5);
|
||||
const [label, setLabel] = useState('');
|
||||
const [minted, setMinted] = useState<string[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const mint = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.mintInvites(count, label.trim() || undefined);
|
||||
setMinted(r.codes);
|
||||
reload();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title={`Invite codes${data ? ` — ${data.unused_count} unused` : ''}`}>
|
||||
{err && <p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
style={{ width: 80 }}
|
||||
value={count}
|
||||
onInput={(e) => setCount(Number((e.target as HTMLInputElement).value))}
|
||||
/>
|
||||
<input
|
||||
style={{ flex: 1 }}
|
||||
placeholder="label (optional)"
|
||||
value={label}
|
||||
onInput={(e) => setLabel((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<button class="primary" disabled={busy} onClick={mint}>
|
||||
Mint
|
||||
</button>
|
||||
</div>
|
||||
{minted && (
|
||||
<div style={{ background: 'var(--smirk-bg-sunken)', padding: 12, borderRadius: 8, marginBottom: 12 }}>
|
||||
<b>New codes — copy now, they are shown once:</b>
|
||||
<pre style={{ margin: '6px 0 0', whiteSpace: 'pre-wrap' }}>{minted.join('\n')}</pre>
|
||||
</div>
|
||||
)}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<tbody>
|
||||
{(data?.invites ?? []).map((i: InviteView) => (
|
||||
<tr style={{ borderTop: '1px solid var(--smirk-border)' }}>
|
||||
<td style={{ fontFamily: 'monospace', padding: '6px 4px' }}>{i.code_prefix}…</td>
|
||||
<td style={{ padding: '6px 4px' }}>{i.label ?? ''}</td>
|
||||
<td style={{ padding: '6px 4px', color: 'var(--smirk-fg-muted)' }}>{i.created_at.slice(0, 10)}</td>
|
||||
<td style={{ padding: '6px 4px' }}>{i.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Overlay = Record<string, Record<string, unknown>>;
|
||||
interface ConfigResp {
|
||||
effective: Overlay;
|
||||
overlay: Overlay;
|
||||
versions: Record<string, number>;
|
||||
runtime_class: Record<string, string>;
|
||||
restart_pending: boolean;
|
||||
}
|
||||
|
||||
function ConfigTab() {
|
||||
const { data, err, reload } = useAsync(() => api.config() as Promise<ConfigResp>);
|
||||
const [edit, setEdit] = useState<Overlay>({});
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setEdit(structuredClone(data.effective));
|
||||
}, [data]);
|
||||
|
||||
if (err) return <Section title="Config">{<p style={{ color: 'var(--smirk-danger)' }}>{err}</p>}</Section>;
|
||||
if (!data) return <Section title="Config">Loading…</Section>;
|
||||
|
||||
const setField = (section: string, field: string, value: unknown) =>
|
||||
setEdit((e) => ({ ...e, [section]: { ...(e[section] ?? {}), [field]: value } }));
|
||||
|
||||
const save = async (section: string) => {
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.putConfig(
|
||||
{ [section]: edit[section] },
|
||||
{ [section]: data.versions[section] ?? 0 },
|
||||
);
|
||||
setMsg(`Saved ${section}.`);
|
||||
reload();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.restart_pending && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(245,158,11,0.12)',
|
||||
border: '1px solid var(--smirk-warn)',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
⚠ A saved change needs a restart to take effect (systemctl restart, or auto mode).
|
||||
</div>
|
||||
)}
|
||||
{msg && <p style={{ color: 'var(--smirk-accent)' }}>{msg}</p>}
|
||||
{Object.keys(edit)
|
||||
.sort()
|
||||
.map((section) => (
|
||||
<Section title={section}>
|
||||
{Object.entries(edit[section] ?? {}).map(([field, value]) => {
|
||||
const cls = data.runtime_class[`${section}.${field}`] ?? 'restart-required';
|
||||
const overridden = data.overlay[section]?.[field] !== undefined;
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
|
||||
<label style={{ flex: 1, minWidth: 0 }}>
|
||||
{field}{' '}
|
||||
<span style={{ fontSize: 11, color: 'var(--smirk-fg-muted)' }}>
|
||||
· {cls === 'restart-required' ? 'restart' : 'live'}
|
||||
{overridden ? ' · db' : ''}
|
||||
</span>
|
||||
</label>
|
||||
{typeof value === 'boolean' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => setField(section, field, (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
style={{ width: 220 }}
|
||||
value={String(value ?? '')}
|
||||
onInput={(e) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
setField(section, field, typeof value === 'number' ? Number(v) : v);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button class="primary" style={{ marginTop: 8 }} onClick={() => save(section)}>
|
||||
Save {section}
|
||||
</button>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── shell ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = ['Status', 'Keys', 'Invites', 'Config'] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
function Console({ onLogout }: { onLogout: () => void }) {
|
||||
const [tab, setTab] = useState<Tab>('Status');
|
||||
return (
|
||||
<div style={{ maxWidth: 760, margin: '0 auto', padding: 20 }}>
|
||||
<header style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0, flex: 1 }}>🐸 Smirk Operator Console</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
api.logout().catch(() => {});
|
||||
setToken(null);
|
||||
onLogout();
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</header>
|
||||
<nav style={{ display: 'flex', gap: 6, marginBottom: 16 }}>
|
||||
{TABS.map((t) => (
|
||||
<button class={t === tab ? 'primary' : ''} onClick={() => setTab(t)}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{tab === 'Status' && <StatusTab />}
|
||||
{tab === 'Keys' && <KeysTab />}
|
||||
{tab === 'Invites' && <InvitesTab />}
|
||||
{tab === 'Config' && <ConfigTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [authed, setAuthed] = useState(false);
|
||||
return authed ? <Console onLogout={() => setAuthed(false)} /> : <Login onDone={() => setAuthed(true)} />;
|
||||
}
|
||||
|
||||
render(<App />, document.getElementById('app')!);
|
||||
@@ -0,0 +1,60 @@
|
||||
// Admin login = a NIP-98 kind-27235 "signed action" over a server-issued nonce.
|
||||
// The backend (admin_verify) binds SIX tags exactly once and a request-descriptor
|
||||
// hash over an EMPTY body. Get any of this wrong and verify returns an opaque 401,
|
||||
// so this mirrors src/core/crypto/nip98.rs precisely.
|
||||
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export interface Challenge {
|
||||
challenge: string;
|
||||
url: string;
|
||||
instance_id: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
/** A NIP-07 signer: `window.nostr` (Smirk / Alby / nos2x). */
|
||||
export interface Nip07 {
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(event: {
|
||||
kind: number;
|
||||
created_at: number;
|
||||
content: string;
|
||||
tags: string[][];
|
||||
}): Promise<{ id: string; pubkey: string; sig: string; [k: string]: unknown }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build + sign the admin_login event for a challenge and return the
|
||||
* `Nostr <base64(event)>` token the backend's `admin_token` field expects.
|
||||
*/
|
||||
export async function buildAdminToken(c: Challenge, signer: Nip07): Promise<string> {
|
||||
// request_descriptor("POST", "/admin/auth/verify", "", b"") then its sha256.
|
||||
const emptyBodyHash = await sha256Hex('');
|
||||
const descriptor = `POST\n/admin/auth/verify\n\n${emptyBodyHash}`;
|
||||
const payload = await sha256Hex(descriptor);
|
||||
|
||||
const signed = await signer.signEvent({
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags: [
|
||||
['u', c.url],
|
||||
['method', 'POST'],
|
||||
['purpose', 'admin_login'],
|
||||
['challenge', c.challenge],
|
||||
['payload', payload],
|
||||
['instance_id', c.instance_id],
|
||||
],
|
||||
});
|
||||
|
||||
return `Nostr ${btoa(JSON.stringify(signed))}`;
|
||||
}
|
||||
|
||||
/** The page-injected NIP-07 provider, or null. Smirk installs `window.nostr`. */
|
||||
export function nip07(): Nip07 | null {
|
||||
const n = (window as unknown as { nostr?: Nip07 }).nostr;
|
||||
return n && typeof n.signEvent === 'function' ? n : null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/* Smirk brand tokens (vendored — the admin-ui is standalone, no @smirk/ui dep). */
|
||||
:root {
|
||||
--smirk-bg: #2c1e12;
|
||||
--smirk-bg-elevated: #3a2a1b;
|
||||
--smirk-bg-sunken: #241810;
|
||||
--smirk-fg: #f3e9dd;
|
||||
--smirk-fg-muted: #b9a891;
|
||||
--smirk-accent: #f5c542;
|
||||
--smirk-accent-fg: #2c1e12;
|
||||
--smirk-border: rgba(255, 255, 255, 0.12);
|
||||
--smirk-border-strong: rgba(255, 255, 255, 0.22);
|
||||
--smirk-danger: #ef4444;
|
||||
--smirk-ok: #22c55e;
|
||||
--smirk-warn: #f59e0b;
|
||||
--smirk-radius: 10px;
|
||||
--smirk-font: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--smirk-bg);
|
||||
color: var(--smirk-fg);
|
||||
font-family: var(--smirk-font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h1, h2, h3 { font-weight: 700; }
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--smirk-border);
|
||||
background: var(--smirk-bg-elevated);
|
||||
color: inherit;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
button.primary { background: var(--smirk-accent); color: var(--smirk-accent-fg); border: none; font-weight: 700; }
|
||||
button.danger { color: var(--smirk-danger); border-color: var(--smirk-danger); }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
input, textarea, select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--smirk-bg-sunken);
|
||||
border: 1px solid var(--smirk-border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
a { color: var(--smirk-accent); }
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Served by the Rust backend at /admin (rust-embed). `base: '/admin/'` makes the
|
||||
// built asset URLs absolute under /admin/assets/ so they resolve regardless of the
|
||||
// trailing slash. Preact JSX via esbuild (no plugin, keeps the toolchain tiny).
|
||||
export default defineConfig({
|
||||
base: '/admin/',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
target: 'es2022',
|
||||
emptyOutDir: true,
|
||||
assetsDir: 'assets',
|
||||
},
|
||||
esbuild: {
|
||||
jsx: 'automatic',
|
||||
jsxImportSource: 'preact',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user