modelbeast/web/src/Settings.jsx
MODELBEAST 810400fb60 Auth + dashboard (HANDOFF2 phases 1-2): guests local-only, system snapshot
Phase 1 — multi-user auth:
- server/auth.py: bcrypt passwords, itsdangerous signed-cookie sessions, sha256
  bearer tokens, FastAPI current_user/require_owner deps, login rate limit
- users + api_tokens tables + jobs/assets.user_id (additive migrations)
- HARD RULE enforced server-side: guests are local-only — /api/operators filters
  out requires_env operators, POST /api/jobs 403s cloud ops for non-owners (proven
  via direct POST in smoke.sh, not just UI). Settings owner-only. auth_secret
  hidden from the settings API. Per-user active-job cap (owner exempt). Own-asset/
  own-job checks. WS auth via cookie or ?token=. Owner bootstrap prints pw once.
- mb-ready: bearer MB_TOKEN; scripts/users.py for out-of-band management
- Frontend: Login gate, header user chip + logout, guest note, username on jobs,
  Users panel in Settings (owner)

Phase 2 — dashboard:
- server/sysinfo.py: psutil CPU/RAM/disk + macmon Apple GPU (util/power/temp, no
  sudo), computed lane occupancy, 24h job summary, recent jobs w/ output thumbs;
  all cached (5s stats, 5min du). /api/system + /api/jobs/recent.
- Dashboard.jsx: snapshot-on-refresh (no polling) — stat cards, per-core strip,
  lane strip, running/queued, recent grid.

tests/smoke.sh rewritten for auth: 28 checks passing incl. all guest-security
rules. Browser-verified owner + guest + dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:06:52 +10:00

117 lines
4.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from "react";
import { addUser, createToken, delUser, getSettings, listUsers, patchUser, putSettings } from "./api";
const LABELS = {
fal_key: "fal.ai API key",
tripo_key: "Tripo API key",
meshy_key: "Meshy API key",
replicate_token: "Replicate token",
openrouter_key: "OpenRouter API key (nano-banana A/B tests)",
hf_token: "HuggingFace token (for SF3D / TRELLIS.2 weights)",
models_dir: "Models directory",
archive_host: "Archive host (rsync)",
archive_path: "Archive path",
};
export default function Settings({ onClose, onSaved }) {
const [data, setData] = useState(null);
const [edits, setEdits] = useState({});
const [saving, setSaving] = useState(false);
useEffect(() => { getSettings().then(setData); }, []);
if (!data) return null;
const secretKeys = new Set(data._secret_keys || []);
const keys = data._env_keys || [];
const save = async () => {
setSaving(true);
const fresh = await putSettings(edits);
setData(fresh); setEdits({}); setSaving(false);
onSaved?.(fresh);
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2>Settings</h2>
<p className="dim">API keys unlock cloud operators. HuggingFace token lets local SF3D / TRELLIS.2 download their gated weights. Secrets are masked and never written to logs.</p>
{keys.map((k) => {
const isSecret = secretKeys.has(k);
const current = data[k] || "";
const placeholder = isSecret && current ? "•••••••• (set — leave blank to keep)" : "";
return (
<label key={k} className="setting">
<span>{LABELS[k] || k}</span>
<input
type={isSecret ? "password" : "text"}
placeholder={placeholder}
defaultValue={isSecret ? "" : current}
onChange={(e) => setEdits({ ...edits, [k]: e.target.value })}
/>
</label>
);
})}
<div className="modal-actions">
<button onClick={onClose}>Close</button>
<button className="go" onClick={save} disabled={saving || !Object.keys(edits).length}>
{saving ? "Saving…" : "Save"}
</button>
</div>
<Users />
</div>
</div>
);
}
function Users() {
const [users, setUsers] = useState([]);
const [nu, setNu] = useState("");
const [np, setNp] = useState("");
const [msg, setMsg] = useState("");
const load = () => listUsers().then(setUsers).catch(() => {});
useEffect(() => { load(); }, []);
const create = async () => {
setMsg("");
try {
await addUser({ username: nu.trim(), password: np });
setNu(""); setNp(""); load();
} catch (e) { setMsg(String(e.message || e).slice(0, 120)); }
};
const mkToken = async (username) => {
const r = await createToken({ username });
setMsg(`token for ${username} (copy now, shown once): ${r.token}`);
};
return (
<div className="users">
<h2>Users · friends can log in and run LOCAL models only</h2>
<table className="users-table">
<tbody>
{users.map((u) => (
<tr key={u.username}>
<td>{u.username}</td>
<td className="dim">{u.role}</td>
<td className="dim">cap {u.max_active_jobs}</td>
<td>
<button onClick={() => mkToken(u.username)} title="mint API token">token</button>
{u.role !== "owner" && <button onClick={() => delUser(u.username).then(load)}>×</button>}
</td>
</tr>
))}
</tbody>
</table>
<div className="user-add">
<input placeholder="new username" value={nu} autoCapitalize="none"
onChange={(e) => setNu(e.target.value)} />
<input type="password" placeholder="password (6+)" value={np}
onChange={(e) => setNp(e.target.value)} />
<button onClick={create} disabled={!nu || np.length < 6}>Add guest</button>
</div>
{msg && <p className="dim user-msg">{msg}</p>}
</div>
);
}