import { useCallback, useEffect, useState } from "react"; import { CheckCircle2, CircleAlert, CircleDashed, Copy, Check, KeyRound, UserPlus, Trash2 } from "lucide-react"; import { api } from "../api.js"; import { useAuth } from "../App.jsx"; import { Badge, Button, cx, Dialog, ErrorNote, Field, Panel, Spinner, timeAgo } from "../ui.jsx"; function Row({ ok, warn, title, children }) { const Icon = ok ? CheckCircle2 : warn ? CircleDashed : CircleAlert; return (
{title}
{children}
); } /** Shows a one-time password with a copy button. */ function TempPassword({ email, password }) { const [copied, setCopied] = useState(false); const copy = () => navigator.clipboard?.writeText(password).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); return (

Send these to {email} privately (not in a group chat). The password is shown only once; they'll be asked to choose their own after signing in.

Sign-in page: {window.location.origin}
Email: {email}
Temporary password: {password}
); } function AccountPanel() { const { user, refresh } = useAuth(); const [name, setName] = useState(user.name); const [pw, setPw] = useState({ current: "", next: "", confirm: "" }); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [done, setDone] = useState(null); const saveName = async (e) => { e.preventDefault(); setBusy("name"); setError(null); setDone(null); try { await api("/api/me", { method: "PATCH", body: { name } }); setDone("Name saved."); refresh(); } catch (err) { setError(err); } setBusy(null); }; const changePassword = async (e) => { e.preventDefault(); setError(null); setDone(null); if (pw.next !== pw.confirm) return setError(new Error("The two new passwords don't match.")); setBusy("pw"); try { await api("/api/me/password", { method: "POST", body: { current: pw.current, next: pw.next } }); setPw({ current: "", next: "", confirm: "" }); setDone("Password changed. You've been signed out on your other devices."); refresh(); } catch (err) { setError(err); } setBusy(null); }; const setP = (k) => (e) => setPw({ ...pw, [k]: e.target.value }); return ( {done &&
{done}
}
setName(e.target.value)} required />
Change password
); } function AddMemberDialog({ onClose, onDone }) { const [f, setF] = useState({ name: "", email: "", role: "member" }); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [created, setCreated] = useState(null); const set = (k) => (e) => setF({ ...f, [k]: e.target.value }); const submit = async (e) => { e.preventDefault(); setBusy(true); setError(null); try { const r = await api("/api/users", { method: "POST", body: f }); setCreated(r); onDone(); } catch (err) { setError(err); } setBusy(false); }; return ( Done : ( <> )}> {created ? : (
)}
); } function TeamPanel() { const { user: me } = useAuth(); const [list, setList] = useState(null); const [error, setError] = useState(null); const [dialog, setDialog] = useState(null); // {kind:'add'} | {kind:'reset'|'remove', user} | {kind:'temp', email, password} const [busy, setBusy] = useState(null); const load = useCallback(() => api("/api/users").then((r) => setList(r.users)).catch(setError), []); useEffect(() => { load(); }, [load]); const act = async (id, fn) => { setBusy(id); setError(null); try { await fn(); await load(); } catch (err) { setError(err); } setBusy(null); }; const update = (u, body) => act(u.id, () => api(`/api/users/${u.id}`, { method: "PATCH", body })); const reset = (u) => act(u.id, async () => { const r = await api(`/api/users/${u.id}/reset-password`, { method: "POST" }); setDialog({ kind: "temp", email: u.email, password: r.tempPassword }); }); const remove = (u) => act(u.id, async () => { await api(`/api/users/${u.id}`, { method: "DELETE" }); setDialog(null); }); return ( setDialog({ kind: "add" })}>Add teammate}> {!list ? : ( {list.map((u) => { const self = u.id === me.id; return ( ); })}
PersonRoleStatusLast sign-in
{u.name}{self && (you)}
{u.email}
{!u.active ? Deactivated : u.mustChange ? Temporary password : Active} {u.lastLoginAt ? timeAgo(u.lastLoginAt) : "Never"} {!self && (
)}
)} {dialog?.kind === "add" && setDialog(null)} onDone={load} />} {dialog?.kind === "temp" && ( setDialog(null)} title="Password reset" footer={}> )} {dialog?.kind === "reset" && ( setDialog(null)} title={`Reset ${dialog.user.name}'s password?`} footer={<>}>

They'll be signed out everywhere and get a temporary password, which you'll see on the next screen.

)} {dialog?.kind === "remove" && ( setDialog(null)} title={`Remove ${dialog.user.name}?`} footer={<>}>

{dialog.user.email} will lose access immediately. Their uploads and history stay. To pause access instead, use Deactivate.

)}
); } function MetaPanel() { const [d, setD] = useState(null); const [token, setToken] = useState(""); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); useEffect(() => { api("/api/meta/connection").then(setD).catch(() => {}); }, []); if (!d) return null; const save = async (e) => { e.preventDefault(); setBusy(true); setMsg(null); try { const r = await api("/api/meta/connection", { method: "PUT", body: { token } }); setD({ source: r.source }); setToken(""); setMsg(`Connected as ${r.name}. Refresh numbers on the Clients page to load every Page.`); } catch (er) { setMsg(er); } setBusy(false); }; return ( {d.source === "env" ?

Connected through the server's .env file (META_SYSTEM_USER_TOKEN).

: (

{d.source === "settings" ? "A token is saved. Paste a new one to replace it." : "Not connected yet."} In Meta Business Settings → Users → System users, generate a token for your system user with pages_show_list, pages_read_engagement, pages_manage_posts, instagram_basic, instagram_content_publish, instagram_manage_insights and instagram_manage_comments, then paste it here. It's stored encrypted and never shown again.

setToken(e.target.value)} placeholder="EAA…" />
)} {msg && (typeof msg === "string" ?
{msg}
: )}
); } function WorkspacePanel() { const { refresh } = useAuth(); const [w, setW] = useState(null); const [busy, setBusy] = useState(null); const [msg, setMsg] = useState(null); const [v, setV] = useState(0); useEffect(() => { api("/api/workspace").then(setW).catch(setMsg); }, []); if (!w) return null; const set = (k, val) => setW({ ...w, [k]: val }); const run = async (what, fn) => { setBusy(what); setMsg(null); try { setW(await fn()); setV((x) => x + 1); refresh(); if (what === "save") setMsg("Saved."); } catch (e) { setMsg(e); } setBusy(null); }; const upload = (file) => run("logo", () => fetch("/api/workspace/logo", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/octet-stream", "X-Filename": file.name }, body: file }).then(async (r) => { const j = await r.json(); if (!r.ok) throw new Error(j.error); return j; })); return (
set("name", e.target.value)} placeholder="e.g. Northstar Digital" />
set("accent", e.target.value)} aria-label="Brand colour" /> set("accent", e.target.value)} placeholder="#3b3778" aria-label="Brand colour hex" />
set("supportEmail", e.target.value)} /> set("website", e.target.value)} placeholder="https://" />
{w.hasLogo && Current logo} {w.hasLogo && }
{msg && (typeof msg === "string" ? {msg} : )}

Your own domain (like social.youragency.com): point the domain at the server and set PUBLIC_URL in .env. See the hosting guide.

); } function NotificationsPanel() { const [d, setD] = useState(null); const [busy, setBusy] = useState(null); const [msg, setMsg] = useState(null); useEffect(() => { api("/api/me/notify").then(setD).catch(setMsg); }, []); if (!d) return null; const toggle = (k) => { setBusy(k); api("/api/me/notify", { method: "PUT", body: { [k]: !d.prefs[k] } }).then(setD).catch(setMsg).finally(() => setBusy(null)); }; const test = async () => { setBusy("test"); setMsg(null); try { const r = await api("/api/notify/test", { method: "POST" }); setMsg(r.sent ? "Test email sent. Check your inbox (and spam)." : new Error(r.reason)); } catch (e) { setMsg(e); } setBusy(null); }; return ( {!d.email &&
Email isn't set up on this server yet, so notifications only appear in the bell. An admin adds SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS and SMTP_FROM to the server's .env.
} {d.email && } {msg && (typeof msg === "string" ?
{msg}
: )}
); } export default function Settings() { const { isAdmin } = useAuth(); const [s, setS] = useState(null); const [error, setError] = useState(null); useEffect(() => { api("/api/status").then(setS).catch(setError); }, []); return (

Settings

Your account{isAdmin ? ", the team's logins" : ""} and this dashboard's connections.

{isAdmin && } {isAdmin && } {isAdmin && } {!s ? : ( <> {s.meta.connected ? `${s.meta.accounts} Pages connected through the agency's system user.` : `Not connected: ${s.meta.error}`} {s.odoo.connected ? `Connected to ${s.odoo.url} (Odoo ${s.odoo.version?.split("-")[0]}). Task creation is ${s.odoo.allowWrites ? "on" : "off (ODOO_ALLOW_WRITES)"}.` : s.odoo.configured ? `Can't connect: ${s.odoo.error}` : "Not configured. Add ODOO_URL, ODOO_DATABASE, ODOO_USERNAME and ODOO_API_KEY."} Model {s.ai.model}. {s.ai.apiKey ? "Using the ANTHROPIC_API_KEY on this server." : "No ANTHROPIC_API_KEY set: runs use this machine's Claude Code login. A hosted server needs an API key."} {s.publishing ? "On. Approved posts go out at their scheduled time; every post still needs an admin's approval." : "Off (ALLOW_PUBLISH). Drafts can be prepared, but nothing is posted to client accounts."} )}
); }