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:
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Organizer Admin",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function AdminLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return children;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user