From 7c596ec6d50e02cfee32de2f843d7f563733fbfe Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Sun, 25 Oct 2026 19:49:15 -0400 Subject: [PATCH] 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 --- connector/src/env.js | 24 ++- connector/src/platforms/vault.js | 40 ++--- connector/src/sealed.js | 26 ++++ connector/test/odoo-settings.test.mjs | 36 +++++ connector/test/server-settings.test.mjs | 51 +++++++ dashboard/server/config.js | 4 +- dashboard/server/index.js | 15 ++ dashboard/server/notify.js | 2 + dashboard/server/odoo.js | 55 +++++-- dashboard/server/settings.js | 116 +++++++++++++++ dashboard/web/src/pages/Connections.jsx | 52 +++++++ dashboard/web/src/pages/Settings.jsx | 89 ++++++++++- dashboard/web/src/pages/Team.jsx | 2 +- deploy/provision-tenants.sh | 2 +- docs/wildcard-subdomains.html | 190 ++++++++++++++++++++++++ 15 files changed, 665 insertions(+), 39 deletions(-) create mode 100644 connector/src/sealed.js create mode 100644 connector/test/odoo-settings.test.mjs create mode 100644 connector/test/server-settings.test.mjs create mode 100644 dashboard/server/settings.js create mode 100644 docs/wildcard-subdomains.html diff --git a/connector/src/env.js b/connector/src/env.js index c947c9c..a4a51e7 100644 --- a/connector/src/env.js +++ b/connector/src/env.js @@ -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)); } diff --git a/connector/src/platforms/vault.js b/connector/src/platforms/vault.js index 327106d..cac072e 100644 --- a/connector/src/platforms/vault.js +++ b/connector/src/platforms/vault.js @@ -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; diff --git a/connector/src/sealed.js b/connector/src/sealed.js new file mode 100644 index 0000000..88e6d28 --- /dev/null +++ b/connector/src/sealed.js @@ -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")); +} diff --git a/connector/test/odoo-settings.test.mjs b/connector/test/odoo-settings.test.mjs new file mode 100644 index 0000000..f71f75b --- /dev/null +++ b/connector/test/odoo-settings.test.mjs @@ -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; } +}); diff --git a/connector/test/server-settings.test.mjs b/connector/test/server-settings.test.mjs new file mode 100644 index 0000000..df3a452 --- /dev/null +++ b/connector/test/server-settings.test.mjs @@ -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 = ""; +}); diff --git a/dashboard/server/config.js b/dashboard/server/config.js index 672cdf7..5aa6d16 100644 --- a/dashboard/server/config.js +++ b/dashboard/server/config.js @@ -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); }, }; diff --git a/dashboard/server/index.js b/dashboard/server/index.js index 564f52e..577f7ef 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -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) => { diff --git a/dashboard/server/notify.js b/dashboard/server/notify.js index 0b30618..87a4e8a 100644 --- a/dashboard/server/notify.js +++ b/dashboard/server/notify.js @@ -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({ diff --git a/dashboard/server/odoo.js b/dashboard/server/odoo.js index bced0f7..bbd2b23 100644 --- a/dashboard/server/odoo.js +++ b/dashboard/server/odoo.js @@ -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) { diff --git a/dashboard/server/settings.js b/dashboard/server/settings.js new file mode 100644 index 0000000..28f0dbc --- /dev/null +++ b/dashboard/server/settings.js @@ -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 " }, + ] }, + { 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) }; +} diff --git a/dashboard/web/src/pages/Connections.jsx b/dashboard/web/src/pages/Connections.jsx index a5955e4..6f6c967 100644 --- a/dashboard/web/src/pages/Connections.jsx +++ b/dashboard/web/src/pages/Connections.jsx @@ -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 ( + {fromSettings ? "Connected" : "From server .env"} : Not connected)}> + {!f ?
{error ? : }
: ( +
+

In Odoo: your avatar → My Profile (or Preferences) → Account Security → New API Key. Use the login of a user who can see your projects.

+
+ + + + + + +
+ + {s.source === "env" &&

Currently using the server's .env. Saving here overrides it.

} + {saved &&

Logged in to Odoo and saved.

} + +
+ + {fromSettings && } +
+ + )} +
+ ); +} + 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() { ))} + {isAdmin && } + {setup && setSetup(null)} onSaved={() => { setSetup(null); loadPlatforms(); }} />} ); diff --git a/dashboard/web/src/pages/Settings.jsx b/dashboard/web/src/pages/Settings.jsx index b21696b..3a6e215 100644 --- a/dashboard/web/src/pages/Settings.jsx +++ b/dashboard/web/src/pages/Settings.jsx @@ -279,7 +279,7 @@ function WorkspacePanel() { {msg && (typeof msg === "string" ? {msg} : )} -

Your own domain (like social.youragency.com): point the domain at the server and set PUBLIC_URL in .env. See the hosting guide.

+

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.

); } @@ -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 ( - {!d.email &&
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.
} + {!d.email &&
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.
}