Build, scan, and push images to Harbor on every main push
CI / test (push) Successful in 25s
CI / lint (push) Successful in 28s
CI / secrets-scan (push) Successful in 5s
CI / vuln-scan (push) Successful in 12s
CI / sast (push) Successful in 10s
CI / build-images (push) Failing after 51s
CI / image-scan (push) Has been skipped
CI / push (push) Has been skipped
CI / test (push) Successful in 25s
CI / lint (push) Successful in 28s
CI / secrets-scan (push) Successful in 5s
CI / vuln-scan (push) Successful in 12s
CI / sast (push) Successful in 10s
CI / build-images (push) Failing after 51s
CI / image-scan (push) Has been skipped
CI / push (push) Has been skipped
Wires up the CD half of the pipeline. New jobs build multi-stage Docker images for the frontend and backend, run a Trivy image scan that fails on HIGH/CRITICAL findings, and push to harbor.tehriehldeal.com on main only. Each push tags <version> (from package.json), <sha>, and latest; a pre-push existence check refuses to overwrite a version tag that already points at a different digest, forcing a real bump. The Vite frontend now reads runtime config from window.__RUNTIME_CONFIG__, populated by /config.js which nginx renders from container env vars at startup via envsubst. A getConfig() helper falls back to import.meta.env for `pnpm dev` and Vitest, so existing test scaffolding keeps working. PWA workbox excludes /config.js from precache and serves it NetworkOnly to keep stale config from surviving a container restart. Bumps frontend 0.0.0→0.1.0 and backend 0.0.1→0.1.0 (production deployment is a meaningful new capability for both packages). Also fixes four pre-existing tsc -b errors that the new vite build step in the frontend Dockerfile would otherwise hit: global.fetch → globalThis.fetch in three test files, null-guard in Activity.tsx account filter, type cast on Recharts Pie onClick in Dashboard.tsx, typed callback signature on the auth.test.ts onAuthStateChange mock. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/coverage
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
**/*.log
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.gitignore
|
||||||
|
.gitleaks.toml
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.claude
|
||||||
|
.husky
|
||||||
|
.prettierignore
|
||||||
|
.prettierrc
|
||||||
|
docker-compose.yml
|
||||||
|
README.md
|
||||||
|
ProjectPlan.md
|
||||||
|
TODO.md
|
||||||
|
LICENSE
|
||||||
+211
-18
@@ -152,21 +152,214 @@ jobs:
|
|||||||
name: semgrep-report
|
name: semgrep-report
|
||||||
path: semgrep.sarif
|
path: semgrep.sarif
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
build-images:
|
||||||
# Future stages (not yet enabled — Dockerfiles do not exist yet):
|
runs-on: ubuntu-latest
|
||||||
#
|
needs: [test, lint, secrets-scan, vuln-scan, sast]
|
||||||
# build-images:
|
steps:
|
||||||
# needs: [test, lint, secrets-scan, vuln-scan, sast]
|
- uses: actions/checkout@v4
|
||||||
# - docker build tehriehlbudget-backend → harbor.<host>/tehriehlbudget/backend:<sha>
|
|
||||||
# - docker build tehriehlbudget-frontend → harbor.<host>/tehriehlbudget/frontend:<sha>
|
- uses: docker/setup-buildx-action@v3
|
||||||
#
|
|
||||||
# image-scan:
|
- name: Compute image tags
|
||||||
# needs: build-images
|
run: |
|
||||||
# - trivy image scan on each built image (severity gate ON here from day one)
|
SHA_SHORT=$(git rev-parse --short HEAD)
|
||||||
# - re-run trivy SBOM on the image so Harbor gets an image-level CycloneDX
|
BACKEND_VERSION=$(jq -r .version tehriehlbudget-backend/package.json)
|
||||||
#
|
FRONTEND_VERSION=$(jq -r .version tehriehlbudget-frontend/package.json)
|
||||||
# push:
|
echo "SHA_SHORT=${SHA_SHORT}" >> "$GITHUB_ENV"
|
||||||
# needs: image-scan
|
echo "BACKEND_VERSION=${BACKEND_VERSION}" >> "$GITHUB_ENV"
|
||||||
# - docker login harbor.<self-hosted-domain> (creds via Gitea Actions secrets)
|
echo "FRONTEND_VERSION=${FRONTEND_VERSION}" >> "$GITHUB_ENV"
|
||||||
# - docker push backend + frontend tags
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
- name: Build backend image
|
||||||
|
run: |
|
||||||
|
docker buildx build --load \
|
||||||
|
-f tehriehlbudget-backend/Dockerfile \
|
||||||
|
-t tehriehlbudget-backend:${SHA_SHORT} \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Build frontend image
|
||||||
|
run: |
|
||||||
|
docker buildx build --load \
|
||||||
|
-f tehriehlbudget-frontend/Dockerfile \
|
||||||
|
-t tehriehlbudget-frontend:${SHA_SHORT} \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Save images for downstream jobs
|
||||||
|
run: |
|
||||||
|
docker save \
|
||||||
|
tehriehlbudget-backend:${SHA_SHORT} \
|
||||||
|
tehriehlbudget-frontend:${SHA_SHORT} \
|
||||||
|
-o /tmp/images.tar
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: built-images
|
||||||
|
path: /tmp/images.tar
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
image-scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-images
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: built-images
|
||||||
|
path: /tmp
|
||||||
|
|
||||||
|
- name: Load images
|
||||||
|
run: |
|
||||||
|
docker load -i /tmp/images.tar
|
||||||
|
SHA_SHORT=$(git rev-parse --short HEAD)
|
||||||
|
echo "SHA_SHORT=${SHA_SHORT}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Trivy image scan — backend (HIGH/CRITICAL gate)
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v "$PWD:/src" \
|
||||||
|
aquasec/trivy:latest image \
|
||||||
|
--severity HIGH,CRITICAL \
|
||||||
|
--exit-code 1 \
|
||||||
|
--ignore-unfixed \
|
||||||
|
--format sarif --output /src/trivy-image-backend.sarif \
|
||||||
|
tehriehlbudget-backend:${SHA_SHORT}
|
||||||
|
|
||||||
|
- name: Trivy image scan — frontend (HIGH/CRITICAL gate)
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v "$PWD:/src" \
|
||||||
|
aquasec/trivy:latest image \
|
||||||
|
--severity HIGH,CRITICAL \
|
||||||
|
--exit-code 1 \
|
||||||
|
--ignore-unfixed \
|
||||||
|
--format sarif --output /src/trivy-image-frontend.sarif \
|
||||||
|
tehriehlbudget-frontend:${SHA_SHORT}
|
||||||
|
|
||||||
|
- name: Trivy SBOM — backend
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v "$PWD:/src" \
|
||||||
|
aquasec/trivy:latest image \
|
||||||
|
--format cyclonedx --output /src/sbom-backend.cdx.json \
|
||||||
|
tehriehlbudget-backend:${SHA_SHORT}
|
||||||
|
|
||||||
|
- name: Trivy SBOM — frontend
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v "$PWD:/src" \
|
||||||
|
aquasec/trivy:latest image \
|
||||||
|
--format cyclonedx --output /src/sbom-frontend.cdx.json \
|
||||||
|
tehriehlbudget-frontend:${SHA_SHORT}
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: trivy-image-reports
|
||||||
|
path: |
|
||||||
|
trivy-image-backend.sarif
|
||||||
|
trivy-image-frontend.sarif
|
||||||
|
sbom-backend.cdx.json
|
||||||
|
sbom-frontend.cdx.json
|
||||||
|
|
||||||
|
push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: image-scan
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
env:
|
||||||
|
HARBOR_HOST: harbor.tehriehldeal.com
|
||||||
|
HARBOR_PROJECT: tehriehlbudget
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Need full history so the back-pushed git tag can be created against
|
||||||
|
# the right commit, and so token-auth on push to origin works.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: built-images
|
||||||
|
path: /tmp
|
||||||
|
|
||||||
|
- name: Load images and compute tag inputs
|
||||||
|
run: |
|
||||||
|
docker load -i /tmp/images.tar
|
||||||
|
SHA_SHORT=$(git rev-parse --short HEAD)
|
||||||
|
BACKEND_VERSION=$(jq -r .version tehriehlbudget-backend/package.json)
|
||||||
|
FRONTEND_VERSION=$(jq -r .version tehriehlbudget-frontend/package.json)
|
||||||
|
echo "SHA_SHORT=${SHA_SHORT}" >> "$GITHUB_ENV"
|
||||||
|
echo "BACKEND_VERSION=${BACKEND_VERSION}" >> "$GITHUB_ENV"
|
||||||
|
echo "FRONTEND_VERSION=${FRONTEND_VERSION}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Refuse to overwrite an existing version tag
|
||||||
|
env:
|
||||||
|
HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }}
|
||||||
|
HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
check_tag() {
|
||||||
|
local svc="$1" ver="$2"
|
||||||
|
local url="https://${HARBOR_HOST}/api/v2.0/projects/${HARBOR_PROJECT}/repositories/${svc}/artifacts/${ver}/tags"
|
||||||
|
local code
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" -u "${HARBOR_USERNAME}:${HARBOR_PASSWORD}" "${url}")
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
echo "::error::Tag ${HARBOR_PROJECT}/${svc}:${ver} already exists in Harbor. Bump ${svc}/package.json before merging."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$code" != "404" ]; then
|
||||||
|
echo "::warning::Unexpected status ${code} checking ${url} — proceeding."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
check_tag backend "${BACKEND_VERSION}"
|
||||||
|
check_tag frontend "${FRONTEND_VERSION}"
|
||||||
|
|
||||||
|
- name: Log in to Harbor
|
||||||
|
env:
|
||||||
|
HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }}
|
||||||
|
HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
echo "${HARBOR_PASSWORD}" | docker login "${HARBOR_HOST}" -u "${HARBOR_USERNAME}" --password-stdin
|
||||||
|
|
||||||
|
- name: Tag and push backend image
|
||||||
|
run: |
|
||||||
|
REPO="${HARBOR_HOST}/${HARBOR_PROJECT}/backend"
|
||||||
|
docker tag tehriehlbudget-backend:${SHA_SHORT} ${REPO}:${BACKEND_VERSION}
|
||||||
|
docker tag tehriehlbudget-backend:${SHA_SHORT} ${REPO}:${SHA_SHORT}
|
||||||
|
docker tag tehriehlbudget-backend:${SHA_SHORT} ${REPO}:latest
|
||||||
|
docker push ${REPO}:${BACKEND_VERSION}
|
||||||
|
docker push ${REPO}:${SHA_SHORT}
|
||||||
|
docker push ${REPO}:latest
|
||||||
|
|
||||||
|
- name: Tag and push frontend image
|
||||||
|
run: |
|
||||||
|
REPO="${HARBOR_HOST}/${HARBOR_PROJECT}/frontend"
|
||||||
|
docker tag tehriehlbudget-frontend:${SHA_SHORT} ${REPO}:${FRONTEND_VERSION}
|
||||||
|
docker tag tehriehlbudget-frontend:${SHA_SHORT} ${REPO}:${SHA_SHORT}
|
||||||
|
docker tag tehriehlbudget-frontend:${SHA_SHORT} ${REPO}:latest
|
||||||
|
docker push ${REPO}:${FRONTEND_VERSION}
|
||||||
|
docker push ${REPO}:${SHA_SHORT}
|
||||||
|
docker push ${REPO}:latest
|
||||||
|
|
||||||
|
- name: Push back per-package git tags
|
||||||
|
env:
|
||||||
|
# Auto-injected per-run token; has push permission to this repo.
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git config user.name "gitea-actions[bot]"
|
||||||
|
git config user.email "gitea-actions[bot]@tehriehldeal.com"
|
||||||
|
git tag "backend-v${BACKEND_VERSION}" -m "backend ${BACKEND_VERSION}" 2>/dev/null || true
|
||||||
|
git tag "frontend-v${FRONTEND_VERSION}" -m "frontend ${FRONTEND_VERSION}" 2>/dev/null || true
|
||||||
|
ORIGIN="$(git remote get-url origin | sed -E "s#https?://([^/]+)#https://${GITEA_TOKEN}@\1#")"
|
||||||
|
# A failed tag push shouldn't undo a successful image push — log
|
||||||
|
# and proceed so we don't poison subsequent retries.
|
||||||
|
git push "$ORIGIN" --tags || echo "::warning::Tag push to origin failed — images are pushed; create the tags manually if needed."
|
||||||
|
|
||||||
|
- name: Log out of Harbor
|
||||||
|
if: always()
|
||||||
|
run: docker logout "${HARBOR_HOST}" || true
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
ARG NODE_VERSION=20
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat openssl
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||||
|
WORKDIR /repo
|
||||||
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
|
COPY tehriehlbudget-backend/package.json tehriehlbudget-backend/
|
||||||
|
COPY tehriehlbudget-frontend/package.json tehriehlbudget-frontend/
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile --filter tehriehlbudget-backend...
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
WORKDIR /repo
|
||||||
|
COPY tehriehlbudget-backend/ tehriehlbudget-backend/
|
||||||
|
RUN pnpm --filter tehriehlbudget-backend exec prisma generate
|
||||||
|
RUN pnpm --filter tehriehlbudget-backend run build
|
||||||
|
|
||||||
|
FROM deps AS prod-deps
|
||||||
|
WORKDIR /repo
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile --prod --filter tehriehlbudget-backend...
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS runtime
|
||||||
|
RUN apk add --no-cache libc6-compat openssl tini
|
||||||
|
WORKDIR /app
|
||||||
|
RUN addgroup -S nodeapp && adduser -S nodeapp -G nodeapp
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=build --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/dist ./dist
|
||||||
|
COPY --from=build --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/prisma ./prisma
|
||||||
|
COPY --from=build --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/package.json ./package.json
|
||||||
|
COPY --from=prod-deps --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/node_modules ./node_modules
|
||||||
|
# Overlay generated Prisma client from the build stage (the prod-deps stage
|
||||||
|
# pruned the `prisma` CLI devDep, which removes the client during install).
|
||||||
|
COPY --from=build --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=build --chown=nodeapp:nodeapp /repo/tehriehlbudget-backend/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
USER nodeapp
|
||||||
|
EXPOSE 3000
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
CMD ["node", "dist/main"]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tehriehlbudget-backend",
|
"name": "tehriehlbudget-backend",
|
||||||
"version": "0.0.1",
|
"version": "0.1.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
ARG NODE_VERSION=20
|
||||||
|
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS deps
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||||
|
WORKDIR /repo
|
||||||
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
|
COPY tehriehlbudget-backend/package.json tehriehlbudget-backend/
|
||||||
|
COPY tehriehlbudget-frontend/package.json tehriehlbudget-frontend/
|
||||||
|
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile --filter tehriehlbudget-frontend...
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
WORKDIR /repo
|
||||||
|
COPY tehriehlbudget-frontend/ tehriehlbudget-frontend/
|
||||||
|
# Build with no VITE_* env: import.meta.env values resolve to "" so the bundle
|
||||||
|
# carries no compile-time secrets. window.__RUNTIME_CONFIG__ supplies them.
|
||||||
|
RUN pnpm --filter tehriehlbudget-frontend run build
|
||||||
|
|
||||||
|
FROM nginxinc/nginx-unprivileged:1.27-alpine AS runtime
|
||||||
|
USER root
|
||||||
|
RUN apk add --no-cache gettext
|
||||||
|
COPY --from=build --chown=nginx:nginx /repo/tehriehlbudget-frontend/dist /usr/share/nginx/html
|
||||||
|
COPY --chown=nginx:nginx tehriehlbudget-frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --chown=nginx:nginx tehriehlbudget-frontend/docker-entrypoint.sh /docker-entrypoint.d/40-render-config.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-render-config.sh
|
||||||
|
USER nginx
|
||||||
|
EXPOSE 8080
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Render runtime config from container env vars. The explicit variable list
|
||||||
|
# prevents envsubst from touching any other ${...} occurrences in static files.
|
||||||
|
envsubst '${VITE_SUPABASE_URL} ${VITE_SUPABASE_ANON_KEY} ${VITE_API_URL}' \
|
||||||
|
< /usr/share/nginx/html/config.template.js \
|
||||||
|
> /usr/share/nginx/html/config.js
|
||||||
@@ -25,6 +25,13 @@ export default defineConfig([
|
|||||||
// sync in PWAUpdatePrompt) trip it. Track these as warnings until
|
// sync in PWAUpdatePrompt) trip it. Track these as warnings until
|
||||||
// we can refactor each one with proper visual testing.
|
// we can refactor each one with proper visual testing.
|
||||||
'react-hooks/set-state-in-effect': 'warn',
|
'react-hooks/set-state-in-effect': 'warn',
|
||||||
|
// Allow underscore-prefixed args/vars as the standard signal for
|
||||||
|
// intentionally-unused parameters (e.g. typed callback signatures
|
||||||
|
// on mocks where the body doesn't reference the arg).
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<script src="/config.js"></script>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Compress text-ish assets on the fly
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
# Block dotfiles served from the static root
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runtime config is rewritten at every container start; never cache it
|
||||||
|
location = /config.js {
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# index.html must always be revalidated so a deploy is picked up
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vite emits hashed filenames into /assets — safe to cache aggressively
|
||||||
|
location /assets/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback: route everything else through index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tehriehlbudget-frontend",
|
"name": "tehriehlbudget-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
window.__RUNTIME_CONFIG__ = {};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
window.__RUNTIME_CONFIG__ = {
|
||||||
|
VITE_SUPABASE_URL: '${VITE_SUPABASE_URL}',
|
||||||
|
VITE_SUPABASE_ANON_KEY: '${VITE_SUPABASE_ANON_KEY}',
|
||||||
|
VITE_API_URL: '${VITE_API_URL}',
|
||||||
|
};
|
||||||
@@ -11,7 +11,7 @@ vi.mock('@/lib/supabase', () => ({
|
|||||||
|
|
||||||
import { ReceiptViewer } from './ReceiptViewer';
|
import { ReceiptViewer } from './ReceiptViewer';
|
||||||
|
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const originalCreateObjectURL = URL.createObjectURL;
|
const originalCreateObjectURL = URL.createObjectURL;
|
||||||
const originalRevokeObjectURL = URL.revokeObjectURL;
|
const originalRevokeObjectURL = URL.revokeObjectURL;
|
||||||
|
|
||||||
@@ -20,13 +20,13 @@ const blobUrl = 'blob:mock-url';
|
|||||||
describe('ReceiptViewer', () => {
|
describe('ReceiptViewer', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGetSession.mockReset();
|
mockGetSession.mockReset();
|
||||||
global.fetch = vi.fn();
|
globalThis.fetch = vi.fn();
|
||||||
URL.createObjectURL = vi.fn(() => blobUrl);
|
URL.createObjectURL = vi.fn(() => blobUrl);
|
||||||
URL.revokeObjectURL = vi.fn();
|
URL.revokeObjectURL = vi.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
global.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
URL.createObjectURL = originalCreateObjectURL;
|
URL.createObjectURL = originalCreateObjectURL;
|
||||||
URL.revokeObjectURL = originalRevokeObjectURL;
|
URL.revokeObjectURL = originalRevokeObjectURL;
|
||||||
});
|
});
|
||||||
@@ -38,7 +38,7 @@ describe('ReceiptViewer', () => {
|
|||||||
|
|
||||||
it('renders an <img> when the loaded blob is an image', async () => {
|
it('renders an <img> when the loaded blob is an image', async () => {
|
||||||
mockGetSession.mockResolvedValue({ data: { session: { access_token: 't' } } });
|
mockGetSession.mockResolvedValue({ data: { session: { access_token: 't' } } });
|
||||||
(global.fetch as any).mockResolvedValue({
|
(globalThis.fetch as any).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
blob: async () => new Blob(['x'], { type: 'image/jpeg' }),
|
blob: async () => new Blob(['x'], { type: 'image/jpeg' }),
|
||||||
});
|
});
|
||||||
@@ -51,7 +51,7 @@ describe('ReceiptViewer', () => {
|
|||||||
|
|
||||||
it('renders an iframe for PDF receipts', async () => {
|
it('renders an iframe for PDF receipts', async () => {
|
||||||
mockGetSession.mockResolvedValue({ data: { session: null } });
|
mockGetSession.mockResolvedValue({ data: { session: null } });
|
||||||
(global.fetch as any).mockResolvedValue({
|
(globalThis.fetch as any).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
blob: async () => new Blob(['x'], { type: 'application/pdf' }),
|
blob: async () => new Blob(['x'], { type: 'application/pdf' }),
|
||||||
});
|
});
|
||||||
@@ -65,7 +65,7 @@ describe('ReceiptViewer', () => {
|
|||||||
|
|
||||||
it('shows the load error when the file fetch fails', async () => {
|
it('shows the load error when the file fetch fails', async () => {
|
||||||
mockGetSession.mockResolvedValue({ data: { session: null } });
|
mockGetSession.mockResolvedValue({ data: { session: null } });
|
||||||
(global.fetch as any).mockResolvedValue({ ok: false, status: 404 });
|
(globalThis.fetch as any).mockResolvedValue({ ok: false, status: 404 });
|
||||||
|
|
||||||
render(<ReceiptViewer receiptPath="receipts/user-1/missing.jpg" onClose={() => {}} />);
|
render(<ReceiptViewer receiptPath="receipts/user-1/missing.jpg" onClose={() => {}} />);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Download, ExternalLink } from 'lucide-react';
|
import { Download, ExternalLink } from 'lucide-react';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { getConfig } from '@/lib/runtime-config';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
receiptPath: string | null;
|
receiptPath: string | null;
|
||||||
@@ -37,7 +38,7 @@ export function ReceiptViewer({ receiptPath, onClose }: Props) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { userId, filename } = parseReceiptPath(receiptPath);
|
const { userId, filename } = parseReceiptPath(receiptPath);
|
||||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
const apiUrl = getConfig('VITE_API_URL') || 'http://localhost:3000';
|
||||||
const { data: session } = await supabase.auth.getSession();
|
const { data: session } = await supabase.auth.getSession();
|
||||||
const token = session.session?.access_token;
|
const token = session.session?.access_token;
|
||||||
|
|
||||||
|
|||||||
@@ -20,16 +20,16 @@ const categories = [
|
|||||||
{ id: 'cat-2', name: 'Dining' } as any,
|
{ id: 'cat-2', name: 'Dining' } as any,
|
||||||
];
|
];
|
||||||
|
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
describe('TransactionForm', () => {
|
describe('TransactionForm', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGetSession.mockReset();
|
mockGetSession.mockReset();
|
||||||
global.fetch = vi.fn();
|
globalThis.fetch = vi.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
global.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the submit button disabled until a primary account is picked', () => {
|
it('keeps the submit button disabled until a primary account is picked', () => {
|
||||||
@@ -152,7 +152,7 @@ describe('TransactionForm', () => {
|
|||||||
mockGetSession.mockResolvedValue({
|
mockGetSession.mockResolvedValue({
|
||||||
data: { session: { access_token: 'tok' } },
|
data: { session: { access_token: 'tok' } },
|
||||||
});
|
});
|
||||||
(global.fetch as any).mockResolvedValue({
|
(globalThis.fetch as any).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ path: 'receipts/u/abc.jpg' }),
|
json: async () => ({ path: 'receipts/u/abc.jpg' }),
|
||||||
});
|
});
|
||||||
@@ -185,8 +185,8 @@ describe('TransactionForm', () => {
|
|||||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||||
|
|
||||||
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
|
await waitFor(() => expect(globalThis.fetch).toHaveBeenCalled());
|
||||||
const fetchCall = (global.fetch as any).mock.calls[0];
|
const fetchCall = (globalThis.fetch as any).mock.calls[0];
|
||||||
expect(fetchCall[0]).toMatch(/\/files\/upload$/);
|
expect(fetchCall[0]).toMatch(/\/files\/upload$/);
|
||||||
expect(fetchCall[1].headers.Authorization).toBe('Bearer tok');
|
expect(fetchCall[1].headers.Authorization).toBe('Bearer tok');
|
||||||
|
|
||||||
@@ -228,6 +228,6 @@ describe('TransactionForm', () => {
|
|||||||
expect.objectContaining({ receiptPath: 'receipts/u/old.jpg' }),
|
expect.objectContaining({ receiptPath: 'receipts/u/old.jpg' }),
|
||||||
);
|
);
|
||||||
// No upload should have been attempted
|
// No upload should have been attempted
|
||||||
expect(global.fetch).not.toHaveBeenCalled();
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Paperclip } from 'lucide-react';
|
import { Paperclip } from 'lucide-react';
|
||||||
import { supabase } from '@/lib/supabase';
|
import { supabase } from '@/lib/supabase';
|
||||||
|
import { getConfig } from '@/lib/runtime-config';
|
||||||
import { toDateInputValue, todayInputValue } from '@/lib/dates';
|
import { toDateInputValue, todayInputValue } from '@/lib/dates';
|
||||||
|
|
||||||
const TRANSACTION_TYPES = ['INCOME', 'EXPENSE', 'TRANSFER'] as const;
|
const TRANSACTION_TYPES = ['INCOME', 'EXPENSE', 'TRANSFER'] as const;
|
||||||
@@ -85,7 +86,7 @@ export function TransactionForm({
|
|||||||
formData.append('file', receiptFile);
|
formData.append('file', receiptFile);
|
||||||
const { data: session } = await supabase.auth.getSession();
|
const { data: session } = await supabase.auth.getSession();
|
||||||
const token = session.session?.access_token;
|
const token = session.session?.access_token;
|
||||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
const apiUrl = getConfig('VITE_API_URL') || 'http://localhost:3000';
|
||||||
const res = await fetch(`${apiUrl}/files/upload`, {
|
const res = await fetch(`${apiUrl}/files/upload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
|||||||
@@ -10,23 +10,23 @@ vi.mock('./supabase', () => ({
|
|||||||
|
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
describe('api', () => {
|
describe('api', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGetSession.mockReset();
|
mockGetSession.mockReset();
|
||||||
global.fetch = vi.fn();
|
globalThis.fetch = vi.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
global.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('attaches a Bearer token when a Supabase session is present', async () => {
|
it('attaches a Bearer token when a Supabase session is present', async () => {
|
||||||
mockGetSession.mockResolvedValue({
|
mockGetSession.mockResolvedValue({
|
||||||
data: { session: { access_token: 'tok-abc' } },
|
data: { session: { access_token: 'tok-abc' } },
|
||||||
});
|
});
|
||||||
(global.fetch as any).mockResolvedValue({
|
(globalThis.fetch as any).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
json: async () => ({ ok: true }),
|
json: async () => ({ ok: true }),
|
||||||
@@ -34,13 +34,13 @@ describe('api', () => {
|
|||||||
|
|
||||||
await api.get('/anything');
|
await api.get('/anything');
|
||||||
|
|
||||||
const [, init] = (global.fetch as any).mock.calls[0];
|
const [, init] = (globalThis.fetch as any).mock.calls[0];
|
||||||
expect(init.headers.Authorization).toBe('Bearer tok-abc');
|
expect(init.headers.Authorization).toBe('Bearer tok-abc');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits the Authorization header when there is no session', async () => {
|
it('omits the Authorization header when there is no session', async () => {
|
||||||
mockGetSession.mockResolvedValue({ data: { session: null } });
|
mockGetSession.mockResolvedValue({ data: { session: null } });
|
||||||
(global.fetch as any).mockResolvedValue({
|
(globalThis.fetch as any).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
json: async () => ({}),
|
json: async () => ({}),
|
||||||
@@ -48,13 +48,13 @@ describe('api', () => {
|
|||||||
|
|
||||||
await api.get('/anything');
|
await api.get('/anything');
|
||||||
|
|
||||||
const [, init] = (global.fetch as any).mock.calls[0];
|
const [, init] = (globalThis.fetch as any).mock.calls[0];
|
||||||
expect(init.headers.Authorization).toBeUndefined();
|
expect(init.headers.Authorization).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns parsed JSON on success', async () => {
|
it('returns parsed JSON on success', async () => {
|
||||||
mockGetSession.mockResolvedValue({ data: { session: null } });
|
mockGetSession.mockResolvedValue({ data: { session: null } });
|
||||||
(global.fetch as any).mockResolvedValue({
|
(globalThis.fetch as any).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
json: async () => ({ id: 1 }),
|
json: async () => ({ id: 1 }),
|
||||||
@@ -66,7 +66,7 @@ describe('api', () => {
|
|||||||
|
|
||||||
it('returns undefined for a 204 No Content response', async () => {
|
it('returns undefined for a 204 No Content response', async () => {
|
||||||
mockGetSession.mockResolvedValue({ data: { session: null } });
|
mockGetSession.mockResolvedValue({ data: { session: null } });
|
||||||
(global.fetch as any).mockResolvedValue({
|
(globalThis.fetch as any).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 204,
|
status: 204,
|
||||||
json: async () => {
|
json: async () => {
|
||||||
@@ -80,7 +80,7 @@ describe('api', () => {
|
|||||||
|
|
||||||
it('throws with the server-supplied message on a JSON error response', async () => {
|
it('throws with the server-supplied message on a JSON error response', async () => {
|
||||||
mockGetSession.mockResolvedValue({ data: { session: null } });
|
mockGetSession.mockResolvedValue({ data: { session: null } });
|
||||||
(global.fetch as any).mockResolvedValue({
|
(globalThis.fetch as any).mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 400,
|
status: 400,
|
||||||
statusText: 'Bad Request',
|
statusText: 'Bad Request',
|
||||||
@@ -92,7 +92,7 @@ describe('api', () => {
|
|||||||
|
|
||||||
it('falls back to statusText when the error body is not JSON', async () => {
|
it('falls back to statusText when the error body is not JSON', async () => {
|
||||||
mockGetSession.mockResolvedValue({ data: { session: null } });
|
mockGetSession.mockResolvedValue({ data: { session: null } });
|
||||||
(global.fetch as any).mockResolvedValue({
|
(globalThis.fetch as any).mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
statusText: 'Internal Server Error',
|
statusText: 'Internal Server Error',
|
||||||
@@ -106,7 +106,7 @@ describe('api', () => {
|
|||||||
|
|
||||||
it('JSON-stringifies the body on POST/PATCH', async () => {
|
it('JSON-stringifies the body on POST/PATCH', async () => {
|
||||||
mockGetSession.mockResolvedValue({ data: { session: null } });
|
mockGetSession.mockResolvedValue({ data: { session: null } });
|
||||||
(global.fetch as any).mockResolvedValue({
|
(globalThis.fetch as any).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
json: async () => ({}),
|
json: async () => ({}),
|
||||||
@@ -114,7 +114,7 @@ describe('api', () => {
|
|||||||
|
|
||||||
await api.post('/x', { name: 'kevin' });
|
await api.post('/x', { name: 'kevin' });
|
||||||
|
|
||||||
const [, init] = (global.fetch as any).mock.calls[0];
|
const [, init] = (globalThis.fetch as any).mock.calls[0];
|
||||||
expect(init.method).toBe('POST');
|
expect(init.method).toBe('POST');
|
||||||
expect(init.body).toBe(JSON.stringify({ name: 'kevin' }));
|
expect(init.body).toBe(JSON.stringify({ name: 'kevin' }));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
|
import { getConfig } from './runtime-config';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
const API_URL = getConfig('VITE_API_URL') || 'http://localhost:3000';
|
||||||
|
|
||||||
async function getAuthHeaders(): Promise<Record<string, string>> {
|
async function getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
const { data } = await supabase.auth.getSession();
|
const { data } = await supabase.auth.getSession();
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
type RuntimeKey = 'VITE_SUPABASE_URL' | 'VITE_SUPABASE_ANON_KEY' | 'VITE_API_URL';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__RUNTIME_CONFIG__?: Partial<Record<RuntimeKey, string>>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfig(key: RuntimeKey): string {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const fromWindow = window.__RUNTIME_CONFIG__?.[key];
|
||||||
|
if (fromWindow !== undefined && fromWindow !== '') return fromWindow;
|
||||||
|
}
|
||||||
|
const fromBuild = (import.meta.env as Record<string, string | undefined>)[key];
|
||||||
|
return fromBuild ?? '';
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { getConfig } from './runtime-config';
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
const supabaseUrl = getConfig('VITE_SUPABASE_URL');
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
const supabaseAnonKey = getConfig('VITE_SUPABASE_ANON_KEY');
|
||||||
|
|
||||||
export const STAY_LOGGED_IN_KEY = 'auth:stay_logged_in';
|
export const STAY_LOGGED_IN_KEY = 'auth:stay_logged_in';
|
||||||
|
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export function Activity() {
|
|||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
setFilters((f) => ({
|
setFilters((f) => ({
|
||||||
...f,
|
...f,
|
||||||
accountId: v === 'all' ? undefined : v,
|
accountId: !v || v === 'all' ? undefined : v,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -300,9 +300,10 @@ export function Dashboard() {
|
|||||||
cy="50%"
|
cy="50%"
|
||||||
outerRadius={80}
|
outerRadius={80}
|
||||||
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
|
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
|
||||||
onClick={(entry: { categoryId?: string }) => {
|
onClick={(entry) => {
|
||||||
if (entry.categoryId) {
|
const categoryId = (entry as unknown as { categoryId?: string }).categoryId;
|
||||||
navigate(`/transactions?categoryId=${entry.categoryId}`);
|
if (categoryId) {
|
||||||
|
navigate(`/transactions?categoryId=${categoryId}`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const { mockSupabase } = vi.hoisted(() => ({
|
|||||||
signUp: vi.fn(),
|
signUp: vi.fn(),
|
||||||
signOut: vi.fn(),
|
signOut: vi.fn(),
|
||||||
getSession: vi.fn(),
|
getSession: vi.fn(),
|
||||||
onAuthStateChange: vi.fn(() => ({
|
onAuthStateChange: vi.fn((_cb?: (event: string, session: unknown) => void) => ({
|
||||||
data: { subscription: { unsubscribe: vi.fn() } },
|
data: { subscription: { unsubscribe: vi.fn() } },
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
|||||||
+10
@@ -1,3 +1,13 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
/// <reference types="vite-plugin-pwa/react" />
|
/// <reference types="vite-plugin-pwa/react" />
|
||||||
/// <reference types="vite-plugin-pwa/info" />
|
/// <reference types="vite-plugin-pwa/info" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_SUPABASE_URL: string;
|
||||||
|
readonly VITE_SUPABASE_ANON_KEY: string;
|
||||||
|
readonly VITE_API_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,13 +43,21 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
// Avoid caching API responses — always go to network
|
// /config.js is rendered at container start from runtime env vars; it
|
||||||
navigateFallbackDenylist: [/^\/api\//],
|
// must never be precached or cached at runtime, otherwise a stale build
|
||||||
|
// can keep serving an old config after a restart.
|
||||||
|
globIgnores: ['**/config.js', '**/config.template.js'],
|
||||||
|
// Avoid caching API responses or runtime config — always go to network
|
||||||
|
navigateFallbackDenylist: [/^\/api\//, /^\/config\.js$/],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
|
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
|
||||||
handler: 'NetworkOnly',
|
handler: 'NetworkOnly',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
urlPattern: ({ url }) => url.pathname === '/config.js',
|
||||||
|
handler: 'NetworkOnly',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
urlPattern: ({ request }) => request.destination === 'image',
|
urlPattern: ({ request }) => request.destination === 'image',
|
||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
|
|||||||
Reference in New Issue
Block a user