quorlasocial/dashboard/web/src/pages/Connections.jsx
metatroncubeswdev 059c236d3f Quorla look for the dashboard; move a connection to another client
- Quorla logo, navy/coral colours and page titles in the app (PDF reports keep agency branding)
- Connections: Move button reassigns an account to another client without reconnecting
- Fix double @ on YouTube handles
- HANDOFF.md with current status and next steps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:32:30 -04:00

250 lines
16 KiB
JavaScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { ArrowRightLeft, Check, CircleAlert, Copy, ExternalLink, Plug, RefreshCw, Settings2, Unplug } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Dialog, ErrorNote, Field, fmt, Panel, Spinner, timeAgo } from "../ui.jsx";
const FORMAT_LABEL = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" };
/** "3 of 15 posts used · resets 9:12 PM" (YouTube: units per day, shared by all clients). */
function QuotaLine({ q }) {
if (!q) return null;
const reset = new Date(q.resetAt).toLocaleString("en-CA", { weekday: "short", hour: "numeric", minute: "2-digit" });
const full = q.remaining <= 0;
return (
<div className={cx("text-[12px]", full ? "text-warn" : "text-muted")}>
{fmt(q.used)} of {fmt(q.limit)} {q.unit} used {q.window === "pt-day" ? "today (all clients)" : "in the last 24 h"}
{(q.used > 0 || full) && <> · {full ? "full, posts wait until" : "frees up"} {reset}</>}
</div>
);
}
function CopyText({ text }) {
const [done, setDone] = useState(false);
const copy = () => navigator.clipboard?.writeText(text).then(() => { setDone(true); setTimeout(() => setDone(false), 1400); }).catch(() => {});
return (
<div className="flex items-center gap-2 rounded-md border border-line bg-canvas px-2.5 py-1.5">
<code className="font-mono text-[12.5px] break-all flex-1 min-w-0">{text}</code>
<button type="button" onClick={copy} className="p-1 rounded hover:bg-white text-muted shrink-0" aria-label="Copy">{done ? <Check className="size-4 text-ok" /> : <Copy className="size-4" />}</button>
</div>
);
}
/** Admin: enter a platform's developer-app credentials (stored encrypted on the server). */
function AppDialog({ platform, onClose, onSaved }) {
const [f, setF] = useState({ clientId: platform.app.clientId || "", clientSecret: "", scopes: (platform.app.scopes || []).join(" ") });
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const set = (k) => (e) => setF({ ...f, [k]: e.target.value });
const save = async (e) => {
e.preventDefault(); setBusy("save"); setError(null);
try { await api(`/api/platforms/${platform.id}/app`, { method: "PUT", body: f }); onSaved(); } catch (err) { setError(err); setBusy(null); }
};
const remove = async () => {
setBusy("remove"); setError(null);
try { await api(`/api/platforms/${platform.id}/app`, { method: "DELETE" }); onSaved(); } catch (err) { setError(err); setBusy(null); }
};
return (
<Dialog open onClose={onClose} wide title={`Set up ${platform.name}`}
footer={<>
{platform.app.configured && platform.app.source === "settings" && <Button variant="danger" className="mr-auto" loading={busy === "remove"} onClick={remove}>Remove app keys</Button>}
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" type="submit" form="app-form" loading={busy === "save"}>Save</Button>
</>}>
<div className="space-y-4 text-[13.5px]">
<ol className="list-decimal pl-5 space-y-1.5 text-ink-2">
{platform.setup.steps.map((s) => <li key={s}>{s}</li>)}
</ol>
<a href={platform.setup.console} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-brand-deep font-medium">Open the {platform.name} developer console <ExternalLink className="size-3.5" /></a>
<Field label="Redirect URI (paste this into the developer console)"><CopyText text={platform.redirectUri} /></Field>
{platform.setup.note && <div className="rounded-md border border-[#f1dcb8] bg-warn-soft text-warn px-3 py-2 text-[13px]">{platform.setup.note}</div>}
<form id="app-form" onSubmit={save} className="grid grid-cols-2 gap-3">
<Field label={platform.id === "tiktok" ? "Client key" : "Client ID / App ID"}><input id="app-client-id" className="input" value={f.clientId} onChange={set("clientId")} required autoComplete="off" /></Field>
<Field label="Client secret" hint={platform.app.configured ? "Leave empty to keep the stored secret." : "Stored encrypted. Never shown again."}>
<input id="app-client-secret" className="input" type="password" value={f.clientSecret} onChange={set("clientSecret")} required={!platform.app.configured} autoComplete="new-password" />
</Field>
<div className="col-span-2">
<Field label="Scopes (optional)" hint={`Leave empty for the defaults: ${platform.defaultScopes.join(" ")}`}>
<input id="app-scopes" className="input" value={f.scopes} onChange={set("scopes")} placeholder={platform.defaultScopes.join(" ")} />
</Field>
</div>
</form>
{platform.app.source === "env" && <p className="text-muted text-[12.5px]">Currently using keys from the server's .env. Saving here overrides them.</p>}
<ErrorNote error={error} />
</div>
</Dialog>
);
}
function PlatformCard({ platform, conn, client, clients, isAdmin, mediaLinks, onSetup, onMoved, reload, setError, quota }) {
const [busy, setBusy] = useState(null);
const [moving, setMoving] = useState(false);
const [targets, setTargets] = useState(null);
const needsMediaLinks = ["tiktok", "threads", "gbp"].includes(platform.id) && !mediaLinks;
const connect = async () => {
setBusy("connect"); setError(null);
try { const { url } = await api(`/api/oauth/${platform.id}/start?client=${encodeURIComponent(client)}`); window.location.assign(url); }
catch (e) { setError(e); setBusy(null); }
};
const run = async (what, fn) => { setBusy(what); setError(null); try { await fn(); await reload(); } catch (e) { setError(e); } setBusy(null); };
const loadTargets = useCallback(() => {
if (!conn || !platform.targetLabel) return;
api(`/api/connections/${conn.id}/targets`).then((r) => setTargets(r.targets)).catch(() => setTargets([]));
}, [conn, platform.targetLabel]);
useEffect(() => { loadTargets(); }, [loadTargets]);
const status = !platform.app.configured ? { tone: "neutral", text: "Not set up" }
: !conn ? { tone: "neutral", text: "Not connected" }
: conn.status === "connected" ? { tone: "ok", text: "Connected" }
: { tone: "bad", text: conn.status === "reconnect" ? "Reconnect needed" : "Error" };
return (
<div className="panel p-4 flex flex-col gap-3 min-w-0">
<div className="flex items-start gap-3">
<span className="size-9 rounded-lg grid place-items-center text-white font-bold text-[13px] shrink-0" style={{ background: platform.color }} aria-hidden="true">{platform.name[0]}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"><span className="font-semibold">{platform.name}</span><Badge tone={status.tone} dot>{status.text}</Badge></div>
<div className="text-[12px] text-muted mt-0.5">Posts: {platform.formats.map((f) => FORMAT_LABEL[f]).join(", ")}</div>
</div>
</div>
{conn && (
<div className="text-[13px] space-y-1">
<div className="flex items-center gap-2 min-w-0">
{conn.account?.avatar && <img src={conn.account.avatar} alt="" className="size-6 rounded-full shrink-0" />}
<span className="font-medium truncate">{conn.account?.name}</span>
{conn.account?.username && <span className="text-muted truncate">@{String(conn.account.username).replace(/^@+/, "")}</span>}
</div>
<div className="text-[12px] text-muted">
{conn.account?.followers != null && <>{fmt(conn.account.followers)} {platform.id === "youtube" ? "subscribers" : platform.id === "reddit" ? "karma" : "followers"} · </>}
Connected {timeAgo(conn.connectedAt)} by {conn.connectedBy}
{conn.account?.url && <> · <a className="text-brand-deep" href={conn.account.url} target="_blank" rel="noreferrer">View profile</a></>}
</div>
<QuotaLine q={quota} />
{conn.lastError && <div className="text-[12px] text-bad">{conn.lastError}</div>}
</div>
)}
{conn && platform.targetLabel && (
<Field label={platform.targetLabel}>
{targets === null ? <div className="text-[12.5px] text-muted">Loading…</div> : (
<select id={`target-${platform.id}`} className="input" value={conn.target?.id || ""} disabled={!isAdmin || busy === "target"}
onChange={(e) => run("target", () => api(`/api/connections/${conn.id}`, { method: "PATCH", body: { targetId: e.target.value } }))}>
<option value="" disabled>{targets.length ? "Choose…" : "Nothing found for this account"}</option>
{targets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
)}
</Field>
)}
{isAdmin && conn && moving && (
<Field label="Move this account to">
<select id={`move-${platform.id}`} className="input" defaultValue="" disabled={busy === "move"}
onChange={(e) => { const to = e.target.value; if (!to) return; setBusy("move"); setError(null);
api(`/api/connections/${conn.id}`, { method: "PATCH", body: { client: to } })
.then(() => { setMoving(false); onMoved(to); }).catch((er) => setError(er)).finally(() => setBusy(null)); }}>
<option value="" disabled>Choose a client…</option>
{clients.filter((c) => c.slug !== client).map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
</select>
</Field>
)}
{needsMediaLinks && <div className="text-[12px] text-warn">Image and video posts need PUBLIC_URL set on the server (e.g. https://app.quorlasocial.com).</div>}
<div className="flex flex-wrap gap-2 mt-auto pt-1">
{isAdmin && !platform.app.configured && <Button size="sm" variant="primary" icon={Settings2} onClick={onSetup}>Set up</Button>}
{isAdmin && platform.app.configured && !conn && <Button size="sm" variant="primary" icon={Plug} loading={busy === "connect"} onClick={connect}>Connect</Button>}
{isAdmin && conn && <Button size="sm" icon={Plug} loading={busy === "connect"} onClick={connect}>Reconnect</Button>}
{isAdmin && conn && <Button size="sm" variant="ghost" icon={RefreshCw} loading={busy === "check"} onClick={() => run("check", () => api(`/api/connections/${conn.id}/check`, { method: "POST" }))}>Check</Button>}
{isAdmin && conn && <Button size="sm" variant="ghost" icon={ArrowRightLeft} onClick={() => setMoving(!moving)}>{moving ? "Cancel move" : "Move"}</Button>}
{isAdmin && conn && <Button size="sm" variant="ghost" icon={Unplug} loading={busy === "remove"} onClick={() => run("remove", () => api(`/api/connections/${conn.id}`, { method: "DELETE" }))}>Disconnect</Button>}
{isAdmin && platform.app.configured && <Button size="sm" variant="ghost" icon={Settings2} onClick={onSetup} aria-label={`${platform.name} app settings`} />}
{!isAdmin && !conn && <span className="text-[12.5px] text-muted">An admin connects this.</span>}
</div>
</div>
);
}
export default function Connections() {
const { isAdmin } = useAuth();
const [params, setParams] = useSearchParams();
const [clients, setClients] = useState(null);
const [platforms, setPlatforms] = useState(null);
const [conns, setConns] = useState([]);
const [error, setError] = useState(null);
const [setup, setSetup] = useState(null);
const [quota, setQuota] = useState({});
const client = params.get("client") || "";
const notice = params.get("connected");
const oauthError = params.get("error");
const loadPlatforms = useCallback(() => api("/api/platforms").then(setPlatforms).catch(setError), []);
const loadConns = useCallback(() => (client ? Promise.all([
api(`/api/connections?client=${encodeURIComponent(client)}`).then((r) => setConns(r.connections)),
api(`/api/quota?client=${encodeURIComponent(client)}`).then((r) => setQuota(r.quota)).catch(() => setQuota({})),
]).catch(setError) : Promise.resolve()), [client]);
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); loadPlatforms(); }, [loadPlatforms]);
useEffect(() => { loadConns(); }, [loadConns]);
useEffect(() => { if (!client && clients?.length) setParams({ client: clients[0].slug }, { replace: true }); }, [client, clients, setParams]);
const current = useMemo(() => clients?.find((c) => c.slug === client), [clients, client]);
const pickClient = (slug) => setParams({ client: slug });
const dismiss = () => setParams({ client });
if (!clients || !platforms) return error ? <ErrorNote error={error} /> : <Spinner />;
return (
<div className="space-y-5">
<header className="flex items-end justify-between gap-4 flex-wrap">
<div>
<h1 className="text-[22px] font-extrabold">Connections</h1>
<p className="text-muted mt-0.5">Each client's accounts on every platform. Connect once; posts then go through Publishing with the usual approval.</p>
</div>
<select id="conn-client" className="input h-9 w-auto" value={client} onChange={(e) => pickClient(e.target.value)} aria-label="Client">
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
</select>
</header>
{notice && (
<div className="rounded-md border border-[#cfe6d7] bg-ok-soft text-ok px-3 py-2 text-[13px] flex justify-between gap-3">
<span>{platforms.platforms.find((p) => p.id === notice)?.name || notice} is connected for {current?.name}.</span>
<button type="button" className="underline" onClick={dismiss}>Dismiss</button>
</div>
)}
{oauthError && (
<div className="rounded-md border border-[#f2cdc8] bg-bad-soft text-bad px-3 py-2 text-[13px] flex justify-between gap-3">
<span className="flex gap-2"><CircleAlert className="size-4 shrink-0 mt-0.5" />{oauthError}</span>
<button type="button" className="underline shrink-0" onClick={dismiss}>Dismiss</button>
</div>
)}
<ErrorNote error={error} />
<Panel title="Facebook & Instagram" subtitle="Connected through the agency's Meta system user, not here.">
<div className="px-4 py-3 text-[13px] flex flex-wrap gap-x-6 gap-y-1">
<span><b>Instagram:</b> {current?.igUsername ? `@${current.igUsername}` : <span className="text-muted">not linked</span>}</span>
<span><b>Facebook:</b> {current?.pageId ? "Page linked" : current?.igOnly ? <span className="text-muted">none (Instagram only)</span> : <span className="text-muted">not linked</span>}</span>
{!current?.linked && <span className="text-muted">Link it from the client's page.</span>}
</div>
{(quota.instagram || quota.facebook) && (
<div className="px-4 pb-3 grid gap-0.5">
{quota.instagram && <div className="flex gap-2 items-baseline"><span className="text-[12px] font-medium w-[70px]">Instagram</span><QuotaLine q={quota.instagram} /></div>}
{quota.facebook && <div className="flex gap-2 items-baseline"><span className="text-[12px] font-medium w-[70px]">Facebook</span><QuotaLine q={quota.facebook} /></div>}
</div>
)}
</Panel>
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(300px,1fr))]">
{platforms.platforms.map((p) => (
<PlatformCard key={`${p.id}-${client}`} platform={p} conn={conns.find((c) => c.platform === p.id)} client={client} clients={clients} isAdmin={isAdmin}
mediaLinks={platforms.mediaLinks} onSetup={() => setSetup(p)} onMoved={pickClient} reload={loadConns} setError={setError} quota={quota[p.id]} />
))}
</div>
{setup && <AppDialog platform={setup} onClose={() => setSetup(null)} onSaved={() => { setSetup(null); loadPlatforms(); }} />}
</div>
);
}