573feea890
* perf: cross-system snappiness batch (middleware, loading states, bundle)
Middleware: resolve the active company at most once per request and run
the user_preferences + first-membership queries in parallel, cutting 1-2
sequential DB round trips from every authenticated page load.
Loading states: add loading.tsx skeletons for the six highest-traffic
dashboard routes, render the real salary page header during load instead
of a full-page skeleton, replace the blank fallback={null} Suspense
flashes on customers/articles, and reshape the settings skeleton to
match the actual form layout.
Bundle: defer recharts chart components via next/dynamic on the KPI page
and report views, replace the import page's framer-motion marching-ants
border with a CSS keyframe, and enable optimizePackageImports for
recharts/date-fns/framer-motion.
Transactions: extract the potential-match lookups into a shared parallel
helper; a single-query PostgREST embed is blocked until the
potential_supplier_invoice_id FK exists in prod (see DECISIONS.md).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(transactions): log potential-match query failures instead of dropping them
A DB error in the invoice/supplier-invoice hint lookups previously
surfaced as "no potential match"; log it so failures are diagnosable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
161 lines
5.7 KiB
TypeScript
161 lines
5.7 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 ?? "";
|
|
|
|
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} 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',
|
|
// 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);
|