Skip to main content

Portal

@cona/portal — the only CONA surface an external customer touches. 38 files, deployed to Vercel, reading through @cona/core only. Small app, but it carries the platform’s only non-Auth0 authentication scheme, so the access flow deserves precision.

Access flow

Access is two-factor: possession of the share link, plus knowledge of the customer’s postal code. Neither alone is sufficient. Participants: Page = /portal/[accessToken], S = portal-session.ts, L = rate-limit.ts, Core = @cona/core/domains/portal. The signed cookie is bound to the access token: verifyPortalSession rejects a cookie whose accessToken does not match the URL (app/lib/portal-session.ts:42). A session stolen from one share link cannot open another.

Routes

Two entry paths: the generic share link, and a Shopify-specific path where a customer arrives from their Shopify account to fetch an order document.

Rate limiting

Three independent limiters (app/lib/rate-limit.ts:11-27), all using atomic Redis helpers from @cona/core/redis: Keying the PLZ limiter per access token rather than per IP is the right choice — a distributed guessing attack against one link is still throttled.

Dependencies

Portal reaches the database only through @cona/core — no direct Prisma, no @cona/database import at all. It is the cleanest of the three Next.js apps in that respect.

Notes

Portal has no proxy.ts or middleware.ts, and sets no security headers in code. Its entire header surface is three entries in apps/portal/vercel.json: X-Content-Type-Options, X-Frame-Options, Referrer-Policy.Compared to webapp it lacks CSP, Strict-Transport-Security, and Permissions-Policy. Webapp generates a per-request CSP nonce in proxy.ts:22; portal has no equivalent.This is a hardening gap on the one publicly reachable surface. It is a headers issue, not an authentication one — the access flow above is sound.
No Sentry, no PostHog. Portal reports to Axiom and OpenTelemetry only. See Observability. Portal tokens are minted upstream. ensurePortalTokensForDocumentsActivity runs as step 5.6 of the shared ingestion pipeline, so documents arrive portal-ready. See Temporal Orchestration.

Organization appearance

Admins configure customer portal appearance under Admin → Portal appearance (/admin/portal). One six-digit brand color generates light/dark wallpaper, card, button, accent, border, and text tokens. The selected hue is retained; shades are adjusted for readable contrast. Visitors can choose light, dark, or system appearance. Heading and body fonts are chosen independently from built-in browser font stacks. Corner style is rounded or square. PNG/JPEG uploads use the existing UploadImage crop flow and organization-scoped cona-logos storage: the small logo is used on compact headers and the full logo on wider headers. Each falls back to the other uploaded logo, then the default CONA asset. Custom logos are never color-inverted. Transparent logos should be checked in both previews. Settings live in nullable organization.portal_branding. Saves validate the complete configuration, current ADMIN/OWNER membership, actor ownership, and logo organization paths, and write an activity log in the same transaction. Only validated appearance is exposed before postal-code verification; document authorization remains separate. Browser logo URLs are signed for one hour. Missing or incompatible settings use defaults; unavailable logos fall back. Apply 20260906190000_expand_organization_portal_branding before deploying the webapp and portal. The migration is additive and older applications can run against it. Rolling back the application does not require removing the column. Branding applies to the customer portal’s verification, document list, empty, and error surfaces. Successful Shopify customer links redirect into this same portal. Unknown credentials retain a generic response. PDF contents continue to follow document templates; the download API and the separate auditor portal are not themed by these settings. Sandbox clone/promotion does not implicitly copy branding or publish another organization’s logo assets.

Customer language and terminology

The customer portal resolves language per request: the access-token customer’s communication_locale, then the organization’s notification_settings.email_locale, then de-DE. Download links first honor documents.communication_locale when present, keeping issued-document language stable. German regional variants use German copy; English variants use English; unsupported configured languages use English copy. Public home, unknown credentials, and Shopify processing pages use the browser’s supported language with the highest Accept-Language quality weight, then English. The auditor portal retains this browser-based selection. The pathname passed to next-intl is set by the portal proxy from the actual URL, overwriting client-supplied headers; it is never an authorization decision. Credential lookups expose only the language, include deleted-record checks, and scope organization settings by the resolved organization. Language lookup failures fall back to the browser without changing document access or PLZ verification. Shared customer terminology translates stable object-type slugs for both the portal and customer email subject, HTML, and Tiptap placeholders. Unknown types render “Dokument” / “Document”; internal object labels are never used as a fallback. Email delivery uses its existing document snapshot → customer → delivery profile / organization → de-DE priority; custom prose is not automatically translated. Default email subjects use “von” in German. Portal redirects and the downstream PDF endpoint return localized download errors; early failures use the browser language before a customer can be resolved. PDF template content remains a separate concern tracked by CONA-1031; this change does not rewrite issued PDFs or stored labels. Deploy the webapp, customer portal and Temporal workers together to cover manual and automatic customer-email delivery. No database migration is required.