feat(hobbs-organizer): v1.1.0 password-protected LAN-only admin for calendar links

- /admin: first-visit password setup (scrypt), signed 30-day session cookie,
  calendars add/remove/reorder/rename/color/hide, masked URLs, per-feed test,
  history (20) with restore; /photo-admin -> /admin#photos placeholder
- settings in local /data (settings.json + auth.json + history/), seeded once
  from the calendar secret; dashboard + health read from settings
- start.mjs drops privileges after reading secrets and runs a front proxy that
  stamps the real client IP; admin refuses non-LAN and proxied requests
- deploy: HOBBS_DATA_DIR=/var/lib/hobbs-organizer/data, image 1.1.0 (1.0.0 kept)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
This commit is contained in:
2026-11-04 18:55:00 -06:00
parent e9c7e9df1a
commit 8715589cbb
28 changed files with 1532 additions and 380 deletions
@@ -0,0 +1,463 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
type Tone = "cyan" | "amber" | "violet" | "emerald" | "rose";
const TONES: Tone[] = ["cyan", "amber", "violet", "emerald", "rose"];
const TONE_COLOR: Record<Tone, string> = {
cyan: "#67e8f9",
amber: "#fbbf24",
violet: "#a78bfa",
emerald: "#34d399",
rose: "#fb7185",
};
type SavedCalendar = { id: string; name: string; tone: Tone; enabled: boolean; masked: string };
type TestResult = { ok: boolean; sourceName?: string; eventCount?: number; upcoming?: number; error?: string };
type DraftCalendar = {
key: string;
id?: string;
name: string;
tone: Tone;
enabled: boolean;
masked?: string;
newUrl: string;
replacing: boolean;
test?: TestResult | "running";
};
type HistoryEntry = { id: string; savedAt: string; calendarCount: number; note: string };
type Tab = "calendars" | "photos" | "account";
async function api<T>(path: string, init?: RequestInit & { body?: string }) {
const response = await fetch(path, {
...init,
credentials: "same-origin",
cache: "no-store",
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
});
const data = (await response.json().catch(() => ({}))) as T & { error?: string };
return { status: response.status, ok: response.ok, data };
}
function toDraft(calendar: SavedCalendar): DraftCalendar {
return { key: calendar.id, ...calendar, newUrl: "", replacing: false };
}
function formatWhen(iso: string) {
const date = new Date(iso);
if (Number.isNaN(date.getTime()) || date.getTime() === 0) return "initial setup";
return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
const inputClass =
"w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-cyan-300/60 focus:outline-none";
const buttonClass =
"rounded-lg px-3 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-40";
const primaryButton = `${buttonClass} bg-cyan-300 text-slate-950 hover:bg-cyan-200`;
const ghostButton = `${buttonClass} border border-white/10 bg-white/5 text-slate-200 hover:bg-white/10`;
const dangerButton = `${buttonClass} border border-rose-400/30 bg-rose-400/10 text-rose-200 hover:bg-rose-400/20`;
function PasswordForm({ mode, onDone }: { mode: "setup" | "login"; onDone: () => void }) {
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setError("");
if (mode === "setup" && password !== confirm) {
setError("The two passwords don't match.");
return;
}
setBusy(true);
const result = await api<{ ok?: boolean }>(`/api/admin/${mode}`, { method: "POST", body: JSON.stringify({ password }) });
setBusy(false);
if (result.ok) onDone();
else setError(result.data.error || "Something went wrong.");
};
return (
<form onSubmit={submit} className="mx-auto mt-16 max-w-sm space-y-4 rounded-2xl border border-white/10 bg-[#0c121d] p-6">
<div>
<h1 className="text-lg font-bold">{mode === "setup" ? "Set the admin password" : "Organizer admin"}</h1>
<p className="mt-1 text-sm text-slate-400">
{mode === "setup"
? "No password exists yet. Choose one now. You'll need it to change calendars and photos."
: "Sign in to manage calendars and photos."}
</p>
</div>
<input
className={inputClass}
type="password"
autoComplete={mode === "setup" ? "new-password" : "current-password"}
placeholder="Password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoFocus
/>
{mode === "setup" && (
<input
className={inputClass}
type="password"
autoComplete="new-password"
placeholder="Confirm password"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
/>
)}
{error && <p className="text-sm text-rose-300">{error}</p>}
<button className={`${primaryButton} w-full`} disabled={busy || password.length < 1}>
{busy ? "…" : mode === "setup" ? "Save password" : "Sign in"}
</button>
</form>
);
}
function TestBadge({ test }: { test?: TestResult | "running" }) {
if (!test) return null;
if (test === "running") return <span className="text-xs text-slate-400">Testing…</span>;
if (!test.ok) return <span className="text-xs text-rose-300">✕ {test.error || "Failed"}</span>;
return (
<span className="text-xs text-emerald-300">
✓ {test.sourceName} · {test.upcoming} upcoming
</span>
);
}
function CalendarsTab() {
const [drafts, setDrafts] = useState<DraftCalendar[]>([]);
const [saved, setSaved] = useState<SavedCalendar[]>([]);
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const [loaded, setLoaded] = useState(false);
const load = useCallback(async () => {
const [calendars, versions] = await Promise.all([
api<{ calendars: SavedCalendar[] }>("/api/admin/calendars"),
api<{ history: HistoryEntry[] }>("/api/admin/history"),
]);
if (calendars.ok) {
setSaved(calendars.data.calendars);
setDrafts(calendars.data.calendars.map(toDraft));
}
if (versions.ok) setHistory(versions.data.history);
setLoaded(true);
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount
void load();
}, [load]);
const update = (key: string, patch: Partial<DraftCalendar>) =>
setDrafts((current) => current.map((draft) => (draft.key === key ? { ...draft, ...patch } : draft)));
const dirty =
drafts.length !== saved.length ||
drafts.some((draft, index) => {
const original = saved[index];
return (
!original ||
draft.id !== original.id ||
draft.name !== original.name ||
draft.tone !== original.tone ||
draft.enabled !== original.enabled ||
draft.newUrl.trim() !== ""
);
});
const addCalendar = () =>
setDrafts((current) => [
...current,
{
key: `new-${Date.now()}`,
name: "",
tone: TONES[current.length % TONES.length],
enabled: true,
newUrl: "",
replacing: true,
},
]);
const move = (index: number, delta: number) =>
setDrafts((current) => {
const next = [...current];
const target = index + delta;
if (target < 0 || target >= next.length) return current;
[next[index], next[target]] = [next[target], next[index]];
return next;
});
const test = async (draft: DraftCalendar) => {
update(draft.key, { test: "running" });
const body = draft.newUrl.trim() ? { url: draft.newUrl.trim() } : { id: draft.id };
const result = await api<TestResult>("/api/admin/calendars/test", { method: "POST", body: JSON.stringify(body) });
update(draft.key, { test: result.data.ok !== undefined ? result.data : { ok: false, error: result.data.error } });
};
const save = async () => {
setBusy(true);
setMessage(null);
const result = await api<{ calendars: SavedCalendar[] }>("/api/admin/calendars", {
method: "PUT",
body: JSON.stringify({
calendars: drafts.map((draft) => ({
id: draft.id,
name: draft.name,
tone: draft.tone,
enabled: draft.enabled,
...(draft.newUrl.trim() ? { url: draft.newUrl.trim() } : {}),
})),
}),
});
setBusy(false);
if (result.ok) {
setMessage({ tone: "ok", text: "Saved. The TVs pick up calendar changes within about 5 minutes." });
await load();
} else {
setMessage({ tone: "error", text: result.data.error || "Save failed." });
}
};
const revert = async (entry: HistoryEntry) => {
if (!window.confirm(`Restore the calendars as they were on ${formatWhen(entry.savedAt)}? The current list is kept in history.`)) return;
const result = await api<{ ok: boolean }>("/api/admin/history", { method: "POST", body: JSON.stringify({ id: entry.id }) });
setMessage(result.ok ? { tone: "ok", text: "Restored." } : { tone: "error", text: result.data.error || "Restore failed." });
await load();
};
if (!loaded) return <p className="text-sm text-slate-400">Loading…</p>;
return (
<div className="space-y-6">
<section className="space-y-3">
{drafts.length === 0 && <p className="text-sm text-slate-400">No calendars yet. Add one below.</p>}
{drafts.map((draft, index) => (
<article key={draft.key} className="space-y-3 rounded-xl border border-white/10 bg-[#0c121d] p-4">
<div className="flex items-center gap-3">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ background: TONE_COLOR[draft.tone] }} />
<input
className={inputClass}
placeholder="Name (blank = use the calendar's own name)"
value={draft.name}
maxLength={50}
onChange={(event) => update(draft.key, { name: event.target.value })}
/>
<label className="flex shrink-0 items-center gap-2 text-xs text-slate-300">
<input
type="checkbox"
checked={draft.enabled}
onChange={(event) => update(draft.key, { enabled: event.target.checked })}
/>
Shown
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-slate-400">Color</span>
{TONES.map((tone) => (
<button
key={tone}
type="button"
aria-label={tone}
onClick={() => update(draft.key, { tone })}
className={`h-6 w-6 rounded-full border-2 ${draft.tone === tone ? "border-white" : "border-transparent"}`}
style={{ background: TONE_COLOR[tone] }}
/>
))}
</div>
{draft.id && !draft.replacing ? (
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
<code className="rounded bg-white/5 px-2 py-1 text-slate-300">{draft.masked}</code>
<button type="button" className="text-cyan-300 underline-offset-2 hover:underline" onClick={() => update(draft.key, { replacing: true })}>
Replace address
</button>
</div>
) : (
<input
className={inputClass}
placeholder="https://calendar.google.com/calendar/ical/…/basic.ics"
autoComplete="off"
spellCheck={false}
value={draft.newUrl}
onChange={(event) => update(draft.key, { newUrl: event.target.value, test: undefined })}
/>
)}
<div className="flex flex-wrap items-center gap-2">
<button type="button" className={ghostButton} onClick={() => test(draft)} disabled={!draft.id && !draft.newUrl.trim()}>
Test
</button>
<button type="button" className={ghostButton} onClick={() => move(index, -1)} disabled={index === 0} aria-label="Move up">
↑
</button>
<button type="button" className={ghostButton} onClick={() => move(index, 1)} disabled={index === drafts.length - 1} aria-label="Move down">
↓
</button>
<button
type="button"
className={dangerButton}
onClick={() => setDrafts((current) => current.filter((entry) => entry.key !== draft.key))}
>
Remove
</button>
<TestBadge test={draft.test} />
</div>
</article>
))}
<div className="flex flex-wrap items-center gap-3">
<button type="button" className={ghostButton} onClick={addCalendar} disabled={drafts.length >= 10}>
+ Add calendar
</button>
<button type="button" className={primaryButton} onClick={save} disabled={!dirty || busy}>
{busy ? "Saving…" : "Save changes"}
</button>
{dirty && (
<button type="button" className={ghostButton} onClick={() => setDrafts(saved.map(toDraft))}>
Discard
</button>
)}
</div>
{message && <p className={`text-sm ${message.tone === "ok" ? "text-emerald-300" : "text-rose-300"}`}>{message.text}</p>}
<p className="text-xs leading-relaxed text-slate-500">
In Google Calendar: Settings → pick the calendar → <em>Integrate calendar</em> → copy the{" "}
<em>Secret address in iCal format</em>. Saved addresses are never shown again. They work like passwords.
</p>
</section>
<section className="space-y-2">
<h2 className="text-sm font-bold uppercase tracking-wider text-slate-400">History</h2>
{history.length === 0 ? (
<p className="text-sm text-slate-500">No earlier versions yet.</p>
) : (
<ul className="divide-y divide-white/5 rounded-xl border border-white/10 bg-[#0c121d]">
{history.map((entry) => (
<li key={entry.id} className="flex items-center justify-between gap-3 px-4 py-2 text-sm">
<span className="text-slate-300">
{formatWhen(entry.savedAt)} <span className="text-slate-500">· {entry.calendarCount} calendar(s)</span>
</span>
<button type="button" className={ghostButton} onClick={() => revert(entry)}>
Restore
</button>
</li>
))}
</ul>
)}
</section>
</div>
);
}
function AccountTab({ onSignedOut }: { onSignedOut: () => void }) {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
const change = async (event: React.FormEvent) => {
event.preventDefault();
const result = await api("/api/admin/password", {
method: "POST",
body: JSON.stringify({ currentPassword: current, newPassword: next }),
});
setMessage(result.ok ? { tone: "ok", text: "Password changed. Other devices were signed out." } : { tone: "error", text: result.data.error || "Failed." });
if (result.ok) {
setCurrent("");
setNext("");
}
};
const signOut = async () => {
await api("/api/admin/logout", { method: "POST" });
onSignedOut();
};
return (
<div className="space-y-6">
<form onSubmit={change} className="max-w-sm space-y-3 rounded-xl border border-white/10 bg-[#0c121d] p-4">
<h2 className="text-sm font-bold">Change password</h2>
<input className={inputClass} type="password" autoComplete="current-password" placeholder="Current password" value={current} onChange={(event) => setCurrent(event.target.value)} />
<input className={inputClass} type="password" autoComplete="new-password" placeholder="New password (8+ characters)" value={next} onChange={(event) => setNext(event.target.value)} />
<button className={primaryButton} disabled={!current || next.length < 8}>
Change password
</button>
{message && <p className={`text-sm ${message.tone === "ok" ? "text-emerald-300" : "text-rose-300"}`}>{message.text}</p>}
</form>
<button type="button" className={ghostButton} onClick={signOut}>
Sign out
</button>
<p className="text-xs text-slate-500">
Forgot the password? On the server: <code>docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs</code>
</p>
</div>
);
}
export default function AdminPage() {
const [state, setState] = useState<"loading" | "setup" | "login" | "ready" | "blocked">("loading");
const [tab, setTab] = useState<Tab>("calendars");
const check = useCallback(async () => {
const result = await api<{ setupRequired: boolean; authenticated: boolean }>("/api/admin/status");
if (result.status === 403) setState("blocked");
else if (result.data.setupRequired) setState("setup");
else setState(result.data.authenticated ? "ready" : "login");
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount
void check();
const fromHash = window.location.hash.replace("#", "") as Tab;
if (["calendars", "photos", "account"].includes(fromHash)) setTab(fromHash);
}, [check]);
const selectTab = (next: Tab) => {
setTab(next);
window.history.replaceState(null, "", `#${next}`);
};
return (
<div className="fixed inset-0 overflow-y-auto bg-[#070b12] text-slate-100">
<main className="mx-auto max-w-2xl px-4 py-6">
{state === "loading" && <p className="text-sm text-slate-400">Loading…</p>}
{state === "blocked" && <p className="mt-16 text-center text-sm text-slate-400">Admin is only available on the home network.</p>}
{(state === "setup" || state === "login") && <PasswordForm mode={state} onDone={() => setState("ready")} />}
{state === "ready" && (
<>
<header className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Organizer admin</h1>
<Link href="/" className="text-xs text-cyan-300 hover:underline">
← Open the dashboard
</Link>
</div>
</header>
<nav className="mb-6 flex gap-1 rounded-xl border border-white/10 bg-white/5 p-1">
{(["calendars", "photos", "account"] as Tab[]).map((name) => (
<button
key={name}
type="button"
onClick={() => selectTab(name)}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold capitalize ${tab === name ? "bg-cyan-300 text-slate-950" : "text-slate-300 hover:bg-white/5"}`}
>
{name}
</button>
))}
</nav>
{tab === "calendars" && <CalendarsTab />}
{tab === "photos" && (
<p className="rounded-xl border border-white/10 bg-[#0c121d] p-4 text-sm text-slate-400">
Photo albums arrive in the next update.
</p>
)}
{tab === "account" && <AccountTab onSignedOut={() => setState("login")} />}
</>
)}
</main>
</div>
);
}