diff --git a/.env.example b/.env.example
index b033bde..93e8fe7 100644
--- a/.env.example
+++ b/.env.example
@@ -64,3 +64,14 @@ DASHBOARD_NIGHTLY_REFRESH=true
# SMTP_USER=support@quorlasocial.com
# SMTP_PASS=
# SMTP_FROM=support@quorlasocial.com
+
+# ---------- Hosted Quorla control centre (only on app.quorlasocial.com) ----------
+# QUORLA_CONTROL=true
+# TENANT_BASE_DOMAIN=quorlasocial.com
+# PROVISION_TOKEN= # openssl rand -hex 32; same value in deploy/provision.env
+# STRIPE_SECRET_KEY= # sk_test_… while testing
+# STRIPE_PRICE_ID= # price_… of the hosted plan (monthly)
+# STRIPE_WEBHOOK_SECRET= # whsec_… from the webhook endpoint https://app.quorlasocial.com/control/stripe/webhook
+# STRIPE_TRIAL_DAYS=14
+# In a hosted agency workspace (set by the provisioning script):
+# SETUP_TOKEN= # only the emailed setup link can create the first admin
diff --git a/.gitignore b/.gitignore
index dd84269..bd1cdea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,7 @@ graphify-out/
*.mp4
!docs/demo-assets/*.mp4
__pycache__/
+
+# Hosted Quorla provisioning secrets
+deploy/provision.env
+deploy/tenant-shared.env
diff --git a/connector/src/meta.js b/connector/src/meta.js
index bd2f927..06213b2 100644
--- a/connector/src/meta.js
+++ b/connector/src/meta.js
@@ -1,16 +1,33 @@
// Thin Meta Graph API client for Facebook Pages + Instagram (via Facebook Login / System User token).
import { loadEnv } from "./env.js";
+import * as vault from "./platforms/vault.js";
loadEnv();
const VERSION = process.env.META_API_VERSION || "v23.0";
const BASE = `https://graph.facebook.com/${VERSION}`;
+// The agency's Meta system user token: from .env, or saved in Settings (encrypted in the vault) for hosted workspaces.
function systemToken() {
- const t = process.env.META_SYSTEM_USER_TOKEN;
- if (!t || t.startsWith("PASTE")) {
- throw new Error("META_SYSTEM_USER_TOKEN is missing. Put it in the .env file in the project root.");
- }
- return t;
+ const env = process.env.META_SYSTEM_USER_TOKEN;
+ if (env && !env.startsWith("PASTE")) return env;
+ const saved = vault.appCredentials("meta");
+ if (saved?.clientSecret) return saved.clientSecret;
+ throw new Error("Meta isn't connected. An admin adds the Meta system user token in Settings → Meta (or META_SYSTEM_USER_TOKEN in .env).");
+}
+/** Save the system user token from Settings; checks it works first. */
+export async function saveSystemToken(token, by) {
+ const t = String(token || "").trim();
+ if (!/^EA[A-Za-z0-9]{20,}$/.test(t)) throw Object.assign(new Error("That doesn't look like a Meta access token (it starts with EA…)."), { status: 400 });
+ const r = await fetch(`${BASE}/me?fields=id,name`, { headers: { Authorization: `Bearer ${t}` } });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok) throw Object.assign(new Error(`Meta didn't accept the token: ${j.error?.message || r.status}`), { status: 400 });
+ vault.saveApp("meta", { clientId: j.name || j.id, clientSecret: t }, by);
+ accountCache = null;
+ return { name: j.name || j.id };
+}
+export function metaSource() {
+ if (process.env.META_SYSTEM_USER_TOKEN && !process.env.META_SYSTEM_USER_TOKEN.startsWith("PASTE")) return "env";
+ return vault.appCredentials("meta") ? "settings" : null;
}
export async function graph(pathname, params = {}, { method = "GET", token } = {}) {
diff --git a/connector/test/control.test.mjs b/connector/test/control.test.mjs
new file mode 100644
index 0000000..dbcc77b
--- /dev/null
+++ b/connector/test/control.test.mjs
@@ -0,0 +1,64 @@
+// Hosted control centre: Stripe webhook signatures, sign-up, subscription lifecycle, provisioning tasks.
+// Temp data folders only; Stripe is simulated.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import crypto from "node:crypto";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-ctl-"));
+Object.assign(process.env, {
+ QUORLA_CONTROL_DIR: path.join(TMP, "control"), QUORLA_DASHBOARD_DATA: path.join(TMP, "dash"), QUORLA_CONTROL: "true",
+ STRIPE_SECRET_KEY: "sk_test_x", STRIPE_PRICE_ID: "price_x", STRIPE_WEBHOOK_SECRET: "whsec_test", PROVISION_TOKEN: "p".repeat(32), SMTP_HOST: "",
+});
+const control = await import("../../dashboard/server/control.js");
+
+const sign = (body, secret = "whsec_test", t = Math.floor(Date.now() / 1000)) => `t=${t},v1=${crypto.createHmac("sha256", secret).update(`${t}.${body}`).digest("hex")}`;
+
+test("webhook signatures: valid accepted; wrong secret, old or missing rejected", () => {
+ const body = JSON.stringify({ type: "ping" });
+ assert.equal(control.verifyWebhook(body, sign(body)).type, "ping");
+ assert.throws(() => control.verifyWebhook(body, sign(body, "whsec_other")), /doesn't match/);
+ assert.throws(() => control.verifyWebhook(body, sign(body, "whsec_test", Math.floor(Date.now() / 1000) - 1000)), /too old/);
+ assert.throws(() => control.verifyWebhook(body, ""), /Bad signature/);
+});
+
+test("sign-up → Stripe Checkout → paid → provisioning → running → billing states", async () => {
+ const realFetch = globalThis.fetch;
+ let sent;
+ globalThis.fetch = async (url, init) => { sent = { url: String(url), body: String(init.body) }; return new Response(JSON.stringify({ url: "https://checkout.stripe.test/s1" }), { status: 200 }); };
+ try {
+ await assert.rejects(control.signup({ agencyName: "A", ownerEmail: "x", slug: "ab" }, "https://app.test"), /valid work email/);
+ await assert.rejects(control.signup({ agencyName: "A", ownerEmail: "a@b.co", slug: "www" }, "https://app.test"), /Workspace address/);
+ const r = await control.signup({ agencyName: "Northstar Digital", ownerName: "Ana", ownerEmail: "ana@northstar.test", slug: "northstar" }, "https://app.test");
+ assert.equal(r.url, "https://checkout.stripe.test/s1");
+ assert.match(sent.url, /checkout\/sessions$/);
+ assert.match(decodeURIComponent(sent.body), /metadata\[slug\]=northstar/);
+ } finally { globalThis.fetch = realFetch; }
+
+ assert.equal(control.list()[0].status, "pending_payment");
+ assert.deepEqual(control.tasks(), [], "nothing to do before payment");
+
+ await control.onStripeEvent({ type: "checkout.session.completed", data: { object: { client_reference_id: "northstar", metadata: { slug: "northstar" }, customer: "cus_1", subscription: "sub_1" } } });
+ const [task] = control.tasks();
+ assert.equal(task.desired, "running");
+ assert.equal(task.port, 4400);
+ assert.ok(task.setupToken);
+
+ assert.throws(() => control.checkProvisionToken("Bearer nope"), /Not allowed/);
+ control.checkProvisionToken(`Bearer ${"p".repeat(32)}`);
+ const t = await control.report("northstar", { actual: "running" });
+ assert.equal(t.status, "active");
+ assert.equal(t.setupToken, undefined, "setup token never leaves the control centre in listings");
+ assert.deepEqual(control.tasks(), []);
+
+ await control.onStripeEvent({ type: "invoice.payment_failed", data: { object: { subscription: "sub_1" } } });
+ assert.equal(control.list()[0].status, "past_due");
+ await control.onStripeEvent({ type: "customer.subscription.updated", data: { object: { id: "sub_1", status: "unpaid", metadata: { slug: "northstar" } } } });
+ assert.equal(control.list()[0].status, "suspended");
+ assert.equal(control.tasks()[0].desired, "stopped");
+ await control.report("northstar", { actual: "stopped" });
+ await control.onStripeEvent({ type: "customer.subscription.updated", data: { object: { id: "sub_1", status: "active", metadata: { slug: "northstar" } } } });
+ assert.equal(control.tasks()[0].desired, "running", "paying again restarts it");
+});
diff --git a/dashboard/server/auth.js b/dashboard/server/auth.js
index 91544aa..52a8994 100644
--- a/dashboard/server/auth.js
+++ b/dashboard/server/auth.js
@@ -68,7 +68,13 @@ export function login(req, res) {
/** First run only: create the first admin from the sign-in screen when no logins exist. */
export function setupFirstAdmin(req, res) {
if (users.hasUsers()) return res.status(403).json({ error: "Setup is already done. Sign in instead." });
- const { email, name, password } = req.body || {};
+ const { email, name, password, setupToken } = req.body || {};
+ // Hosted workspaces are created with SETUP_TOKEN: only the person with the emailed setup link can make the first admin.
+ const need = process.env.SETUP_TOKEN || "";
+ if (need) {
+ const a = Buffer.from(String(setupToken || "")), b = Buffer.from(need);
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(403).json({ error: "Use the setup link from your welcome email to create the first account." });
+ }
try {
if (!password) throw Object.assign(new Error("Choose a password."), { status: 400 });
const { user } = users.createUser({ email, name, password, role: "admin" }, "first-run setup");
diff --git a/dashboard/server/config.js b/dashboard/server/config.js
index 20211c2..672cdf7 100644
--- a/dashboard/server/config.js
+++ b/dashboard/server/config.js
@@ -10,7 +10,7 @@ loadEnv();
export { PROJECT_ROOT };
export const CLIENTS_DIR = path.join(PROJECT_ROOT, "clients");
export const TEMPLATE_DIR = path.join(CLIENTS_DIR, "_template");
-export const DATA_DIR = path.join(PROJECT_ROOT, "data", "dashboard");
+export const DATA_DIR = process.env.QUORLA_DASHBOARD_DATA || path.join(PROJECT_ROOT, "data", "dashboard"); // override for tests
export const BRANDING_DIR = path.join(PROJECT_ROOT, "branding");
export const WEB_DIST = path.join(PROJECT_ROOT, "dashboard", "web", "dist");
diff --git a/dashboard/server/control.js b/dashboard/server/control.js
new file mode 100644
index 0000000..195f7ef
--- /dev/null
+++ b/dashboard/server/control.js
@@ -0,0 +1,207 @@
+// Control centre for hosted Quorla (only when QUORLA_CONTROL=true, i.e. on app.quorlasocial.com).
+// One Quorla per agency: its own container, data and address. This module keeps the list of agencies ("tenants"),
+// takes sign-ups through Stripe Checkout, follows the subscription through Stripe webhooks, and tells the
+// provisioning script on the server what each agency's container should be (running / stopped / removed).
+// The web app never runs Docker itself; deploy/provision-tenants.sh does, using PROVISION_TOKEN.
+import fs from "node:fs";
+import path from "node:path";
+import crypto from "node:crypto";
+import { PROJECT_ROOT } from "../../connector/src/env.js";
+import * as notify from "./notify.js";
+
+const DIR = process.env.QUORLA_CONTROL_DIR || path.join(PROJECT_ROOT, "data", "control"); // override for tests
+const FILE = path.join(DIR, "tenants.json");
+fs.mkdirSync(DIR, { recursive: true });
+const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } };
+const write = (db) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(db, null, 1)); fs.renameSync(tmp, FILE); };
+const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
+const now = () => new Date().toISOString();
+
+export const enabled = () => process.env.QUORLA_CONTROL === "true";
+const BASE_DOMAIN = () => process.env.TENANT_BASE_DOMAIN || "quorlasocial.com";
+const RESERVED = new Set(["app", "www", "api", "mail", "webmail", "admin", "status", "help", "docs", "blog", "billing", "control", "quorla", "test", "staging"]);
+const PUBLIC = (t) => { const { setupToken, ...rest } = t; return { ...rest, setupPending: Boolean(setupToken) }; };
+
+export function list() { return Object.values(read()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map(PUBLIC); }
+
+// ---------- Stripe (REST, no SDK) ----------
+async function stripe(pathname, form) {
+ const key = process.env.STRIPE_SECRET_KEY;
+ if (!key) throw err("Stripe isn't set up (STRIPE_SECRET_KEY).", 503);
+ const body = new URLSearchParams();
+ const add = (k, v) => { if (v === undefined || v === null) return; if (typeof v === "object") for (const [kk, vv] of Object.entries(v)) add(`${k}[${kk}]`, vv); else body.append(k, String(v)); };
+ for (const [k, v] of Object.entries(form || {})) add(k, v);
+ const r = await fetch(`https://api.stripe.com/v1/${pathname}`, { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/x-www-form-urlencoded" }, body });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok) throw err(`Stripe: ${j.error?.message || r.status}`, 502);
+ return j;
+}
+
+/** Verify a Stripe webhook: Stripe-Signature "t=…,v1=…" over `${t}.${rawBody}`, within 5 minutes. */
+export function verifyWebhook(rawBody, header) {
+ const secret = process.env.STRIPE_WEBHOOK_SECRET;
+ if (!secret) throw err("Webhook secret missing.", 503);
+ const parts = Object.fromEntries(String(header || "").split(",").map((p) => p.split("=")).filter((p) => p.length === 2).map(([k, v]) => [k.trim(), v.trim()]));
+ const sigs = String(header || "").split(",").filter((p) => p.trim().startsWith("v1=")).map((p) => p.trim().slice(3));
+ const t = Number(parts.t);
+ if (!t || !sigs.length) throw err("Bad signature header.", 400);
+ if (Math.abs(Date.now() / 1000 - t) > 300) throw err("Webhook too old.", 400);
+ const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
+ const ok = sigs.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
+ if (!ok) throw err("Signature doesn't match.", 400);
+ return JSON.parse(rawBody);
+}
+
+// ---------- sign-up ----------
+function validate({ agencyName, ownerName, ownerEmail, slug }) {
+ const s = String(slug || "").toLowerCase().trim();
+ if (!String(agencyName || "").trim()) throw err("Enter your agency's name.");
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(ownerEmail || "").trim())) throw err("Enter a valid work email.");
+ if (!/^[a-z][a-z0-9-]{2,30}$/.test(s) || RESERVED.has(s)) throw err("Workspace address: 3–31 lowercase letters, numbers or dashes, starting with a letter.");
+ const db = read();
+ if (db[s] && !["pending_payment", "cancelled"].includes(db[s].status)) throw err("That workspace address is taken. Try another.");
+ return { slug: s, agencyName: String(agencyName).trim().slice(0, 60), ownerName: String(ownerName || "").trim().slice(0, 60), ownerEmail: String(ownerEmail).trim().toLowerCase().slice(0, 120) };
+}
+
+/** Public sign-up: creates the tenant record and returns where to send the person (Stripe Checkout or a thank-you). */
+export async function signup(input, origin) {
+ const t = validate(input);
+ const db = read();
+ db[t.slug] = {
+ ...t, plan: "hosted", status: "pending_payment", desired: "none", actual: "none", port: null,
+ url: `https://${t.slug}.${BASE_DOMAIN()}`, setupToken: crypto.randomBytes(18).toString("base64url"),
+ createdAt: now(), updatedAt: now(), stripe: {}, history: [{ at: now(), event: "signed up" }],
+ };
+ write(db);
+ if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_PRICE_ID) {
+ // No Stripe yet: an admin approves the agency by hand on the Agencies page.
+ await notify.notify({ type: "tenant_signup", title: `New agency sign-up: ${t.agencyName}`, body: `${t.ownerName} <${t.ownerEmail}> · ${t.slug}`, link: "/admin/agencies", to: "admins" });
+ return { next: "manual" };
+ }
+ const trial = Number(process.env.STRIPE_TRIAL_DAYS || 0);
+ const session = await stripe("checkout/sessions", {
+ mode: "subscription", customer_email: t.ownerEmail, client_reference_id: t.slug,
+ "line_items[0][price]": process.env.STRIPE_PRICE_ID, "line_items[0][quantity]": 1,
+ success_url: `${origin}/signup/done?w=${t.slug}`, cancel_url: `${origin}/signup?cancelled=1`,
+ metadata: { slug: t.slug }, subscription_data: { metadata: { slug: t.slug }, ...(trial > 0 ? { trial_period_days: trial } : {}) },
+ allow_promotion_codes: "true",
+ });
+ return { next: "stripe", url: session.url };
+}
+
+// ---------- subscription lifecycle ----------
+function update(slug, fn, event) {
+ const db = read();
+ const t = db[slug];
+ if (!t) return null;
+ fn(t);
+ t.updatedAt = now();
+ if (event) (t.history ||= []).push({ at: now(), event });
+ t.history = t.history.slice(-100);
+ write(db);
+ return t;
+}
+const slugFromObj = (o) => o?.metadata?.slug || o?.client_reference_id || o?.subscription_details?.metadata?.slug || null;
+const bySubscription = (id) => Object.values(read()).find((t) => t.stripe?.subscription === id)?.slug || null;
+
+/** Apply one Stripe event. Returns a short description for the log. */
+export async function onStripeEvent(evt) {
+ const o = evt.data?.object || {};
+ switch (evt.type) {
+ case "checkout.session.completed": {
+ const slug = slugFromObj(o);
+ if (!slug) return "no slug";
+ update(slug, (t) => { t.status = "provisioning"; t.desired = "running"; t.stripe = { customer: o.customer, subscription: o.subscription }; }, "paid; workspace queued");
+ await notify.notify({ type: "tenant_paid", level: "ok", title: `New paying agency: ${read()[slug]?.agencyName}`, link: "/admin/agencies", to: "admins", email: true });
+ return `provisioning ${slug}`;
+ }
+ case "customer.subscription.updated": {
+ const slug = slugFromObj(o) || bySubscription(o.id);
+ if (!slug) return "unknown subscription";
+ update(slug, (t) => {
+ if (["active", "trialing"].includes(o.status)) { if (t.status === "suspended" && t.suspendedFor === "billing") { t.desired = "running"; delete t.suspendedFor; } if (t.actual === "running") t.status = "active"; }
+ else if (o.status === "past_due") t.status = "past_due";
+ else if (["unpaid", "canceled", "incomplete_expired"].includes(o.status)) { t.status = "suspended"; t.suspendedFor = "billing"; t.desired = "stopped"; }
+ }, `subscription ${o.status}`);
+ return `${slug} ${o.status}`;
+ }
+ case "customer.subscription.deleted": {
+ const slug = slugFromObj(o) || bySubscription(o.id);
+ if (!slug) return "unknown subscription";
+ update(slug, (t) => { t.status = "cancelled"; t.desired = "stopped"; }, "subscription cancelled; workspace stopped (data kept)");
+ return `${slug} cancelled`;
+ }
+ case "invoice.payment_failed": {
+ const slug = bySubscription(o.subscription);
+ if (slug) update(slug, (t) => { if (t.status === "active") t.status = "past_due"; }, "payment failed");
+ return `payment failed ${slug || ""}`;
+ }
+ default: return `ignored ${evt.type}`;
+ }
+}
+
+// ---------- admin actions ----------
+export function approve(slug) { return PUBLIC(update(slug, (t) => { t.status = "provisioning"; t.desired = "running"; }, "approved by admin") || (() => { throw err("Unknown agency.", 404); })()); }
+export function suspend(slug) { return PUBLIC(update(slug, (t) => { t.status = "suspended"; t.suspendedFor = "admin"; t.desired = "stopped"; }, "suspended by admin") || (() => { throw err("Unknown agency.", 404); })()); }
+export function resume(slug) { return PUBLIC(update(slug, (t) => { t.desired = "running"; delete t.suspendedFor; t.status = t.actual === "running" ? "active" : "provisioning"; }, "resumed by admin") || (() => { throw err("Unknown agency.", 404); })()); }
+/** Remove the container. Data stays on disk (the script archives it) until someone deletes it by hand. */
+export function remove(slug) { return PUBLIC(update(slug, (t) => { t.status = "removed"; t.desired = "removed"; }, "removal requested by admin") || (() => { throw err("Unknown agency.", 404); })()); }
+
+export async function billingPortal(slug, returnUrl) {
+ const t = read()[slug];
+ if (!t?.stripe?.customer) throw err("This agency has no Stripe customer yet.");
+ return (await stripe("billing_portal/sessions", { customer: t.stripe.customer, return_url: returnUrl })).url;
+}
+
+// ---------- provisioning script API ----------
+export function checkProvisionToken(header) {
+ const need = process.env.PROVISION_TOKEN || "";
+ const got = String(header || "").replace(/^Bearer\s+/i, "");
+ if (!need || got.length !== need.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(need))) throw err("Not allowed.", 401);
+}
+
+/** What the script should do: every tenant whose container isn't in its desired state. Assigns ports. */
+export function tasks() {
+ const db = read();
+ const used = new Set(Object.values(db).map((t) => t.port).filter(Boolean));
+ let changed = false;
+ const out = [];
+ for (const t of Object.values(db)) {
+ if (t.desired === "none" || t.desired === t.actual) continue;
+ if (t.desired === "running" && !t.port) { let p = 4400; while (used.has(p)) p++; t.port = p; used.add(p); changed = true; }
+ out.push({ slug: t.slug, desired: t.desired, port: t.port, agencyName: t.agencyName, url: t.url, setupToken: t.setupToken || null });
+ }
+ if (changed) write(db);
+ return out;
+}
+
+/** The script reports a container's state. First time a workspace runs: email the owner their setup link. */
+export async function report(slug, { actual, error }) {
+ const before = read()[slug];
+ if (!before) throw err("Unknown agency.", 404);
+ const t = update(slug, (x) => {
+ if (error) { x.lastError = String(error).slice(0, 500); return; }
+ x.actual = actual; delete x.lastError;
+ if (actual === "running" && ["provisioning", "past_due"].includes(x.status)) x.status = "active";
+ }, error ? `provisioning error: ${String(error).slice(0, 120)}` : `container ${actual}`);
+ if (!error && actual === "running" && before.actual !== "running" && t.setupToken && !t.welcomedAt) {
+ const sent = await notify.sendEmail({
+ to: t.ownerEmail, subject: `Your Quorla workspace is ready`, heading: `Welcome to Quorla, ${t.ownerName || t.agencyName}`,
+ lines: [`Your workspace for ${t.agencyName} is ready at ${t.url}.`, "Use the button to create your admin account. The link works once; after that, sign in with your email and password.", "Then follow the Getting started list: connect your clients' accounts, invite your team and schedule your first post."],
+ button: { label: "Create my account", url: `${t.url}/?setup=${t.setupToken}` }, fromName: "Quorla",
+ });
+ update(slug, (x) => { if (sent.sent) x.welcomedAt = now(); }, sent.sent ? "welcome email sent" : `welcome email not sent: ${sent.reason}`);
+ }
+ return PUBLIC(read()[slug]);
+}
+
+/** Send the welcome email again (the setup link only works until the first account exists). */
+export async function resendWelcome(slug) {
+ const t = read()[slug];
+ if (!t) throw err("Unknown agency.", 404);
+ if (t.actual !== "running") throw err("The workspace isn't running yet.");
+ update(slug, (x) => { delete x.welcomedAt; });
+ await report(slug, { actual: "running" });
+ update(slug, () => {}, "welcome email re-sent");
+ return PUBLIC(read()[slug]);
+}
diff --git a/dashboard/server/control/done.html b/dashboard/server/control/done.html
new file mode 100644
index 0000000..dd3bfa1
--- /dev/null
+++ b/dashboard/server/control/done.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+Your Quorla workspace is on its way
+
+
+
+
+
+
+
+
Thanks, you're in
+
We're setting up now. You'll get an email with your setup link within a few minutes.
+
We've got your details. We'll set up and email you the setup link shortly.
+
Nothing in your inbox after 15 minutes? Check spam, or write to support@quorlasocial.com.
+
+
+
+
+
diff --git a/dashboard/server/control/signup.html b/dashboard/server/control/signup.html
new file mode 100644
index 0000000..056da09
--- /dev/null
+++ b/dashboard/server/control/signup.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+Start your Quorla workspace
+
+
+
+
+
+
+
+ Start your Quorla workspace
+ Your agency gets its own private workspace: your clients, your team, your data. Set up takes a few minutes.
+ Checkout was cancelled. Your details are below if you'd like to try again.
+
+ By continuing you agree to the Terms and Privacy Policy. Questions? support@quorlasocial.com
+
+
+
+
diff --git a/dashboard/server/index.js b/dashboard/server/index.js
index 65aa29b..564f52e 100644
--- a/dashboard/server/index.js
+++ b/dashboard/server/index.js
@@ -18,6 +18,7 @@ import * as activity from "./activity.js";
import * as health from "./health.js";
import * as onboarding from "./onboarding.js";
import * as workspace from "./workspace.js";
+import * as control from "./control.js";
import * as odoo from "./odoo.js";
import * as jobs from "./jobs.js";
import * as docs from "./docs.js";
@@ -35,6 +36,15 @@ const app = express();
app.disable("x-powered-by");
// Behind Caddy in Docker the proxy is on the private network: TRUST_PROXY=uniquelocal (see docker-compose.yml).
app.set("trust proxy", process.env.TRUST_PROXY || "loopback");
+// Stripe webhook needs the raw body to verify the signature, so it comes before the JSON parser.
+app.post("/control/stripe/webhook", express.raw({ type: "*/*", limit: "1mb" }), async (req, res) => {
+ if (!control.enabled()) return res.status(404).end();
+ try {
+ const evt = control.verifyWebhook(req.body.toString("utf8"), req.headers["stripe-signature"]);
+ console.log("Stripe:", evt.type, await control.onStripeEvent(evt));
+ res.json({ received: true });
+ } catch (e) { console.error("Stripe webhook:", e.message); res.status(e.status || 400).json({ error: e.message }); }
+});
app.use(express.json({ limit: "200kb" }));
app.use((_req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
@@ -59,7 +69,7 @@ const h = (fn) => async (req, res) => {
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) => { const w = workspace.get(); res.json({ user: currentUser(req), agency: workspace.agencyName(), workspace: { accent: w.accent, hasLogo: w.hasLogo }, setupNeeded: !users.hasUsers() }); });
+app.get("/api/auth/me", (req, res) => { const w = workspace.get(); res.json({ user: currentUser(req), agency: workspace.agencyName(), workspace: { accent: w.accent, hasLogo: w.hasLogo }, control: control.enabled(), setupNeeded: !users.hasUsers() }); });
// The agency's own logo (white-label), public so the sign-in page and client pages can show it.
app.get("/brand/agency-logo", (_req, res) => { const f = workspace.logoPath(); if (!f) return res.status(404).end(); res.setHeader("Cache-Control", "no-cache"); res.sendFile(f); });
// App (Quorla) branding. Agency branding for PDF reports stays in branding/ and is used by tools/report-pdf.
@@ -93,6 +103,23 @@ app.post("/p/:token/decision/:post", noIndex, (req, res) => {
catch (e) { res.status(e.status || 400).json({ error: e.message }); }
});
+// Hosted Quorla control centre (QUORLA_CONTROL=true): public sign-up and the provisioning script's API.
+const CONTROL_DIR = fileURLToPath(new URL("./control/", import.meta.url));
+const signupHits = new Map();
+app.get("/signup", (req, res) => (control.enabled() ? res.sendFile(path.join(CONTROL_DIR, "signup.html")) : res.status(404).send("Not found")));
+app.get("/signup/done", (req, res) => (control.enabled() ? res.sendFile(path.join(CONTROL_DIR, "done.html")) : res.status(404).send("Not found")));
+app.post("/control/signup", async (req, res) => {
+ if (!control.enabled()) return res.status(404).end();
+ const ip = req.ip, hour = Date.now() - 3600000;
+ const hits = (signupHits.get(ip) || []).filter((t) => t > hour);
+ if (hits.length >= 10) return res.status(429).json({ error: "Too many sign-ups from here. Try again later or email support@quorlasocial.com." });
+ signupHits.set(ip, [...hits, Date.now()]);
+ if (String(req.body?.website || "")) return res.json({ next: "manual" }); // honeypot
+ try { res.json(await control.signup(req.body || {}, `${req.protocol}://${req.get("host")}`)); } catch (e) { res.status(e.status || 400).json({ error: e.message }); }
+});
+app.get("/control/provision/tasks", (req, res) => { try { control.checkProvisionToken(req.headers.authorization); res.json({ tasks: control.tasks() }); } catch (e) { res.status(e.status || 400).json({ error: e.message }); } });
+app.post("/control/provision/:slug/report", async (req, res) => { try { control.checkProvisionToken(req.headers.authorization); res.json(await control.report(req.params.slug, req.body || {})); } catch (e) { res.status(e.status || 400).json({ error: e.message }); } });
+
// Link-in-bio pages (public): /l/ and counted redirects /l//go/.
app.get("/l/:path", (req, res) => {
const page = bio.byPath(req.params.path);
@@ -365,12 +392,23 @@ app.post("/api/brandkit/:client/check", h(async (req) => ({ warnings: brandkit.c
app.get("/api/bio/:client", h(async (req) => ({ page: bio.get(req.params.client), stats: bio.stats(req.params.client, Number(req.query.days) || 30) })));
app.put("/api/bio/:client", requireAdmin, h(async (req) => ({ page: bio.save(req.params.client, req.body || {}), stats: bio.stats(req.params.client) })));
+// ---------- Meta connection from Settings (hosted workspaces can't edit .env) ----------
+app.get("/api/meta/connection", requireAdmin, h(async () => ({ source: meta.metaSource() })));
+app.put("/api/meta/connection", requireAdmin, h(async (req) => ({ ...(await meta.saveSystemToken(req.body?.token, who(req))), source: meta.metaSource() })));
+
// ---------- workspace (white-label) ----------
app.get("/api/workspace", h(async () => workspace.get()));
app.put("/api/workspace", requireAdmin, h(async (req) => workspace.save(req.body || {}, who(req))));
app.post("/api/workspace/logo", requireAdmin, express.raw({ type: "*/*", limit: "3mb" }), h(async (req) => workspace.saveLogo(req.body, req.headers["x-filename"])));
app.delete("/api/workspace/logo", requireAdmin, h(async () => { workspace.removeLogo(); return workspace.get(); }));
+// ---------- hosted agencies (control centre) ----------
+const needControl = (_req, res, next) => (control.enabled() ? next() : res.status(404).json({ error: "Not available." }));
+app.get("/api/control/tenants", requireAdmin, needControl, h(async () => ({ tenants: control.list(), stripe: Boolean(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PRICE_ID), provisioner: Boolean(process.env.PROVISION_TOKEN) })));
+for (const action of ["approve", "suspend", "resume", "remove"]) app.post(`/api/control/tenants/:slug/${action}`, requireAdmin, needControl, h(async (req) => ({ tenant: control[action](req.params.slug) })));
+app.post("/api/control/tenants/:slug/welcome", requireAdmin, needControl, h(async (req) => ({ tenant: await control.resendWelcome(req.params.slug) })));
+app.post("/api/control/tenants/:slug/portal", requireAdmin, needControl, h(async (req) => ({ url: await control.billingPortal(req.params.slug, `${req.protocol}://${req.get("host")}/admin/agencies`) })));
+
// ---------- getting started checklist ----------
app.get("/api/onboarding", requireAdmin, h(async () => onboarding.steps()));
app.post("/api/onboarding/dismiss", requireAdmin, h(async (req) => { onboarding.dismiss(req.body?.dismissed !== false); return onboarding.steps(); }));
diff --git a/dashboard/web/src/App.jsx b/dashboard/web/src/App.jsx
index 38769fb..cff50d2 100644
--- a/dashboard/web/src/App.jsx
+++ b/dashboard/web/src/App.jsx
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom";
-import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays, Swords, Activity, History } from "lucide-react";
+import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays, Swords, Activity, History, Building2 } from "lucide-react";
import { api } from "./api.js";
import { cx, Spinner } from "./ui.jsx";
import Login from "./pages/Login.jsx";
@@ -14,7 +14,7 @@ import Published from "./pages/Published.jsx";
import Calendar from "./pages/Calendar.jsx";
import Competitors from "./pages/Competitors.jsx";
import NotificationBell from "./Notifications.jsx";
-import { Audit, Health } from "./pages/Admin.jsx";
+import { Agencies, Audit, Health } from "./pages/Admin.jsx";
import Inbox from "./pages/Inbox.jsx";
import Library from "./pages/Library.jsx";
import Connections from "./pages/Connections.jsx";
@@ -45,11 +45,12 @@ const NAV = [
{ to: "/runs", label: "AI runs", icon: Sparkles },
{ to: "/admin/health", label: "System health", icon: Activity, admin: true, group: "Admin" },
{ to: "/admin/audit", label: "Audit log", icon: History, admin: true },
+ { to: "/admin/agencies", label: "Agencies", icon: Building2, admin: true, control: true },
{ to: "/settings", label: "Settings", icon: Settings2, group: "" },
];
function Shell({ children }) {
- const { user, signOut, agency, workspace } = useAuth();
+ const { user, signOut, agency, workspace, control } = useAuth();
return (