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
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’scommunication_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.