From 075b79f6b28358fa878e03a7464548d0b02daf3b Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 23 Oct 2026 10:35:23 -0400 Subject: [PATCH] Hosted Quorla: sign-up, Stripe billing, one workspace per agency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 .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 --- .env.example | 11 ++ .gitignore | 4 + connector/src/meta.js | 27 +++- connector/test/control.test.mjs | 64 +++++++++ dashboard/server/auth.js | 8 +- dashboard/server/config.js | 2 +- dashboard/server/control.js | 207 +++++++++++++++++++++++++++ dashboard/server/control/done.html | 34 +++++ dashboard/server/control/signup.html | 72 ++++++++++ dashboard/server/index.js | 40 +++++- dashboard/web/src/App.jsx | 14 +- dashboard/web/src/pages/Admin.jsx | 73 ++++++++++ dashboard/web/src/pages/Login.jsx | 2 +- dashboard/web/src/pages/Settings.jsx | 30 ++++ deploy/provision-tenants.sh | 103 +++++++++++++ deploy/provision.env.example | 9 ++ docs/multi-tenant.md | 99 +++++++++++++ 17 files changed, 784 insertions(+), 15 deletions(-) create mode 100644 connector/test/control.test.mjs create mode 100644 dashboard/server/control.js create mode 100644 dashboard/server/control/done.html create mode 100644 dashboard/server/control/signup.html create mode 100644 deploy/provision-tenants.sh create mode 100644 deploy/provision.env.example create mode 100644 docs/multi-tenant.md 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 + + + + +
+ Quorla +
+

Thanks, you're in

+ + +

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.

+ +
+
+
+
We send your setup link here.
+
+ +
.quorlasocial.com
+
Lowercase letters, numbers and dashes.
+
+ + + +
+

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 (