diff --git a/.gitea/workflows/frontend-image.yml b/.gitea/workflows/frontend-image.yml index 0b82383..28c7d85 100644 --- a/.gitea/workflows/frontend-image.yml +++ b/.gitea/workflows/frontend-image.yml @@ -63,8 +63,6 @@ jobs: with: context: ./ApiCore-Frontend push: true - build-args: | - NEXT_PUBLIC_API_URL=${{ vars.NEXT_PUBLIC_API_URL }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha diff --git a/ApiCore-Frontend/.env.example b/ApiCore-Frontend/.env.example index 8f22ed6..84f8354 100644 --- a/ApiCore-Frontend/.env.example +++ b/ApiCore-Frontend/.env.example @@ -1,3 +1,3 @@ # Kopieren nach .env.local und mit echten Werten befuellen. .env* ist gitignored. -NEXT_PUBLIC_API_URL="http://localhost:3001/api/v1" +API_URL="http://localhost:3001/api/v1" diff --git a/ApiCore-Frontend/Dockerfile b/ApiCore-Frontend/Dockerfile index a8fbcc7..f84b787 100644 --- a/ApiCore-Frontend/Dockerfile +++ b/ApiCore-Frontend/Dockerfile @@ -2,12 +2,6 @@ FROM node:22-alpine AS builder WORKDIR /app -# NEXT_PUBLIC_* vars are inlined into the client bundle at build time, so -# this must be supplied per-environment via: -# docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com . -# Setting it only in the runtime ConfigMap has no effect on the built bundle. -ARG NEXT_PUBLIC_API_URL -ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL COPY package.json package-lock.json ./ RUN npm ci COPY . . diff --git a/ApiCore-Frontend/src/app/api/runtime-config/route.ts b/ApiCore-Frontend/src/app/api/runtime-config/route.ts new file mode 100644 index 0000000..9811414 --- /dev/null +++ b/ApiCore-Frontend/src/app/api/runtime-config/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; + +// Server-only env var (no NEXT_PUBLIC_ prefix), so it is NOT inlined into the +// client bundle at build time. force-dynamic makes Next.js read process.env +// fresh on every request instead of caching this route's output, so a single +// built image can be promoted across environments and reconfigured purely via +// the container's runtime env (e.g. a Kubernetes ConfigMap) without rebuilding. +export const dynamic = 'force-dynamic'; + +export interface RuntimeConfig { + apiUrl: string; +} + +export function GET(): NextResponse { + const apiUrl = process.env.API_URL ?? 'http://localhost:3001/api/v1'; + return NextResponse.json({ apiUrl }); +} diff --git a/ApiCore-Frontend/src/lib/auth/api-client.ts b/ApiCore-Frontend/src/lib/auth/api-client.ts index cb2ceef..4c2bacd 100644 --- a/ApiCore-Frontend/src/lib/auth/api-client.ts +++ b/ApiCore-Frontend/src/lib/auth/api-client.ts @@ -1,7 +1,6 @@ import { getAccessToken, setAccessToken } from './token-store'; import { refreshRequest } from './api'; - -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1'; +import { getApiUrl } from '../config/runtime-config'; export class ApiError extends Error { status: number; @@ -14,7 +13,8 @@ export class ApiError extends Error { async function rawFetch(path: string, init: RequestInit): Promise { const token = getAccessToken(); - return fetch(`${API_URL}${path}`, { + const apiUrl = await getApiUrl(); + return fetch(`${apiUrl}${path}`, { ...init, credentials: 'include', headers: { @@ -60,7 +60,8 @@ export async function apiFetchBlob(path: string, fallbackFilename: string): Prom async function rawFetchFormData(path: string, formData: FormData): Promise { const token = getAccessToken(); - return fetch(`${API_URL}${path}`, { + const apiUrl = await getApiUrl(); + return fetch(`${apiUrl}${path}`, { method: 'POST', body: formData, credentials: 'include', diff --git a/ApiCore-Frontend/src/lib/auth/api.ts b/ApiCore-Frontend/src/lib/auth/api.ts index c3dd636..5c26834 100644 --- a/ApiCore-Frontend/src/lib/auth/api.ts +++ b/ApiCore-Frontend/src/lib/auth/api.ts @@ -1,4 +1,4 @@ -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1'; +import { getApiUrl } from '../config/runtime-config'; export interface AccessTokenResponse { accessToken: string; @@ -16,7 +16,8 @@ export function isMfaRequired(response: LoginResponse): response is MfaRequiredR } export async function loginRequest(email: string, password: string): Promise { - const response = await fetch(`${API_URL}/auth/login`, { + const apiUrl = await getApiUrl(); + const response = await fetch(`${apiUrl}/auth/login`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, @@ -29,7 +30,8 @@ export async function loginRequest(email: string, password: string): Promise { - const response = await fetch(`${API_URL}/auth/mfa/verify`, { + const apiUrl = await getApiUrl(); + const response = await fetch(`${apiUrl}/auth/mfa/verify`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, @@ -50,7 +52,8 @@ export async function verifyMfaRequest(mfaToken: string, code: string): Promise< let inFlightRefresh: Promise | null = null; async function performRefresh(): Promise { - const response = await fetch(`${API_URL}/auth/refresh`, { + const apiUrl = await getApiUrl(); + const response = await fetch(`${apiUrl}/auth/refresh`, { method: 'POST', credentials: 'include', }); @@ -70,7 +73,8 @@ export function refreshRequest(): Promise { } export async function logoutRequest(): Promise { - await fetch(`${API_URL}/auth/logout`, { method: 'POST', credentials: 'include' }); + const apiUrl = await getApiUrl(); + await fetch(`${apiUrl}/auth/logout`, { method: 'POST', credentials: 'include' }); } export interface CurrentUserProfile { @@ -85,7 +89,8 @@ export interface CurrentUserProfile { } export async function getCurrentUserRequest(accessToken: string): Promise { - const response = await fetch(`${API_URL}/auth/me`, { + const apiUrl = await getApiUrl(); + const response = await fetch(`${apiUrl}/auth/me`, { credentials: 'include', headers: { Authorization: `Bearer ${accessToken}` }, }); diff --git a/ApiCore-Frontend/src/lib/config/runtime-config.ts b/ApiCore-Frontend/src/lib/config/runtime-config.ts new file mode 100644 index 0000000..684d07a --- /dev/null +++ b/ApiCore-Frontend/src/lib/config/runtime-config.ts @@ -0,0 +1,22 @@ +import type { RuntimeConfig } from '@/app/api/runtime-config/route'; + +let cachedApiUrl: Promise | null = null; + +async function fetchApiUrl(): Promise { + const response = await fetch('/api/runtime-config'); + if (!response.ok) { + throw new Error('Runtime-Konfiguration konnte nicht geladen werden.'); + } + const config = (await response.json()) as RuntimeConfig; + return config.apiUrl; +} + +export function getApiUrl(): Promise { + if (!cachedApiUrl) { + cachedApiUrl = fetchApiUrl().catch((error: unknown) => { + cachedApiUrl = null; + throw error; + }); + } + return cachedApiUrl; +} diff --git a/CLAUDE.md b/CLAUDE.md index 69fa2e0..6c6c09d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ npm run start:dev # http://localhost:3001, Swagger unter /api/v1/docs # Frontend cd ApiCore-Frontend npm install -npm run dev # http://localhost:3000, erwartet Backend unter NEXT_PUBLIC_API_URL +npm run dev # http://localhost:3000, erwartet Backend unter API_URL (Laufzeit-Env, siehe .env.example) ``` ## Lint / Typecheck / Tests / Build diff --git a/deploy/k8s/frontend/configmap.example.yaml b/deploy/k8s/frontend/configmap.example.yaml index cd013fc..0b0ca60 100644 --- a/deploy/k8s/frontend/configmap.example.yaml +++ b/deploy/k8s/frontend/configmap.example.yaml @@ -3,4 +3,6 @@ kind: ConfigMap metadata: name: apicore-frontend-config data: - NEXT_PUBLIC_API_URL: "https://api.apicore.example.com/api/v1" + # Server-only env var, read at request time (see src/app/api/runtime-config/route.ts). + # Changing this and restarting the pods takes effect immediately - no rebuild needed. + API_URL: "https://api.apicore.example.com/api/v1"