mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-21 17:28:40 +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,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); }
|
||||
Reference in New Issue
Block a user