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>
This commit is contained in:
metatroncubeswdev 2026-10-23 10:35:23 -04:00
parent 82814f3757
commit 075b79f6b2
17 changed files with 784 additions and 15 deletions

View File

@ -64,3 +64,14 @@ DASHBOARD_NIGHTLY_REFRESH=true
# SMTP_USER=support@quorlasocial.com # SMTP_USER=support@quorlasocial.com
# SMTP_PASS= # SMTP_PASS=
# SMTP_FROM=support@quorlasocial.com # 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

4
.gitignore vendored
View File

@ -30,3 +30,7 @@ graphify-out/
*.mp4 *.mp4
!docs/demo-assets/*.mp4 !docs/demo-assets/*.mp4
__pycache__/ __pycache__/
# Hosted Quorla provisioning secrets
deploy/provision.env
deploy/tenant-shared.env

View File

@ -1,16 +1,33 @@
// Thin Meta Graph API client for Facebook Pages + Instagram (via Facebook Login / System User token). // Thin Meta Graph API client for Facebook Pages + Instagram (via Facebook Login / System User token).
import { loadEnv } from "./env.js"; import { loadEnv } from "./env.js";
import * as vault from "./platforms/vault.js";
loadEnv(); loadEnv();
const VERSION = process.env.META_API_VERSION || "v23.0"; const VERSION = process.env.META_API_VERSION || "v23.0";
const BASE = `https://graph.facebook.com/${VERSION}`; 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() { function systemToken() {
const t = process.env.META_SYSTEM_USER_TOKEN; const env = process.env.META_SYSTEM_USER_TOKEN;
if (!t || t.startsWith("PASTE")) { if (env && !env.startsWith("PASTE")) return env;
throw new Error("META_SYSTEM_USER_TOKEN is missing. Put it in the .env file in the project root."); 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).");
} }
return t; /** 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 } = {}) { export async function graph(pathname, params = {}, { method = "GET", token } = {}) {

View File

@ -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");
});

View File

@ -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. */ /** First run only: create the first admin from the sign-in screen when no logins exist. */
export function setupFirstAdmin(req, res) { export function setupFirstAdmin(req, res) {
if (users.hasUsers()) return res.status(403).json({ error: "Setup is already done. Sign in instead." }); 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 { try {
if (!password) throw Object.assign(new Error("Choose a password."), { status: 400 }); if (!password) throw Object.assign(new Error("Choose a password."), { status: 400 });
const { user } = users.createUser({ email, name, password, role: "admin" }, "first-run setup"); const { user } = users.createUser({ email, name, password, role: "admin" }, "first-run setup");

View File

@ -10,7 +10,7 @@ loadEnv();
export { PROJECT_ROOT }; export { PROJECT_ROOT };
export const CLIENTS_DIR = path.join(PROJECT_ROOT, "clients"); export const CLIENTS_DIR = path.join(PROJECT_ROOT, "clients");
export const TEMPLATE_DIR = path.join(CLIENTS_DIR, "_template"); 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 BRANDING_DIR = path.join(PROJECT_ROOT, "branding");
export const WEB_DIST = path.join(PROJECT_ROOT, "dashboard", "web", "dist"); export const WEB_DIST = path.join(PROJECT_ROOT, "dashboard", "web", "dist");

207
dashboard/server/control.js Normal file
View File

@ -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]);
}

View File

@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Your Quorla workspace is on its way</title>
<link rel="icon" type="image/png" href="/brand/mark.png">
<style>
* { box-sizing:border-box } [hidden]{display:none!important}
body { margin:0; background:#f6f5fb; color:#1f2024; font:16px/1.6 Inter,"Segoe UI",system-ui,sans-serif }
main { max-width:520px; margin:0 auto; padding:56px 16px; text-align:center }
img { height:34px; margin-bottom:28px } h1 { font-size:26px; color:#242142; margin:0 0 10px }
p { color:#5d6070 } .box { background:#fff; border:1px solid #e6e5ef; border-radius:14px; padding:24px }
a { color:#3b3778 }
</style>
</head>
<body>
<main>
<img src="/brand/logo.png" alt="Quorla">
<div class="box">
<h1>Thanks, you're in</h1>
<p id="paid">We're setting up <b id="addr"></b> now. You'll get an email with your setup link within a few minutes.</p>
<p id="manual" hidden>We've got your details. We'll set up <b id="addr2"></b> and email you the setup link shortly.</p>
<p>Nothing in your inbox after 15 minutes? Check spam, or write to <a href="mailto:support@quorlasocial.com">support@quorlasocial.com</a>.</p>
</div>
</main>
<script>
const q = new URLSearchParams(location.search), w = (q.get("w") || "").replace(/[^a-z0-9-]/g, "");
const host = `${w}.quorlasocial.com`;
document.getElementById("addr").textContent = host; document.getElementById("addr2").textContent = host;
if (q.get("manual")) { document.getElementById("paid").hidden = true; document.getElementById("manual").hidden = false; }
</script>
</body>
</html>

View File

@ -0,0 +1,72 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Start your Quorla workspace</title>
<meta name="description" content="Create a hosted Quorla workspace for your agency.">
<link rel="icon" type="image/png" href="/brand/mark.png">
<style>
:root { --navy:#242142; --navy2:#3b3778; --coral:#fc6d45; --ink:#1f2024; --muted:#5d6070; --line:#e6e5ef; --soft:#f6f5fb; --bad:#b4372b; --bg:#fff; }
* { box-sizing:border-box } [hidden]{display:none!important}
body { margin:0; background:var(--soft); color:var(--ink); font:16px/1.55 Inter,"Segoe UI",system-ui,sans-serif; -webkit-font-smoothing:antialiased }
main { max-width: 520px; margin: 0 auto; padding: 40px 16px 64px }
.logo { height: 34px; display:block; margin-bottom: 26px }
h1 { font-size: 28px; line-height:1.2; margin:0 0 6px; color:var(--navy) }
p.sub { color:var(--muted); margin:0 0 22px }
form { background:var(--bg); border:1px solid var(--line); border-radius:14px; padding:22px }
label { display:block; font-size:13.5px; font-weight:600; margin:0 0 5px; color:var(--ink) }
.f { margin-bottom: 15px }
input { width:100%; font:inherit; padding:11px 12px; border:1px solid #d3d2e0; border-radius:8px; background:#fff; color:var(--ink) }
input:focus-visible, button:focus-visible { outline:2px solid var(--coral); outline-offset:2px }
.addr { display:flex; align-items:center; border:1px solid #d3d2e0; border-radius:8px; overflow:hidden }
.addr input { border:0; border-radius:0 } .addr span { padding:0 12px; color:var(--muted); white-space:nowrap; font-size:14px; background:var(--soft); align-self:stretch; display:flex; align-items:center }
.hint { font-size:12.5px; color:var(--muted); margin-top:4px }
button { width:100%; font:600 16px/1 inherit; font-family:inherit; min-height:48px; border:0; border-radius:10px; background:var(--navy); color:#fff; cursor:pointer; margin-top:6px }
button:hover { background:var(--navy2) } button[disabled]{ opacity:.6; cursor:default }
.err { color:var(--bad); font-size:14px; margin-top:10px }
.note { font-size:13px; color:var(--muted); margin-top:16px; text-align:center }
.hp { position:absolute; left:-9999px }
a { color:var(--navy2) }
</style>
</head>
<body>
<main>
<a href="https://quorlasocial.com"><img class="logo" src="/brand/logo.png" alt="Quorla"></a>
<h1>Start your Quorla workspace</h1>
<p class="sub">Your agency gets its own private workspace: your clients, your team, your data. Set up takes a few minutes.</p>
<p class="err" id="cancelled" hidden>Checkout was cancelled. Your details are below if you'd like to try again.</p>
<form id="f" novalidate>
<div class="f"><label for="agencyName">Agency name</label><input id="agencyName" name="agencyName" required autocomplete="organization"></div>
<div class="f"><label for="ownerName">Your name</label><input id="ownerName" name="ownerName" autocomplete="name"></div>
<div class="f"><label for="ownerEmail">Work email</label><input id="ownerEmail" name="ownerEmail" type="email" required autocomplete="email"><div class="hint">We send your setup link here.</div></div>
<div class="f">
<label for="slug">Workspace address</label>
<div class="addr"><input id="slug" name="slug" required pattern="[a-z][a-z0-9-]{2,30}" placeholder="youragency" autocapitalize="none" spellcheck="false"><span id="domain">.quorlasocial.com</span></div>
<div class="hint">Lowercase letters, numbers and dashes.</div>
</div>
<input class="hp" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
<button id="go" type="submit">Continue to payment</button>
<div class="err" id="err" role="alert"></div>
</form>
<p class="note">By continuing you agree to the <a href="https://quorlasocial.com/terms">Terms</a> and <a href="https://quorlasocial.com/privacy">Privacy Policy</a>. Questions? <a href="mailto:support@quorlasocial.com">support@quorlasocial.com</a></p>
</main>
<script>
const f = document.getElementById("f"), err = document.getElementById("err"), go = document.getElementById("go");
if (new URLSearchParams(location.search).get("cancelled")) document.getElementById("cancelled").hidden = false;
const slug = document.getElementById("slug"), agency = document.getElementById("agencyName");
agency.addEventListener("input", () => { if (!slug.dataset.touched) slug.value = agency.value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/^[^a-z]+/, "").slice(0, 31); });
slug.addEventListener("input", () => { slug.dataset.touched = "1"; slug.value = slug.value.toLowerCase().replace(/[^a-z0-9-]/g, ""); });
f.addEventListener("submit", async (e) => {
e.preventDefault(); err.textContent = ""; go.disabled = true; go.textContent = "One moment…";
try {
const r = await fetch("/control/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(f))) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || "Something went wrong. Try again.");
if (j.next === "stripe" && j.url) location.href = j.url;
else location.href = `/signup/done?w=${encodeURIComponent(slug.value)}&manual=1`;
} catch (ex) { err.textContent = ex.message; go.disabled = false; go.textContent = "Continue to payment"; }
});
</script>
</body>
</html>

View File

@ -18,6 +18,7 @@ import * as activity from "./activity.js";
import * as health from "./health.js"; import * as health from "./health.js";
import * as onboarding from "./onboarding.js"; import * as onboarding from "./onboarding.js";
import * as workspace from "./workspace.js"; import * as workspace from "./workspace.js";
import * as control from "./control.js";
import * as odoo from "./odoo.js"; import * as odoo from "./odoo.js";
import * as jobs from "./jobs.js"; import * as jobs from "./jobs.js";
import * as docs from "./docs.js"; import * as docs from "./docs.js";
@ -35,6 +36,15 @@ const app = express();
app.disable("x-powered-by"); app.disable("x-powered-by");
// Behind Caddy in Docker the proxy is on the private network: TRUST_PROXY=uniquelocal (see docker-compose.yml). // 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"); 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(express.json({ limit: "200kb" }));
app.use((_req, res, next) => { app.use((_req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff"); 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/login", login);
app.post("/api/auth/logout", logout); app.post("/api/auth/logout", logout);
app.post("/api/auth/setup", setupFirstAdmin); 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. // 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.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. // 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 }); } 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/<path> and counted redirects /l/<path>/go/<link id>. // Link-in-bio pages (public): /l/<path> and counted redirects /l/<path>/go/<link id>.
app.get("/l/:path", (req, res) => { app.get("/l/:path", (req, res) => {
const page = bio.byPath(req.params.path); 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.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) }))); 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) ---------- // ---------- workspace (white-label) ----------
app.get("/api/workspace", h(async () => workspace.get())); app.get("/api/workspace", h(async () => workspace.get()));
app.put("/api/workspace", requireAdmin, h(async (req) => workspace.save(req.body || {}, who(req)))); 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.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(); })); 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 ---------- // ---------- getting started checklist ----------
app.get("/api/onboarding", requireAdmin, h(async () => onboarding.steps())); 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(); })); app.post("/api/onboarding/dismiss", requireAdmin, h(async (req) => { onboarding.dismiss(req.body?.dismissed !== false); return onboarding.steps(); }));

View File

@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom"; 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 { api } from "./api.js";
import { cx, Spinner } from "./ui.jsx"; import { cx, Spinner } from "./ui.jsx";
import Login from "./pages/Login.jsx"; import Login from "./pages/Login.jsx";
@ -14,7 +14,7 @@ import Published from "./pages/Published.jsx";
import Calendar from "./pages/Calendar.jsx"; import Calendar from "./pages/Calendar.jsx";
import Competitors from "./pages/Competitors.jsx"; import Competitors from "./pages/Competitors.jsx";
import NotificationBell from "./Notifications.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 Inbox from "./pages/Inbox.jsx";
import Library from "./pages/Library.jsx"; import Library from "./pages/Library.jsx";
import Connections from "./pages/Connections.jsx"; import Connections from "./pages/Connections.jsx";
@ -45,11 +45,12 @@ const NAV = [
{ to: "/runs", label: "AI runs", icon: Sparkles }, { to: "/runs", label: "AI runs", icon: Sparkles },
{ to: "/admin/health", label: "System health", icon: Activity, admin: true, group: "Admin" }, { to: "/admin/health", label: "System health", icon: Activity, admin: true, group: "Admin" },
{ to: "/admin/audit", label: "Audit log", icon: History, admin: true }, { 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: "" }, { to: "/settings", label: "Settings", icon: Settings2, group: "" },
]; ];
function Shell({ children }) { function Shell({ children }) {
const { user, signOut, agency, workspace } = useAuth(); const { user, signOut, agency, workspace, control } = useAuth();
return ( return (
<div className="min-h-screen flex"> <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"> <aside className="w-[228px] shrink-0 bg-white border-r border-line flex flex-col fixed inset-y-0 left-0 z-10">
@ -63,7 +64,7 @@ function Shell({ children }) {
: agency && <div className="mt-2.5 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint truncate" title={agency}>{agency}</div>} : agency && <div className="mt-2.5 text-[11px] font-semibold tracking-[0.08em] uppercase text-faint truncate" title={agency}>{agency}</div>}
</div> </div>
<nav className="px-2 mt-2 space-y-0.5 flex-1 overflow-y-auto" aria-label="Main"> <nav className="px-2 mt-2 space-y-0.5 flex-1 overflow-y-auto" aria-label="Main">
{NAV.filter((n) => !n.admin || user.role === "admin").map(({ to, label, icon: Icon, end, group }) => [ {NAV.filter((n) => (!n.admin || user.role === "admin") && (!n.control || control)).map(({ to, label, icon: Icon, end, group }) => [
group !== undefined && (group ? <div key={`g-${group}`} className="eyebrow px-2.5 pt-4 pb-1 text-faint">{group}</div> : <div key="g-sep" className="pt-3" />), group !== undefined && (group ? <div key={`g-${group}`} className="eyebrow px-2.5 pt-4 pb-1 text-faint">{group}</div> : <div key="g-sep" className="pt-3" />),
<NavLink <NavLink
key={to} key={to}
@ -112,7 +113,7 @@ export default function App() {
const refresh = useCallback(() => { const refresh = useCallback(() => {
api("/api/auth/me") api("/api/auth/me")
.then((r) => { .then((r) => {
setState({ loading: false, user: r.user, setupNeeded: r.setupNeeded, agency: r.agency, workspace: r.workspace }); setState({ loading: false, user: r.user, setupNeeded: r.setupNeeded, agency: r.agency, workspace: r.workspace, control: r.control });
applyAccent(r.workspace?.accent); applyAccent(r.workspace?.accent);
document.title = r.agency ? `Quorla · ${r.agency}` : "Quorla"; document.title = r.agency ? `Quorla · ${r.agency}` : "Quorla";
}) })
@ -135,7 +136,7 @@ export default function App() {
if (!state.user) return <Login setupNeeded={state.setupNeeded} agency={state.agency} hasLogo={state.workspace?.hasLogo} onSignedIn={refresh} />; if (!state.user) return <Login setupNeeded={state.setupNeeded} agency={state.agency} hasLogo={state.workspace?.hasLogo} onSignedIn={refresh} />;
return ( return (
<AuthCtx.Provider value={{ user: state.user, agency: state.agency, workspace: state.workspace, signOut, refresh, isAdmin: state.user.role === "admin" }}> <AuthCtx.Provider value={{ user: state.user, agency: state.agency, workspace: state.workspace, control: state.control, signOut, refresh, isAdmin: state.user.role === "admin" }}>
<BrowserRouter> <BrowserRouter>
<Shell> <Shell>
<Routes> <Routes>
@ -148,6 +149,7 @@ export default function App() {
<Route path="/competitors" element={<Competitors />} /> <Route path="/competitors" element={<Competitors />} />
<Route path="/admin/health" element={<Health />} /> <Route path="/admin/health" element={<Health />} />
<Route path="/admin/audit" element={<Audit />} /> <Route path="/admin/audit" element={<Audit />} />
<Route path="/admin/agencies" element={<Agencies />} />
<Route path="/inbox" element={<Inbox />} /> <Route path="/inbox" element={<Inbox />} />
<Route path="/connections" element={<Connections />} /> <Route path="/connections" element={<Connections />} />
<Route path="/team" element={<Team />} /> <Route path="/team" element={<Team />} />

View File

@ -95,3 +95,76 @@ export function Audit() {
</div> </div>
); );
} }
const T_TONE = { active: "ok", provisioning: "brand", pending_payment: "neutral", past_due: "warn", suspended: "bad", cancelled: "neutral", removed: "neutral" };
const T_LABEL = { active: "Active", provisioning: "Setting up", pending_payment: "Awaiting payment", past_due: "Payment overdue", suspended: "Suspended", cancelled: "Cancelled", removed: "Removed" };
/** Admin → Agencies (control centre only): every hosted workspace, its subscription and container. */
export function Agencies() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(null);
const load = useCallback(() => api("/api/control/tenants").then(setData).catch(setError), []);
useEffect(() => { load(); }, [load]);
const act = async (slug, action, confirmText) => {
if (confirmText && !window.confirm(confirmText)) return;
setBusy(`${slug}-${action}`); setError(null);
try {
const r = await api(`/api/control/tenants/${slug}/${action}`, { method: "POST" });
if (r.url) window.open(r.url, "_blank", "noopener");
await load();
} catch (e) { setError(e); }
setBusy(null);
};
return (
<div className="space-y-5">
<header className="flex items-end justify-between gap-4 flex-wrap">
<div>
<h1 className="text-[22px] font-extrabold">Agencies</h1>
<p className="text-muted mt-0.5">Hosted Quorla workspaces: one private copy per agency. Sign-up page: <a className="text-brand-deep underline" href="/signup" target="_blank" rel="noreferrer">/signup</a></p>
</div>
<Button icon={RefreshCw} onClick={load}>Refresh</Button>
</header>
{data && (!data.stripe || !data.provisioner) && (
<div className="rounded-md bg-warn-soft text-warn text-[13px] px-3 py-2">
{!data.stripe && "Stripe isn't set up: sign-ups wait for you to approve them here. "}
{!data.provisioner && "PROVISION_TOKEN isn't set: the server script can't create workspaces yet. "}
See docs/multi-tenant.md.
</div>
)}
<ErrorNote error={error} />
<Panel>
{!data ? <Spinner /> : !data.tenants.length ? <Empty icon={History} title="No agencies yet">When an agency signs up at /signup it appears here.</Empty> : (
<div className="overflow-x-auto">
<table className="tbl min-w-[900px]">
<thead><tr><th>Agency</th><th>Status</th><th>Workspace</th><th>Owner</th><th>Signed up</th><th><span className="sr-only">Actions</span></th></tr></thead>
<tbody>
{data.tenants.map((t) => (
<tr key={t.slug}>
<td><div className="font-medium">{t.agencyName}</div><div className="text-[12px] text-muted">{t.slug}{t.port ? ` · port ${t.port}` : ""}</div></td>
<td>
<Badge tone={T_TONE[t.status] || "neutral"} dot>{T_LABEL[t.status] || t.status}</Badge>
<div className="text-[11.5px] text-muted mt-0.5">container: {t.actual}{t.desired !== t.actual && t.desired !== "none" ? ` → ${t.desired}` : ""}</div>
{t.lastError && <div className="text-[11.5px] text-bad mt-0.5">{t.lastError}</div>}
</td>
<td><a className="text-brand-deep underline text-[13px]" href={t.url} target="_blank" rel="noreferrer">{t.url.replace(/^https:\/\//, "")}</a>{t.setupPending && !t.welcomedAt && t.actual === "running" ? <div className="text-[11.5px] text-warn">welcome email not sent</div> : null}</td>
<td className="text-[13px]">{t.ownerName}<div className="text-[12px] text-muted">{t.ownerEmail}</div></td>
<td className="text-[13px] text-muted">{timeAgo(t.createdAt)}</td>
<td className="text-right whitespace-nowrap space-x-1">
{t.status === "pending_payment" && <Button size="sm" loading={busy === `${t.slug}-approve`} onClick={() => act(t.slug, "approve", `Create ${t.agencyName}'s workspace without payment?`)}>Approve</Button>}
{["active", "past_due", "provisioning"].includes(t.status) && <Button size="sm" variant="ghost" loading={busy === `${t.slug}-suspend`} onClick={() => act(t.slug, "suspend", `Stop ${t.agencyName}'s workspace? Their data is kept.`)}>Suspend</Button>}
{["suspended", "cancelled"].includes(t.status) && <Button size="sm" loading={busy === `${t.slug}-resume`} onClick={() => act(t.slug, "resume")}>Resume</Button>}
{t.actual === "running" && <Button size="sm" variant="ghost" loading={busy === `${t.slug}-welcome`} onClick={() => act(t.slug, "welcome")}>Resend welcome</Button>}
{t.stripe?.customer && <Button size="sm" variant="ghost" loading={busy === `${t.slug}-portal`} onClick={() => act(t.slug, "portal")}>Billing</Button>}
{["suspended", "cancelled"].includes(t.status) && <Button size="sm" variant="danger" loading={busy === `${t.slug}-remove`} onClick={() => act(t.slug, "remove", `Remove ${t.agencyName}'s container? Their data folder is archived on the server, not deleted.`)}>Remove</Button>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Panel>
</div>
);
}

View File

@ -17,7 +17,7 @@ export default function Login({ onSignedIn, setupNeeded, agency, hasLogo }) {
if (setupNeeded && password !== confirm) return setError(new Error("The two passwords don't match.")); if (setupNeeded && password !== confirm) return setError(new Error("The two passwords don't match."));
setBusy(true); setBusy(true);
try { try {
if (setupNeeded) await api("/api/auth/setup", { method: "POST", body: { name, email, password } }); if (setupNeeded) await api("/api/auth/setup", { method: "POST", body: { name, email, password, setupToken: new URLSearchParams(location.search).get("setup") || "" } });
else await api("/api/auth/login", { method: "POST", body: { email, password } }); else await api("/api/auth/login", { method: "POST", body: { email, password } });
onSignedIn(); onSignedIn();
} catch (err) { } catch (err) {

View File

@ -212,6 +212,35 @@ function TeamPanel() {
); );
} }
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() { function WorkspacePanel() {
const { refresh } = useAuth(); const { refresh } = useAuth();
const [w, setW] = useState(null); const [w, setW] = useState(null);
@ -293,6 +322,7 @@ export default function Settings() {
<p className="text-muted mt-0.5">Your account{isAdmin ? ", the team's logins" : ""} and this dashboard's connections.</p> <p className="text-muted mt-0.5">Your account{isAdmin ? ", the team's logins" : ""} and this dashboard's connections.</p>
</header> </header>
{isAdmin && <WorkspacePanel />} {isAdmin && <WorkspacePanel />}
{isAdmin && <MetaPanel />}
<AccountPanel /> <AccountPanel />
<NotificationsPanel /> <NotificationsPanel />
{isAdmin && <TeamPanel />} {isAdmin && <TeamPanel />}

103
deploy/provision-tenants.sh Normal file
View File

@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Hosted Quorla: create / start / stop / archive one container per agency, as the control centre asks.
# Runs on the server as the deploy user (needs docker; sudo only for reloading nginx). Schedule every 5 minutes:
# */5 * * * * /home/dev/APPS/quorlasocial/deploy/provision-tenants.sh >> /home/dev/APPS/quorla-tenants/provision.log 2>&1
# Settings come from deploy/provision.env (copy provision.env.example). See docs/multi-tenant.md.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
REPO="$(dirname "$HERE")"
# shellcheck disable=SC1091
source "$HERE/provision.env"
: "${CONTROL_URL:?set CONTROL_URL in provision.env}" "${PROVISION_TOKEN:?set PROVISION_TOKEN}" "${TENANTS_DIR:?set TENANTS_DIR}"
IMAGE="${IMAGE:-quorla:latest}"
BASE_DOMAIN="${TENANT_BASE_DOMAIN:-quorlasocial.com}"
SHARED_ENV="${SHARED_ENV:-$HERE/tenant-shared.env}" # SMTP_*, optional ANTHROPIC_API_KEY, platform app keys
MAP="$TENANTS_DIR/nginx-tenants.map"
mkdir -p "$TENANTS_DIR/_archive"
log() { echo "$(date -u +%FT%TZ) $*"; }
json() { python3 -c "import json,sys; d=json.load(sys.stdin); $1"; }
report() { # slug json
curl -fsS -m 30 -X POST -H "Authorization: Bearer $PROVISION_TOKEN" -H "Content-Type: application/json" \
--data "$2" "$CONTROL_URL/control/provision/$1/report" >/dev/null || log "report failed for $1"
}
write_env() { # slug name setup_token
local dir="$TENANTS_DIR/$1"
[ -f "$dir/.env" ] && return 0
umask 077
{
echo "DASHBOARD_SECRET=$(openssl rand -hex 32)"
echo "AGENCY_NAME=$2"
echo "PUBLIC_URL=https://$1.$BASE_DOMAIN"
echo "APP_DOMAIN=$1.$BASE_DOMAIN"
echo "SETUP_TOKEN=$3"
echo "ALLOW_PUBLISH=true"
echo "SCHEDULER_ENABLED=true"
[ -f "$SHARED_ENV" ] && grep -E '^[A-Z0-9_]+=' "$SHARED_ENV"
} > "$dir/.env"
}
run_container() { # slug port
local slug="$1" port="$2" dir="$TENANTS_DIR/$1"
docker run -d --name "quorla-$slug" --restart unless-stopped \
-p "127.0.0.1:$port:4310" --env-file "$dir/.env" \
-e DASHBOARD_HOST=0.0.0.0 -e DASHBOARD_COOKIE_SECURE=true -e TRUST_PROXY=uniquelocal \
-v "$dir/data:/app/data" -v "$dir/clients:/app/clients" -v "$dir/team:/app/team" \
-v "$dir/reports:/app/reports" -v "$dir/branding:/app/branding" -v "$dir/backups:/app/backups:ro" \
--label quorla.tenant="$slug" --label quorla.port="$port" "$IMAGE" >/dev/null
}
wait_healthy() { # port
for _ in $(seq 1 30); do curl -fsS -m 3 "http://127.0.0.1:$1/api/auth/me" >/dev/null 2>&1 && return 0; sleep 2; done
return 1
}
rebuild_map() {
local tmp; tmp="$(mktemp)"
docker ps --filter label=quorla.tenant --format '{{.Label "quorla.tenant"}} {{.Label "quorla.port"}}' \
| while read -r slug port; do echo "$slug.$BASE_DOMAIN 127.0.0.1:$port;"; done | sort > "$tmp"
if ! cmp -s "$tmp" "$MAP" 2>/dev/null; then
mv "$tmp" "$MAP"
if sudo -n nginx -t >/dev/null 2>&1; then sudo -n systemctl reload nginx && log "nginx reloaded"; else log "nginx -t failed or sudo not allowed; map updated but not reloaded"; fi
else rm -f "$tmp"; fi
}
tasks="$(curl -fsS -m 30 -H "Authorization: Bearer $PROVISION_TOKEN" "$CONTROL_URL/control/provision/tasks")"
echo "$tasks" | json 'print("\n".join("\t".join([t["slug"], t["desired"], str(t.get("port") or ""), t.get("agencyName") or "", t.get("setupToken") or ""]) for t in d["tasks"]))' |
while IFS=$'\t' read -r slug desired port name token; do
[ -z "$slug" ] && continue
dir="$TENANTS_DIR/$slug"
case "$desired" in
running)
mkdir -p "$dir"/{data,clients,team,reports,backups}
[ -d "$dir/branding" ] || cp -r "$REPO/branding" "$dir/branding"
write_env "$slug" "$name" "$token"
if docker inspect "quorla-$slug" >/dev/null 2>&1; then docker start "quorla-$slug" >/dev/null; else run_container "$slug" "$port"; fi
if wait_healthy "$port"; then log "$slug running on $port"; report "$slug" '{"actual":"running"}';
else log "$slug did not start"; report "$slug" "{\"error\":\"container did not become healthy on port $port\"}"; fi
;;
stopped)
docker stop "quorla-$slug" >/dev/null 2>&1 || true
log "$slug stopped"; report "$slug" '{"actual":"stopped"}'
;;
removed)
docker rm -f "quorla-$slug" >/dev/null 2>&1 || true
if [ -d "$dir" ]; then tar -czf "$TENANTS_DIR/_archive/$slug-$(date +%Y%m%d-%H%M).tar.gz" -C "$dir" . && rm -rf "$dir"; fi
log "$slug removed (data archived)"; report "$slug" '{"actual":"removed"}'
;;
esac
done
# Upgrade: after `docker compose up -d --build` in the main app, move every agency onto the new image.
current="$(docker image inspect -f '{{.Id}}' "$IMAGE")"
docker ps -a --filter label=quorla.tenant --format '{{.Names}} {{.Image}} {{.Label "quorla.tenant"}} {{.Label "quorla.port"}} {{.State}}' |
while read -r cname _ slug port state; do
used="$(docker inspect -f '{{.Image}}' "$cname")"
[ "$used" = "$current" ] && continue
[ "$state" = "running" ] || continue
docker rm -f "$cname" >/dev/null && run_container "$slug" "$port" && wait_healthy "$port" && log "$slug upgraded" || log "$slug upgrade FAILED"
done
rebuild_map

View File

@ -0,0 +1,9 @@
# Copy to deploy/provision.env (never commit it). Used by deploy/provision-tenants.sh.
CONTROL_URL=https://app.quorlasocial.com
# Same value as PROVISION_TOKEN in the main app's .env. Generate with: openssl rand -hex 32
PROVISION_TOKEN=
TENANTS_DIR=/home/dev/APPS/quorla-tenants
IMAGE=quorla:latest
TENANT_BASE_DOMAIN=quorlasocial.com
# Settings every agency workspace gets (SMTP for emails; optional shared AI key and platform app keys).
SHARED_ENV=/home/dev/APPS/quorlasocial/deploy/tenant-shared.env

99
docs/multi-tenant.md Normal file
View File

@ -0,0 +1,99 @@
# Hosted Quorla: one workspace per agency
How the hosted plan works and how to switch it on. Self-hosted customers don't need any of this: they run one Quorla
with `docker compose up -d --build` as in `docs/hosting.html`.
## How it fits together
```
agency signs up at app.quorlasocial.com/signup ──► Stripe Checkout ──► Stripe webhook ──► app marks the agency "setting up"
│
cron every 5 min: deploy/provision-tenants.sh ◄── asks app what to do (PROVISION_TOKEN) ◄──────────┘
│ creates ~/APPS/quorla-tenants/<agency>/ (own .env, data, clients…), runs container quorla-<agency> on 127.0.0.1:44xx,
│ updates the nginx map so <agency>.quorlasocial.com reaches it, reports back
▼
app emails the owner a one-time setup link: https://<agency>.quorlasocial.com/?setup=<token>
```
- Each agency is a **separate container with its own data folder**: no agency can see another's data.
- The main app (app.quorlasocial.com) is the **control centre** (`QUORLA_CONTROL=true`): sign-up page, Stripe webhook,
Admin → **Agencies** page (suspend, resume, billing portal, resend welcome, remove).
- The web app never runs Docker. Only the provisioning script on the server does.
- A new workspace's first admin can only be created with the emailed setup link (`SETUP_TOKEN`).
- Payment fails: the agency shows "Payment overdue" (still running) until Stripe gives up, then it's stopped. Data is kept.
- Subscription cancelled: workspace stopped, data kept. **Remove** archives the data to `quorla-tenants/_archive/` first.
- Upgrades: after `git pull && docker compose up -d --build` in the main app, the next script run moves every agency
onto the new image (data untouched).
## One-time setup (server, as `dev`; ~30 minutes)
1. **DNS:** add a wildcard record `*.quorlasocial.com A 72.62.169.43` (Hestia → DNS → quorlasocial.com → Add record).
2. **Wildcard certificate** for `*.quorlasocial.com`. With DNS on Hestia:
`sudo v-add-letsencrypt-domain user quorlasocial.com "*.quorlasocial.com"` (or certbot with the DNS challenge).
3. **nginx** (Hestia includes `/etc/nginx/conf.d/*.conf`). Create `/etc/nginx/conf.d/quorla-tenants.conf`:
```nginx
map $host $quorla_tenant {
default "";
include /home/dev/APPS/quorla-tenants/nginx-tenants.map;
}
server {
listen 72.62.169.43:443 ssl;
http2 on;
server_name *.quorlasocial.com;
ssl_certificate /home/user/conf/web/quorlasocial.com/ssl/quorlasocial.com.pem; # adjust to the wildcard cert path
ssl_certificate_key /home/user/conf/web/quorlasocial.com/ssl/quorlasocial.com.key;
client_max_body_size 500m;
proxy_read_timeout 600s;
location / {
if ($quorla_tenant = "") { return 404; }
proxy_pass http://$quorla_tenant;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
```
`touch /home/dev/APPS/quorla-tenants/nginx-tenants.map` first, then `sudo nginx -t && sudo systemctl reload nginx`.
app.quorlasocial.com keeps its own Hestia domain; the exact name wins over the wildcard.
4. **Let the script reload nginx** without a password: `sudo visudo -f /etc/sudoers.d/quorla` →
`dev ALL=(root) NOPASSWD: /usr/sbin/nginx -t, /usr/bin/systemctl reload nginx`
5. **Script settings:** `cp deploy/provision.env.example deploy/provision.env` and fill it in. Create
`deploy/tenant-shared.env` with what every agency gets, at least email:
```
SMTP_HOST=mail.quorlasocial.com
SMTP_PORT=587
SMTP_USER=support@quorlasocial.com
SMTP_PASS=…
SMTP_FROM=support@quorlasocial.com
```
6. **Main app .env:** `QUORLA_CONTROL=true`, `TENANT_BASE_DOMAIN=quorlasocial.com`, the same `PROVISION_TOKEN`, and the
Stripe values below. Rebuild: `docker compose up -d --build`.
7. **Cron:** `crontab -e` →
`*/5 * * * * /home/dev/APPS/quorlasocial/deploy/provision-tenants.sh >> /home/dev/APPS/quorla-tenants/provision.log 2>&1`
## Stripe
1. Stripe Dashboard (start in **Test mode**) → Product catalog → **Add product** "Quorla hosted", recurring monthly price.
Copy the price id (`price_…`) → `STRIPE_PRICE_ID`.
2. Developers → API keys → secret key (`sk_test_…`) → `STRIPE_SECRET_KEY`.
3. Developers → Webhooks → **Add endpoint** `https://app.quorlasocial.com/control/stripe/webhook`, events:
`checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`.
Copy the signing secret (`whsec_…`) → `STRIPE_WEBHOOK_SECRET`.
4. Settings → Billing → **Customer portal**: turn on cancel, update payment method, invoices (used by the Billing button).
5. Optional `STRIPE_TRIAL_DAYS=14`. Test with card 4242 4242 4242 4242, then repeat in Live mode with live keys.
Without Stripe keys, sign-ups still work: they land on Admin → Agencies as "Awaiting payment" and you **Approve** them.
## What each agency sets up in their workspace
- **Meta:** Settings → Meta: paste their own system user token (their Business Manager, their Pages).
- **Other platforms:** Connections → each platform → Set up with their own developer app keys, **or** you add their
callback URL (`https://<agency>.quorlasocial.com/oauth/<platform>/callback`) to Quorla's TikTok/Google/LinkedIn apps and
put those keys in `tenant-shared.env` (`TIKTOK_CLIENT_ID`… ). Platform apps need each exact callback URL listed.
- **Workspace:** Settings → Workspace: their name, logo and colour (white-label).
## Check it works
- Admin → Agencies shows the agency move: Awaiting payment → Setting up → Active.
- `tail -f ~/APPS/quorla-tenants/provision.log` shows `running on 44xx`.
- The owner gets the welcome email and creates their account from the link; the link stops working afterwards.