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 (
);
}
/** 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}
{copied ? : }
);
}
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}
}
);
}
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 : (
<>Cancel Add teammate >
)}>
{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 ? : (
Person Role Status Last sign-in
{list.map((u) => {
const self = u.id === me.id;
return (
{u.name}{self && (you) }
{u.email}
update(u, { role: e.target.value })} aria-label={`Role for ${u.name}`}>
Member
Admin
{!u.active ? Deactivated : u.mustChange ? Temporary password : Active }
{u.lastLoginAt ? timeAgo(u.lastLoginAt) : "Never"}
{!self && (
setDialog({ kind: "reset", user: u })}>Reset password
update(u, { active: !u.active })}>{u.active ? "Deactivate" : "Reactivate"}
setDialog({ kind: "remove", user: u })} aria-label={`Remove ${u.name}`} />
)}
);
})}
)}
{dialog?.kind === "add" && setDialog(null)} onDone={load} />}
{dialog?.kind === "temp" && (
setDialog(null)} title="Password reset" footer={ setDialog(null)}>Done }>
)}
{dialog?.kind === "reset" && (
setDialog(null)} title={`Reset ${dialog.user.name}'s password?`}
footer={<> setDialog(null)}>Cancel reset(dialog.user)}>Reset password >}>
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={<> setDialog(null)}>Cancel remove(dialog.user)}>Remove >}>
{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).
: (
)}
{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 (
{w.hasLogo &&
}
{busy === "logo" ? "Uploading…" : w.hasLogo ? "Replace logo" : "Upload logo"}
e.target.files[0] && upload(e.target.files[0])} />
{w.hasLogo &&
run("rmlogo", () => api("/api/workspace/logo", { method: "DELETE" }))}>Remove }
run("save", () => api("/api/workspace", { method: "PUT", body: { name: w.name, accent: w.accent, supportEmail: w.supportEmail, website: w.website } }))}>Save workspace
{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.
}
toggle("email")} />
Email me when something needs me A client approved or asked for changes, a post failed, or a TikTok post is waiting in the TikTok app.
toggle("digest")} />
Morning summary at 8:00 Drafts waiting for approval, client answers, failed posts, connections to reconnect, and your planned work due in the next two days. Only sent when there's something to say.
{d.email && Send me a test 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 (
{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."}
>
)}
);
}