Files
accounted/next.config.ts
T
Jakob Wennberg 0626bb6326 feat(app): prompt to reload when a newer deploy is live (#951)
Long-open tabs keep running the JS bundle they first loaded, so a shipped
change can look "missing" (e.g. a new settings field appearing only after a
full reload) until the whole app is reloaded. Add a small, unobtrusive prompt
that detects a newer deploy and offers a one-click reload.

- next.config: inline the deploy's commit SHA into the client bundle as
  NEXT_PUBLIC_BUILD_ID (empty in dev / self-hosted, which disables the check).
- /api/version: public, no-store route returning the running deployment's SHA
  at request time.
- DeployReloadPrompt: compares the two on load, on tab focus, and on a 30-min
  backstop; shows a bottom banner with "Ladda om" on mismatch. Mounted once in
  the root layout. No service worker, degrades to a no-op with no build id.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 22:14:37 +02:00

183 lines
7.0 KiB
TypeScript

import path from "node:path";
import { fileURLToPath } from "node:url";
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
const isDev = process.env.NODE_ENV === "development";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
// WebSocket origin for Supabase Realtime. Hosted projects are covered by the
// wss://*.supabase.co wildcard below, but a SELF-HOSTED Supabase URL is not:
// Realtime opens wss://<supabase-host>/realtime/v1/websocket, and WebKit
// throws synchronously on a CSP-blocked `new WebSocket()`, unmounting the
// dashboard into the error boundary (issue #893). The Docker image bakes the
// __NEXT_PUBLIC_SUPABASE_WS_URL__ sentinel at build time and
// docker-entrypoint.sh substitutes the real value at runtime (a build-time
// https-to-wss replace would only rewrite the sentinel); the fallback derives
// wss:/ws: from the https/http URL for non-Docker builds where the real URL
// is present at build time. Empty supabaseUrl stays empty, mirroring how
// ${supabaseUrl} is interpolated below (extra whitespace is valid in CSP).
const supabaseWsUrl =
process.env.NEXT_PUBLIC_SUPABASE_WS_URL ??
supabaseUrl.replace(/^http(s?):/, "ws$1:");
const cspDirectives = [
"default-src 'self'",
// Recapt: scoped to the two specific hosts the SDK actually contacts:
// `cdn.recapt.app` for the script bundle and `api.recapt.app` for
// ingestion. The previous wildcard (`https://*.recapt.app`) allowed
// exfiltration to any subdomain of recapt.app and is intentionally
// narrowed.
`connect-src 'self' ${supabaseUrl} ${supabaseWsUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com https://api.recapt.app https://cdn.recapt.app`,
`style-src 'self' 'unsafe-inline' https://*.enablebanking.com`,
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com https://cdn.recapt.app`,
"img-src 'self' data: blob: https:",
"font-src 'self'",
"worker-src 'self' blob:",
// object-src must explicitly allow blob:: Chrome's built-in PDF viewer
// renders inline PDFs via an internal <embed>, which falls under
// object-src. Without this, blob:-URL invoice previews (created via
// URL.createObjectURL on /api/invoices/preview-pdf responses) show
// "Det här innehållet har blockerats" in Chrome. Firefox uses PDF.js and
// Edge uses its own viewer, so neither hits this. See crbug.com/271452.
"object-src 'self' blob:",
`frame-src 'self' blob: ${supabaseUrl}`,
"frame-ancestors 'none'",
].join("; ");
const nextConfig: NextConfig = {
output: 'standalone',
// Build id inlined into the client bundle so a running tab can tell when a
// newer deploy is live (see components/system/DeployReloadPrompt). On Vercel
// this is the commit SHA; empty elsewhere (dev / self-hosted), which disables
// the check. The /api/version route reads the same var at runtime to compare.
env: {
NEXT_PUBLIC_BUILD_ID: process.env.VERCEL_GIT_COMMIT_SHA ?? '',
},
// Multiple lockfiles exist above this project (e.g. a parent yarn.lock),
// which makes Turbopack infer the wrong workspace root. Pin it explicitly.
turbopack: {
root: projectRoot,
},
experimental: {
optimizePackageImports: ['recharts', 'date-fns', 'framer-motion'],
},
async redirects() {
return [
{
source: '/nyckeltal',
destination: '/kpi',
permanent: true,
},
// Docs canonicalised to docs.gnubok.se. Every `docs_url` field on the
// v1 error envelope still points at this host; the 308 forwards both
// humans and agents to the docs subdomain without us needing to
// mass-update structured-errors.
{
source: '/docs/api',
destination: 'https://docs.gnubok.se/',
permanent: true,
},
{
source: '/docs/api/:path*',
destination: 'https://docs.gnubok.se/:path*',
permanent: true,
},
{
source: '/llms-full.txt',
destination: 'https://docs.gnubok.se/llms-full.txt',
permanent: true,
},
]
},
async headers() {
// The catch-all excludes /api/documents/:id/inline so the strict
// X-Frame-Options: DENY + frame-ancestors 'none' don't conflict with
// the embeddable override below: Next.js applies every matching
// header rule, and duplicate X-Frame-Options/CSP values trigger
// "Det här innehållet har blockerats" in Chromium browsers.
return [
{
source: "/((?!api/documents/[^/]+/inline$).*)",
headers: [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-Frame-Options",
value: "DENY",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=()",
},
{
key: "Content-Security-Policy",
value: cspDirectives,
},
],
},
// Document inline-preview proxy must be embeddable in same-origin
// iframes (used by the verifikat document preview Sheet). Excluded
// from the catch-all above so these values aren't shadowed by the
// stricter defaults.
//
// CSP is intentionally minimal: only `frame-ancestors 'self'`
// prevents cross-origin clickjacking on the user's documents.
// Adding `object-src 'none'` (or `default-src 'none'`) here breaks
// Chrome's built-in PDF viewer: Chrome renders inline PDFs through
// an internal <embed>, which the directive forbids, surfacing as
// "Det här innehållet har blockerats" in the document preview Sheet.
// Firefox uses PDF.js and Edge uses its own viewer, so neither hits
// this. See crbug.com/271452. X-Content-Type-Options: nosniff plus
// the explicit Content-Type from the route handler already prevent
// MIME-confusion abuse.
{
source: "/api/documents/:id/inline",
headers: [
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "strict-origin-when-cross-origin",
},
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=()",
},
{
key: "Content-Security-Policy",
value: "frame-ancestors 'self'",
},
],
},
];
},
};
export default withNextIntl(nextConfig);