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 all — src/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
| Gate | Checks | Where |
|---|---|---|
| Middleware | Cookie presence only — never the signature | client-app/middleware.ts |
requireSession() | Asks the backend: GET /api/auth/session with the token as bearer | Node handlers |
requirePortalPermission() | approvals.view / approvals.decide, from the backend's resolved set | Route 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 see | Can do |
|---|---|
| Today's blogs, news and competitor stories | Approve or skip a story |
| Article summaries and source links | Un-skip something they skipped |
| Generated scripts — 3 cuts, 4 sections | Trigger generation (implicitly, by approving) |
| The posting calendar and day plans | Retry a failed generation |
| Brand profile — company, categories, competitor count, audience | Approve 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 produces | Collection | Client screen |
|---|---|---|
| Ingested, scored articles | content_news_items | Discovery tab |
| Generated scripts | content_scripts | Scripts tab |
| Posting schedule | content_calendar_entries | Calendar tab |
| Brand profile | content_tenants and friends | Company 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
- Open the portal, land on
/login. - Sign in with the credentials the agency gave you.
- Discovery tab, today's date. Pick a pile — Blogs, News or Competitors.
- One story at a time: read it, then Write a script (
Y) or Skip (N). - Approving starts generation in the background — up to about five minutes.
- Switch to Scripts. Pick a duration, read Hook / Script / Caption / Keywords.
- Copy a section, or share the lot to WhatsApp.
- Approve the script, or send it back.
- Calendar shows when approved work is scheduled. Change the day with the date arrows; sign out from the menu.