Content OS

Client Portal — App Flows

The approval gate in the middle of the admin pipeline. One screen, three tabs, and a deliberately small surface.

Code: client-app/ · Port: 3001 · Access: one client login per portal Back to apps index · Admin console


What it is

Four page files, five API routes — and every one has real logic. It is small on purpose, not unfinished. Its own README states the intent: no configs, creators, hooks, audience, drafts, or admin vocabulary.

The whole product is one screen: /dashboard, with three tabs and a date picker. / is a redirect to it.


Sign in

/login
      |
POST /api/portal/auth/login                   <- rewritten straight to apps/backend/
      |                                          this app serves NO api routes
      |
rate limit, scrypt compare, role expansion,   <- all in apps/backend/
approvals.view gate, HS256 signature
      |
Set-Cookie: content_engine_client_token       <- sameSite=strict, httpOnly, 15 days
      |                                          its own name and its own endpoint,
      |                                          so no request can ask for the
      |                                          console's cookie instead
      |
/dashboard

The portal authenticates nobody. It opens no database, holds no JWT_SECRET, and runs no crypto — apps/backend/ does all of it, against the same control-DB users collection the console uses. Any user with a tenantId and approvals.view signs in and sees exactly that workspace; a platform admin is refused, since they have no workspace to show here.

It serves no API routes at allsrc/app/api/ does not exist. /api/* is a wildcard rewrite, with middleware.ts turning the portal cookie into an Authorization: Bearer header on the way through. That hop is required, not cosmetic: the backend recognises the console's cookie name and a bearer, never the portal's cookie, so two cookies can never compete to select a tenant.

The two-cookie split survives as two ENDPOINTS instead: /api/auth/login mints the console's, /api/portal/auth/login mints the portal's, and no request can ask for the other one. The backend sets the portal cookie and never reads it.

Three gates, in order

GateChecksWhere
MiddlewareCookie presence only — never the signatureclient-app/middleware.ts
requireSession()Asks the backend: GET /api/auth/session with the token as bearerNode handlers
requirePortalPermission()approvals.view / approvals.decide, from the backend's resolved setRoute handlers

Signature checking was never done in middleware — the note in the code says edge/runtime discrepancies were redirect-looping valid sessions. It is not done anywhere in this app now: lib/auth-token.ts was a 98% byte-copy of the backend's signer, which meant a browser-facing process could mint a token for any tenant it named. Deleted.

Login already refuses a session without approvals.view, but login is not the gate: a cookie outlives a role change and there is no revocation, so every route re-checks. A valid login with no workspace bounces to /login?error=no_tenants.


The core loop: review today's stories

/dashboard?date=YYYY-MM-DD
      |
content_news_items for that day, scored and sorted
      |
split into three queues by source group:  Blogs | News | Competitors
      |
ONE card at a time — not a list
      |
   [Y] Write a script          [N] Skip
      |                            |
reviewStatus: approved         reviewStatus: disapproved
      |                            + delete matching content_script_queue rows
upsert content_script_queue
      |
upsert content_generation_jobs  { status: queued, origin: "client" }
      |
      |  the admin worker picks it up — the portal never calls the admin API
      |
poll every 5s, up to 5 minutes
      |
"Writing the script..."  ->  "Script ready"

Keyboard-driven (Y / N), optimistic with rollback. A skip is reversible from a "Show what you already decided" section.

Failure is handled honestly: if the approval saved but the enqueue failed, the card says "Approved, but the script couldn't be started" and offers Retry.


Reviewing the script

Three duration cuts (30s / 60s / 120s, default 60s) and four tabs — Hook, Script, Caption, Keywords.

Redaction happens at the data layer, not the UI. toClientVersions() keeps only hook, script body, caption and CTA. On-screen text, b-roll, editing notes, framework and scores never reach the browser — the comment in the code is explicit that this is done at the data boundary so a future component change cannot leak them back in.

Actions: Approve (content_scripts.status = approved) or Send back (back to draft). Plus copy-to-clipboard per section and a WhatsApp share whose payload is trimmed to the four client-facing blocks.


Calendar

Month grid with dots on days that have posts, and a day panel showing status, time, caption, notes, keywords, platform, pillar, owner and channel badges.

Read-only. Nothing schedules or reschedules from here.


What a client can see, versus do

Can seeCan do
Today's blogs, news and competitor storiesApprove or skip a story
Article summaries and source linksUn-skip something they skipped
Generated scripts — 3 cuts, 4 sectionsTrigger generation (implicitly, by approving)
The posting calendar and day plansRetry a failed generation
Brand profile — company, categories, competitor count, audienceApprove a script, or send it back

Absent entirely: commenting, uploads, downloads, editing script text, scheduling, inviting users, notifications, and any settings screen.


How it connects to the admin app

Same MongoDB, same collections, direct access — no API hop. The portal never calls the admin app over HTTP; the two meet in the database.

Admin producesCollectionClient screen
Ingested, scored articlescontent_news_itemsDiscovery tab
Generated scriptscontent_scriptsScripts tab
Posting schedulecontent_calendar_entriesCalendar tab
Brand profilecontent_tenants and friendsCompany popover

And what the client hands back: reviewStatus on news items, rows in content_script_queue, jobs tagged origin: "client", and status on scripts.

Tenant scoping is consistent. tenantId is never taken from the request — it is resolved server-side from the session every time, along the chain JWT.sub → customers._id → content_tenants.customerId.


Where it stops

  • A rejection carries no reason. Skip and send-back are both silent — there is no comment or feedback field anywhere, so the admin side learns that something was rejected but never why. The single biggest gap.
  • One tenant only. getTenantIdForCustomer() takes the most recently updated tenant and there is no workspace switcher, so a customer with two tenants silently only ever sees one.
  • No blog review. The "Blogs" queue reviews blog-sourced news items, not the long-form drafts the admin app writes. There is no content_blog_* access at all.
  • Session expiry is untidy on two routes. The PATCH handlers do not wrap requireSession(), so an expired-but-present cookie returns a 500 rather than a clean 401.
  • No password reset, MFA, or revocation — consistent with the admin app.

The whole thing in nine steps

  1. Open the portal, land on /login.
  2. Sign in with the credentials the agency gave you.
  3. Discovery tab, today's date. Pick a pile — Blogs, News or Competitors.
  4. One story at a time: read it, then Write a script (Y) or Skip (N).
  5. Approving starts generation in the background — up to about five minutes.
  6. Switch to Scripts. Pick a duration, read Hook / Script / Caption / Keywords.
  7. Copy a section, or share the lot to WhatsApp.
  8. Approve the script, or send it back.
  9. Calendar shows when approved work is scheduled. Change the day with the date arrows; sign out from the menu.
Source: roadmap/apps/client-app.md