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>
117 lines
4.1 KiB
JavaScript
117 lines
4.1 KiB
JavaScript
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>
|
||
);
|
||
}
|