diff --git a/dashboard/server/docs.js b/dashboard/server/docs.js index b433b96..60c5695 100644 --- a/dashboard/server/docs.js +++ b/dashboard/server/docs.js @@ -4,6 +4,7 @@ import path from "node:path"; import { marked } from "marked"; import { safeSlug } from "./clients.js"; import { toPdf } from "../../tools/report-pdf/build.mjs"; +import { reportOptions } from "./workspace.js"; /** Resolve a document path inside one client's folder (blocks ../ escapes). */ export function resolveDoc(slug, rel) { @@ -42,6 +43,6 @@ export function renderDoc(slug, rel) { export function exportPdf(slug, rel) { const full = resolveDoc(slug, rel); if (!full.endsWith(".md")) throw Object.assign(new Error("Pick a markdown document."), { status: 400 }); - const written = toPdf(full); + const written = toPdf(full, reportOptions()); return path.relative(safeSlug(slug), written).replace(/\\/g, "/"); } diff --git a/dashboard/server/index.js b/dashboard/server/index.js index b896156..65aa29b 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -16,6 +16,8 @@ import * as monthly from "./monthly.js"; import * as bio from "./bio.js"; 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 odoo from "./odoo.js"; import * as jobs from "./jobs.js"; import * as docs from "./docs.js"; @@ -57,7 +59,9 @@ 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) => res.json({ user: currentUser(req), agency: AGENCY_NAME, 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 }, 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. app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-logo-app.png"))); app.get("/brand/logo-white.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "quorla-logo-app-white.png"))); @@ -361,6 +365,16 @@ 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) }))); +// ---------- 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(); })); + +// ---------- 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(); })); + // ---------- admin: audit log and system health ---------- app.get("/api/admin/audit", requireAdmin, h(async (req) => ({ items: activity.list({ days: Number(req.query.days) || 30, user: req.query.user || undefined, client: req.query.client || undefined, q: req.query.q || undefined }) }))); app.get("/api/admin/health", requireAdmin, h(async () => health.checks())); @@ -552,7 +566,7 @@ if (process.env.SCHEDULER_ENABLED !== "false") { const host = process.env.DASHBOARD_HOST || "127.0.0.1"; app.listen(PORT, host, () => { - console.log(`${AGENCY_NAME} dashboard on http://${host === "0.0.0.0" ? "localhost" : host}:${PORT}`); + console.log(`${workspace.agencyName()} dashboard on http://${host === "0.0.0.0" ? "localhost" : host}:${PORT}`); if (!users.hasUsers()) console.log("No logins yet. Open the dashboard to create the first admin."); if (!ODOO.url) console.log("Odoo not configured: the Team page will show setup steps."); }); diff --git a/dashboard/server/monthly.js b/dashboard/server/monthly.js index aeaf38c..c72c5a0 100644 --- a/dashboard/server/monthly.js +++ b/dashboard/server/monthly.js @@ -3,7 +3,8 @@ // Written as clients//reports/YYYY-MM-monthly.md, exported to the branded PDF, optionally emailed to the client. import fs from "node:fs"; import path from "node:path"; -import { DATA_DIR, AGENCY_NAME } from "./config.js"; +import { DATA_DIR } from "./config.js"; +import { agencyName, reportOptions } from "./workspace.js"; import * as clients from "./clients.js"; import * as metrics from "./metrics.js"; import * as published from "./published.js"; @@ -123,7 +124,7 @@ export function build(slug, month = lastMonth()) { for (const u of upcoming.slice(0, 10)) lines.push(`- ${day(u.date)}: ${clean(u.title, 90)} (${u.stage === "published" ? "published" : u.stage === "scheduled" ? "scheduled" : "in planning"})`); if (upcoming.length > 10) lines.push(`- …and ${upcoming.length - 10} more`); } else lines.push("We'll share next month's plan with you for approval."); - lines.push("", `*Prepared by ${AGENCY_NAME}. Numbers come from Instagram and Facebook insights; reach isn't available for every platform.*`, ""); + lines.push("", `*Prepared by ${agencyName()}. Numbers come from Instagram and Facebook insights; reach isn't available for every platform.*`, ""); const reportsDir = path.join(dir, "reports"); fs.mkdirSync(reportsDir, { recursive: true }); @@ -136,7 +137,7 @@ export function build(slug, month = lastMonth()) { export function buildWithPdf(slug, month) { const md = build(slug, month); const dir = clients.safeSlug(slug); - const pdf = toPdf(path.join(dir, md)); + const pdf = toPdf(path.join(dir, md), reportOptions()); return { md, pdf: path.relative(dir, pdf).replace(/\\/g, "/") }; } @@ -153,7 +154,7 @@ export async function emailReport(slug, month = lastMonth()) { subject: `${c?.name || "Your"} social media report · ${monthLabel(month)}`, heading: `Your ${monthLabel(month)} social media report`, lines: [`Hi${link.contactName ? ` ${link.contactName}` : ""}, attached is how ${c?.name || "your accounts"} did in ${monthLabel(month)}: what we posted, what worked, how you compare with competitors, and what's coming next month.`, "Reply to this email with any questions."], - fromName: AGENCY_NAME, + fromName: agencyName(), attachments: [{ filename: `${(c?.name || slug).replace(/[^\w-]+/g, "-")}-${month}-report.pdf`, path: pdf }], }); if (!r.sent) throw err(`Couldn't send: ${r.reason}`); diff --git a/dashboard/server/notify-events.js b/dashboard/server/notify-events.js index 20143e5..7942b6c 100644 --- a/dashboard/server/notify-events.js +++ b/dashboard/server/notify-events.js @@ -2,7 +2,7 @@ import * as notify from "./notify.js"; import * as portal from "./portal.js"; import * as clients from "./clients.js"; -import { AGENCY_NAME } from "./config.js"; +import { agencyName } from "./workspace.js"; import * as sched from "../../connector/src/schedule.js"; import * as plan from "../../connector/src/plan.js"; import * as extra from "../../connector/src/platforms/index.js"; @@ -34,8 +34,8 @@ async function emailClient(slug) { "Approve them or tell us what to change. Nothing is posted until our team gives the final go-ahead.", ], button: { label: "Review posts", url: notify.appUrl(`/p/${link.token}`) }, - fromName: AGENCY_NAME, - footer: `${AGENCY_NAME} · You're getting this because you review posts for ${clientName(slug)}.`, + fromName: agencyName(), + footer: `${agencyName()} · You're getting this because you review posts for ${clientName(slug)}.`, }); } diff --git a/dashboard/server/notify.js b/dashboard/server/notify.js index 9367423..407a1d1 100644 --- a/dashboard/server/notify.js +++ b/dashboard/server/notify.js @@ -5,7 +5,8 @@ import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import nodemailer from "nodemailer"; -import { DATA_DIR, AGENCY_NAME } from "./config.js"; +import { DATA_DIR } from "./config.js"; +import { agencyName } from "./workspace.js"; import * as users from "./users.js"; const FILE = path.join(DATA_DIR, "notifications.json"); @@ -35,7 +36,7 @@ function html({ heading, lines, button, footer }) {

${esc(heading)}

${lines.map((l) => `

${esc(l)}

`).join("")} ${button ? `

${esc(button.label)}

` : ""} -

${esc(footer || AGENCY_NAME)}

`; +

${esc(footer || agencyName())}

`; } /** Send one email. Returns { sent } or { sent:false, reason }. Never throws (notifications mustn't break work). */ @@ -45,7 +46,7 @@ export async function sendEmail({ to, subject, heading, lines, button, footer, f if (!to) return { sent: false, reason: "No recipient." }; try { await m.sendMail({ - from: { name: fromName || AGENCY_NAME, address: fromAddress() }, to, subject, replyTo, + from: { name: fromName || agencyName(), address: fromAddress() }, to, subject, replyTo, text: [heading, "", ...lines, ...(button ? ["", `${button.label}: ${button.url}`] : [])].join("\n"), html: html({ heading, lines, button, footer }), attachments, diff --git a/dashboard/server/onboarding.js b/dashboard/server/onboarding.js new file mode 100644 index 0000000..16bfc32 --- /dev/null +++ b/dashboard/server/onboarding.js @@ -0,0 +1,39 @@ +// "Getting started" checklist for admins: each step is checked against the real state, not ticked by hand. +import fs from "node:fs"; +import path from "node:path"; +import { DATA_DIR } from "./config.js"; +import * as users from "./users.js"; +import * as clients from "./clients.js"; +import * as notify from "./notify.js"; +import * as meta from "../../connector/src/meta.js"; +import * as sched from "../../connector/src/schedule.js"; +import * as lib from "../../connector/src/library.js"; +import * as extra from "../../connector/src/platforms/index.js"; + +const FILE = path.join(DATA_DIR, "onboarding.json"); +const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } }; + +export async function steps() { + let metaOk = false; + try { metaOk = (await meta.listAccounts()).length > 0; } catch { /* not connected */ } + const list = clients.listClients(); + const posts = sched.listPosts({}); + const portal = (() => { try { return Object.keys(JSON.parse(fs.readFileSync(path.join(DATA_DIR, "portal.json"), "utf8"))).length; } catch { return 0; } })(); + const workspace = (() => { try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, "workspace.json"), "utf8")); } catch { return {}; } })(); + const items = [ + { id: "workspace", title: "Name your workspace", text: "Your agency's name and logo appear in the app, on client approval pages and in reports.", done: Boolean(workspace.name), link: "/settings#workspace" }, + { id: "meta", title: "Connect Facebook and Instagram", text: "Add the Meta system user token to the server's .env (see the hosting guide). Every client's Page and Instagram account then shows up here.", done: metaOk, link: "/settings" }, + { id: "client", title: "Add your first client", text: "Pick their Facebook Page or Instagram account from the list.", done: list.length > 0, link: "/" }, + { id: "team", title: "Invite your team", text: "Add designers, editors and managers. Each gets their own login.", done: users.listUsers().length > 1, link: "/settings" }, + { id: "platforms", title: "Connect another platform", text: "TikTok, YouTube, Google Business Profile, LinkedIn and more, per client.", done: extra.vault.listConnections({}).length > 0, link: "/connections" }, + { id: "email", title: "Set up email", text: "So people get notified and clients get their approval emails and monthly reports.", done: notify.emailConfigured(), link: "/admin/health" }, + { id: "library", title: "Upload finished work", text: "Designers upload to the Content library; an admin reviews it.", done: lib.listItems({}).length > 0, link: "/library" }, + { id: "post", title: "Schedule and approve a post", text: "Create a draft, approve it, and Quorla publishes at the client's local time.", done: posts.some((p) => ["approved", "published", "partial"].includes(p.status)), link: "/publishing" }, + { id: "portal", title: "Give a client their approval link", text: "They approve posts or ask for changes without a login.", done: portal > 0, link: "/" }, + ]; + return { dismissed: Boolean(read().dismissed), done: items.filter((s) => s.done).length, total: items.length, items }; +} + +export function dismiss(value = true) { + fs.writeFileSync(FILE, JSON.stringify({ ...read(), dismissed: Boolean(value), at: new Date().toISOString() })); +} diff --git a/dashboard/server/portal.js b/dashboard/server/portal.js index 02ef7b0..261b2a5 100644 --- a/dashboard/server/portal.js +++ b/dashboard/server/portal.js @@ -3,7 +3,8 @@ import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; -import { DATA_DIR, AGENCY_NAME } from "./config.js"; +import { DATA_DIR } from "./config.js"; +import { agencyName, logoPath as logoPathFn } from "./workspace.js"; import * as clients from "./clients.js"; import * as sched from "../../connector/src/schedule.js"; @@ -70,7 +71,7 @@ export function view(slug) { media: (p.media || []).map((m, i) => ({ url: `media/${p.id}/${i}`, video: /\.(mp4|mov|m4v|webm)$/i.test(m) })), review: { status: p.clientReview.status, note: p.clientReview.note || "", by: p.clientReview.by || null, decidedAt: p.clientReview.decidedAt || null }, })); - return { client: c?.name || slug, agency: AGENCY_NAME, posts }; + return { client: c?.name || slug, agency: agencyName(), logo: Boolean(logoPathFn()), posts }; } /** A media file of one of this client's posts that is in review (no other files are reachable). */ diff --git a/dashboard/server/portal/index.html b/dashboard/server/portal/index.html index 13a13c9..6990e83 100644 --- a/dashboard/server/portal/index.html +++ b/dashboard/server/portal/index.html @@ -45,6 +45,7 @@
+

Posts for your approval

Loading…

@@ -120,6 +121,7 @@ $("title").textContent = d.client; $("sub").textContent = "Posts your social media team prepared. Approve them, or tell us what to change."; $("foot").textContent = `Prepared by ${d.agency}. Nothing is posted until your team gives the final go-ahead.`; + if (d.logo) { $("logo").alt = d.agency; $("logo").hidden = false; } const waiting = d.posts.filter((p) => p.review.status === "pending"); const done = d.posts.filter((p) => p.review.status !== "pending"); $("waiting").replaceChildren(...(waiting.length ? waiting.map(card) : [el("div", { class: "card empty" }, "You're all caught up. Nothing is waiting for your approval.")])); diff --git a/dashboard/server/workspace.js b/dashboard/server/workspace.js new file mode 100644 index 0000000..3b70b30 --- /dev/null +++ b/dashboard/server/workspace.js @@ -0,0 +1,53 @@ +// Workspace (white-label): the agency's name, logo, brand colour and support email. +// Used in the app (sidebar, sign-in), client approval pages, emails and the PDF reports. +import fs from "node:fs"; +import path from "node:path"; +import { DATA_DIR, AGENCY_NAME } from "./config.js"; + +const FILE = path.join(DATA_DIR, "workspace.json"); +const LOGO = path.join(DATA_DIR, "workspace-logo"); +const HEX = /^#[0-9a-f]{6}$/i; +const err = (msg, status = 400) => Object.assign(new Error(msg), { status }); +const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return {}; } }; + +export function get() { + const w = read(); + return { name: w.name || "", accent: w.accent || "", supportEmail: w.supportEmail || "", website: w.website || "", hasLogo: Boolean(logoPath()), updatedAt: w.updatedAt || null }; +} + +/** The name shown to people: the workspace name, else AGENCY_NAME from .env. */ +export const agencyName = () => read().name || AGENCY_NAME; + +export function save(patch, by) { + const cur = read(); + const next = { ...cur }; + if (patch.name !== undefined) next.name = String(patch.name).trim().slice(0, 60); + if (patch.accent !== undefined) { if (patch.accent && !HEX.test(patch.accent)) throw err("Brand colour must be like #242142."); next.accent = patch.accent || ""; } + if (patch.supportEmail !== undefined) { const e = String(patch.supportEmail).trim(); if (e && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)) throw err("That email doesn't look right."); next.supportEmail = e; } + if (patch.website !== undefined) { const u = String(patch.website).trim(); if (u && !/^https?:\/\//i.test(u)) throw err("Website must start with https://"); next.website = u; } + next.updatedAt = new Date().toISOString(); next.updatedBy = by; + fs.writeFileSync(FILE, JSON.stringify(next, null, 1)); + return get(); +} + +export function logoPath() { + for (const ext of [".png", ".jpg"]) if (fs.existsSync(LOGO + ext)) return LOGO + ext; + return null; +} + +/** Save an uploaded logo (PNG or JPG, up to 2 MB). */ +export function saveLogo(buf, filename) { + if (!Buffer.isBuffer(buf) || !buf.length) throw err("Choose an image."); + if (buf.length > 2 * 1024 * 1024) throw err("Logo must be 2 MB or smaller."); + const png = buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + const jpg = buf[0] === 0xff && buf[1] === 0xd8; + if (!png && !jpg) throw err("Logo must be a PNG or JPG."); + removeLogo(); + fs.writeFileSync(LOGO + (png ? ".png" : ".jpg"), buf); + return get(); +} + +export function removeLogo() { for (const ext of [".png", ".jpg"]) fs.rmSync(LOGO + ext, { force: true }); } + +/** Options for tools/report-pdf toPdf(): the agency's logo when one is set. */ +export const reportOptions = () => (logoPath() ? { logoPath: logoPath(), logoAlt: agencyName() } : {}); diff --git a/dashboard/web/src/App.jsx b/dashboard/web/src/App.jsx index 3dd4b33..38769fb 100644 --- a/dashboard/web/src/App.jsx +++ b/dashboard/web/src/App.jsx @@ -22,6 +22,16 @@ import Connections from "./pages/Connections.jsx"; const AuthCtx = createContext(null); export const useAuth = () => useContext(AuthCtx); +/** White-label: the agency's brand colour replaces Quorla's navy for buttons, links and highlights. */ +function applyAccent(hex) { + const root = document.documentElement.style; + if (/^#[0-9a-f]{6}$/i.test(hex || "")) { + root.setProperty("--color-brand", hex); + root.setProperty("--color-brand-deep", `color-mix(in srgb, ${hex} 78%, black)`); + root.setProperty("--color-brand-soft", `color-mix(in srgb, ${hex} 10%, white)`); + } else for (const k of ["--color-brand", "--color-brand-deep", "--color-brand-soft"]) root.removeProperty(k); +} + const NAV = [ { to: "/", label: "Clients", icon: LayoutGrid, end: true }, { to: "/calendar", label: "Content calendar", icon: CalendarDays }, @@ -39,16 +49,18 @@ const NAV = [ ]; function Shell({ children }) { - const { user, signOut, agency } = useAuth(); + const { user, signOut, agency, workspace } = useAuth(); return (