metatroncubeswdev 075b79f6b2 Hosted Quorla: sign-up, Stripe billing, one workspace per agency
- Control centre (QUORLA_CONTROL=true on app.quorlasocial.com): public /signup → Stripe Checkout,
  verified Stripe webhook (paid, updated, cancelled, payment failed), Admin → Agencies
  (approve without Stripe, suspend, resume, billing portal, resend welcome, remove)
- deploy/provision-tenants.sh (cron): one container per agency with its own .env and data,
  nginx map for <agency>.quorlasocial.com, upgrades agencies after a rebuild, archives removed data
- New workspaces: first admin only via the emailed one-time setup link (SETUP_TOKEN)
- Meta system user token can be saved in Settings (encrypted) for workspaces that can't edit .env
- docs/multi-tenant.md: DNS, wildcard cert, nginx, sudoers, cron, Stripe setup; tests for the control centre

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:35:23 -04:00

355 lines
22 KiB
JavaScript

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 (
<div className="flex gap-3 px-5 py-4">
<Icon className={cx("size-5 shrink-0 mt-0.5", ok ? "text-ok" : warn ? "text-warn" : "text-bad")} strokeWidth={2} />
<div className="min-w-0">
<div className="font-medium text-ink">{title}</div>
<div className="text-[13px] text-muted mt-0.5">{children}</div>
</div>
</div>
);
}
/** 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 (
<div className="space-y-3 text-[13px]">
<p>Send these to <b>{email}</b> privately (not in a group chat). The password is shown only once; they'll be asked to choose their own after signing in.</p>
<div className="rounded-md border border-line bg-canvas px-3 py-2.5 space-y-1">
<div><span className="text-muted">Sign-in page:</span> {window.location.origin}</div>
<div><span className="text-muted">Email:</span> {email}</div>
<div className="flex items-center gap-2">
<span className="text-muted">Temporary password:</span>
<code className="font-mono text-[14px] font-semibold">{password}</code>
<button onClick={copy} className="p-1 rounded hover:bg-white text-muted" aria-label="Copy password">
{copied ? <Check className="size-4 text-ok" /> : <Copy className="size-4" />}
</button>
</div>
</div>
</div>
);
}
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 (
<Panel title="Your account" subtitle={`${user.email} · ${user.role}`} bodyClass="px-5 py-4 space-y-5">
<ErrorNote error={error} />
{done && <div className="rounded-md border border-[#cfe6d7] bg-ok-soft text-ok px-3 py-2 text-[13px]">{done}</div>}
<form onSubmit={saveName} className="flex items-end gap-2 max-w-[460px]">
<div className="flex-1"><Field label="Name"><input className="input" value={name} onChange={(e) => setName(e.target.value)} required /></Field></div>
<Button type="submit" loading={busy === "name"} disabled={name.trim() === user.name}>Save</Button>
</form>
<form onSubmit={changePassword} className="space-y-3 max-w-[460px]">
<div className="text-[13px] font-semibold text-ink">Change password</div>
<Field label="Current password"><input className="input" type="password" autoComplete="current-password" value={pw.current} onChange={setP("current")} required /></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="New password" hint="At least 10 characters."><input className="input" type="password" autoComplete="new-password" minLength={10} value={pw.next} onChange={setP("next")} required /></Field>
<Field label="Confirm new password"><input className="input" type="password" autoComplete="new-password" value={pw.confirm} onChange={setP("confirm")} required /></Field>
</div>
<Button type="submit" variant="primary" icon={KeyRound} loading={busy === "pw"}>Change password</Button>
</form>
</Panel>
);
}
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 (
<Dialog open onClose={onClose} title={created ? "Teammate added" : "Add a teammate"}
footer={created ? <Button variant="primary" onClick={onClose}>Done</Button> : (
<><Button variant="ghost" onClick={onClose}>Cancel</Button><Button variant="primary" type="submit" form="add-member" loading={busy}>Add teammate</Button></>
)}>
{created ? <TempPassword email={created.user.email} password={created.tempPassword} /> : (
<form id="add-member" onSubmit={submit} className="space-y-3.5">
<Field label="Name"><input className="input" value={f.name} onChange={set("name")} required autoFocus /></Field>
<Field label="Email"><input className="input" type="email" value={f.email} onChange={set("email")} required /></Field>
<Field label="Role" hint="Members upload work and prepare drafts. Admins also approve posts and replies, and manage the team.">
<select className="input" value={f.role} onChange={set("role")}>
<option value="member">Member (designer, editor)</option>
<option value="admin">Admin</option>
</select>
</Field>
<ErrorNote error={error} />
</form>
)}
</Dialog>
);
}
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 (
<Panel title="Team members" subtitle="Everyone who can sign in to this dashboard."
action={<Button size="sm" variant="primary" icon={UserPlus} onClick={() => setDialog({ kind: "add" })}>Add teammate</Button>}>
<ErrorNote error={error} className="m-4" />
{!list ? <Spinner /> : (
<table className="tbl">
<thead><tr><th>Person</th><th>Role</th><th>Status</th><th>Last sign-in</th><th /></tr></thead>
<tbody>
{list.map((u) => {
const self = u.id === me.id;
return (
<tr key={u.id} className={cx(!u.active && "opacity-60")}>
<td>
<div className="font-medium text-ink">{u.name}{self && <span className="text-muted font-normal"> (you)</span>}</div>
<div className="text-[12px] text-muted">{u.email}</div>
</td>
<td>
<select className="input h-8 w-[110px]" value={u.role} disabled={self || busy === u.id} onChange={(e) => update(u, { role: e.target.value })} aria-label={`Role for ${u.name}`}>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</td>
<td>
{!u.active ? <Badge>Deactivated</Badge> : u.mustChange ? <Badge tone="warn">Temporary password</Badge> : <Badge tone="ok">Active</Badge>}
</td>
<td className="text-[13px] text-muted whitespace-nowrap">{u.lastLoginAt ? timeAgo(u.lastLoginAt) : "Never"}</td>
<td className="text-right whitespace-nowrap">
{!self && (
<div className="inline-flex gap-1.5">
<Button size="sm" variant="ghost" icon={KeyRound} disabled={busy === u.id} onClick={() => setDialog({ kind: "reset", user: u })}>Reset password</Button>
<Button size="sm" variant="ghost" disabled={busy === u.id} onClick={() => update(u, { active: !u.active })}>{u.active ? "Deactivate" : "Reactivate"}</Button>
<Button size="sm" variant="ghost" icon={Trash2} disabled={busy === u.id} onClick={() => setDialog({ kind: "remove", user: u })} aria-label={`Remove ${u.name}`} />
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
{dialog?.kind === "add" && <AddMemberDialog onClose={() => setDialog(null)} onDone={load} />}
{dialog?.kind === "temp" && (
<Dialog open onClose={() => setDialog(null)} title="Password reset" footer={<Button variant="primary" onClick={() => setDialog(null)}>Done</Button>}>
<TempPassword email={dialog.email} password={dialog.password} />
</Dialog>
)}
{dialog?.kind === "reset" && (
<Dialog open onClose={() => setDialog(null)} title={`Reset ${dialog.user.name}'s password?`}
footer={<><Button variant="ghost" onClick={() => setDialog(null)}>Cancel</Button><Button variant="primary" loading={busy === dialog.user.id} onClick={() => reset(dialog.user)}>Reset password</Button></>}>
<p className="text-[13px]">They'll be signed out everywhere and get a temporary password, which you'll see on the next screen.</p>
</Dialog>
)}
{dialog?.kind === "remove" && (
<Dialog open onClose={() => setDialog(null)} title={`Remove ${dialog.user.name}?`}
footer={<><Button variant="ghost" onClick={() => setDialog(null)}>Cancel</Button><Button variant="danger" loading={busy === dialog.user.id} onClick={() => remove(dialog.user)}>Remove</Button></>}>
<p className="text-[13px]">{dialog.user.email} will lose access immediately. Their uploads and history stay. To pause access instead, use Deactivate.</p>
</Dialog>
)}
</Panel>
);
}
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 (
<Panel title="Meta (Facebook & Instagram)" subtitle="Quorla reads and posts through your Meta business's system user." bodyClass="px-5 py-4 space-y-3">
{d.source === "env" ? <p className="text-[13px] text-muted">Connected through the server's .env file (META_SYSTEM_USER_TOKEN).</p> : (
<form onSubmit={save} className="space-y-2">
<p className="text-[13px] text-muted">{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.</p>
<div className="flex gap-2">
<label htmlFor="meta-token" className="sr-only">System user token</label>
<input id="meta-token" type="password" autoComplete="off" className="input" value={token} onChange={(e) => setToken(e.target.value)} placeholder="EAA…" />
<Button type="submit" variant="primary" loading={busy} disabled={!token.trim()}>Save</Button>
</div>
</form>
)}
{msg && (typeof msg === "string" ? <div className="text-[12.5px] text-ok">{msg}</div> : <ErrorNote error={msg} />)}
</Panel>
);
}
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 (
<Panel title="Workspace" subtitle="Your agency's name, logo and colour. Used in the app, on client approval pages, in emails and on PDF reports." bodyClass="px-5 py-4 space-y-4">
<div id="workspace" className="grid sm:grid-cols-2 gap-3">
<Field label="Agency name"><input id="ws-name" className="input" value={w.name} onChange={(e) => set("name", e.target.value)} placeholder="e.g. Northstar Digital" /></Field>
<Field label="Brand colour" hint="Buttons, links and highlights. Leave empty for Quorla's navy.">
<div className="flex gap-2 items-center">
<input id="ws-accent" type="color" className="size-9 rounded border border-line" value={w.accent || "#3b3778"} onChange={(e) => set("accent", e.target.value)} aria-label="Brand colour" />
<input className="input" value={w.accent} onChange={(e) => set("accent", e.target.value)} placeholder="#3b3778" aria-label="Brand colour hex" />
</div>
</Field>
<Field label="Support email" hint="Shown to clients as the reply-to address."><input id="ws-email" type="email" className="input" value={w.supportEmail} onChange={(e) => set("supportEmail", e.target.value)} /></Field>
<Field label="Website"><input id="ws-web" className="input" value={w.website} onChange={(e) => set("website", e.target.value)} placeholder="https://" /></Field>
</div>
<Field label="Logo" hint="PNG or JPG, up to 2 MB. A wide logo on a transparent background works best. Replaces the Quorla logo in the app and on reports.">
<div className="flex items-center gap-3 flex-wrap">
{w.hasLogo && <img src={`/brand/agency-logo?v=${v}`} alt="Current logo" className="h-10 w-auto max-w-[200px] object-contain border border-line rounded p-1 bg-white" />}
<label className="inline-flex items-center h-8 px-3 rounded-md border border-line-strong text-[13px] cursor-pointer hover:bg-canvas">
{busy === "logo" ? "Uploading…" : w.hasLogo ? "Replace logo" : "Upload logo"}
<input type="file" accept="image/png,image/jpeg" className="sr-only" onChange={(e) => e.target.files[0] && upload(e.target.files[0])} />
</label>
{w.hasLogo && <Button size="sm" variant="ghost" loading={busy === "rmlogo"} onClick={() => run("rmlogo", () => api("/api/workspace/logo", { method: "DELETE" }))}>Remove</Button>}
</div>
</Field>
<div className="flex items-center gap-3">
<Button variant="primary" loading={busy === "save"} onClick={() => run("save", () => api("/api/workspace", { method: "PUT", body: { name: w.name, accent: w.accent, supportEmail: w.supportEmail, website: w.website } }))}>Save workspace</Button>
{msg && (typeof msg === "string" ? <span className="text-[12.5px] text-ok">{msg}</span> : <ErrorNote error={msg} />)}
</div>
<p className="text-[12px] text-muted">Your own domain (like social.youragency.com): point the domain at the server and set PUBLIC_URL in .env. See the hosting guide.</p>
</Panel>
);
}
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 (
<Panel title="Notifications" subtitle="The bell at the top of the sidebar always shows them. Choose what also comes by email." bodyClass="px-5 py-4 space-y-3">
{!d.email && <div className="text-[13px] rounded-md bg-warn-soft text-warn px-3 py-2">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.</div>}
<label htmlFor="np-email" className="flex items-start gap-2.5 text-[13.5px]">
<input id="np-email" type="checkbox" className="mt-1" checked={d.prefs.email} disabled={busy === "email"} onChange={() => toggle("email")} />
<span><b>Email me when something needs me</b><br /><span className="text-muted text-[12.5px]">A client approved or asked for changes, a post failed, or a TikTok post is waiting in the TikTok app.</span></span>
</label>
<label htmlFor="np-digest" className="flex items-start gap-2.5 text-[13.5px]">
<input id="np-digest" type="checkbox" className="mt-1" checked={d.prefs.digest} disabled={busy === "digest"} onChange={() => toggle("digest")} />
<span><b>Morning summary at 8:00</b><br /><span className="text-muted text-[12.5px]">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.</span></span>
</label>
{d.email && <Button size="sm" loading={busy === "test"} onClick={test}>Send me a test email</Button>}
{msg && (typeof msg === "string" ? <div className="text-[12.5px] text-ok">{msg}</div> : <ErrorNote error={msg} />)}
</Panel>
);
}
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 (
<div className="space-y-5 max-w-[960px]">
<header>
<h1 className="text-[22px] font-extrabold">Settings</h1>
<p className="text-muted mt-0.5">Your account{isAdmin ? ", the team's logins" : ""} and this dashboard's connections.</p>
</header>
{isAdmin && <WorkspacePanel />}
{isAdmin && <MetaPanel />}
<AccountPanel />
<NotificationsPanel />
{isAdmin && <TeamPanel />}
<ErrorNote error={error} />
<Panel title="Connections" subtitle="Changed in the server's .env for now." bodyClass="divide-y divide-line">
{!s ? <Spinner /> : (
<>
<Row ok={s.meta.connected} title="Meta (Facebook & Instagram)">
{s.meta.connected ? `${s.meta.accounts} Pages connected through the agency's system user.` : `Not connected: ${s.meta.error}`}
</Row>
<Row ok={s.odoo.connected} warn={!s.odoo.configured} title="Odoo · team tasks">
{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."}
</Row>
<Row ok warn={!s.ai.apiKey} title="Claude · AI runs">
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."}
</Row>
<Row ok={s.publishing} warn={!s.publishing} title="Publishing">
{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."}
</Row>
</>
)}
</Panel>
</div>
);
}