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>
This commit is contained in:
metatroncubeswdev 2026-10-21 12:32:30 -04:00
parent af2fbb9f8e
commit 059c236d3f
14 changed files with 128 additions and 38 deletions

38
HANDOFF.md Normal file
View File

@ -0,0 +1,38 @@
# Quorla: handoff (21 Oct 2026)
Where things stand and what's next. Live app: https://app.quorlasocial.com (HestiaCP server, `~/APPS/quorlasocial`,
Docker behind the Hestia nginx proxy on 127.0.0.1:4310). Hosting steps: `docs/hosting.html`.
## Live and working
- Dashboard on the server: team logins, admin can add teammates and reset passwords, scheduler runs there.
- Meta (Facebook + Instagram) through the system user. Infini is Instagram-only (no Facebook Page by choice).
- Connections page with quotas (`data/quota.json`; YouTube 10,000 units/day Pacific time, rolling 24 h for others).
- **TikTok:** Sandbox app "Quorla testing" connected (test user @metatron_digitalagency). Domain verified
(two DNS TXT records, both must stay). Details: `docs/tiktok-app.md`.
- **Google:** connecting works now.
- YouTube → Cloud project **Quorla**. GBP → project **GMB Audit** (gmb-audit-489406, GBP API already approved).
- Both are in Testing mode. Only test users can connect: dev@metatroncubesolutions.com and
metatroncubesoftwaresolutions@gmail.com. Add more under Google Auth Platform → Audience → Test users.
- Testing-mode tokens expire after 7 days, so reconnect weekly until the app is verified. Details: `docs/google-app.md`.
- Search Console: quorlasocial.com verified. Legal pages: /privacy and /terms.
## To do next
1. **TikTok, YouTube and Google Business Profile are on the wrong client** (C2C Agricorp India). After the next
deploy, open Connections → C2C Agricorp India and click **Move** on each card, then pick
MetatronCube Software Solutions Canada. No reconnecting needed. Then choose the Metatroncube location for GBP.
2. First test posts, as drafts approved by a person before anything publishes:
- TikTok: private ("Only me"). Needs a short vertical 9:16 video.
- YouTube: unlisted/private upload with a title.
- GBP: one update post on the Metatroncube profile.
3. TikTok Production: record the demo video (script in `docs/tiktok-app.md`), fill in the listing, and submit for review.
4. Google: submit the OAuth app for verification (YouTube also needs its API audit before the quota goes above the default).
5. Still to set up: LinkedIn, Pinterest, Threads (Meta permissions) and Reddit apps (`docs/platforms.md`).
6. Later: marketing site, Stripe. (The dashboard now uses the Quorla logo and colours; PDF reports keep the agency branding.)
7. Optional: on the server, run `git stash drop` (left over from the branding CRLF fix).
## Rules that don't change
- Nothing publishes without a person approving the exact caption and media.
- White-label: no Metatroncube mention on client posts.
- Keys and secrets live only in `.env` or the encrypted Connections vault, never in chat, Git, or docs.
- Tests: `node --test connector/test/platforms.test.mjs connector/test/quota.test.mjs`. On Windows, list the files by name;
passing the folder fails.

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -128,6 +128,22 @@ export function updateConnection(id, patch) {
return publicConn(c);
}
/** Move a connection to another client (e.g. connected under the wrong one). Keeps tokens and target. */
export function moveConnection(id, client, by) {
const db = read();
const c = db.connections.find((x) => x.id === id);
if (!c) throw err("Connection not found.", 404);
if (c.client === client) return publicConn(c);
if (db.connections.some((x) => x.client === client && x.platform === c.platform)) {
throw err("That client already has an account connected on this platform. Disconnect it there first.");
}
c.client = client;
c.movedBy = by || null;
c.updatedAt = now();
write(db);
return publicConn(c);
}
export function removeConnection(id) {
const db = read();
const before = db.connections.length;

View File

@ -315,3 +315,14 @@ test("expired tokens are refreshed and saved; failed refresh asks to reconnect",
test("posting to an unconnected platform is refused clearly", () => {
assert.throws(() => P.validate("linkedin", "nobody", { type: "TEXT", media: [], caption: "x", options: {} }), /isn't connected for this client/);
});
test("vault: a connection can move to another client, but not onto an existing one", () => {
const tok = { accessToken: "a", refreshToken: "r" };
const a = vault.saveConnection({ client: "move-from", platform: "tiktok", account: { id: "u1", name: "One" }, tokens: tok, by: "t" });
vault.saveConnection({ client: "move-taken", platform: "tiktok", account: { id: "u2", name: "Two" }, tokens: tok, by: "t" });
assert.throws(() => vault.moveConnection(a.id, "move-taken"), /already has an account/);
const moved = vault.moveConnection(a.id, "move-to", "t");
assert.equal(moved.client, "move-to");
assert.equal(vault.connectionFor("move-from", "tiktok"), null);
assert.equal(vault.connectionFor("move-to", "tiktok", { withTokens: true }).tokens.accessToken, "a");
});

View File

@ -47,8 +47,11 @@ app.post("/api/auth/login", login);
app.post("/api/auth/logout", logout);
app.post("/api/auth/setup", setupFirstAdmin);
app.get("/api/auth/me", (req, res) => res.json({ user: currentUser(req), agency: AGENCY_NAME, setupNeeded: !users.hasUsers() }));
app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-logo.png")));
app.get("/brand/mark.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-mark.png")));
// App (Quorla) branding. Agency branding for PDF reports stays in branding/ and is used by tools/report-pdf.
app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-logo-app.png")));
app.get("/brand/logo-white.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-logo-app-white.png")));
app.get("/brand/mark.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-mark-app.png")));
app.get("/brand/icon.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-icon-1024.png")));
// Public legal pages (linked from platform app listings: TikTok, Google, LinkedIn, Pinterest, Meta).
const LEGAL_DIR = fileURLToPath(new URL("./legal/", import.meta.url));
@ -109,7 +112,14 @@ app.get("/api/oauth/:platform/start", requireAdmin, h(async (req) => {
app.post("/api/connections/:id/check", requireAdmin, h(async (req) => ({ connection: await extra.checkConnection(req.params.id) })));
app.get("/api/connections/:id/creator-info", h(async (req) => ({ creator: await extra.creatorInfo(req.params.id) })));
app.get("/api/connections/:id/targets", h(async (req) => ({ targets: await extra.targetsFor(req.params.id) })));
app.patch("/api/connections/:id", requireAdmin, h(async (req) => ({ connection: await extra.setTarget(req.params.id, String(req.body?.targetId || "")) })));
app.patch("/api/connections/:id", requireAdmin, h(async (req) => {
if (req.body?.client !== undefined) {
const to = String(req.body.client || "");
if (!clients.getClient(to)) throw Object.assign(new Error("Pick a client to move it to."), { status: 400 });
return { connection: extra.vault.moveConnection(req.params.id, to, req.user.name || req.user.email) };
}
return { connection: await extra.setTarget(req.params.id, String(req.body?.targetId || "")) };
}));
app.delete("/api/connections/:id", requireAdmin, h(async (req) => { extra.vault.removeConnection(req.params.id); return { ok: true }; }));
// ---------- team members (admins) ----------

View File

@ -5,10 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" type="image/png" href="/brand/mark.png" />
<link rel="apple-touch-icon" href="/brand/icon.png" />
<meta name="theme-color" content="#242142" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap" rel="stylesheet" />
<title>Social Command · Metatroncube</title>
<title>Quorla</title>
</head>
<body>
<div id="root"></div>

View File

@ -29,15 +29,13 @@ const NAV = [
];
function Shell({ children }) {
const { user, signOut } = useAuth();
const { user, signOut, agency } = useAuth();
return (
<div className="min-h-screen flex">
<aside className="w-[228px] shrink-0 bg-white border-r border-line flex flex-col fixed inset-y-0 left-0 z-10">
<div className="px-4 pt-4 pb-3">
<div className="h-[46px] overflow-hidden">
<img src="/brand/logo.png" alt="Agency logo" className="h-[64px] -mt-[7px]" />
</div>
<div className="mt-2 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint">Social command</div>
<img src="/brand/logo.png" alt="Quorla" className="h-[30px] w-auto" />
{agency && <div className="mt-2.5 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint truncate" title={agency}>{agency}</div>}
</div>
<nav className="px-2 mt-2 space-y-0.5 flex-1">
{NAV.map(({ to, label, icon: Icon, end }) => (
@ -89,7 +87,7 @@ export default function App() {
api("/api/auth/me")
.then((r) => {
setState({ loading: false, user: r.user, setupNeeded: r.setupNeeded, agency: r.agency });
if (r.agency) document.title = `Social Command · ${r.agency}`;
document.title = r.agency ? `Quorla · ${r.agency}` : "Quorla";
})
.catch(() => setState({ loading: false, user: null }));
}, []);
@ -110,7 +108,7 @@ export default function App() {
if (!state.user) return <Login setupNeeded={state.setupNeeded} agency={state.agency} onSignedIn={refresh} />;
return (
<AuthCtx.Provider value={{ user: state.user, signOut, refresh, isAdmin: state.user.role === "admin" }}>
<AuthCtx.Provider value={{ user: state.user, agency: state.agency, signOut, refresh, isAdmin: state.user.role === "admin" }}>
<BrowserRouter>
<Shell>
<Routes>

View File

@ -9,8 +9,8 @@ import {
} from "../ui.jsx";
const FORMAT = {
reel: { label: "Reel", color: "#3a7bbf" },
carousel: { label: "Carousel", color: "#52ace0" },
reel: { label: "Reel", color: "#3b3778" },
carousel: { label: "Carousel", color: "#fc6d45" },
image: { label: "Image", color: "#a9afba" },
};
const formatOf = (p) => (p.media_product_type === "REELS" || p.media_type === "VIDEO" ? "reel" : p.media_type === "CAROUSEL_ALBUM" ? "carousel" : "image");
@ -278,15 +278,15 @@ function Performance({ snapshot, history, refreshing, onRefresh }) {
<AreaChart data={ig.dailyReach.map((x) => ({ ...x, label: fmtDate(x.date) }))} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="reachFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3a7bbf" stopOpacity={0.22} />
<stop offset="100%" stopColor="#3a7bbf" stopOpacity={0} />
<stop offset="0%" stopColor="#3b3778" stopOpacity={0.18} />
<stop offset="100%" stopColor="#3b3778" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} stroke="#eef0f3" />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#8a8f99" }} tickLine={false} axisLine={{ stroke: "#e4e7ec" }} minTickGap={24} />
<YAxis tick={{ fontSize: 11, fill: "#8a8f99" }} tickLine={false} axisLine={false} width={48} allowDecimals={false} tickFormatter={(v) => (v >= 10000 ? `${Math.round(v / 1000)}k` : v)} />
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8, border: "1px solid #e4e7ec" }} formatter={(v) => [fmt(v), "Reached"]} />
<Area type="monotone" dataKey="value" stroke="#3a7bbf" strokeWidth={2} fill="url(#reachFill)" isAnimationActive={false} />
<Area type="monotone" dataKey="value" stroke="#3b3778" strokeWidth={2} fill="url(#reachFill)" isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Check, CircleAlert, Copy, ExternalLink, Plug, RefreshCw, Settings2, Unplug } from "lucide-react";
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";
@ -77,8 +77,9 @@ function AppDialog({ platform, onClose, onSaved }) {
);
}
function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, reload, setError, quota }) {
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;
@ -114,7 +115,7 @@ function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, re
<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">@{conn.account.username}</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"} · </>}
@ -138,6 +139,18 @@ function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, re
</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">
@ -145,6 +158,7 @@ function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, re
{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>}
@ -224,8 +238,8 @@ export default function Connections() {
<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} isAdmin={isAdmin}
mediaLinks={platforms.mediaLinks} onSetup={() => setSetup(p)} reload={loadConns} setError={setError} quota={quota[p.id]} />
<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>

View File

@ -2,7 +2,7 @@ import { useState } from "react";
import { api } from "../api.js";
import { Button, ErrorNote, Field } from "../ui.jsx";
export default function Login({ onSignedIn, setupNeeded, agency = "Metatroncube" }) {
export default function Login({ onSignedIn, setupNeeded, agency }) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@ -28,23 +28,21 @@ export default function Login({ onSignedIn, setupNeeded, agency = "Metatroncube"
return (
<div className="min-h-screen grid lg:grid-cols-[1fr_520px] bg-white">
<div className="hidden lg:flex flex-col justify-between bg-[#1f2024] text-white p-12 relative overflow-hidden">
<div className="absolute -right-32 -top-32 size-[420px] rounded-full bg-[radial-gradient(circle,rgba(82,172,224,0.28),transparent_65%)]" />
<div className="text-[12px] font-semibold tracking-[0.12em] uppercase text-sky">{agency} · Social command</div>
<div className="hidden lg:flex flex-col justify-between bg-brand-deep text-white p-12 relative overflow-hidden">
<div className="absolute -right-32 -top-32 size-[420px] rounded-full bg-[radial-gradient(circle,rgba(252,109,69,0.30),transparent_65%)]" />
<img src="/brand/logo-white.png" alt="Quorla" className="h-[34px] w-auto self-start" />
<div className="max-w-md">
<h1 className="text-white text-[34px] font-extrabold leading-[1.15]">Every client account, the team's work and the reports, in one place.</h1>
<p className="text-[#b7bac3] mt-4 text-[14.5px] leading-relaxed">
Live Instagram and Facebook numbers, AI-run audits and weekly plans, branded PDF reports and Odoo tasks.
<p className="text-[#c9c7dd] mt-4 text-[14.5px] leading-relaxed">
Plan, approve and publish to Instagram, Facebook, TikTok, YouTube, LinkedIn and more, with live numbers, AI-run audits and weekly plans, and reports your clients can read.
</p>
</div>
<div className="text-[12px] text-[#8b8f99]">Internal tool · client data is confidential</div>
<div className="text-[12px] text-[#9e9bbd]">{agency ? `${agency} workspace · ` : ""}client data is confidential</div>
</div>
<div className="flex items-center justify-center p-8">
<form onSubmit={submit} className="w-full max-w-[340px]">
<div className="h-[54px] overflow-hidden mb-8">
<img src="/brand/logo.png" alt={agency} className="h-[76px] -mt-[8px]" />
</div>
<img src="/brand/logo.png" alt="Quorla" className="h-[36px] w-auto mb-8" />
<h2 className="text-[20px] font-extrabold">{setupNeeded ? "Create the admin account" : "Sign in"}</h2>
<p className="text-muted text-[13px] mt-1 mb-6">
{setupNeeded ? "First run: this person can add the rest of the team from Settings." : "Use your agency email."}

View File

@ -1,14 +1,17 @@
@import "tailwindcss";
/* Metatroncube design tokens — keep in sync with branding/report.css */
/* Quorla app tokens (navy #242142, coral #FC6D45). Agency PDF reports keep their own styles in branding/report.css. */
@theme {
--font-sans: "Inter", "Segoe UI", system-ui, sans-serif;
--font-display: "Plus Jakarta Sans", "Segoe UI", system-ui, sans-serif;
--color-brand: #3a7bbf;
--color-brand-deep: #2c5f96;
--color-brand-soft: #eaf2fa;
--color-sky: #52ace0;
--color-brand: #3b3778;
--color-brand-deep: #242142;
--color-brand-soft: #efeef7;
--color-sky: #fc6d45;
--color-accent: #fc6d45;
--color-accent-deep: #c2461a;
--color-accent-soft: #fff0ea;
--color-ink: #1f2024;
--color-ink-2: #45474f;
--color-muted: #6b6e78;
@ -28,7 +31,7 @@
html { -webkit-font-smoothing: antialiased; }
body { @apply bg-canvas text-ink font-sans text-[13.5px] leading-[1.5]; }
h1, h2, h3 { @apply font-display text-ink; letter-spacing: -0.01em; }
::selection { background: #cfe2f5; }
::selection { background: #ffd9cb; }
:focus-visible { outline: 2px solid var(--color-brand); outline-offset: 2px; border-radius: 4px; }
}

View File

@ -196,7 +196,7 @@ export function Spark({ values, width = 96, height = 26 }) {
<svg width={width} height={height} aria-hidden="true" className="block">
{values.map((v, i) => {
const h = Math.max(2, (v / max) * (height - 2));
return <rect key={i} x={i * w + 1} y={height - h} width={Math.max(2, w - 2)} height={h} rx={1} fill={i === values.length - 1 ? "#3a7bbf" : "#b9d2ea"} />;
return <rect key={i} x={i * w + 1} y={height - h} width={Math.max(2, w - 2)} height={h} rx={1} fill={i === values.length - 1 ? "#fc6d45" : "#c9c7e0"} />;
})}
</svg>
);