diff --git a/.env.example b/.env.example index 0ecd089..a9ddf3c 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,4 @@ VITE_API_BASE_URL=http://127.0.0.1:4242 VITE_KEYCLOAK_URL=http://localhost:8080/ VITE_KEYCLOAK_REALM=dev VITE_KEYCLOAK_CLIENT_ID=bff-dashboard +VITE_API_MOCKING_ENABLED=false \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3999cfc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + groups: + all-github-actions: + patterns: + - "*" + schedule: + interval: "monthly" + day: "monday" + time: "08:30" + timezone: "Europe/Vienna" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" + assignees: + - "cleot" # devops \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index beee703..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Build - -on: - push: - branches: ["*"] - pull_request: - branches: ["*"] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - node-version: ['v22.11.0'] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Node.js (${{ matrix.node-version }}) - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - - name: Install dependencies - run: npm ci - - - name: Lint - run: npm run lint - - - name: Run tests and generate coverage - run: npm run test -- --coverage - - - name: Build - run: npm run build - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5465acf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: ["master", "dev"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + name: Validate (lint, test, build) + runs-on: ubuntu-latest + env: + NODE_VERSION: 22 + + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Set up Node.js (${{ env.NODE_VERSION }}) + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Run tests and generate coverage + run: npm run test -- --coverage + + - name: Build + run: npm run build + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index af96d65..46eec18 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -52,7 +52,7 @@ jobs: steps: - name: Checkout ${{ github.ref_name }} - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.ref }} fetch-depth: 0 @@ -68,7 +68,7 @@ jobs: echo "Validation successful: Running from 'dev' branch." - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' @@ -125,7 +125,7 @@ jobs: steps: - name: Checkout ${{ github.ref_name }} - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.ref }} fetch-depth: 0 @@ -141,7 +141,7 @@ jobs: echo "Validation successful: Running from tag '${{ github.ref_name }}'." - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' @@ -219,7 +219,7 @@ jobs: steps: - name: Checkout ${{ github.ref_name }} - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.ref }} fetch-depth: 0 @@ -235,7 +235,7 @@ jobs: echo "Validation successful: Running from tag '${{ github.ref_name }}'." - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..a9b5f25 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,56 @@ +name: build and push nightly image + +on: + push: + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE_NAME: "bcr-wdc-dashboard-ui" + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Login to GHCR + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - id: meta + name: Metadata + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + with: + images: | + ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=nightly + + - id: push + name: Build & push ${{ env.IMAGE_NAME }} + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ github.repository }}/${{ env.IMAGE_NAME }} + cache-to: type=gha,mode=max,scope=${{ github.repository }}/${{ env.IMAGE_NAME }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2c99fd1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: release tagged image + +on: + push: + tags: + - "v*.*.*" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE_NAME: "bcr-wdc-dashboard-ui" + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Login to GHCR + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - id: meta + name: Metadata + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + with: + images: | + ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + + - id: push + name: Build & push ${{ env.IMAGE_NAME }} + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ github.repository }}/${{ env.IMAGE_NAME }} + cache-to: type=gha,mode=max,scope=${{ github.repository }}/${{ env.IMAGE_NAME }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 68ec519..78b8322 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,8 +19,13 @@ RUN npm run build -- --mode=${VITE_MODE} FROM nginx:1.27.5-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 +RUN apk add --no-cache jq + COPY --from=builder /app/dist /usr/share/nginx/html +COPY docker/entrypoint.d/ /docker-entrypoint.d/ +RUN chmod +x /docker-entrypoint.d/*.sh + COPY docker/nginx/snippets/proxy-params.conf /etc/nginx/snippets/proxy-params.conf # each time nginx is started it will perform variable substition in all template # files found in `/etc/nginx/templates/*.template`, and copy the results (without diff --git a/docker-compose.yml b/docker-compose.yml index ca25048..3c7d97f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: - VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL} - VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM} - VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID} + - VITE_API_MOCKING_ENABLED=${VITE_API_MOCKING_ENABLED} + - VITE_BITCR_DEV_INCLUDE_CROWDIN_IN_CONTEXT_TOOLING=${VITE_BITCR_DEV_INCLUDE_CROWDIN_IN_CONTEXT_TOOLING} healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80"] interval: 30s diff --git a/docker/entrypoint.d/00-env-config.sh b/docker/entrypoint.d/00-env-config.sh new file mode 100644 index 0000000..1fda8df --- /dev/null +++ b/docker/entrypoint.d/00-env-config.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +set -e + +JSON_ENV=$(jq -n \ + --arg VITE_API_BASE_URL "$VITE_API_BASE_URL" \ + --arg VITE_API_MOCKING_ENABLED "$VITE_API_MOCKING_ENABLED" \ + --arg VITE_KEYCLOAK_URL "$VITE_KEYCLOAK_URL" \ + --arg VITE_KEYCLOAK_REALM "$VITE_KEYCLOAK_REALM" \ + --arg VITE_KEYCLOAK_CLIENT_ID "$VITE_KEYCLOAK_CLIENT_ID" \ + --arg VITE_BITCR_DEV_INCLUDE_CROWDIN_IN_CONTEXT_TOOLING "$VITE_BITCR_DEV_INCLUDE_CROWDIN_IN_CONTEXT_TOOLING" \ + '{VITE_API_BASE_URL: $VITE_API_BASE_URL, VITE_API_MOCKING_ENABLED: $VITE_API_MOCKING_ENABLED, VITE_KEYCLOAK_URL: $VITE_KEYCLOAK_URL, VITE_KEYCLOAK_REALM: $VITE_KEYCLOAK_REALM, VITE_KEYCLOAK_CLIENT_ID: $VITE_KEYCLOAK_CLIENT_ID, VITE_BITCR_DEV_INCLUDE_CROWDIN_IN_CONTEXT_TOOLING: $VITE_BITCR_DEV_INCLUDE_CROWDIN_IN_CONTEXT_TOOLING}') + +cat < /usr/share/nginx/html/config.js +window.__ENV__ = ${JSON_ENV}; +EOF diff --git a/docker/nginx/templates/default.conf.template b/docker/nginx/templates/default.conf.template index 9b20461..c0c3b86 100644 --- a/docker/nginx/templates/default.conf.template +++ b/docker/nginx/templates/default.conf.template @@ -49,6 +49,10 @@ server { add_header Cache-Control "no-store"; } + location = /config.js { + add_header Cache-Control "no-store"; + } + location ~* \.(?:json)$ { expires 1d; add_header Cache-Control "public"; diff --git a/index.html b/index.html index 3dc45c5..c5f713b 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,7 @@
+ diff --git a/public/config.js b/public/config.js new file mode 100644 index 0000000..68a8529 --- /dev/null +++ b/public/config.js @@ -0,0 +1 @@ +window.__ENV__ = window.__ENV__ || {} diff --git a/src/constants/meta.ts b/src/constants/meta.ts index 354ac2b..647852c 100644 --- a/src/constants/meta.ts +++ b/src/constants/meta.ts @@ -1,6 +1,8 @@ +import { env } from "@/lib/env" + export default { - devModeEnabled: import.meta.env.DEV, - apiBaseUrl: import.meta.env.VITE_API_BASE_URL as string, - apiMocksEnabled: import.meta.env.VITE_API_MOCKING_ENABLED === "true", - crowdinInContextToolingEnabled: import.meta.env.VITE_BITCR_DEV_INCLUDE_CROWDIN_IN_CONTEXT_TOOLING === "true", + devModeEnabled: env.devModeEnabled, + apiBaseUrl: env.apiBaseUrl, + apiMocksEnabled: env.apiMocksEnabled, + crowdinInContextToolingEnabled: env.crowdinInContextToolingEnabled, } diff --git a/src/generated/client/types.gen.ts b/src/generated/client/types.gen.ts index ab59c0c..4782774 100644 --- a/src/generated/client/types.gen.ts +++ b/src/generated/client/types.gen.ts @@ -126,6 +126,14 @@ export type AnonPublicData = { /** * --------------------------- Quote info request */ + +export type MintingStatus = { + status: 'Disabled' +} | { + status: 'Enabled'; + minted: number; +}; + export type InfoReply = { bill: BillInfo; id: string; @@ -160,6 +168,7 @@ export type InfoReply = { keyset_id: string; discounted: number; status: 'Accepted'; + minting_status: MintingStatus; } | { bill: BillInfo; id: string; diff --git a/src/keycloak.tsx b/src/keycloak.tsx index c106bd8..558b4d6 100644 --- a/src/keycloak.tsx +++ b/src/keycloak.tsx @@ -1,9 +1,10 @@ import Keycloak from "keycloak-js" +import { env } from "@/lib/env" const keycloak = new Keycloak({ - url: import.meta.env.VITE_KEYCLOAK_URL as string, - realm: import.meta.env.VITE_KEYCLOAK_REALM as string, - clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID as string, + url: env.keycloakUrl, + realm: env.keycloakRealm, + clientId: env.keycloakClientId, }) export const initKeycloak = async (): Promise => { diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e3464d4..58fb3d9 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,9 +1,10 @@ import { client as heyApiClient } from "@/generated/client/client.gen" import * as sdk from "@/generated/client/sdk.gen" +import { env } from "@/lib/env" import keycloak from "../keycloak" heyApiClient.setConfig({ - baseUrl: import.meta.env.VITE_API_BASE_URL as string, + baseUrl: env.apiBaseUrl, }) // Add the auth token interceptor diff --git a/src/lib/env.test.ts b/src/lib/env.test.ts new file mode 100644 index 0000000..9df5ab1 --- /dev/null +++ b/src/lib/env.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +const loadEnv = async () => { + const module = await import("./env") + return module.env +} + +afterEach(() => { + vi.resetModules() + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +describe("env runtime resolution", () => { + it("prefers runtime env values when provided", async () => { + vi.stubEnv("VITE_API_BASE_URL", "https://fallback.example.com") + vi.stubEnv("VITE_KEYCLOAK_URL", "https://fallback-keycloak.example.com") + vi.stubEnv("VITE_KEYCLOAK_REALM", "fallback-realm") + vi.stubEnv("VITE_KEYCLOAK_CLIENT_ID", "fallback-client") + + vi.stubGlobal("window", { + __ENV__: { + VITE_API_BASE_URL: "https://runtime.example.com", + VITE_API_MOCKING_ENABLED: "true", + VITE_KEYCLOAK_URL: "https://runtime-keycloak.example.com", + VITE_KEYCLOAK_REALM: "runtime-realm", + VITE_KEYCLOAK_CLIENT_ID: "runtime-client", + VITE_BITCR_DEV_INCLUDE_CROWDIN_IN_CONTEXT_TOOLING: "true", + }, + }) + + const env = await loadEnv() + + expect(env.apiBaseUrl).toBe("https://runtime.example.com") + expect(env.apiMocksEnabled).toBe(true) + expect(env.keycloakUrl).toBe("https://runtime-keycloak.example.com") + expect(env.keycloakRealm).toBe("runtime-realm") + expect(env.keycloakClientId).toBe("runtime-client") + expect(env.crowdinInContextToolingEnabled).toBe(true) + }) + + it("falls back to build-time env when runtime values are empty", async () => { + vi.stubEnv("VITE_API_BASE_URL", "https://fallback.example.com") + vi.stubEnv("VITE_API_MOCKING_ENABLED", "true") + vi.stubEnv("VITE_KEYCLOAK_URL", "https://fallback-keycloak.example.com") + vi.stubEnv("VITE_KEYCLOAK_REALM", "fallback-realm") + vi.stubEnv("VITE_KEYCLOAK_CLIENT_ID", "fallback-client") + vi.stubEnv("VITE_BITCR_DEV_INCLUDE_CROWDIN_IN_CONTEXT_TOOLING", "false") + + vi.stubGlobal("window", { + __ENV__: { + VITE_API_BASE_URL: "", + VITE_API_MOCKING_ENABLED: "", + VITE_KEYCLOAK_URL: "", + VITE_KEYCLOAK_REALM: "", + VITE_KEYCLOAK_CLIENT_ID: "", + VITE_BITCR_DEV_INCLUDE_CROWDIN_IN_CONTEXT_TOOLING: "", + }, + }) + + const env = await loadEnv() + + expect(env.apiBaseUrl).toBe("https://fallback.example.com") + expect(env.apiMocksEnabled).toBe(true) + expect(env.keycloakUrl).toBe("https://fallback-keycloak.example.com") + expect(env.keycloakRealm).toBe("fallback-realm") + expect(env.keycloakClientId).toBe("fallback-client") + expect(env.crowdinInContextToolingEnabled).toBe(false) + }) + + it("handles SSR where window is undefined", async () => { + vi.stubEnv("VITE_API_BASE_URL", "https://fallback.example.com") + vi.stubEnv("VITE_KEYCLOAK_URL", "https://fallback-keycloak.example.com") + vi.stubEnv("VITE_KEYCLOAK_REALM", "fallback-realm") + vi.stubEnv("VITE_KEYCLOAK_CLIENT_ID", "fallback-client") + + vi.stubGlobal("window", undefined) + + const env = await loadEnv() + + expect(env.apiBaseUrl).toBe("https://fallback.example.com") + expect(env.keycloakUrl).toBe("https://fallback-keycloak.example.com") + expect(env.keycloakRealm).toBe("fallback-realm") + expect(env.keycloakClientId).toBe("fallback-client") + }) +}) diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..9f5d5b4 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,34 @@ +type RuntimeEnv = Partial<{ + VITE_API_BASE_URL: string + VITE_API_MOCKING_ENABLED: string + VITE_KEYCLOAK_URL: string + VITE_KEYCLOAK_REALM: string + VITE_KEYCLOAK_CLIENT_ID: string + VITE_BITCR_DEV_INCLUDE_CROWDIN_IN_CONTEXT_TOOLING: string +}> + +const runtimeEnv: RuntimeEnv = + typeof window !== "undefined" ? (window as { __ENV__?: RuntimeEnv }).__ENV__ ?? {} : {} + +const fallbackEnv = import.meta.env as ImportMetaEnv & RuntimeEnv + +const getEnvValue = (key: K): RuntimeEnv[K] | undefined => { + const value = runtimeEnv[key] + + if (value !== undefined && value !== null && value !== "") { + return value + } + + return fallbackEnv[key] +} + +export const env = { + devModeEnabled: fallbackEnv.DEV, + apiBaseUrl: getEnvValue("VITE_API_BASE_URL")!, + apiMocksEnabled: (getEnvValue("VITE_API_MOCKING_ENABLED") ?? "false") === "true", + keycloakUrl: getEnvValue("VITE_KEYCLOAK_URL")!, + keycloakRealm: getEnvValue("VITE_KEYCLOAK_REALM")!, + keycloakClientId: getEnvValue("VITE_KEYCLOAK_CLIENT_ID")!, + crowdinInContextToolingEnabled: + (getEnvValue("VITE_BITCR_DEV_INCLUDE_CROWDIN_IN_CONTEXT_TOOLING") ?? "false") === "true", +} diff --git a/src/main.tsx b/src/main.tsx index c047c76..cf47978 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -58,3 +58,5 @@ void prepare().then(() => { , ) }) + +export { App } diff --git a/src/pages/quotes/QuotePage.tsx b/src/pages/quotes/QuotePage.tsx index 137ba2e..995f4ca 100644 --- a/src/pages/quotes/QuotePage.tsx +++ b/src/pages/quotes/QuotePage.tsx @@ -220,13 +220,13 @@ function DenyConfirmDrawer({ children, onSubmit, ...drawerProps }: DenyConfirmDr function QuoteActions({ value, isFetching, - newKeyset, + mintingEnabled, ebillPaid, requestedToPay, }: { value: InfoReply isFetching: boolean - newKeyset: boolean + mintingEnabled: boolean ebillPaid: boolean requestedToPay: boolean }) { @@ -471,7 +471,7 @@ function QuoteActions({ trigger={