Custom UI Tutorial
Porta gives you full control over every user-facing page — login, consent, password reset, magic link, two-factor authentication, invitations, and all transactional emails. This guide covers everything from quick API-driven branding to building completely custom templates.
What Can Be Customized?
| Layer | What It Covers | Effort |
|---|---|---|
| API-driven branding | Uploaded or external logo/favicon, colors, company name, custom CSS per org | Zero code — API/CLI only |
| Custom CSS injection | Full style override via customCss field (up to 10KB) | CSS only |
| Template override | Replace any or all Handlebars templates via Docker volume mount | HTML/Handlebars |
| Email templates | Customize HTML and plain-text transactional emails | HTML/Handlebars |
Quick Start: API-Driven Branding
The fastest way to customize Porta's UI is through per-organization branding. No template files needed — just set values via the Admin API or CLI.
Available Branding Settings
| Setting | API Field | Default | Description |
|---|---|---|---|
| Uploaded logo | Branding asset logo | (none) | Preferred image for page and HTML email headers |
| Uploaded favicon | Branding asset favicon | (none) | Preferred browser tab icon |
| Logo URL | logoUrl | (none) | External fallback when no logo asset is stored |
| Favicon URL | faviconUrl | (none) | External fallback when no favicon asset is stored |
| Primary Color | primaryColor | #3B82F6 | Buttons, links, accents (sets CSS --primary variable) |
| Company Name | companyName | Organization name | Page titles, headers, footers, email signatures |
| Custom CSS | customCss | (none) | Raw CSS injected into <head> (max 10KB) |
Setting Branding via CLI
# Set branding for an organization
porta org branding <org-id> \
--logo-url "https://cdn.example.com/logo.png" \
--favicon-url "https://cdn.example.com/favicon.ico" \
--primary-color "#E11D48" \
--company-name "Acme Corp"Setting Branding via Admin API
curl -X PUT https://porta.local:3443/api/admin/organizations/<org-id>/branding \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"logoUrl": "https://cdn.example.com/logo.png",
"faviconUrl": "https://cdn.example.com/favicon.ico",
"primaryColor": "#E11D48",
"companyName": "Acme Corp",
"customCss": "body { font-family: \"Inter\", sans-serif; }"
}'Uploading Logo and Favicon Assets
The embedded Admin UI can add, replace, and remove PNG, JPEG, WebP, ICO, and SVG assets. You can also use the Admin API or TypeScript SDK. The API body contains image bytes encoded as standard base64:
curl -X PUT https://porta.local:3443/api/admin/organizations/<org-id>/branding/logo \\
-H "Authorization: Bearer $TOKEN" \\
-H "Content-Type: application/json" \\
-d '{
"data": "<base64-encoded-image-bytes>",
"contentType": "image/png"
}'Logo assets may contain up to 2 MiB of decoded data. Favicon assets may contain up to 512 KiB. Porta verifies the declared type against the image content and sanitizes SVG through its existing SVG validation path. See the Branding Assets API for the complete contract.
An uploaded asset has priority over its corresponding external URL. Removing the asset restores the configured URL fallback. If neither exists, templates receive null. Company name falls back to the organization name, and primary color falls back to #3B82F6.
External image fallbacks must use HTTPS. Non-production deployments may also use HTTP on exact loopback hosts. Authentication-page Content Security Policy (CSP) permits only same-origin uploads, data: images, and validated external fallback origins. SVG asset responses use an additional restrictive sandbox policy.
INFO
The embedded Admin UI intentionally manages image URLs, uploaded images, company name, and primary color only. Existing customCss API and conventional CLI support remains available for template operators.
Custom CSS Examples
The customCss field lets you override any style without touching templates:
/* Change the font */
body {
font-family: 'Inter', system-ui, sans-serif;
}
/* Rounded buttons */
.btn {
border-radius: 24px;
}
/* Custom background */
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* Hide the footer */
.footer {
display: none;
}
/* Dark mode */
body {
background: #1a1a2e;
color: #eee;
}
.card {
background: #16213e;
border-color: #0f3460;
}
.btn {
background: #e94560;
}TIP
Custom CSS is injected after the default styles, so your rules take precedence. Use browser dev tools on the login page to inspect class names and structure.
Understanding the Template System
Porta uses Handlebars as its template engine. Templates are organized in a clear directory structure:
templates/default/
├── layouts/
│ └── main.hbs # Base HTML layout wrapping all pages
├── pages/
│ ├── login.hbs # Login page (password + magic link forms)
│ ├── consent.hbs # OAuth consent screen
│ ├── forgot-password.hbs # Password reset request form
│ ├── reset-password.hbs # New password form
│ ├── reset-success.hbs # Password reset confirmation
│ ├── magic-link-sent.hbs # "Check your email" page
│ ├── magic-link-success.hbs # Magic link verification success
│ ├── accept-invite.hbs # User invitation acceptance
│ ├── invite-success.hbs # Invitation accepted confirmation
│ ├── invite-expired.hbs # Expired invitation page
│ ├── two-factor-setup.hbs # 2FA setup (QR code for TOTP)
│ ├── two-factor-verify.hbs # 2FA code entry page
│ ├── logout.hbs # Logout confirmation
│ ├── logout-success.hbs # Post-logout success page
│ └── error.hbs # Error page
├── partials/
│ ├── header.hbs # Organization logo/name header
│ ├── footer.hbs # Copyright footer
│ └── flash-messages.hbs # Success/error notification banners
└── emails/
├── magic-link.hbs # Magic link email (HTML)
├── magic-link.txt.hbs # Magic link email (plain text)
├── password-reset.hbs # Password reset email (HTML)
├── password-reset.txt.hbs
├── invitation.hbs # Invitation email (HTML)
├── invitation.txt.hbs
├── otp-code.hbs # 2FA OTP code email (HTML)
├── otp-code.txt.hbs
├── password-changed.hbs # Password changed notification (HTML)
├── password-changed.txt.hbs
├── welcome.hbs # Welcome email (HTML)
└── welcome.txt.hbsHow Templates Are Rendered
- The layout (
layouts/main.hbs) provides the HTML shell —<head>,<body>, styles, branding CSS variable, and the{{{body}}}placeholder - The page (e.g.,
pages/login.hbs) provides the content rendered inside the layout - Partials (
{{> header}},{{> footer}},{{> flash-messages}}) are reusable snippets included by pages - Branding values are resolved from uploaded assets, configured fallbacks, and defaults, then injected as template variables
- Custom CSS from the branding is injected into the layout's
<head>section
Template Variables Reference
All templates have access to these variables:
Global Variables (All Pages)
| Variable | Type | Description |
|---|---|---|
branding.logoUrl | string | null | Public uploaded-logo URL, configured fallback, or null |
branding.faviconUrl | string | null | Public uploaded-favicon URL, configured fallback, or null |
branding.primaryColor | string | Hex color (default #3B82F6) |
branding.companyName | string | Organization display name |
branding.customCss | string | null | Raw CSS for injection |
pageTitle | string | Page title (e.g., "Sign In", "Reset Password") |
locale | string | Current locale (e.g., en) |
year | number | Current year (for copyright footers) |
t | function | Translation helper — {{t "key"}} |
Login Page (pages/login.hbs)
| Variable | Type | Description |
|---|---|---|
uid | string | OIDC interaction ID |
csrfToken | string | CSRF token for form submission |
loginHint | string | null | Pre-filled email from OIDC login_hint parameter |
flash.error | string | null | Error message to display |
flash.success | string | null | Success message to display |
showPassword | boolean | Whether to show the password form |
showMagicLink | boolean | Whether to show the magic link form |
showForgotPassword | boolean | Whether to show "Forgot password?" link |
Consent Page (pages/consent.hbs)
When the consent page is shown depends on the client's trust and the request:
- A trusted client (
requireConsentisfalse, the default) is auto-consented. - A third-party client (
requireConsentistrue) shows this page for every scope, claim, or resource the user has not already granted. - A
prompt=consentrequest shows the page for a trusted client only when a new scope, claim, or resource is requested. - Once the user grants a scope it is remembered, so the same scope never prompts again — even if the client sends
prompt=consent. A new scope prompts again.
| Variable | Type | Description |
|---|---|---|
uid | string | OIDC interaction ID |
csrfToken | string | CSRF token |
client | object | Client metadata (name, logoUri, tosUri, policyUri) |
scopes | string[] | Requested scopes |
claims | string[] | Requested claims |
2FA Pages (pages/two-factor-verify.hbs)
| Variable | Type | Description |
|---|---|---|
uid | string | OIDC interaction ID |
csrfToken | string | CSRF token |
method | string | Current 2FA method (email_otp, totp, recovery) |
maskedEmail | string | null | Masked email for OTP display |
flash.error | string | null | Error message |
Email Templates
| Variable | Type | Description |
|---|---|---|
branding.* | object | Same branding variables as pages |
url | string | Action URL (magic link, reset link, invite link) |
code | string | OTP code (for otp-code template) |
expiresIn | string | Human-readable expiry (e.g., "15 minutes") |
userName | string | Recipient's display name |
year | number | Current year |
Creating a Custom Login Page
Let's create a completely custom login page. Start by copying the default templates:
Step 1: Copy Default Templates
# Create a directory for your custom templates
mkdir -p my-templates
# Copy the default templates as a starting point
docker cp porta-app:/app/templates/default/. my-templates/Or if working from the source repository:
cp -r templates/default my-templatesStep 2: Edit the Login Page
Edit my-templates/pages/login.hbs:
Step 3: Customize the Layout
Edit my-templates/layouts/main.hbs to change the overall page structure, add external fonts, or modify the base styles:
Step 4: Mount in Docker Compose
services:
porta:
image: blendsdk/porta:latest
volumes:
- ./my-templates:/app/templates/default
# ... rest of configurationRestart the container and your custom templates are live:
docker compose up -dImportant
When mounting a custom templates directory, you must include all template files — the entire layouts/, pages/, partials/, and emails/ directories. Porta does not merge custom templates with defaults; it reads from the mounted directory exclusively.
Customizing Email Templates
Email templates come in pairs — an HTML version (.hbs) and a plain-text version (.txt.hbs). Both are sent together as a multipart email so recipients always get a readable version.
Available Email Templates
| Template | When It's Sent |
|---|---|
magic-link | User requests a magic link login |
password-reset | User requests a password reset |
invitation | Admin invites a user to an organization |
otp-code | 2FA email OTP code delivery |
password-changed | Notification after password is changed |
welcome | Welcome email after account creation |
Example: Custom Magic Link Email
Edit my-templates/emails/magic-link.hbs:
And the plain-text version my-templates/emails/magic-link.txt.hbs:
Docker Compose Configuration
Development Setup (With Hot Reload)
For developing custom templates, mount your templates directory and restart on changes:
services:
porta:
image: blendsdk/porta:latest
ports:
- '3000:3000'
env_file: [.env]
environment:
DATABASE_URL: postgresql://porta:porta_secret@postgres:5432/porta
REDIS_URL: redis://redis:6379
volumes:
# Mount custom templates (changes visible on next page load)
- ./my-templates:/app/templates/default
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
postgres:
image: postgres:16-alpine
environment: { POSTGRES_DB: porta, POSTGRES_USER: porta, POSTGRES_PASSWORD: porta_secret }
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck: { test: ['CMD-SHELL', 'pg_isready -U porta'], interval: 5s, retries: 5 }
redis:
image: redis:7-alpine
healthcheck: { test: ['CMD', 'redis-cli', 'ping'], interval: 5s, retries: 5 }
volumes:
pgdata:Production Setup
For production, you can either:
Option A: Volume mount (recommended for easy updates)
volumes:
- ./my-templates:/app/templates/default:ro # read-only mountOption B: Custom Docker image (for immutable deployments)
FROM blendsdk/porta:latest
COPY my-templates/ /app/templates/default/Testing Custom Templates
Quick Test Workflow
- Start Porta with your custom templates mounted
- Create a test organization and set branding:bash
porta org branding <org-id> \ --logo-url "https://example.com/logo.png" \ --primary-color "#E11D48" \ --company-name "Test Corp" - Open the login page at
https://porta.local:3443/<org-slug>/auth - Test each flow:
- Password login
- Magic link request (check MailHog at
http://localhost:8025for emails) - Password reset flow
- 2FA setup and verification (if enabled)
Template Reload
Templates are read from disk on each request in development. After editing a template file, simply refresh the page to see changes — no container restart needed.
MailHog for Email Testing
For testing email templates locally, use the development Docker Compose profile which includes MailHog:
docker compose --profile dev up -dThen open http://localhost:8025 to view all sent emails.
Internationalization (i18n)
Porta supports locale-based translations. Translation files are stored in locales/default/<locale>/:
locales/default/
└── en/
├── common.json # Shared strings (buttons, labels)
├── login.json # Login page strings
├── consent.json # Consent page strings
├── errors.json # Error messages
├── emails.json # Email subject lines and content
└── ...Use the {{t "key"}} helper in templates to reference translated strings:
To add a new language, create a new locale directory (e.g., locales/default/nl/) with the same JSON files and mount it alongside your custom templates.
Next Steps
- Capabilities Overview — See everything Porta can do
- Authentication Modes — Login methods and 2FA in depth
- Admin API Reference — Branding API details
- CLI Reference —
porta org brandingcommand - Deployment Guide — Production deployment with custom templates