metatroncubeswdev 7c596ec6d5 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>
2026-10-25 19:49:15 -04:00

117 lines
6.7 KiB
JavaScript

// 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) };
}