Settings on the site instead of .env; Odoo in Connections

- Server settings (Settings page): SMTP, AI, publishing, Meta, platform keys and,
  on the control centre only, Stripe/billing. Stored sealed in data/settings.json and
  applied over .env in every process; hosted agency workspaces don't see our email,
  platform keys or billing.
- Odoo connection per workspace in Connections (login checked before saving).
- provision-tenants.sh marks agency containers with QUORLA_HOSTED_TENANT=true.
- docs/wildcard-subdomains.html: one-time setup for automatic agency subdomains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-25 19:49:15 -04:00
parent e905236f41
commit 7c596ec6d5
15 changed files with 665 additions and 39 deletions

View File

@ -2,6 +2,7 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { open } from "./sealed.js";
const here = path.dirname(fileURLToPath(import.meta.url));
export const PROJECT_ROOT = path.resolve(here, "..", "..");
@ -10,7 +11,7 @@ export function loadEnv() {
// Docker's env_file keeps stray spaces/tabs after "=", which breaks tokens. Trim everything we read.
for (const [k, v] of Object.entries(process.env)) if (/^[A-Z0-9_]+$/.test(k) && v !== v.trim()) process.env[k] = v.trim();
const file = path.join(PROJECT_ROOT, ".env");
if (!fs.existsSync(file)) return;
if (!fs.existsSync(file)) return applySaved();
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (!m || line.trim().startsWith("#")) continue;
@ -18,4 +19,25 @@ export function loadEnv() {
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
if (!(m[1] in process.env)) process.env[m[1]] = v;
}
applySaved();
}
// Settings saved on the site (Settings → Server settings) override .env. Stored sealed in data/settings.json.
export const SETTINGS_FILE = process.env.QUORLA_SETTINGS_FILE || path.join(PROJECT_ROOT, "data", "settings.json"); // override for tests
/** What .env (or the container) said before a site setting replaced it, so clearing one falls back correctly. */
export const ENV_BASE = {};
export function savedSettings() {
try { return open(JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf8")).sealed) || {}; } catch { return {}; }
}
/** Set one variable from the site (or restore the .env value when value is null). */
export function applySetting(k, value) {
if (!(k in ENV_BASE)) ENV_BASE[k] = process.env[k];
const v = value ?? ENV_BASE[k];
if (v === undefined) delete process.env[k]; else process.env[k] = v;
}
function applySaved() {
for (const [k, v] of Object.entries(savedSettings())) if (/^[A-Z0-9_]+$/.test(k)) applySetting(k, String(v));
}

View File

@ -5,32 +5,14 @@ import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { PROJECT_ROOT, loadEnv } from "../env.js";
import { seal, open } from "../sealed.js";
loadEnv();
const FILE = process.env.QUORLA_CONNECTIONS_FILE || path.join(PROJECT_ROOT, "data", "connections.json"); // override for tests
const err = (message, status = 400) => Object.assign(new Error(message), { status });
const now = () => new Date().toISOString();
function key() {
const secret = process.env.QUORLA_SECRET_KEY || process.env.DASHBOARD_SECRET;
if (!secret) throw err("Set DASHBOARD_SECRET (or QUORLA_SECRET_KEY) in .env before connecting platforms: it encrypts the stored tokens.", 500);
return crypto.createHash("sha256").update(`quorla-vault:${secret}`).digest();
}
export function seal(value) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv("aes-256-gcm", key(), iv);
const body = Buffer.concat([c.update(JSON.stringify(value), "utf8"), c.final()]);
return `v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${body.toString("base64url")}`;
}
export function open(sealed) {
const [v, iv, tag, body] = String(sealed || "").split(".");
if (v !== "v1") throw err("Unreadable secret.", 500);
const d = crypto.createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64url"));
d.setAuthTag(Buffer.from(tag, "base64url"));
return JSON.parse(Buffer.concat([d.update(Buffer.from(body, "base64url")), d.final()]).toString("utf8"));
}
export { seal, open };
function read() {
try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return { apps: {}, connections: [] }; }
@ -70,6 +52,24 @@ export function removeApp(platform) {
const db = read(); delete db.apps[platform]; write(db);
}
// ---------- workspace integrations (e.g. the agency's Odoo) ----------
/** Stored settings for an integration, decrypted, or null. */
export function integration(name) {
const rec = read().integrations?.[name];
if (!rec) return null;
try { return { ...open(rec.sealed), updatedAt: rec.updatedAt, updatedBy: rec.updatedBy }; } catch { return null; }
}
export function saveIntegration(name, value, by) {
const db = read();
db.integrations = { ...db.integrations, [name]: { sealed: seal(value), updatedAt: now(), updatedBy: by } };
write(db);
}
export function removeIntegration(name) {
const db = read(); if (db.integrations) delete db.integrations[name]; write(db);
}
// ---------- connected accounts ----------
const publicConn = (c) => {
const { sealed, ...rest } = c;

26
connector/src/sealed.js Normal file
View File

@ -0,0 +1,26 @@
// AES-256-GCM sealing for secrets kept on disk (platform keys, tokens, site settings).
// The key comes from QUORLA_SECRET_KEY (or DASHBOARD_SECRET), which therefore stays in .env.
import crypto from "node:crypto";
const err = (message, status = 400) => Object.assign(new Error(message), { status });
function key() {
const secret = process.env.QUORLA_SECRET_KEY || process.env.DASHBOARD_SECRET;
if (!secret) throw err("Set DASHBOARD_SECRET (or QUORLA_SECRET_KEY) in .env before connecting platforms: it encrypts the stored tokens.", 500);
return crypto.createHash("sha256").update(`quorla-vault:${secret}`).digest();
}
export function seal(value) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv("aes-256-gcm", key(), iv);
const body = Buffer.concat([c.update(JSON.stringify(value), "utf8"), c.final()]);
return `v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${body.toString("base64url")}`;
}
export function open(sealed) {
const [v, iv, tag, body] = String(sealed || "").split(".");
if (v !== "v1") throw err("Unreadable secret.", 500);
const d = crypto.createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64url"));
d.setAuthTag(Buffer.from(tag, "base64url"));
return JSON.parse(Buffer.concat([d.update(Buffer.from(body, "base64url")), d.final()]).toString("utf8"));
}

View File

@ -0,0 +1,36 @@
// Odoo settings from Connections: login checked before saving, stored encrypted, key never returned, env fallback.
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-odoo-"));
const FILE = path.join(TMP, "connections.json");
Object.assign(process.env, { QUORLA_CONNECTIONS_FILE: FILE, DASHBOARD_SECRET: "s".repeat(32), QUORLA_DASHBOARD_DATA: path.join(TMP, "dash"), ODOO_URL: "", ODOO_API_KEY: "" });
const odoo = await import("../../dashboard/server/odoo.js");
test("save checks the login, stores it sealed and hides the key; disconnect clears it", async () => {
const realFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (url, init) => {
const { params } = JSON.parse(init.body);
calls.push({ url: String(url), params });
const ok = params.args[2] === "good-key";
return new Response(JSON.stringify({ result: params.method === "login" ? (ok ? 7 : false) : null }));
};
try {
assert.equal(odoo.settings().configured, false);
const form = { url: "https://acme.odoo.com/odoo/action-1", db: "acme", username: "ops@acme.test", allowWrites: true };
await assert.rejects(odoo.saveSettings({ ...form, apiKey: "bad-key" }, "Ana"), /rejected the login/);
await assert.rejects(odoo.saveSettings({ ...form, apiKey: "" }, "Ana"), /required/);
const s = await odoo.saveSettings({ ...form, apiKey: "good-key" }, "Ana");
assert.equal(calls.at(-1).url, "https://acme.odoo.com/jsonrpc", "address trimmed to the site root");
assert.deepEqual([s.configured, s.source, s.url, s.allowWrites, s.updatedBy], [true, "settings", "https://acme.odoo.com", true, "Ana"]);
assert.equal(JSON.stringify(s).includes("good-key"), false);
assert.equal(fs.readFileSync(FILE, "utf8").includes("good-key"), false, "key encrypted at rest");
const again = await odoo.saveSettings({ ...form, apiKey: "", allowWrites: false }, "Ana");
assert.equal(again.allowWrites, false, "empty key keeps the stored one");
assert.equal(odoo.removeSettings().configured, false);
} finally { globalThis.fetch = realFetch; }
});

View File

@ -0,0 +1,51 @@
// Server settings on the site: override .env live, fall back when cleared, secrets hidden, scopes per workspace type.
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-settings-"));
const FILE = path.join(TMP, "settings.json");
Object.assign(process.env, {
QUORLA_SETTINGS_FILE: FILE, QUORLA_DASHBOARD_DATA: path.join(TMP, "dash"), QUORLA_CONTROL_DIR: path.join(TMP, "control"),
DASHBOARD_SECRET: "s".repeat(32), SMTP_HOST: "env.mail.test", QUORLA_CONTROL: "", QUORLA_HOSTED_TENANT: "",
});
delete process.env.SMTP_PASS;
const settings = await import("../../dashboard/server/settings.js");
const field = (r, key) => r.groups.flatMap((g) => g.fields).find((f) => f.key === key);
test("saved values override .env, secrets stay hidden, clearing falls back", () => {
assert.equal(field(settings.list(), "SMTP_HOST").source, "env");
let r = settings.save({ values: { SMTP_HOST: "site.mail.test", SMTP_PASS: "hunter2", ALLOW_PUBLISH: "true" } });
assert.deepEqual(r.changed.sort(), ["ALLOW_PUBLISH", "SMTP_HOST", "SMTP_PASS"]);
assert.equal(process.env.SMTP_HOST, "site.mail.test", "applied live");
assert.equal(process.env.SMTP_PASS, "hunter2");
assert.deepEqual([field(r, "SMTP_PASS").value, field(r, "SMTP_PASS").set, field(r, "SMTP_PASS").source], ["", true, "site"]);
assert.equal(JSON.stringify(r).includes("hunter2"), false);
assert.equal(fs.readFileSync(FILE, "utf8").includes("hunter2"), false, "encrypted at rest");
r = settings.save({ values: { SMTP_PASS: "" } });
assert.equal(process.env.SMTP_PASS, "hunter2", "empty secret keeps the stored one");
r = settings.save({ clear: ["SMTP_HOST", "SMTP_PASS"] });
assert.equal(process.env.SMTP_HOST, "env.mail.test", "back to .env");
assert.equal(process.env.SMTP_PASS, undefined);
assert.equal(field(r, "SMTP_HOST").source, "env");
assert.throws(() => settings.save({ values: { SMTP_PORT: "abc" } }), /whole number/);
assert.throws(() => settings.save({ values: { DASHBOARD_SECRET: "x" } }), /can't be changed here/);
assert.deepEqual(settings.save({ values: { META_API_VERSION: "v24.0" } }).restart, ["META_API_VERSION"]);
});
test("billing only on the control centre; our email and platform keys hidden in hosted agency workspaces", () => {
const groups = () => settings.list().groups.map((g) => g.name);
assert.ok(!groups().includes("Billing & sign-ups"));
process.env.QUORLA_CONTROL = "true";
assert.ok(groups().includes("Billing & sign-ups"));
process.env.QUORLA_CONTROL = "";
process.env.QUORLA_HOSTED_TENANT = "true";
assert.ok(!groups().includes("Email (SMTP)") && !groups().includes("Platforms") && groups().includes("AI runs"));
assert.equal(field(settings.list(), "PUBLIC_URL"), undefined);
assert.throws(() => settings.save({ values: { SMTP_HOST: "x" } }), /can't be changed here/);
process.env.QUORLA_HOSTED_TENANT = "";
});

View File

@ -35,6 +35,6 @@ export const ODOO = {
// AI runs (audits, plans, reports) go through the Claude Agent SDK.
export const AI = {
model: process.env.DASHBOARD_AI_MODEL || "claude-opus-5-5",
maxTurns: Number(process.env.DASHBOARD_AI_MAX_TURNS || 80),
get model() { return process.env.DASHBOARD_AI_MODEL || "claude-opus-5-5"; }, // read live: changeable in Settings
get maxTurns() { return Number(process.env.DASHBOARD_AI_MAX_TURNS || 80); },
};

View File

@ -20,6 +20,7 @@ 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 settings from "./settings.js";
import * as jobs from "./jobs.js";
import * as docs from "./docs.js";
import * as meta from "../../connector/src/meta.js";
@ -178,6 +179,20 @@ app.get("/api/platforms", h(async (req) => ({
})));
app.put("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.saveApp(req.params.id, req.body || {}, req.user.name || req.user.email); return { ok: true }; }));
app.delete("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.removeApp(req.params.id); return { ok: true }; }));
app.get("/api/settings/server", requireAdmin, h(async () => settings.list()));
app.put("/api/settings/server", requireAdmin, h(async (req) => settings.save(req.body || {}, (keys) => {
if (keys.some((k) => k.startsWith("SMTP_"))) notify.resetMailer();
console.log(`Server settings changed by ${req.user.email}: ${keys.join(", ")}`);
})));
// Only inside Docker, where the container comes straight back up (restart policy).
app.post("/api/settings/restart", requireAdmin, h(async () => {
if (!fs.existsSync("/.dockerenv")) throw Object.assign(new Error("Restart the dashboard on the server yourself."), { status: 400 });
setTimeout(() => process.exit(0), 500);
return { ok: true };
}));
app.get("/api/integrations/odoo", requireAdmin, h(async () => ({ odoo: odoo.settings() })));
app.put("/api/integrations/odoo", requireAdmin, h(async (req) => ({ odoo: await odoo.saveSettings(req.body || {}, req.user.name || req.user.email) })));
app.delete("/api/integrations/odoo", requireAdmin, h(async () => ({ odoo: odoo.removeSettings() })));
app.get("/api/connections", h(async (req) => ({ connections: extra.vault.listConnections({ client: req.query.client || undefined }) })));
app.get("/api/quota", h(async (req) => ({ quota: sched.quotaSummary(clients.safeSlug(req.query.client) && String(req.query.client)) })));
app.get("/api/oauth/:platform/start", requireAdmin, h(async (req) => {

View File

@ -18,6 +18,8 @@ const writeJson = (f, v) => { const tmp = f + ".tmp"; fs.writeFileSync(tmp, JSON
// ---------- email ----------
let transport = null;
export function emailConfigured() { return Boolean(process.env.SMTP_HOST); }
/** Drop the cached connection after SMTP settings change. */
export function resetMailer() { transport = null; }
function mailer() {
if (!emailConfigured()) return null;
transport ||= nodemailer.createTransport({

View File

@ -1,14 +1,23 @@
// Odoo (workplace.metatroncube.in) over JSON-RPC: team, projects and Project tasks.
// Reads always work when configured. Creating/moving tasks needs ODOO_ALLOW_WRITES=true.
import { ODOO } from "./config.js";
// Odoo over JSON-RPC: team, projects and Project tasks.
// Settings come from Connections → Odoo (stored encrypted in the vault), else from .env (ODOO_*).
// Reads always work when configured. Creating/moving tasks needs "allow writes".
import { ODOO as ENV_ODOO } from "./config.js";
import * as vault from "../../connector/src/platforms/vault.js";
let uidPromise = null;
const cache = new Map();
const bad = (message, status = 400) => Object.assign(new Error(message), { status });
function load() {
const v = vault.integration("odoo");
return v ? { ...v, source: "settings" } : { ...ENV_ODOO, source: ENV_ODOO.url ? "env" : null };
}
let ODOO = load();
export const configured = () => Boolean(ODOO.url && ODOO.db && ODOO.username && ODOO.secret);
async function rpc(service, method, args) {
const res = await fetch(`${ODOO.url}/jsonrpc`, {
async function rpc(service, method, args, url = ODOO.url) {
const res = await fetch(`${url}/jsonrpc`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { service, method, args }, id: Date.now() }),
@ -21,14 +30,42 @@ async function rpc(service, method, args) {
}
async function uid() {
if (!configured()) throw Object.assign(new Error("Odoo isn't configured. Add ODOO_URL, ODOO_DATABASE, ODOO_USERNAME and ODOO_API_KEY to .env."), { status: 503 });
if (!configured()) throw bad("Odoo isn't connected. An admin can connect it in Connections → Odoo.", 503);
uidPromise ??= rpc("common", "login", [ODOO.db, ODOO.username, ODOO.secret]).then((id) => {
if (!id) throw new Error("Odoo login failed — check the username and API key in .env.");
if (!id) throw new Error("Odoo login failed — check the username and API key in Connections → Odoo.");
return id;
});
try { return await uidPromise; } catch (e) { uidPromise = null; throw e; }
}
function reset() { ODOO = load(); uidPromise = null; cache.clear(); }
/** What the Connections page shows (never the key). */
export function settings() {
return { configured: configured(), source: ODOO.source, url: ODOO.url, db: ODOO.db, username: ODOO.username, allowWrites: ODOO.allowWrites, updatedAt: ODOO.updatedAt || null, updatedBy: ODOO.updatedBy || null };
}
/** Check the login against Odoo, then store it encrypted. Empty key keeps the stored one. */
export async function saveSettings({ url, db, username, apiKey, allowWrites }, by) {
const next = {
url: String(url || "").trim().replace(/\/+$/, "").replace(/\/(web|odoo)(\/.*)?$/, ""),
db: String(db || "").trim(),
username: String(username || "").trim(),
secret: String(apiKey || "").trim() || (ODOO.source === "settings" ? ODOO.secret : ""),
allowWrites: Boolean(allowWrites),
};
if (!/^https?:\/\/[^\s/]+/.test(next.url)) throw bad("Enter the Odoo address, e.g. https://yourcompany.odoo.com");
if (!next.db || !next.username || !next.secret) throw bad("Database, username and API key are all required.");
let id;
try { id = await rpc("common", "login", [next.db, next.username, next.secret], next.url); } catch (e) { throw bad(`Couldn't reach Odoo: ${e.message}`); }
if (!id) throw bad("Odoo rejected the login. Check the database name, the username (login email) and the API key.");
vault.saveIntegration("odoo", next, by);
reset();
return settings();
}
export function removeSettings() { vault.removeIntegration("odoo"); reset(); return settings(); }
async function call(model, method, args = [], kwargs = {}) {
return rpc("object", "execute_kw", [ODOO.db, await uid(), ODOO.secret, model, method, args, kwargs]);
}
@ -118,7 +155,7 @@ export async function workload() {
}
function assertWritable() {
if (!ODOO.allowWrites) throw Object.assign(new Error("Task creation is turned off. Set ODOO_ALLOW_WRITES=true in .env to allow it."), { status: 403 });
if (!ODOO.allowWrites) throw Object.assign(new Error("Task creation is turned off. Turn on “Allow creating and moving tasks” in Connections → Odoo."), { status: 403 });
}
export async function createTask({ projectId, name, description = "", userIds = [], deadline = null }) {
@ -147,7 +184,7 @@ export async function moveTask(taskId, stageId) {
export async function taskActivity(taskRef, { note, stageMatch } = {}) {
const id = Number(String(taskRef || "").replace(/\D/g, ""));
if (!id || !configured()) return { skipped: "no task or Odoo not configured" };
if (!ODOO.allowWrites) return { skipped: "ODOO_ALLOW_WRITES is off" };
if (!ODOO.allowWrites) return { skipped: "Odoo writes are off" };
try {
if (note) await call("project.task", "message_post", [[id]], { body: note, message_type: "comment", subtype_xmlid: "mail.mt_note" });
if (stageMatch) {

View File

@ -0,0 +1,116 @@
// Server settings an admin can change on the site instead of in .env (Settings → Server settings).
// Saved values are sealed in data/settings.json and override .env in every process (see connector/src/env.js).
// Scopes: "all" = every workspace; "self" = hidden in hosted agency workspaces (we provide those);
// "control" = only on the control centre (app.quorlasocial.com). Secrets are never sent back to the browser.
import fs from "node:fs";
import path from "node:path";
import { SETTINGS_FILE, ENV_BASE, savedSettings, applySetting } from "../../connector/src/env.js";
import { seal } from "../../connector/src/sealed.js";
import * as control from "./control.js";
const bad = (message, status = 400) => Object.assign(new Error(message), { status });
const GROUPS = [
{ name: "General", fields: [
{ key: "PUBLIC_URL", label: "Public address", hint: "Where this dashboard is reached, e.g. https://social.youragency.com. Used in email links and for platforms that fetch media.", scope: "self", type: "url" },
{ key: "DASHBOARD_SESSION_HOURS", label: "Stay signed in (hours)", type: "number", placeholder: "12", restart: true },
] },
{ name: "Publishing", fields: [
{ key: "ALLOW_PUBLISH", label: "Allow publishing", hint: "Off: drafts can be prepared but nothing is posted. Every post still needs approval.", type: "bool", default: false },
{ key: "SCHEDULER_ENABLED", label: "Run the scheduler", hint: "Posts approved posts at their time.", type: "bool", default: true, restart: true },
{ key: "DASHBOARD_NIGHTLY_REFRESH", label: "Nightly numbers refresh and monthly reports", type: "bool", default: true, restart: true },
] },
{ name: "Meta", fields: [
{ key: "META_MEDIA_PAGE_ID", label: "Agency Facebook Page ID", hint: "Needed to post images for Instagram-only clients." },
{ key: "META_API_VERSION", label: "Graph API version", placeholder: "v23.0", scope: "self", restart: true },
] },
{ name: "AI runs", fields: [
{ key: "ANTHROPIC_API_KEY", label: "Anthropic API key", hint: "For audits, weekly plans and reports.", secret: true },
{ key: "DASHBOARD_AI_MODEL", label: "Model", placeholder: "claude-opus-5-5" },
{ key: "DASHBOARD_AI_MAX_TURNS", label: "Max steps per run", type: "number", placeholder: "80" },
] },
{ name: "Email (SMTP)", scope: "self", fields: [
{ key: "SMTP_HOST", label: "Host", placeholder: "mail.example.com" },
{ key: "SMTP_PORT", label: "Port", type: "number", placeholder: "587" },
{ key: "SMTP_USER", label: "Username" },
{ key: "SMTP_PASS", label: "Password", secret: true },
{ key: "SMTP_FROM", label: "From address", placeholder: "Quorla <support@example.com>" },
] },
{ name: "Platforms", scope: "self", fields: [
{ key: "YOUTUBE_API_KEY", label: "YouTube Data API key", hint: "Reads public comments for the Inbox.", secret: true },
{ key: "LINKEDIN_API_VERSION", label: "LinkedIn API version", placeholder: "202606" },
{ key: "PINTEREST_SANDBOX", label: "Pinterest sandbox (Trial access)", type: "bool", default: false },
{ key: "REDDIT_USER_AGENT", label: "Reddit user agent", placeholder: "web:com.youragency.app:1.0 (by /u/yourname)" },
] },
{ name: "Billing & sign-ups", scope: "control", fields: [
{ key: "STRIPE_SECRET_KEY", label: "Stripe secret key", secret: true },
{ key: "STRIPE_PRICE_ID", label: "Stripe price ID", placeholder: "price_…" },
{ key: "STRIPE_WEBHOOK_SECRET", label: "Stripe webhook secret", secret: true, hint: "Webhook URL: /control/stripe/webhook on this site." },
{ key: "STRIPE_TRIAL_DAYS", label: "Free trial (days)", type: "number", placeholder: "0" },
{ key: "TENANT_BASE_DOMAIN", label: "Agency workspace domain", placeholder: "quorlasocial.com" },
] },
];
const hosted = () => process.env.QUORLA_HOSTED_TENANT === "true";
const visible = (scope) => scope === "control" ? control.enabled() : scope === "self" ? !hosted() : true;
const visibleFields = () => GROUPS.flatMap((g) => (visible(g.scope) ? g.fields.filter((f) => visible(f.scope)) : []));
/** Everything the Settings page needs. Secrets come back only as set / not set. */
export function list() {
const saved = savedSettings();
return {
hosted: hosted(),
canRestart: fs.existsSync("/.dockerenv"),
groups: GROUPS.filter((g) => visible(g.scope)).map((g) => ({
name: g.name,
fields: g.fields.filter((f) => visible(f.scope)).map((f) => {
const value = process.env[f.key];
const fromEnv = (f.key in ENV_BASE ? ENV_BASE[f.key] : value) !== undefined;
return {
key: f.key, label: f.label, hint: f.hint || "", placeholder: f.placeholder || "", type: f.type || "text", secret: Boolean(f.secret), restart: Boolean(f.restart),
default: f.default ?? null,
source: f.key in saved ? "site" : fromEnv ? "env" : null,
set: Boolean(value),
value: f.secret ? "" : value ?? "",
};
}),
})),
};
}
function clean(f, raw) {
const v = String(raw ?? "").trim();
if (/[\r\n]/.test(v)) throw bad(`${f.label}: one line only.`);
if (f.type === "number" && !/^\d+$/.test(v)) throw bad(`${f.label} must be a whole number.`);
if (f.type === "bool" && !["true", "false"].includes(v)) throw bad(`${f.label} must be on or off.`);
if (f.type === "url" && !/^https?:\/\/[^\s/]+/.test(v)) throw bad(`${f.label} must start with https://`);
return f.type === "url" ? v.replace(/\/+$/, "") : v;
}
/**
* values: { KEY: "new value" } (an empty secret keeps the stored one); clear: [KEY] goes back to .env.
* Returns the fresh list plus which saved keys need a restart.
*/
export function save({ values = {}, clear = [] } = {}, onChange = () => {}) {
const fields = new Map(visibleFields().map((f) => [f.key, f]));
const next = savedSettings();
const changed = [];
for (const k of [...Object.keys(values), ...clear]) if (!fields.has(k)) throw bad(`${k} can't be changed here.`);
for (const [k, raw] of Object.entries(values)) {
const f = fields.get(k);
if (f.secret && !String(raw ?? "").trim()) continue;
if (!f.secret && !String(raw ?? "").trim()) { if (k in next) { delete next[k]; changed.push(k); } continue; }
const v = clean(f, raw);
if (next[k] !== v) { next[k] = v; changed.push(k); }
}
for (const k of clear) if (k in next) { delete next[k]; changed.push(k); }
if (changed.length) {
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
const tmp = `${SETTINGS_FILE}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ sealed: seal(next), updatedAt: new Date().toISOString() }), { mode: 0o600 });
fs.renameSync(tmp, SETTINGS_FILE);
for (const k of changed) applySetting(k, next[k] ?? null);
onChange(changed);
}
return { ...list(), changed, restart: changed.filter((k) => fields.get(k).restart) };
}

View File

@ -77,6 +77,56 @@ function AppDialog({ platform, onClose, onSaved }) {
);
}
/** The agency's own Odoo (team tasks, workload). One per workspace, not per client. */
function OdooPanel() {
const [s, setS] = useState(null);
const [f, setF] = useState(null);
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const [saved, setSaved] = useState(false);
const fill = (o) => { setS(o); setF({ url: o.url || "", db: o.db || "", username: o.username || "", apiKey: "", allowWrites: Boolean(o.allowWrites) }); };
useEffect(() => { api("/api/integrations/odoo").then((r) => fill(r.odoo)).catch(setError); }, []);
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === "checkbox" ? e.target.checked : e.target.value });
const save = async (e) => {
e.preventDefault(); setBusy("save"); setError(null); setSaved(false);
try { fill((await api("/api/integrations/odoo", { method: "PUT", body: f })).odoo); setSaved(true); } catch (err) { setError(err); } finally { setBusy(null); }
};
const remove = async () => {
setBusy("remove"); setError(null); setSaved(false);
try { fill((await api("/api/integrations/odoo", { method: "DELETE" })).odoo); } catch (err) { setError(err); } finally { setBusy(null); }
};
const fromSettings = s?.source === "settings";
return (
<Panel title="Odoo · team tasks" subtitle="Your agency's Odoo. Powers the Team page, task workload and tasks created from the calendar and library."
action={s && (s.configured ? <Badge tone="ok" dot>{fromSettings ? "Connected" : "From server .env"}</Badge> : <Badge>Not connected</Badge>)}>
{!f ? <div className="px-4 py-3">{error ? <ErrorNote error={error} /> : <Spinner />}</div> : (
<form onSubmit={save} className="px-4 py-3 space-y-3 text-[13.5px]">
<p className="text-ink-2">In Odoo: your avatar → <b>My Profile</b> (or Preferences) → <b>Account Security</b> → <b>New API Key</b>. Use the login of a user who can see your projects.</p>
<div className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(240px,1fr))]">
<Field label="Odoo address"><input id="odoo-url" className="input" value={f.url} onChange={set("url")} placeholder="https://yourcompany.odoo.com" required /></Field>
<Field label="Database" hint="Shown at /web/database/selector, or ask your Odoo admin."><input id="odoo-db" className="input" value={f.db} onChange={set("db")} required autoComplete="off" /></Field>
<Field label="Username (login email)"><input id="odoo-user" className="input" value={f.username} onChange={set("username")} required autoComplete="off" /></Field>
<Field label="API key" hint={fromSettings ? "Leave empty to keep the stored key." : "Stored encrypted. Never shown again."}>
<input id="odoo-key" className="input" type="password" value={f.apiKey} onChange={set("apiKey")} required={!fromSettings} autoComplete="new-password" />
</Field>
</div>
<label className="flex items-start gap-2">
<input id="odoo-writes" type="checkbox" className="mt-0.5 accent-brand" checked={f.allowWrites} onChange={set("allowWrites")} />
<span>Allow creating and moving tasks <span className="text-muted">(off = read only)</span></span>
</label>
{s.source === "env" && <p className="text-muted text-[12.5px]">Currently using the server's .env. Saving here overrides it.</p>}
{saved && <p className="text-ok text-[13px] flex items-center gap-1"><Check className="size-4" />Logged in to Odoo and saved.</p>}
<ErrorNote error={error} />
<div className="flex gap-2">
<Button variant="primary" type="submit" loading={busy === "save"}>Test & save</Button>
{fromSettings && <Button variant="danger" type="button" loading={busy === "remove"} onClick={remove}>Disconnect</Button>}
</div>
</form>
)}
</Panel>
);
}
function PlatformCard({ platform, conn, client, clients, isAdmin, mediaLinks, onSetup, onMoved, reload, setError, quota }) {
const [busy, setBusy] = useState(null);
const [moving, setMoving] = useState(false);
@ -243,6 +293,8 @@ export default function Connections() {
))}
</div>
{isAdmin && <OdooPanel />}
{setup && <AppDialog platform={setup} onClose={() => setSetup(null)} onSaved={() => { setSetup(null); loadPlatforms(); }} />}
</div>
);

View File

@ -279,7 +279,7 @@ function WorkspacePanel() {
<Button variant="primary" loading={busy === "save"} onClick={() => run("save", () => api("/api/workspace", { method: "PUT", body: { name: w.name, accent: w.accent, supportEmail: w.supportEmail, website: w.website } }))}>Save workspace</Button>
{msg && (typeof msg === "string" ? <span className="text-[12.5px] text-ok">{msg}</span> : <ErrorNote error={msg} />)}
</div>
<p className="text-[12px] text-muted">Your own domain (like social.youragency.com): point the domain at the server and set PUBLIC_URL in .env. See the hosting guide.</p>
<p className="text-[12px] text-muted">Your own domain (like social.youragency.com): point the domain at the server and set the public address under Server settings. See the hosting guide.</p>
</Panel>
);
}
@ -294,7 +294,7 @@ function NotificationsPanel() {
const test = async () => { setBusy("test"); setMsg(null); try { const r = await api("/api/notify/test", { method: "POST" }); setMsg(r.sent ? "Test email sent. Check your inbox (and spam)." : new Error(r.reason)); } catch (e) { setMsg(e); } setBusy(null); };
return (
<Panel title="Notifications" subtitle="The bell at the top of the sidebar always shows them. Choose what also comes by email." bodyClass="px-5 py-4 space-y-3">
{!d.email && <div className="text-[13px] rounded-md bg-warn-soft text-warn px-3 py-2">Email isn't set up on this server yet, so notifications only appear in the bell. An admin adds SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS and SMTP_FROM to the server's .env.</div>}
{!d.email && <div className="text-[13px] rounded-md bg-warn-soft text-warn px-3 py-2">Email isn't set up on this server yet, so notifications only appear in the bell. An admin can set it up under Settings → Server settings → Email.</div>}
<label htmlFor="np-email" className="flex items-start gap-2.5 text-[13.5px]">
<input id="np-email" type="checkbox" className="mt-1" checked={d.prefs.email} disabled={busy === "email"} onChange={() => toggle("email")} />
<span><b>Email me when something needs me</b><br /><span className="text-muted text-[12.5px]">A client approved or asked for changes, a post failed, or a TikTok post is waiting in the TikTok app.</span></span>
@ -309,6 +309,84 @@ function NotificationsPanel() {
);
}
const SOURCE = { site: ["Saved here", "brand"], env: ["From .env", "neutral"] };
/** Everything that used to live only in the server's .env. Super admins on the control centre also see billing. */
function ServerSettingsPanel() {
const [d, setD] = useState(null);
const [edits, setEdits] = useState({});
const [busy, setBusy] = useState(null);
const [msg, setMsg] = useState(null);
const [restart, setRestart] = useState([]);
useEffect(() => { api("/api/settings/server").then(setD).catch(setMsg); }, []);
if (!d) return msg ? <ErrorNote error={msg} /> : null;
const val = (f) => (f.key in edits ? edits[f.key] : f.type === "bool" ? String(f.value ? f.value === "true" : f.default) : f.value);
const edit = (k, v) => setEdits({ ...edits, [k]: v });
const send = async (body, what) => {
setBusy(what); setMsg(null);
try {
const r = await api("/api/settings/server", { method: "PUT", body });
setD(r); setEdits({});
if (r.restart.length) setRestart((x) => [...new Set([...x, ...r.restart])]);
setMsg(r.changed.length ? `Saved ${r.changed.length} setting${r.changed.length > 1 ? "s" : ""}.` : "Nothing changed.");
} catch (e) { setMsg(e); }
setBusy(null);
};
const restartNow = async () => {
setBusy("restart"); setMsg(null);
try { await api("/api/settings/restart", { method: "POST" }); setMsg("Restarting… reload this page in about 20 seconds."); setRestart([]); } catch (e) { setMsg(e); }
setBusy(null);
};
const dirty = Object.keys(edits).length > 0;
return (
<Panel title="Server settings" subtitle={d.hosted ? "Settings for your workspace. Email and platform keys are provided by Quorla." : "Everything that used to be set in the server's .env. Saved values override .env and are stored encrypted."} bodyClass="px-5 py-4 space-y-6">
{d.groups.map((g) => (
<section key={g.name} className="space-y-3">
<h3 className="text-[13px] font-semibold uppercase tracking-wide text-muted">{g.name}</h3>
<div className="grid sm:grid-cols-2 gap-3">
{g.fields.map((f) => {
const src = SOURCE[f.source];
const label = (
<span className="flex items-center gap-2 flex-wrap">
{f.label}
{src && <Badge tone={src[1]}>{src[0]}</Badge>}
{f.restart && <span className="text-[11.5px] text-muted font-normal">applies after restart</span>}
{f.source === "site" && <button type="button" className="text-[11.5px] underline text-muted font-normal" disabled={!!busy} onClick={() => send({ clear: [f.key] }, f.key)}>use .env</button>}
</span>
);
const id = `ss-${f.key}`;
if (f.type === "bool") return (
<label key={f.key} htmlFor={id} className="flex items-start gap-2.5 text-[13.5px] sm:col-span-2">
<input id={id} type="checkbox" className="mt-1" checked={val(f) === "true"} onChange={(e) => edit(f.key, String(e.target.checked))} />
<span><b className="font-medium">{label}</b>{f.hint && <span className="block text-muted text-[12.5px]">{f.hint}</span>}</span>
</label>
);
return (
<Field key={f.key} label={label} hint={f.secret ? (f.set ? "Set. Leave empty to keep it." : "Not set. Stored encrypted, never shown again.") + (f.hint ? ` ${f.hint}` : "") : f.hint}>
<input id={id} className="input" type={f.secret ? "password" : f.type === "number" ? "number" : "text"} autoComplete={f.secret ? "new-password" : "off"}
value={val(f)} placeholder={f.secret && f.set ? "••••••••" : f.placeholder} onChange={(e) => edit(f.key, e.target.value)} />
</Field>
);
})}
</div>
</section>
))}
{restart.length > 0 && (
<div className="text-[13px] rounded-md bg-warn-soft text-warn px-3 py-2 flex items-center gap-3 flex-wrap">
<span>{restart.join(", ")} take effect after the dashboard restarts.</span>
{d.canRestart ? <Button size="sm" loading={busy === "restart"} onClick={restartNow}>Restart now</Button> : <span>Restart it on the server.</span>}
</div>
)}
<div className="flex items-center gap-3 flex-wrap">
<Button variant="primary" disabled={!dirty} loading={busy === "save"} onClick={() => send({ values: edits }, "save")}>Save server settings</Button>
{dirty && <Button variant="ghost" onClick={() => setEdits({})}>Discard changes</Button>}
{msg && (typeof msg === "string" ? <span className="text-[12.5px] text-ok">{msg}</span> : <ErrorNote error={msg} />)}
</div>
<p className="text-[12px] text-muted">The secret that encrypts these, the port, and the setup and provisioning tokens stay in .env.</p>
</Panel>
);
}
export default function Settings() {
const { isAdmin } = useAuth();
const [s, setS] = useState(null);
@ -326,8 +404,9 @@ export default function Settings() {
<AccountPanel />
<NotificationsPanel />
{isAdmin && <TeamPanel />}
{isAdmin && <ServerSettingsPanel />}
<ErrorNote error={error} />
<Panel title="Connections" subtitle="Changed in the server's .env for now." bodyClass="divide-y divide-line">
<Panel title="Connections" subtitle="Current status. Change these under Server settings, Meta and Connections." bodyClass="divide-y divide-line">
{!s ? <Spinner /> : (
<>
<Row ok={s.meta.connected} title="Meta (Facebook & Instagram)">
@ -335,8 +414,8 @@ export default function Settings() {
</Row>
<Row ok={s.odoo.connected} warn={!s.odoo.configured} title="Odoo · team tasks">
{s.odoo.connected
? `Connected to ${s.odoo.url} (Odoo ${s.odoo.version?.split("-")[0]}). Task creation is ${s.odoo.allowWrites ? "on" : "off (ODOO_ALLOW_WRITES)"}.`
: s.odoo.configured ? `Can't connect: ${s.odoo.error}` : "Not configured. Add ODOO_URL, ODOO_DATABASE, ODOO_USERNAME and ODOO_API_KEY."}
? `Connected to ${s.odoo.url} (Odoo ${s.odoo.version?.split("-")[0]}). Task creation is ${s.odoo.allowWrites ? "on" : "off (turn it on in Connections → Odoo)"}.`
: s.odoo.configured ? `Can't connect: ${s.odoo.error}` : "Not connected. Connect it in Connections → Odoo."}
</Row>
<Row ok warn={!s.ai.apiKey} title="Claude · AI runs">
Model {s.ai.model}. {s.ai.apiKey ? "Using the ANTHROPIC_API_KEY on this server." : "No ANTHROPIC_API_KEY set: runs use this machine's Claude Code login. A hosted server needs an API key."}

View File

@ -35,7 +35,7 @@ export default function Team() {
<h1 className="text-[22px] font-extrabold">Team</h1>
<Panel>
<Empty icon={Users2} title={data.status.configured ? "Can't reach Odoo" : "Connect Odoo to see the team"}>
{data.status.error || "Add ODOO_URL, ODOO_DATABASE, ODOO_USERNAME and ODOO_API_KEY to the project .env, then restart the dashboard."}
{data.status.error || "An admin can connect it in Connections → Odoo (address, database, username and API key)."}
</Empty>
</Panel>
</div>

View File

@ -43,7 +43,7 @@ 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 \
-e DASHBOARD_HOST=0.0.0.0 -e DASHBOARD_COOKIE_SECURE=true -e TRUST_PROXY=uniquelocal -e QUORLA_HOSTED_TENANT=true \
-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

View File

@ -0,0 +1,190 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Automatic agency subdomains</title>
<style>
:root { --navy:#242142; --coral:#FC6D45; --ink:#1f2024; --muted:#6b6e78; --line:#e4e7ec; --bg:#f5f6f8; --card:#fff; --code:#14132a; --ok:#1d7a46; --warn:#9a5b00; --warnbg:#fff6e5; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --ink:#e8e8ee; --muted:#a3a5b0; --line:#2c2b45; --bg:#12111f; --card:#1b1a2e; --code:#0b0a16; --warnbg:#2a2210; --warn:#f0b65a; --ok:#5fcf8f; } }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.6 Inter, "Segoe UI", Arial, sans-serif; }
main { max-width: 860px; margin: 0 auto; padding: 32px 16px 64px; }
h1 { font-size: 26px; margin: 0 0 6px; color: var(--ink); }
h2 { font-size: 18px; margin: 0 0 6px; display:flex; gap:10px; align-items:center; }
.num { flex:none; width:28px; height:28px; border-radius:50%; background:var(--navy); color:#fff; display:grid; place-items:center; font-size:14px; }
.lede { color: var(--muted); margin: 0 0 24px; }
section { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; margin: 0 0 16px; }
p { margin: 8px 0; }
.why { color: var(--muted); font-size: 14px; }
pre { position: relative; background: var(--code); color: #e9e8ff; border-radius: 8px; padding: 14px 16px; overflow-x: auto; font: 13px/1.55 ui-monospace, Consolas, monospace; margin: 10px 0; }
pre .c { color: #8f8cb8; }
button.copy { position:absolute; top:8px; right:8px; background:#2f2c55; color:#fff; border:0; border-radius:6px; padding:4px 10px; font-size:12px; cursor:pointer; }
button.copy:hover { background: var(--coral); }
.check { border-left: 3px solid var(--ok); padding: 4px 0 4px 12px; margin: 10px 0; font-size: 14px; }
.warn { background: var(--warnbg); color: var(--warn); border-radius: 8px; padding: 10px 14px; font-size: 14px; margin: 10px 0; }
code { font: 13px ui-monospace, Consolas, monospace; background: rgba(127,127,160,.15); padding: 1px 5px; border-radius: 4px; }
ul { margin: 6px 0; padding-left: 20px; }
.meta { display:flex; flex-wrap:wrap; gap:8px 20px; font-size:13px; color:var(--muted); margin-bottom:20px; }
label.done { display:flex; gap:8px; align-items:center; font-size:14px; color:var(--muted); margin-top:10px; cursor:pointer; }
</style>
</head>
<body>
<main>
<h1>Automatic agency subdomains</h1>
<p class="lede">Do this once. After it, every approved agency gets <code>name.quorlasocial.com</code> with HTTPS automatically, without anyone touching Hestia.</p>
<div class="meta">
<span>Server: 72.62.169.43 (HestiaCP user <code>user</code>)</span>
<span>Run as: <code>dev</code>, with sudo where shown</span>
<span>Time: about 30 minutes</span>
</div>
<section>
<p><b>What this changes.</b> Today a new agency's workspace (container, data, port and welcome email) is created by the provisioning script, but its web address needs three manual steps in Hestia: DNS, SSL and nginx. This setup replaces those with:</p>
<ul>
<li>one wildcard DNS record</li>
<li>one wildcard certificate</li>
<li>one nginx rule that reads <code>nginx-tenants.map</code> (the script already keeps that file up to date)</li>
</ul>
<p>Nothing changes for <b>app.quorlasocial.com</b> or <b>clickstocart.quorlasocial.com</b>: nginx always prefers an exact server name over the wildcard.</p>
<div class="warn">If any command's output looks different from what this page expects, stop and paste the output to Claude before going on.</div>
</section>
<section>
<h2><span class="num">0</span>Check and back up</h2>
<pre><button class="copy">Copy</button>sudo nginx -t
nginx -v
sudo /usr/local/hestia/bin/v-list-web-domain user quorlasocial.com
sudo /usr/local/hestia/bin/v-list-dns-records user quorlasocial.com | grep -E '^ID|\*'
which nginx systemctl
sudo tar -czf ~/nginx-backup-$(date +%F).tar.gz /etc/nginx</pre>
<div class="check">
<b>Expect:</b>
<ul>
<li><code>nginx -t</code> says the syntax is ok.</li>
<li>The web-domain line tells you whether <code>quorlasocial.com</code> is a web domain on Hestia. Note the answer, because it decides step 2.</li>
<li>The DNS line tells you whether a <code>*</code> record already exists.</li>
<li>Note the two paths from <code>which</code>, usually <code>/usr/sbin/nginx</code> and <code>/usr/bin/systemctl</code>.</li>
</ul>
</div>
<label class="done"><input type="checkbox"> Done</label>
</section>
<section>
<h2><span class="num">1</span>Wildcard DNS</h2>
<p class="why">Every <code>anything.quorlasocial.com</code> then points at this server. Skip this step if step 0 already showed a <code>*</code> record.</p>
<pre><button class="copy">Copy</button>sudo /usr/local/hestia/bin/v-add-dns-record user quorlasocial.com '*' A 72.62.169.43
dig +short test123.quorlasocial.com @8.8.8.8</pre>
<div class="check"><b>Expect:</b> <code>72.62.169.43</code>. It can take a few minutes to appear.</div>
<label class="done"><input type="checkbox"> Done</label>
</section>
<section>
<h2><span class="num">2</span>Wildcard SSL certificate</h2>
<p class="why">Hestia can issue a <code>*.quorlasocial.com</code> certificate because the DNS zone is on this server, and it renews the certificate by itself.</p>
<p><b>Only if step 0 showed that quorlasocial.com is <u>not</u> a web domain yet</b>, add it first. Do this only if the bare domain isn't hosted somewhere else (for example a marketing site); if it is, ask Claude first.</p>
<pre><button class="copy">Copy</button>sudo /usr/local/hestia/bin/v-add-web-domain user quorlasocial.com</pre>
<p>Then add the wildcard alias and issue the certificate:</p>
<pre><button class="copy">Copy</button>sudo /usr/local/hestia/bin/v-add-web-domain-alias user quorlasocial.com '*.quorlasocial.com'
sudo /usr/local/hestia/bin/v-add-letsencrypt-domain user quorlasocial.com '*.quorlasocial.com'
sudo openssl x509 -in /home/user/conf/web/quorlasocial.com/ssl/quorlasocial.com.pem -noout -subject -ext subjectAltName -enddate</pre>
<div class="check"><b>Expect:</b> the certificate lists <code>DNS:*.quorlasocial.com</code> and an end date about 90 days away.</div>
<label class="done"><input type="checkbox"> Done</label>
</section>
<section>
<h2><span class="num">3</span>One nginx rule for every agency</h2>
<p class="why">This file lives outside Hestia's per-domain folders, so Hestia rebuilds don't overwrite it. It looks up the agency's port by the subdomain. Addresses that aren't in the map get a 404.</p>
<pre><button class="copy">Copy</button>touch /home/dev/APPS/quorla-tenants/nginx-tenants.map
sudo tee /etc/nginx/conf.d/quorla-tenants.conf &gt;/dev/null &lt;&lt;'EOF'
map $host $quorla_tenant {
default "";
include /home/dev/APPS/quorla-tenants/nginx-tenants.map;
}
server {
listen 72.62.169.43:80;
server_name *.quorlasocial.com;
return 301 https://$host$request_uri;
}
server {
listen 72.62.169.43:443 ssl;
server_name *.quorlasocial.com;
ssl_certificate /home/user/conf/web/quorlasocial.com/ssl/quorlasocial.com.pem;
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_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
EOF
sudo nginx -t &amp;&amp; sudo systemctl reload nginx</pre>
<div class="check">
<b>Expect:</b> <code>syntax is ok</code>. If nginx warns that <code>*.quorlasocial.com</code> is a "conflicting server name", the step 2 alias made Hestia's quorlasocial.com config claim the wildcard too. Paste the warning to Claude; the fix is a one-line change.
</div>
<label class="done"><input type="checkbox"> Done</label>
</section>
<section>
<h2><span class="num">4</span>Let the script reload nginx</h2>
<p class="why">The script runs as <code>dev</code>. It needs exactly two root commands and nothing else. Use the paths from step 0 if they differ.</p>
<pre><button class="copy">Copy</button>echo 'dev ALL=(root) NOPASSWD: /usr/sbin/nginx -t, /usr/bin/systemctl reload nginx' | sudo tee /etc/sudoers.d/quorla
sudo chmod 440 /etc/sudoers.d/quorla
sudo visudo -c
sudo -n nginx -t &amp;&amp; echo "sudo rule works"</pre>
<div class="check"><b>Expect:</b> <code>parsed OK</code>, then <code>sudo rule works</code>, without being asked for a password.</div>
<label class="done"><input type="checkbox"> Done</label>
</section>
<section>
<h2><span class="num">5</span>Run the script every 5 minutes</h2>
<p class="why">It picks up approved agencies, creates their workspaces, moves agencies onto new versions after a deploy, and refreshes the nginx map.</p>
<pre><button class="copy">Copy</button>bash /home/dev/APPS/quorlasocial/deploy/provision-tenants.sh
cat /home/dev/APPS/quorla-tenants/nginx-tenants.map
( crontab -l 2&gt;/dev/null | grep -v provision-tenants.sh; echo '*/5 * * * * /home/dev/APPS/quorlasocial/deploy/provision-tenants.sh &gt;&gt; /home/dev/APPS/quorla-tenants/provision.log 2&gt;&amp;1' ) | crontab -
crontab -l</pre>
<div class="check"><b>Expect:</b> the map contains <code>clickstocart.quorlasocial.com 127.0.0.1:4400;</code>, the script logs <code>nginx reloaded</code> the first time, and the cron line is listed.</div>
<label class="done"><input type="checkbox"> Done</label>
</section>
<section>
<h2><span class="num">6</span>Test</h2>
<pre><button class="copy">Copy</button>curl -sI https://app.quorlasocial.com | head -1
curl -sI https://clickstocart.quorlasocial.com | head -1
curl -sI https://nobody-here.quorlasocial.com | head -1</pre>
<div class="check"><b>Expect:</b> the first two return 200 (or a redirect), and the last returns 404 with a valid certificate (no SSL error).</div>
<p>Then do a real end-to-end test:</p>
<ul>
<li>Sign up a test agency at <code>https://app.quorlasocial.com/signup</code>.</li>
<li>Approve it in Admin → Agencies.</li>
<li>Wait up to 5 minutes, then open <code>https://&lt;test&gt;.quorlasocial.com</code>. The welcome email's link should work.</li>
<li>Remove the test agency in Agencies afterwards. Its data is archived.</li>
</ul>
<label class="done"><input type="checkbox"> Done</label>
</section>
<section>
<h2>Undo</h2>
<pre><button class="copy">Copy</button>sudo rm /etc/nginx/conf.d/quorla-tenants.conf
sudo nginx -t &amp;&amp; sudo systemctl reload nginx
crontab -l | grep -v provision-tenants.sh | crontab -</pre>
<p class="why">The DNS record and the certificate are harmless to leave in place. Existing workspaces keep running either way.</p>
</section>
</main>
<script>
document.querySelectorAll("button.copy").forEach((b) => b.addEventListener("click", async () => {
const text = b.parentElement.innerText.replace(/^Copy\n?/, "");
try { await navigator.clipboard.writeText(text); b.textContent = "Copied"; } catch { b.textContent = "Select & copy"; }
setTimeout(() => (b.textContent = "Copy"), 1500);
}));
</script>
</body>
</html>