diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..6728e0a --- /dev/null +++ b/HANDOFF.md @@ -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. diff --git a/branding/quorla-logo-app-white.png b/branding/quorla-logo-app-white.png new file mode 100644 index 0000000..da8ca6d Binary files /dev/null and b/branding/quorla-logo-app-white.png differ diff --git a/branding/quorla-logo-app.png b/branding/quorla-logo-app.png new file mode 100644 index 0000000..45790c2 Binary files /dev/null and b/branding/quorla-logo-app.png differ diff --git a/branding/quorla-mark-app.png b/branding/quorla-mark-app.png new file mode 100644 index 0000000..c8da31d Binary files /dev/null and b/branding/quorla-mark-app.png differ diff --git a/connector/src/platforms/vault.js b/connector/src/platforms/vault.js index 8cc0a36..327106d 100644 --- a/connector/src/platforms/vault.js +++ b/connector/src/platforms/vault.js @@ -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; diff --git a/connector/test/platforms.test.mjs b/connector/test/platforms.test.mjs index 50ce165..d404b0c 100644 --- a/connector/test/platforms.test.mjs +++ b/connector/test/platforms.test.mjs @@ -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"); +}); diff --git a/dashboard/server/index.js b/dashboard/server/index.js index 9da0a95..265c988 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -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) ---------- diff --git a/dashboard/web/index.html b/dashboard/web/index.html index f98df87..c517744 100644 --- a/dashboard/web/index.html +++ b/dashboard/web/index.html @@ -5,10 +5,12 @@ + + - Social Command · Metatroncube + Quorla
diff --git a/dashboard/web/src/App.jsx b/dashboard/web/src/App.jsx index 699fab2..0359efb 100644 --- a/dashboard/web/src/App.jsx +++ b/dashboard/web/src/App.jsx @@ -29,15 +29,13 @@ const NAV = [ ]; function Shell({ children }) { - const { user, signOut } = useAuth(); + const { user, signOut, agency } = useAuth(); return (
diff --git a/dashboard/web/src/pages/Connections.jsx b/dashboard/web/src/pages/Connections.jsx index 31e2640..a5955e4 100644 --- a/dashboard/web/src/pages/Connections.jsx +++ b/dashboard/web/src/pages/Connections.jsx @@ -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
{conn.account?.avatar && } {conn.account?.name} - {conn.account?.username && @{conn.account.username}} + {conn.account?.username && @{String(conn.account.username).replace(/^@+/, "")}}
{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 )} + {isAdmin && conn && moving && ( + + + + )} + {needsMediaLinks &&
Image and video posts need PUBLIC_URL set on the server (e.g. https://app.quorlasocial.com).
}
@@ -145,6 +158,7 @@ function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, re {isAdmin && platform.app.configured && !conn && } {isAdmin && conn && } {isAdmin && conn && } + {isAdmin && conn && } {isAdmin && conn && } {isAdmin && platform.app.configured &&