diff --git a/branding/quorla-icon-1024.png b/branding/quorla-icon-1024.png new file mode 100644 index 0000000..714afa7 Binary files /dev/null and b/branding/quorla-icon-1024.png differ diff --git a/connector/src/platforms/index.js b/connector/src/platforms/index.js index fc5b98f..b232dfa 100644 --- a/connector/src/platforms/index.js +++ b/connector/src/platforms/index.js @@ -107,6 +107,14 @@ export async function checkConnection(connId) { } } +/** Platform-specific live account settings shown on the post screen (TikTok's creator info). */ +export async function creatorInfo(connId) { + const { conn, tokens } = await tokensFor(connId); + const a = adapter(conn.platform); + if (!a.creatorInfo) throw err(`${a.name} has no creator settings.`); + return a.creatorInfo(tokens); +} + // ---------- posting ---------- /** Problems that stop a post going to this platform (checked when the draft is saved). */ export function validate(platform, client, post) { @@ -121,6 +129,7 @@ export function validate(platform, client, post) { if (f.required && (opts[f.key] === undefined || opts[f.key] === "")) throw err(`${a.name}: ${f.label.toLowerCase()} is required.`); if (f.max && String(opts[f.key] || "").length > f.max) throw err(`${a.name}: ${f.label.toLowerCase()} is longer than ${f.max} characters.`); } + if (a.checkOptions) a.checkOptions(opts); const cap = a.limits?.caption; const caption = opts.caption ?? post.caption ?? ""; if (cap && caption.length > cap) throw err(`${a.name}: the caption is ${caption.length} characters; the limit is ${cap}. Shorten it or add a ${a.name} caption.`); diff --git a/connector/src/platforms/tiktok.js b/connector/src/platforms/tiktok.js index 709f2a3..9551df3 100644 --- a/connector/src/platforms/tiktok.js +++ b/connector/src/platforms/tiktok.js @@ -50,12 +50,32 @@ export default { limits: { caption: 2200, images: 35 }, fields: [ { key: "privacy", label: "Who can view", type: "select", options: PRIVACY, required: true, hint: "TikTok requires you to choose. Unaudited apps can only use “Only me”." }, - { key: "allowComments", label: "Allow comments", type: "checkbox", default: true }, + { key: "allowComments", label: "Allow comments", type: "checkbox", default: false }, { key: "allowDuet", label: "Allow Duet", type: "checkbox", default: false, onlyFor: ["REELS"] }, { key: "allowStitch", label: "Allow Stitch", type: "checkbox", default: false, onlyFor: ["REELS"] }, { key: "title", label: "Photo post title", type: "text", max: 90, onlyFor: ["IMAGE", "CAROUSEL"], hint: "Optional, up to 90 characters. The caption becomes the description." }, + { key: "discloseCommercial", label: "Disclose commercial content", type: "checkbox", default: false, hint: "Turn on if this promotes the client's own business or a third party." }, + { key: "brandOrganic", label: "Your brand (labelled “Promotional content”)", type: "checkbox", default: false }, + { key: "brandedContent", label: "Branded content (labelled “Paid partnership”)", type: "checkbox", default: false }, ], + /** What this creator allows right now (TikTok requires the post screen to follow it). */ + async creatorInfo(tokens) { + const { data } = await request("TikTok", `${API}/post/publish/creator_info/query/`, { token: tokens.accessToken, method: "POST", json: {} }); + const c = data.data || {}; + return { + nickname: c.creator_nickname || null, username: c.creator_username || null, avatar: c.creator_avatar_url || null, + privacyOptions: c.privacy_level_options || [], commentDisabled: Boolean(c.comment_disabled), duetDisabled: Boolean(c.duet_disabled), + stitchDisabled: Boolean(c.stitch_disabled), maxVideoSeconds: c.max_video_post_duration_sec || null, + }; + }, + + /** TikTok's commercial-content rules, checked when the draft is saved and again before posting. */ + checkOptions(opts) { + if (opts.discloseCommercial && !opts.brandOrganic && !opts.brandedContent) throw new Error("TikTok: you turned on commercial content disclosure. Choose “Your brand”, “Branded content” or both."); + if (opts.brandedContent && opts.privacy === "SELF_ONLY") throw new Error("TikTok: branded content can't be private (“Only me”). Choose another audience."); + }, + async identify(tokens) { const { data } = await request("TikTok", `${API}/user/info/`, { token: tokens.accessToken, query: { fields: "open_id,avatar_url,display_name,username,follower_count,profile_deep_link" } }); const u = data.data?.user || {}; @@ -65,7 +85,10 @@ export default { async publish({ tokens, conn, post, opts, caption }) { const token = tokens.accessToken; if (!opts.privacy) throw new Error("TikTok: choose who can view the post."); + this.checkOptions(opts); const creator = (await request("TikTok", `${API}/post/publish/creator_info/query/`, { token, method: "POST", json: {} })).data.data || {}; + const brand = { brand_content_toggle: Boolean(opts.discloseCommercial && opts.brandedContent), brand_organic_toggle: Boolean(opts.discloseCommercial && opts.brandOrganic) }; + const noComment = opts.allowComments !== true || Boolean(creator.comment_disabled); if (creator.privacy_level_options && !creator.privacy_level_options.includes(opts.privacy)) { throw new Error(`TikTok: this account can't post as “${opts.privacy}”. Allowed: ${creator.privacy_level_options.join(", ")}.`); } @@ -77,7 +100,7 @@ export default { const { data } = await request("TikTok", `${API}/post/publish/video/init/`, { token, method: "POST", json: { - post_info: { title: String(caption || "").slice(0, 2200), privacy_level: opts.privacy, disable_comment: opts.allowComments === false, disable_duet: !opts.allowDuet, disable_stitch: !opts.allowStitch }, + post_info: { title: String(caption || "").slice(0, 2200), privacy_level: opts.privacy, disable_comment: noComment, disable_duet: !opts.allowDuet || Boolean(creator.duet_disabled), disable_stitch: !opts.allowStitch || Boolean(creator.stitch_disabled), ...brand }, source_info: { source: "FILE_UPLOAD", video_size: bytes.length, chunk_size: plan.chunkSize, total_chunk_count: plan.count }, }, }); @@ -93,7 +116,7 @@ export default { const { data } = await request("TikTok", `${API}/post/publish/content/init/`, { token, method: "POST", json: { - post_info: { title: String(opts.title || "").slice(0, 90), description: String(caption || "").slice(0, 4000), privacy_level: opts.privacy, disable_comment: opts.allowComments === false, auto_add_music: true }, + post_info: { title: String(opts.title || "").slice(0, 90), description: String(caption || "").slice(0, 4000), privacy_level: opts.privacy, disable_comment: noComment, auto_add_music: true, ...brand }, source_info: { source: "PULL_FROM_URL", photo_cover_index: 0, photo_images: urls }, post_mode: "DIRECT_POST", media_type: "PHOTO", }, diff --git a/connector/test/platforms.test.mjs b/connector/test/platforms.test.mjs index c641363..0ca4c33 100644 --- a/connector/test/platforms.test.mjs +++ b/connector/test/platforms.test.mjs @@ -214,6 +214,21 @@ test("TikTok: connect, privacy is enforced, video uploads and photo posts pull f assert.equal(init.source_info.total_chunk_count, 1); assert.equal(init.post_info.privacy_level, "SELF_ONLY"); assert.equal(sent(/upload\.tiktok/)[0].headers["Content-Range"], "bytes 0-1023/1024"); + assert.equal(init.post_info.disable_comment, true, "comments are off unless the person turns them on"); + assert.equal(init.post_info.brand_content_toggle, false); + // commercial content disclosure rules + assert.throws(() => P.validate("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "SELF_ONLY", discloseCommercial: true } } }), /Choose “Your brand”/); + assert.throws(() => P.validate("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "SELF_ONLY", discloseCommercial: true, brandedContent: true } } }), /can't be private/); + routes = routes.filter((x) => !String(x.match).includes("creator_info")); + route("POST", "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", json({ data: { creator_nickname: "Acme", creator_username: "acme", privacy_level_options: ["PUBLIC_TO_EVERYONE", "SELF_ONLY"], comment_disabled: true, max_video_post_duration_sec: 600 }, error: { code: "ok" } })); + const info = await P.creatorInfo(conn.id); + assert.deepEqual([info.nickname, info.commentDisabled, info.maxVideoSeconds, info.privacyOptions.length], ["Acme", true, 600, 2]); + calls = []; + await P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "Promo", options: { tiktok: { privacy: "PUBLIC_TO_EVERYONE", allowComments: true, discloseCommercial: true, brandOrganic: true } } }); + const promo = bodyJson(sent(/video\/init/)[0]).post_info; + assert.equal(promo.brand_organic_toggle, true); + assert.equal(promo.brand_content_toggle, false); + assert.equal(promo.disable_comment, true, "creator turned comments off in TikTok, so they stay off"); route("POST", "https://open.tiktokapis.com/v2/post/publish/content/init/", json({ data: { publish_id: "pub2" }, error: { code: "ok" } })); await P.publish("tiktok", "acme", { type: "CAROUSEL", media: [IMG, IMG2], caption: "Menu", options: { tiktok: { privacy: "SELF_ONLY", title: "New menu" } } }); const photo = bodyJson(sent(/content\/init/)[0]); diff --git a/dashboard/server/index.js b/dashboard/server/index.js index 2df5dbf..2afd37f 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import express from "express"; +import { fileURLToPath } from "node:url"; import { PORT, WEB_DIST, AGENCY_NAME, BRANDING_DIR, ODOO } from "./config.js"; import { login, logout, currentUser, requireAuth, requireAdmin, setupFirstAdmin, issueSession } from "./auth.js"; import * as users from "./users.js"; @@ -49,6 +50,12 @@ app.get("/api/auth/me", (req, res) => res.json({ user: currentUser(req), agency: app.get("/brand/logo.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-logo.png"))); app.get("/brand/mark.png", (_req, res) => res.sendFile(path.join(BRANDING_DIR, "metatroncube-mark.png"))); +// Public legal pages (linked from platform app listings: TikTok, Google, LinkedIn, Pinterest, Meta). +const LEGAL_DIR = fileURLToPath(new URL("./legal/", import.meta.url)); +app.get("/privacy", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "privacy.html"))); +app.get("/terms", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "terms.html"))); +app.get("/legal/legal.css", (_req, res) => res.sendFile(path.join(LEGAL_DIR, "legal.css"))); + // Signed, expiring media links that platforms (TikTok, Threads, Google Business Profile) fetch posts from. app.get("/m/:token/:name", (req, res) => { const f = resolveMediaToken(req.params.token); @@ -99,6 +106,7 @@ app.get("/api/oauth/:platform/start", requireAdmin, h(async (req) => { return { url: extra.startConnect({ platform: req.params.platform, client, redirectUri: redirectUri(req, req.params.platform), user: req.user }) }; })); app.post("/api/connections/:id/check", requireAdmin, h(async (req) => ({ connection: await extra.checkConnection(req.params.id) }))); +app.get("/api/connections/:id/creator-info", h(async (req) => ({ creator: await extra.creatorInfo(req.params.id) }))); app.get("/api/connections/:id/targets", h(async (req) => ({ targets: await extra.targetsFor(req.params.id) }))); app.patch("/api/connections/:id", requireAdmin, h(async (req) => ({ connection: await extra.setTarget(req.params.id, String(req.body?.targetId || "")) }))); app.delete("/api/connections/:id", requireAdmin, h(async (req) => { extra.vault.removeConnection(req.params.id); return { ok: true }; })); diff --git a/dashboard/server/legal/legal.css b/dashboard/server/legal/legal.css new file mode 100644 index 0000000..81d52a0 --- /dev/null +++ b/dashboard/server/legal/legal.css @@ -0,0 +1,13 @@ +:root { --bg: #ffffff; --fg: #1f2024; --muted: #6b6e78; --line: #e4e7ec; --accent: #2c5f96; color-scheme: light dark; } +@media (prefers-color-scheme: dark) { :root { --bg: #14171b; --fg: #e8eaed; --muted: #9aa0a8; --line: #2a2f36; --accent: #7fb2e5; } } +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.65 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } +main { max-width: 720px; margin: 0 auto; padding: 48px 20px 80px; } +.brand { font-weight: 800; letter-spacing: .02em; color: var(--accent); margin: 0 0 24px; } +h1 { font-size: 32px; line-height: 1.2; margin: 0 0 6px; } +.meta { color: var(--muted); font-size: 14px; margin: 0 0 28px; } +h2 { font-size: 19px; margin: 32px 0 8px; padding-top: 20px; border-top: 1px solid var(--line); } +ul { padding-left: 1.2em; } +li { margin: 6px 0; } +a { color: var(--accent); } +.links { margin-top: 40px; font-size: 14px; } diff --git a/dashboard/server/legal/privacy.html b/dashboard/server/legal/privacy.html new file mode 100644 index 0000000..d36883d --- /dev/null +++ b/dashboard/server/legal/privacy.html @@ -0,0 +1,75 @@ + + + + + +Privacy Policy · Quorla + + + +
+

Quorla

+

Privacy Policy

+

Effective 20 October 2026 · Operated by Metatroncube Software Solutions, Waterloo, Ontario, Canada

+ +

Quorla is a social media management service used by marketing agencies to plan, approve and publish content for the businesses they work with, and to see how that content performs. This policy explains what information Quorla handles, why, and the choices you have.

+ +

Who this covers

+ + +

Information we collect

+ + +

Data from connected platforms

+

Quorla only uses platform data to provide the features the account owner and their agency use, and only within each platform's terms:

+ +

We never sell platform data, never use it for advertising, and never use it to train AI models.

+ +

How we use information

+ + +

Sharing

+

Information is shared only with the agency that controls the account in Quorla, with the social platforms when you publish to them, with service providers that host or process data for us (hosting, AI processing, email), or when the law requires it. Each provider may use the data only to provide its service to us.

+ +

Security and storage

+

Quorla is served over HTTPS. Platform access tokens and app keys are encrypted at rest. Access is limited to the agency's signed-in users, and only admins can connect accounts or approve posts.

+ +

Retention and deletion

+ + +

Your rights

+

Depending on where you live (for example under Canada's PIPEDA or the EU/UK GDPR), you may have the right to access, correct, delete or move your information, and to object to some processing. Contact us to use these rights.

+ +

Children

+

Quorla is a business tool and is not intended for anyone under 18.

+ +

Changes

+

We will post changes on this page and update the date at the top. For significant changes we will notify agency admins.

+ +

Contact

+

Metatroncube Software Solutions, Waterloo, Ontario, Canada
Email: support@quorlasocial.com

+ +
+ + diff --git a/dashboard/server/legal/terms.html b/dashboard/server/legal/terms.html new file mode 100644 index 0000000..7c00fea --- /dev/null +++ b/dashboard/server/legal/terms.html @@ -0,0 +1,68 @@ + + + + + +Terms of Service · Quorla + + + +
+

Quorla

+

Terms of Service

+

Effective 20 October 2026 · Operated by Metatroncube Software Solutions, Waterloo, Ontario, Canada

+ +

These terms apply to anyone who uses Quorla, the social media management service operated by Metatroncube Software Solutions ("we", "us"). By signing in to or using Quorla you agree to them.

+ +

1. The service

+

Quorla lets agencies plan, review, approve and publish content to social media accounts they manage, and see performance. Posts are published only after a person with admin rights approves them.

+ +

2. Accounts

+ + +

3. Connecting social accounts

+ + +

4. Your content

+

You keep all rights to the content you upload or create. You give us permission to store, process and publish it only as needed to provide Quorla to you. You confirm you have the rights to everything you publish, including music, images and people's likenesses, and that paid partnerships and promotional content are disclosed as the law and each platform require.

+ +

5. Acceptable use

+

You must not use Quorla to:

+ + +

6. AI features

+

AI-generated drafts are suggestions. A person must review them before they are published, and you are responsible for what you approve.

+ +

7. Fees

+

Fees, if any, are set out in your plan or agreement with us.

+ +

8. Availability and changes

+

We work to keep Quorla available but don't guarantee it will be uninterrupted. Platforms can change or limit their APIs at any time, which may affect features. We may update Quorla and these terms; we'll post changes here and notify agency admins of significant ones.

+ +

9. Disclaimer and liability

+

Quorla is provided "as is". To the extent the law allows, we are not liable for indirect or consequential losses, for actions taken by social platforms, or for content you approve. Our total liability for any claim is limited to the fees you paid us in the 12 months before the claim.

+ +

10. Ending use

+

You can stop using Quorla at any time. We may suspend accounts that break these terms. Our Privacy Policy explains what happens to data afterwards.

+ +

11. Governing law

+

These terms are governed by the laws of the Province of Ontario and the federal laws of Canada that apply there.

+ +

Contact

+

Metatroncube Software Solutions, Waterloo, Ontario, Canada
Email: support@quorlasocial.com

+ +
+ + diff --git a/dashboard/web/src/PlatformPicker.jsx b/dashboard/web/src/PlatformPicker.jsx index 2e5d0ec..30fc634 100644 --- a/dashboard/web/src/PlatformPicker.jsx +++ b/dashboard/web/src/PlatformPicker.jsx @@ -47,7 +47,90 @@ function TargetSelect({ conn, value, onChange, id }) { ); } +const TT_PRIVACY = { PUBLIC_TO_EVERYONE: "Everyone", MUTUAL_FOLLOW_FRIENDS: "Friends", FOLLOWER_OF_CREATOR: "Followers", SELF_ONLY: "Only me" }; + +/** TikTok's post screen, following its Content Posting API guidelines (creator info, no default audience, disclosure, consent). */ +function TikTokFields({ platform, type, options, setOption, caption }) { + const [creator, setCreator] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { api(`/api/connections/${platform.conn.id}/creator-info`).then((r) => setCreator(r.creator)).catch(setError); }, [platform.conn.id]); + const video = type === "REELS"; + const branded = Boolean(options.discloseCommercial && options.brandedContent); + const organic = Boolean(options.discloseCommercial && options.brandOrganic); + const check = (key, label, disabled, note) => ( + + ); + const limit = platform.limits?.caption; + const used = (options.caption ?? caption ?? "").length; + + return ( +
+
+
+ {error &&
Couldn't load this TikTok account's settings: {error.message}. Reconnect TikTok on the Connections page.
} + {!creator && !error &&
Loading the account's TikTok settings…
} + {creator && ( + <> +
+ + + + {!video && ( + + setOption("title", e.target.value)} /> + + )} +
+
+ {check("allowComments", "Allow comments", creator.commentDisabled, "off in this account's TikTok settings")} + {video && check("allowDuet", "Allow Duet", creator.duetDisabled, "off in TikTok settings")} + {video && check("allowStitch", "Allow Stitch", creator.stitchDisabled, "off in TikTok settings")} +
+ {video && creator.maxVideoSeconds &&
This account can post videos up to {Math.floor(creator.maxVideoSeconds / 60)} min {creator.maxVideoSeconds % 60 ? `${creator.maxVideoSeconds % 60} s` : ""}.
} +
+ +

Turn on if this post promotes the client's own business, a product or a third party.

+ {options.discloseCommercial && ( +
+ {check("brandOrganic", "Your brand: you're promoting yourself or your own business")} + {check("brandedContent", "Branded content: you're promoting another brand or a third party", options.privacy === "SELF_ONLY", "not available for “Only me”")} + {(organic || branded) &&
The post will be labelled “{branded ? "Paid partnership" : "Promotional content"}”.
} + {!organic && !branded &&
Choose at least one option.
} +
+ )} +
+ + )} + +