A developer-facing tool for managing TMF630-compliant transformation definitions. Built with Next.js 15, React 19, TypeScript, and Tailwind CSS.
Part of the PIA Transformation Suite — works alongside transformation-app (backend API).
| Dashboard | Transformations List |
|---|---|
![]() |
![]() |
| Transformation Detail | New Transformation |
|---|---|
![]() |
![]() |
| Playground | Login |
|---|---|
![]() |
![]() |
Transformation UI is a web-based management tool for creating, editing, testing, and monitoring data transformation definitions. These transformations convert data from one format to another using the JSLT language (a JSON query and transformation language, similar to XSLT but for JSON).
- Developers who need to write and test JSLT transformation rules
- Integration engineers who map data between different TMF630-compliant APIs
- DevOps teams who need to monitor transformation engine status
| Concept | Description |
|---|---|
| Transformation Definition | A named, versioned rule that describes how to convert input JSON into output JSON using JSLT |
| Transformation ID | Unique identifier for each transformation (e.g. order-to-invoice, customer-mapping) |
| JSLT | JSON transformation language — you write JSLT expressions to transform JSON data |
| Engine | The backend service that executes JSLT transformations (currently only JSLT engine is available) |
| Version | Each transformation is versioned. Edits create new versions automatically |
┌─────────────────┐ ┌─────────────────────┐ ┌──────────────┐
│ │ REST │ │ JSLT │ │
│ Transformation │ ──────► │ transformation-app │ ──────► │ JSLT Engine │
│ UI (this app) │ API │ (Spring Boot API) │ execute │ │
│ │ ◄────── │ │ ◄────── │ │
└─────────────────┘ └─────────────────────┘ └──────────────┘
When you first open the app, you'll be redirected to the Login page. The app uses OpenID Connect (OIDC) Single Sign-On (SSO) — click the "Single Sign-On" button and enter your credentials.
OIDC is optional at startup. If
OIDC_ISSUERis not configured, the app still starts — the login page shows an "OIDC not configured" warning and the Sign-In button is disabled. This prevents runtime crashes likeInvalidEndpointswhen the OIDC provider is unavailable.
There are 3 roles that control what you can do:
| Role | Can View | Can Create/Edit | Can Delete |
|---|---|---|---|
| Reader | ✅ All pages | ❌ | ❌ |
| Writer | ✅ All pages | ✅ Create & edit transformations | ❌ |
| Admin | ✅ All pages | ✅ Create & edit | ✅ Delete transformations |
Your role is displayed at the bottom of the sidebar, under your email address.
The landing page for unauthenticated users.
- Left panel — Branding area showing the app name and key features (JSLT Editor, Version Tracking, Live Playground)
- Right panel — "Single Sign-On" button that redirects to the OIDC provider
- Top-right — Language switcher (EN/TR) and dark mode toggle are available even before login
- After successful login, you're automatically redirected to the Dashboard
The home page after login. Gives you a quick overview of the system.
What you'll see:
- Total Transformations — Number of transformation definitions registered in the system
- Last 24h Activity — How many transformations were modified in the last 24 hours
- Active Engines — Number of available transformation engines (currently 1: JSLT)
- Engine Status — Shows which engines are online and available
- Quick Actions — Shortcut buttons:
- + New Transformation — Jump directly to the creation form
- Quick Test — Open the Playground
- View All — Go to the full transformations list
- Recently Created — Last 5 newly created transformation definitions
- Recently Updated — Last 5 recently modified transformations
- Activity Feed — Combined timeline of all recent create/update events, sorted by time
How to use it: This is your starting point. Glance at the KPI cards to understand the current state, use Quick Actions to jump to common tasks, or click on any transformation in the recent lists to view its details.
The main list of all transformation definitions in the system.
Table columns:
| Column | Description |
|---|---|
| Transformation ID | Unique name (e.g. order-to-invoice). Click to open detail page |
| Version | Current version number (auto-incremented on each edit) |
| Engine | Which engine processes this transformation (e.g. JSLT) |
| Description | Short text describing what the transformation does |
| Created By | Email of the person who created it |
| Created On | Creation date/time |
| Modified On | Last modification date/time |
Features:
- Search — Type in the search bar to filter by transformation ID (searches as you type)
- Sort — Click any column header to sort ascending/descending. Uses TMF630 sort convention via
@pia-team/pia-ui-tmf630-query-core(-fielddesc,fieldasc) - TMF630 Filters — Click the filter icon to open the advanced filter panel (powered by
@pia-team/pia-ui-tmf630-search). Supports the full TMF630 QueryDSL operator set:- Field (e.g.
transformationId,createdOn,engine) - Operator (e.g.
eq,contains,gte,between,isnull,regex, and more) - Value (text, date/time picker, enum dropdown depending on field type)
- Date/time values are serialized with local timezone offset (ISO-8601, e.g.
2026-03-15T14:30:00+03:00) - Active filters appear as removable chips below the search bar
- Field (e.g.
- Pagination — TMF630 pagination headers (
X-Total-Count,Content-Range, HTTP 200/206) parsed viaparseTMF630Headersfrom the shared library. Navigate between pages using the controls at the bottom (10, 20, 50, 100 per page) - Export — Download the current (filtered) list as CSV or JSON
- + New Transformation button — Opens the creation form (visible to Writers and Admins only)
How to use it: Use search and filters to find the transformation you need, then click on its ID to view/edit. Sort by "Modified On" descending to see the most recently changed ones first.
Detailed view of a single transformation definition. Has 3 tabs:
The main editing interface.
- Header — Shows transformation ID, current version, engine type, creation/modification dates
- JSLT Definition — Monaco code editor showing the JSLT transformation code. Syntax highlighting and auto-indentation are built in
- Description — Editable text field for documentation
- Context Variables — Optional JSON context that can be passed to the transformation engine
- Test Section — Built-in testing without leaving the page:
- Enter sample Input JSON in the input editor
- Click Test to execute the transformation
- See the Output Result instantly
- Save — After editing the JSLT code or description, click Save. This creates a new version automatically
- Conflict Detection — If someone else edited the same transformation while you were editing, a conflict dialog appears letting you choose which version to keep
- Shows a list of all previous versions of this transformation
- Each version shows: version number, who modified it, when, and what changed
- You can view the JSLT code of any past version
- Same as the global Playground page, but pre-loaded with this transformation's definition
- Useful for quick testing without switching pages
How to use it: Open a transformation from the list → review/edit the JSLT code → test with sample input → save when ready. The version history gives you a full audit trail.
Form for creating a brand new transformation definition. Only visible to Writer and Admin roles.
Form fields:
| Field | Required | Description |
|---|---|---|
| Transformation ID | ✅ | Unique name (e.g. order-to-invoice). Cannot be changed after creation |
| Engine | ✅ | Select the transformation engine (currently: JSLT) |
| Description | ❌ | What this transformation does |
| JSLT Definition | ✅ | The transformation code in JSLT syntax. Written in a Monaco code editor |
| Context Variables | ❌ | Optional JSON context variables |
Built-in testing before save:
- Write your JSLT definition in the code editor
- Enter sample Input JSON
- Click Test to execute and see the result immediately
- If the result is correct, click Save to create the transformation
Validation:
- Transformation ID must be unique (server-side check)
- JSLT definition cannot be empty
- If validation fails, error messages appear next to the relevant fields
How to use it: Fill in the ID and engine, write your JSLT code, test it with sample data, then save. After saving, you'll be redirected to the detail page of your new transformation.
A standalone JSLT testing sandbox — no need to create or save anything.
Layout (3 panels):
| Panel | Purpose |
|---|---|
| JSLT Definition (left) | Write or paste your JSLT transformation code |
| Input JSON (center) | The source JSON data you want to transform |
| Output Result (right) | The transformation result (read-only, appears after executing) |
How to use it:
- Select an Engine from the dropdown (default: JSLT)
- Optionally select an existing Transformation to load its definition
- Write or paste a JSLT expression in the definition panel
- Paste or type the Input JSON you want to transform
- Click the ▶ Run button
- The Output panel shows the result or error message
- Execution time is displayed next to the Run button
Additional features:
- Load existing transformation — Dropdown to pick a saved transformation and load its JSLT code
- Version selector — If you loaded an existing transformation, switch between its versions
- Context Variables — Expand the context panel to add JSON variables accessible in the JSLT code
- Format/Copy — Quick action buttons to format JSON or copy content
- Reset — Clear all panels and start fresh
Use cases:
- Experimenting with JSLT syntax before creating a real transformation
- Debugging a transformation that's not producing expected output
- Learning JSLT by trying different expressions on sample data
The sidebar appears on all authenticated pages:
- Collapse/Expand — Click the toggle button (top-right of sidebar) to collapse it to icon-only mode. Hover to temporarily expand
- Navigation links — Dashboard, Transformations, Playground
- Bottom section — Language switcher (EN/TR), dark mode toggle, user info (email + role), logout button
- Prefetch — Hovering over "Transformations" link pre-fetches the data for faster navigation
- Dashboard — KPI cards (total transformations, 24h activity), engine status, recent activity feed, quick actions
- Transformations List — Paginated table with search, sort, column toggle, inline filters
- Transformation Detail — View/edit transformation definitions with Monaco code editor (JSLT syntax)
- Create Transformation — Form with validation (Zod), JSLT code editor, live preview
- Playground — Interactive JSLT testing sandbox with input/output panels
- Authentication — OIDC SSO via NextAuth.js, role-based access (reader/writer/admin)
- i18n — English & Turkish (next-intl), switchable at runtime
- Dark Mode — System-aware theme toggle (next-themes)
- Responsive — Collapsible sidebar, mobile-friendly layout
The advanced filter panel on the Transformations List page is fully configurable via a JSON file — no code changes required. Admins can control which fields are searchable, which operators are available per field, and how values are displayed.
| Environment | Location |
|---|---|
| Local dev | transformation-ui/search-config.json (project root) |
| Docker Compose | mvnx-compose/transformation-ui/search-config.json (volume-mounted) |
| Kubernetes | ConfigMap transformation-ui-env-js-config → search-config.json key |
The file is served at runtime via GET /api/search-config. The SearchConfigProvider in the layout fetches it once on app load.
New to search-config.json? See the comprehensive Creating a search-config.json — Complete Guide in the
pia-ui-componentsREADME. It covers all field types, all 22 TMF630 operators, operator presets, validation rules, and a step-by-step walkthrough for new projects.
The config file references a JSON Schema bundled in @pia-team/pia-ui-tmf630-query-core. This gives you autocomplete, hover docs, and validation in VS Code / Cursor:
{
"$schema": "node_modules/@pia-team/pia-ui-tmf630-query-core/search-config.schema.json",
"fields": { ... }
}The $schema key is ignored at runtime — it only activates IDE support.
{
"displayPattern": "dd/MM/yyyy HH:mm",
"fields": {
"transformationId": {
"operatorSet": "text-search",
"validation": { "maxLength": 255 }
},
"engine": {
"type": "enum",
"operatorSet": "selection",
"values": [
{ "displayName": "JSLT", "serverValue": "JSLT" }
]
},
"createdBy": {
"type": "email",
"operators": ["eq", "ne", "contains", "containsi", "startswith", "startswithi", "in", "nin"]
},
"createdOn": {
"type": "offsetDateTime",
"operatorSet": "date-range",
"displayFormat": "date",
"displayPattern": "dd/MM/yyyy"
},
"modifiedBy": {
"type": "email",
"operatorSet": "text-search",
"nullable": true
},
"modifiedOn": {
"type": "offsetDateTime",
"operatorSet": "date-range",
"displayFormat": "datetime",
"nullable": true
},
"transformationVersion": {
"displayName": "Version",
"type": "numeric",
"operatorSet": "numeric",
"validation": { "min": 1 }
}
},
"defaults": {
"defaultField": "transformationId",
"defaultOperator": "containsi"
},
"responseFields": [
"transformationId", "engine", "createdBy", "createdOn",
"modifiedBy", "modifiedOn", "transformationVersion"
]
}| Property | Type | Required | Description |
|---|---|---|---|
type |
string | No | Field type: text (default), numeric, enum, email, url, date, dateTime, offsetDateTime, instant |
displayName |
string | No | Label shown in the UI. Auto-derived from field key if omitted (e.g., createdOn → "Created On") |
displayFormat |
string | No | For temporal types only: "date" (date picker) or "datetime" (datetime picker) |
responseDisplayFormat |
string | No | For temporal types only: "date" or "datetime". Controls table column display independently from filter picker. Falls back to displayFormat if omitted. |
operatorSet |
string | No | Named operator preset (see table below). Auto-derived from type if omitted |
operators |
string[] | No | Explicit operator list. Overrides operatorSet when provided (advanced usage) |
nullable |
boolean | No | If true, appends isnull and isnotnull operators to whichever operator set is active |
values |
array | No | Enum options: [{ "displayName": "...", "serverValue": "..." }]. Only for type: "enum" |
validation |
object | No | Client-side validation: maxLength, minLength, min, max, pattern, patternMessage, required |
Presets simplify configuration — each preset is a curated list of TMF630 operators appropriate for the field type.
| Preset Name | Operators | Auto-used by types |
|---|---|---|
text-search |
eq, ne, contains, containsi, startswith, startswithi, endswith, endswithi, in, nin | text, email, url |
text-exact |
eq, ne, eqi, nei, in, nin | — |
selection |
eq, ne, in, nin | enum |
date-range |
eq, ne, gt, gte, lt, lte, between | date, dateTime, offsetDateTime, instant |
numeric |
eq, ne, gt, gte, lt, lte, between, in, nin | numeric |
The system follows a 3-level priority chain:
1. operators: [...] (highest — explicit list, full admin control)
2. operatorSet: "..." (named preset)
3. auto-derived (lowest — based on field type)
nullable: true always appends isnull and isnotnull regardless of which level is active.
Scenario 1 — Basic (auto-derived, no overrides)
Just specify the field type; operators are auto-derived:
{
"fields": {
"name": {},
"createdOn": { "type": "offsetDateTime", "displayFormat": "date" }
}
}name gets text-search operators automatically. createdOn gets date-range operators.
Scenario 2 — Named preset override
Admin wants to restrict a text field to exact-match operators only:
{
"fields": {
"code": {
"operatorSet": "text-exact"
}
}
}Result: eq, ne, eqi, nei, in, nin (no contains, startswith, endswith).
Scenario 3 — Explicit operator list (advanced)
Admin wants full control over exactly which operators are available:
{
"fields": {
"createdBy": {
"type": "email",
"operators": ["eq", "ne", "contains", "containsi", "startswith", "startswithi", "in", "nin"]
}
}
}Result: only these 8 operators appear in the dropdown. endswith/endswithi are excluded.
Scenario 4 — Nullable fields
Backend DTO field can be null (e.g., modifiedOn is null for newly created records):
{
"fields": {
"modifiedOn": {
"type": "offsetDateTime",
"operatorSet": "date-range",
"displayFormat": "datetime",
"nullable": true
}
}
}Result: eq, ne, gt, gte, lt, lte, between, isnull, isnotnull.
Scenario 5 — Enum with dynamic values
Engine values are loaded dynamically from the backend API. Static values serve as a fallback:
{
"fields": {
"engine": {
"type": "enum",
"operatorSet": "selection",
"values": [
{ "displayName": "JSLT", "serverValue": "JSLT" }
]
}
}
}At runtime, page.tsx overrides enumOptions with fresh data from GET /api/proxy/engines. If the API is down, the static values are shown.
Scenario 6 — Client-side validation
{
"fields": {
"transformationId": {
"validation": { "maxLength": 255 }
},
"transformationVersion": {
"type": "numeric",
"validation": { "min": 1, "max": 99999 }
},
"customField": {
"validation": {
"pattern": "^[A-Z]{3}-\\d+$",
"patternMessage": "Must match format ABC-123"
}
}
}
}| Config type | Filter Picker | Table Column | Wire format sent to backend |
|---|---|---|---|
offsetDateTime + displayFormat: "date" + displayPattern: "dd/MM/yyyy" |
Date-only | 19/02/2026 |
2026-03-15T00:00:00+03:00 (auto day-boundary expansion) |
offsetDateTime + displayFormat: "date" + responseDisplayFormat: "datetime" |
Date-only | Date + time | 2026-03-15T00:00:00+03:00 |
offsetDateTime + displayFormat: "datetime" + displayPattern: "dd/MM/yyyy HH:mm" |
Date + time | 19/02/2026 14:30 |
2026-03-15T14:30:00+03:00 |
instant |
Date + time | Date + time | 2026-03-15T11:30:00Z (converted to UTC) |
Table column display is driven by
displayPatternfromsearch-config.json. If a field has its owndisplayPattern, that is used; otherwise the global (context-level)displayPatternapplies. If neither is set,Intl.DateTimeFormatlocale-based formatting is used as fallback. |date| Date-only | Date-only |2026-03-15|
The displayPattern at the root level (e.g., "dd/MM/yyyy HH:mm") is inherited by all temporal fields. Individual fields can override it (e.g., createdOn uses "dd/MM/yyyy" for date-only display). The pattern controls both filter chip display and table column formatting. See the Display Pattern Tokens section in pia-ui-components README for all supported tokens (yyyy, MM, dd, HH, hh, mm, ss, a).
Automatic day-boundary expansion: When
displayFormat: "date"is set on a temporal field (likecreatedOnwith typeoffsetDateTime), the library automatically expands operators to day-boundary ranges. For example,createdOn.eq=2026-03-15becomescreatedOn.gte=2026-03-15T00:00:00+03:00 & createdOn.lt=2026-03-16T00:00:00+03:00. This works for both flat query params and JsonPath filters — no extra code needed. See the full expansion table in the pia-ui-components README.
The filter panel automatically adapts the value input based on the selected operator. This is handled by the library's FilterRow component with built-in components from @pia-team/pia-ui-tmf630-search:
| Operator | Input Type | Component | Value | Example |
|---|---|---|---|---|
between |
Dual from/to inputs | BetweenValueInput |
["2026-01-01", "2026-12-31"] |
Date range, version range |
in, nin (enum) |
Checkbox dropdown | MultiSelectInput |
["JSLT", "XSLT"] |
Select multiple engines |
in, nin (text) |
Tag/chip input | TagValueInput |
["Alice", "Bob"] |
Multiple user names |
isnull, isnotnull |
No input | Hidden | "true" |
Check for null modifiedOn |
| All others | Single input | <input> / DatePicker / Select |
"Alice" |
Standard text/date/enum |
The renderValueInput prop in page.tsx handles custom rendering (date pickers, enum selects) and receives betweenIndex (0 = from, 1 = to) and displayFormat for dual inputs:
renderValueInput={({ value, onChange, placeholder, className, type, enumOptions, displayFormat, betweenIndex }) =>
type === "date" ? (
<DatePicker
value={value || undefined}
onChange={onChange}
placeholder={
betweenIndex === 0 ? "From" :
betweenIndex === 1 ? "To" :
placeholder
}
className={className}
mode={displayFormat === "datetime" ? "datetime" : "date"}
/>
) : type === "enum" && enumOptions?.length ? (
<Select value={value || undefined} onValueChange={onChange}>
<SelectTrigger className={className}>
<SelectValue placeholder={placeholder || "Select..."} />
</SelectTrigger>
<SelectContent>
{enumOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<input
type={type === "numeric" ? "number" : "text"}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={
betweenIndex === 0 ? "From" :
betweenIndex === 1 ? "To" :
placeholder
}
className={className}
/>
)
}Multi-value operators are serialized as repeated query parameters per the TMF630 toolkit specification:
# between
birthdate.between=1990-01-01&birthdate.between=1999-12-31
# in
surname.in=Doe&surname.in=Brown
# isnull (no value needed)
name.isnull=true
| Category | Technology |
|---|---|
| Framework | Next.js 15 (App Router, standalone output) |
| Language | TypeScript 5 |
| UI | React 19, Tailwind CSS 3, Radix UI, Lucide icons |
| State | TanStack React Query v5 |
| Auth | NextAuth.js v5 (Generic OIDC) |
| Forms | React Hook Form + Zod validation |
| Editor | Monaco Editor (@monaco-editor/react) |
| TMF630 | @pia-team/pia-ui-tmf630-query-core (types, filter/sort serialization, pagination headers) |
| Filters | @pia-team/pia-ui-tmf630-search (TMF630 QueryDSL filter panel, chips, headless hook) |
| i18n | next-intl |
| Testing | Vitest + Testing Library (unit), Playwright (e2e) |
transformation-ui/
├── src/
│ ├── app/ # Next.js App Router pages
│ │ ├── (auth)/login/ # Login page
│ │ ├── (main)/ # Authenticated layout (sidebar)
│ │ │ ├── page.tsx # Dashboard
│ │ │ ├── transformations/
│ │ │ │ ├── page.tsx # List
│ │ │ │ ├── [id]/ # Detail
│ │ │ │ └── new/ # Create
│ │ │ └── playground/ # JSLT playground
│ │ └── api/
│ │ ├── auth/ # NextAuth endpoints
│ │ ├── config/ # Public env config (client)
│ │ └── proxy/ # API proxy to backend
│ ├── components/ # Shared UI components
│ │ ├── layout/ # Sidebar, breadcrumb, page header
│ │ ├── shared/ # Theme toggle, language switcher
│ │ └── ui/ # Radix-based primitives (button, dialog, etc.)
│ ├── features/ # Feature modules
│ │ ├── auth/ # Auth hooks, user menu
│ │ ├── dashboard/ # KPI cards, activity feed
│ │ └── transformations/ # List, detail, form, hooks
│ ├── instrumentation.ts # Loads env.json into process.env at Node.js runtime
│ ├── lib/ # Shared utilities
│ │ ├── api/ # API client, endpoints, types (re-exports TMF630 types from @pia-team/pia-ui-tmf630-query-core)
│ │ ├── env/ # Environment config (context, reader)
│ │ └── providers/ # Auth, query, theme, toast providers
│ └── messages/ # i18n translation files (en.json, tr.json)
├── env.json # Runtime config (gitignored). Secrets via env vars in production
├── next.config.ts # Reads env.json → process.env at build/startup
├── Dockerfile # Dev Docker build
├── Dockerfile_release # Production multi-stage build
└── .github/workflows/ # CI/CD (GitHub Actions)
Configuration uses two layers:
| Layer | Contains | Committed to git? |
|---|---|---|
env.json |
All config (URLs, roles, OIDC, secrets for dev) | No (gitignored) |
| Environment variables | Overrides (e.g. NEXT_PUBLIC_BASE_PATH in Docker) |
Never |
Priority rule: Environment variables always take precedence over env.json. If a value is set in both, the env var wins.
These values are required for the app to function. They can be provided via environment variables or in env.json:
| env.json key | Environment Variable | Description |
|---|---|---|
oidc.clientSecret |
OIDC_CLIENT_SECRET |
OIDC client secret from your identity provider |
nextAuth.secret |
NEXTAUTH_SECRET |
Session encryption key (generate with openssl rand -base64 32) |
Production: Use environment variables or a secret manager (Kubernetes Secrets, etc.) — never commit secrets to version control.
Local / Docker Compose: Secrets can be placed in
env.jsonfor convenience since this file is gitignored and volume-mounted at runtime.
Do NOT set
AUTH_BASE_PATHmanually. The auth basePath is auto-derived fromNEXT_PUBLIC_BASE_PATH:basePath = AUTH_BASE_PATH || `${NEXT_PUBLIC_BASE_PATH}/api/auth`Setting
AUTH_BASE_PATH=/api/authwhileNEXT_PUBLIC_BASE_PATH=/transformation-uicauses all auth endpoints to return 400 Bad Request. This happens because the route handler prepends the Next.js basePath to the URL (/transformation-ui/api/auth/session), but Auth.js expects its basePath (/api/auth) to match — they don't, so every request fails.Correct behavior: Leave
AUTH_BASE_PATHunset → the code derives/transformation-ui/api/authautomatically.
env.json + Environment Variables
│
├─► next.config.ts Reads at build/startup → sets process.env.*
│ │ (env vars take precedence over env.json)
│ ├─ OIDC_* OIDC config (issuer, clientId, etc.)
│ └─ NEXTAUTH_* Auth config (secret, url, trustHost)
│
├─► instrumentation.ts Re-reads env.json at Node.js runtime
│ (ensures env vars are available for Auth.js)
│
├─► /api/config route Serves ONLY public fields to client
│ └─ env-context.tsx React context fetches from /api/config
│
└─► /api/proxy route Uses API_PROXY_TARGET for backend calls
Fallback when
env.jsonis missing: The/api/configroute builds the public config fromprocess.envvariables (NEXT_PUBLIC_API_BASE_URL,NEXT_PUBLIC_BASE_PATH,OIDC_ISSUER,OIDC_PROVIDER_NAME,ROLE_*). This ensures the client receives OIDC status and basePath even in Docker/Kubernetes deployments whereenv.jsonis not mounted.
Security: Secrets are never sent to the browser. The
/api/configendpoint only serves filtered public fields. In production, secrets should come from environment variables rather thanenv.json.
# 1. Clone & install
git clone https://github.com/pia-team/transformation-ui.git
cd transformation-ui
npm install
# 2. Create env.json (copy from template below)
# See "env.json for Local Development" section
# 3. Start dev server
npm run dev
# → http://localhost:3000/transformation-ui
# (root "/" auto-redirects to basePath)For local development, secrets can be included in env.json for convenience (the file is gitignored):
{
"api": {
"baseUrl": "",
"proxyTarget": "https://localhost:8443"
},
"basePath": "/transformation-ui",
"oidc": {
"issuer": "https://mvnx.compose.test:8443/keycloak/realms/dsync",
"clientId": "federation",
"clientSecret": "<your-client-secret>",
"providerName": "SSO Login"
},
"roles": {
"path": "realm_access.roles",
"read": "ebu-federation-reader",
"write": "ebu-federation-writer",
"admin": "ebu-federation-admin"
},
"nextAuth": {
"secret": "<generate-with: openssl rand -base64 32>",
"trustHost": "true"
},
"node": {
"tlsRejectUnauthorized": false
}
}Secrets in
env.jsonare acceptable here because the file is listed in.gitignoreand only used on your local machine.Note:
nextAuth.urlis intentionally omitted — Auth.js auto-detects the URL viatrustHost: truein local dev, which avoidsenv-url-basepath-mismatchwarnings and logout/redirect issues.
In the mvnx-compose project, env.json is volume-mounted into the container. The only required Docker Compose environment variable is NEXT_PUBLIC_BASE_PATH (needed at build time for middleware):
# docker-compose.yml
transformation-ui:
image: ghcr.io/pia-team/transformation-ui:1.2.0
environment:
- NEXT_PUBLIC_BASE_PATH=/transformation-ui
volumes:
- ./transformation-ui/env.json:/app/env.json:ro
depends_on:
keycloak: { condition: service_healthy }
nginx: { condition: service_healthy }
transformation-app: { condition: service_healthy }All configuration including secrets is in env.json (volume-mounted, not committed to the transformation-ui repo):
{
"api": {
"baseUrl": "",
"proxyTarget": "http://transformation-app:8080/transformation/v1"
},
"basePath": "/transformation-ui",
"oidc": {
"issuer": "https://mvnx.compose.test:8443/keycloak/realms/dsync",
"clientId": "federation",
"clientSecret": "<your-client-secret>",
"providerName": "SSO Login"
},
"roles": {
"path": "realm_access.roles",
"read": "ebu-federation-reader",
"write": "ebu-federation-writer",
"admin": "ebu-federation-admin"
},
"nextAuth": {
"secret": "<generate-with: openssl rand -base64 32>",
"url": "https://mvnx.compose.test:8443/transformation-ui",
"trustHost": "true"
},
"node": {
"tlsRejectUnauthorized": false
}
}Note: In Docker Compose,
nextAuth.urlis required because Auth.js needs to know the external HTTPS URL for correct callback/redirect URL generation behind nginx.
Key differences from local:
| Field | Local | Docker Compose |
|---|---|---|
api.proxyTarget |
https://localhost:8443 (via nginx on host) |
http://transformation-app:8080/transformation/v1 (direct) |
basePath |
"/transformation-ui" |
"/transformation-ui" |
nextAuth.url |
(omitted — auto-detected) | "https://mvnx.compose.test:8443/transformation-ui" |
trustHost |
"true" |
"true" |
The nginx reverse proxy passes requests to the Next.js server. Large proxy buffers are required because Auth.js OIDC callback responses can have very large headers:
location /transformation-ui {
set $transformation_ui_upstream http://transformation-ui:3000;
proxy_pass $transformation_ui_upstream;
proxy_set_header Host $host:8443;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host:8443;
proxy_set_header X-Forwarded-Port 8443;
proxy_buffering off;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}Important: Without the increased
proxy_buffer_size/proxy_buffers, OIDC login callbacks will fail with502 Bad Gateway(upstream sent too big header).
Uses GitHub Actions to build and push Docker images to GitHub Container Registry (GHCR).
# 1. Ensure you're on develop and up-to-date
git checkout develop
git pull
# 2. Create and push a semver tag
git tag 1.1.0
git push origin 1.1.0
# 3. On GitHub:
# → Releases → Draft a new Release
# → Select tag → Generate Release Notes → Publish Release
# 4. GitHub Actions builds & pushes the image automatically
# → ghcr.io/pia-team/transformation-ui:1.1.0# In docker-compose.yml (instead of local build):
transformation-ui:
image: ghcr.io/pia-team/transformation-ui:1.2.0
environment:
- NEXT_PUBLIC_BASE_PATH=/transformation-ui
volumes:
- ./transformation-ui/env.json:/app/env.json:roConfiguration is split between Deployment env vars and a ConfigMap-mounted env.json.
All URL-based variables must include the basePath (/transformation-ui):
env:
# --- Auth / OIDC ---
- name: OIDC_ISSUER
value: "https://diam.dev.mvnx-gcu.com/realms/orbitant-realm"
- name: OIDC_CLIENT_ID
value: "orbitant-backend-client"
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: transformation-suite
key: APPLICATION_S2S_CLIENT_SECRET
- name: NEXTAUTH_SECRET
valueFrom:
secretKeyRef:
name: transformation-suite
key: APPLICATION_S2S_CLIENT_SECRET
- name: AUTH_TRUST_HOST
value: "true"
# --- URLs (must include basePath!) ---
- name: AUTH_URL
value: "https://transformation-ui.dev.mvnx-gcu.com/transformation-ui"
- name: NEXTAUTH_URL
value: "https://transformation-ui.dev.mvnx-gcu.com/transformation-ui"
- name: NEXT_PUBLIC_APP_URL
value: "https://transformation-ui.dev.mvnx-gcu.com/transformation-ui"
- name: NEXT_PUBLIC_BASE_PATH
value: "/transformation-ui"
- name: NEXT_PUBLIC_API_BASE_URL
value: "https://transformation-ui.dev.mvnx-gcu.com/transformation-ui/"
# --- DO NOT set AUTH_BASE_PATH (auto-derived, see warning above) ---
# --- Performance ---
- name: NODE_OPTIONS
value: "--max-http-header-size=1000000"
- name: NODE_TLS_REJECT_UNAUTHORIZED
value: "1"{
"api": {
"baseUrl": "https://transformation-ui.dev.mvnx-gcu.com/transformation-ui/",
"proxyTarget": "https://transformations.dev.mvnx-gcu.com/transformation/v1"
},
"basePath": "/transformation-ui",
"publicUrl": "https://transformation-ui.dev.mvnx-gcu.com/transformation-ui",
"oidc": {
"issuer": "https://diam.dev.mvnx-gcu.com/realms/orbitant-realm",
"clientId": "orbitant-backend-client",
"providerName": "SSO Login"
},
"roles": {
"path": "realm_access.roles",
"read": "mvnx-reader",
"write": "mvnx-writer",
"admin": "mvnx-admin"
},
"nextAuth": {
"url": "https://transformation-ui.dev.mvnx-gcu.com/transformation-ui",
"trustHost": "true"
},
"node": {
"tlsRejectUnauthorized": true
}
}Critical consistency rules:
AUTH_URL,NEXTAUTH_URL,NEXT_PUBLIC_APP_URL, andnextAuth.urlmust all include the basePath (e.g./transformation-ui)nextAuth.urlmust not end with/api/auth— Auth.js appends that automaticallyNEXT_PUBLIC_BASE_PATHin the Deployment must matchbasePathin the ConfigMapNODE_TLS_REJECT_UNAUTHORIZED=1in the Deployment andtlsRejectUnauthorized: truein the ConfigMap must be consistent- Never set
AUTH_BASE_PATH— it conflicts with the basePath prepending logic in the route handler
| Command | Description |
|---|---|
npm run dev |
Start development server |
npm run build |
Production build |
npm start |
Start production server |
npm run lint |
ESLint check |
npm run lint:fix |
ESLint auto-fix |
npm run format |
Prettier format |
npm run test |
Run unit tests (Vitest) |
npm run test:watch |
Unit tests in watch mode |
npm run test:coverage |
Unit tests with coverage |
npm run test:e2e |
Run E2E tests (Playwright) |
Private — PIA Team






{ "api": { "baseUrl": "", // Client API base URL (empty = same-origin proxy) "proxyTarget": "http://...", // Backend API URL for server-side proxy }, "basePath": "/transformation-ui", // Next.js basePath (set when behind nginx) "oidc": { "issuer": "https://host/realms/myrealm", // OIDC issuer URL (uses .well-known discovery) "clientId": "federation", // OIDC client ID "clientSecret": "...", // OIDC client secret (dev only — use env var in production) "providerName": "SSO Login", // Display name for the provider (optional) }, "roles": { "path": "realm_access.roles", // JWT claim path for roles "read": "ebu-federation-reader", // Reader role name "write": "ebu-federation-writer", // Writer role name "admin": "ebu-federation-admin", // Admin role name }, "nextAuth": { "secret": "...", // Session encryption key (dev only — use env var in production) "url": "https://...", // External app URL (only needed in Docker/production behind proxy) "trustHost": "true", // Set "true" when behind reverse proxy or in local dev }, "node": { "tlsRejectUnauthorized": false, // Set false for self-signed certs (dev only!) }, }