feat(branding): implement dynamic branding in service worker and reports (#383)

* feat(branding): implement dynamic branding in service worker and reports

* refactor(service-worker): remove push notification handling code

* feat(service-worker): implement dynamic branding in service worker and related scripts
This commit is contained in:
Mattsson
2026-04-30 17:17:41 +02:00
committed by GitHub
parent c86dbdc60d
commit 5e1b0f791d
11 changed files with 81 additions and 9 deletions
+4
View File
@@ -51,6 +51,10 @@ supabase/.temp/
# swarm audit reports (generated by /swarm)
.swarm/
# generated service worker (built from public/sw.template.js by
# scripts/inject-public-branding.mjs — runs via predev/prebuild)
/public/sw.js
# dev docs (internal reference, not published)
/dev_docs
+3
View File
@@ -30,6 +30,9 @@ ENV NEXT_PUBLIC_APP_URL=__NEXT_PUBLIC_APP_URL__
ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=__NEXT_PUBLIC_VAPID_PUBLIC_KEY__
ENV NEXT_PUBLIC_SELF_HOSTED=__NEXT_PUBLIC_SELF_HOSTED__
ENV NEXT_PUBLIC_REQUIRE_MFA=__NEXT_PUBLIC_REQUIRE_MFA__
# Keep the branding placeholder intact through prebuild's inject script so
# docker-entrypoint.sh can substitute the runtime value into public/sw.js.
ENV NEXT_PUBLIC_BRANDING_APP_NAME=__NEXT_PUBLIC_BRANDING_APP_NAME__
ENV NEXT_TELEMETRY_DISABLED=1
+12 -1
View File
@@ -53,6 +53,17 @@ Resolution order (last wins): **defaults → env vars → extension override**.
`NEXT_PUBLIC_*` env vars are inlined at build time. Changing them requires a fresh `npm run build` to propagate.
`NEXT_PUBLIC_BRANDING_APP_NAME` also stamps the service worker push-notification fallback title in `public/sw.js`. This happens at build time for Vercel/local builds (via `scripts/inject-public-branding.mjs`, run from `prebuild`) and at container start for Docker (via `docker-entrypoint.sh`).
### Email / Resend (when `email` or `invoice-inbox` extensions are enabled)
| Env var | Purpose |
|---|---|
| `RESEND_API_KEY` | Resend API key — required for both outbound mail and the inbox webhook |
| `RESEND_FROM_EMAIL` | Default `From` address (e.g. `noreply@your-brand.se`); also used as the address you From-spoof through Resend |
| `RESEND_INBOUND_DOMAIN` | Domain used to compose per-company invoice-inbox addresses: `{local-part}@{RESEND_INBOUND_DOMAIN}` |
| `RESEND_INBOUND_WEBHOOK_SECRET` | Verifies the `/inbound` webhook signature from Resend |
## Things you MUST NOT change
These are stable contracts. Renaming them breaks existing data, sessions, or external clients (npm package consumers, MCP connectors, browser sessions, invite links). Leave them alone in your fork:
@@ -75,9 +86,9 @@ A few things that look brand-related but are configured elsewhere:
- **Resend sending domain** — verify `noreply@your-brand.se` (or wherever) in Resend, set `RESEND_FROM_EMAIL`.
- **DNS / domain** — point `app.your-brand.se` at your Vercel deployment.
- **OAuth redirect allowlist for MCP** — `app/api/mcp-oauth/authorize/route.ts` lists `claude.ai/api/*`, `claude.com/api/*`, and localhost. Your domain is the OAuth issuer, not a redirect target — no change needed unless you're integrating with new MCP clients.
- **Service worker push notification fallback title** (`public/sw.js`) — currently hardcoded as `'Ekonomi'`. Service workers can't read env vars at runtime; change the file directly in your fork if it matters.
- **iCal feed PRODID** (`lib/calendar/ics-generator.ts`) — defaults to `erp-base.se`, callers may pass their domain.
- **`NEXT_PUBLIC_APP_URL`** — used as the OAuth issuer. Set this to your domain (e.g. `https://app.your-brand.se`).
- **Skatteverket submission identity** — `extensions/general/skatteverket/lib/api-client.ts` does not set a custom `User-Agent`; submissions go out with the Node/Vercel runtime default. If your deployment needs to identify itself to Skatteverket under a different brand, that's a future enhancement (env var + header), not something the current branding service covers.
## Staying in sync with upstream
-1
View File
@@ -36,7 +36,6 @@ export async function GET(request: Request) {
fiscal_period_id: periodId,
company_name: company.company_name || 'Unknown',
org_number: company.org_number,
program_name: 'ERPBase',
})
// Return as downloadable file
+9
View File
@@ -40,4 +40,13 @@ if [ -d /app/.next/static ]; then
{} +
fi
# Stamp the service worker fallback notification title with the brand name.
# public/sw.js is served as a static file (not bundled by Next), so NEXT_PUBLIC_*
# inlining doesn't reach it — substitute the placeholder here at container start.
if [ -f /app/public/sw.js ]; then
sed -i \
-e "s|__NEXT_PUBLIC_BRANDING_APP_NAME__|${NEXT_PUBLIC_BRANDING_APP_NAME:-Gnubok}|g" \
/app/public/sw.js
fi
exec "$@"
-2
View File
@@ -118,7 +118,6 @@ export async function generateFullArchive(
company_name: company.company_name || 'Unknown',
trade_name: company.trade_name,
org_number: company.org_number,
program_name: 'ERPBase',
})
sieFolder.file(`${periodLabel(period)}.se`, sie)
@@ -135,7 +134,6 @@ export async function generateFullArchive(
company_name: company.company_name || 'Unknown',
trade_name: company.trade_name,
org_number: company.org_number,
program_name: 'ERPBase',
})
zip.file('bokforing.se', sie)
+6 -1
View File
@@ -1,4 +1,9 @@
import type { NEDeclaration, SRUFile, SRURecord } from '@/lib/reports/ne-bilaga/types'
import { getBranding } from '@/lib/branding/service'
function sanitizeString(str: string): string {
return str.replace(/#/g, '').replace(/[\r\n]/g, ' ').substring(0, 250)
}
/**
* SRU File Generator
@@ -54,7 +59,7 @@ export function generateSRUFile(declaration: NEDeclaration): SRUFile {
// File header
records.push({ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' })
records.push({ fieldCode: 'SESSION', value: '1' })
records.push({ fieldCode: 'PROGRAMNAMN', value: 'ERPBase' })
records.push({ fieldCode: 'PROGRAMNAMN', value: sanitizeString(getBranding().appName) })
records.push({ fieldCode: 'PROGRAMVERSION', value: '1.0' })
records.push({
fieldCode: 'SKAPAT',
+7 -1
View File
@@ -1,7 +1,12 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { getBranding } from '@/lib/branding/service'
import type { SIEExportOptions, JournalEntry, JournalEntryLine, BASAccount } from '@/types'
function sanitizeProgramName(str: string): string {
return str.replace(/"/g, '').replace(/[\r\n]/g, ' ').substring(0, 60)
}
/**
* Generate SIE4 export file
*
@@ -81,7 +86,8 @@ export async function generateSIEExport(
lines.push('#FLAGGA 0')
lines.push('#FORMAT PC8')
lines.push('#SIETYP 4')
lines.push(`#PROGRAM "${options.program_name || 'ERPBase'}" "1.0"`)
const programName = sanitizeProgramName(options.program_name || getBranding().appName)
lines.push(`#PROGRAM "${programName}" "1.0"`)
lines.push(`#GEN ${formatSIEDate(now)}`)
if (options.org_number) {
+2 -2
View File
@@ -5,9 +5,9 @@
"license": "AGPL-3.0-or-later",
"scripts": {
"setup:extensions": "npx tsx scripts/generate-extension-registry.ts",
"predev": "npm run setup:extensions",
"predev": "npm run setup:extensions && node scripts/inject-public-branding.mjs",
"dev": "next dev",
"prebuild": "npm run setup:extensions",
"prebuild": "npm run setup:extensions && node scripts/inject-public-branding.mjs",
"build": "next build",
"start": "next start",
"lint": "eslint",
+5 -1
View File
@@ -1,6 +1,10 @@
/**
* Service Worker for Push Notifications
* Handles incoming push notifications and notification click events
*
* NOTE: This is the source template. The deployed file at public/sw.js is
* generated by scripts/inject-public-branding.mjs (runs via predev/prebuild)
* and is gitignored. Edit this template, not the generated file.
*/
// Handle push events
@@ -32,7 +36,7 @@ self.addEventListener('push', (event) => {
}
event.waitUntil(
self.registration.showNotification(title || 'Ekonomi', options)
self.registration.showNotification(title || '__NEXT_PUBLIC_BRANDING_APP_NAME__', options)
)
})
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
/**
* Stamps public/sw.js from public/sw.template.js with the runtime brand name.
*
* The service worker is served as a static file (Next.js does not bundle
* public/), so NEXT_PUBLIC_* inlining doesn't reach it. We generate sw.js
* from a template at build time (Vercel/local) so the deployed file shows
* the configured brand name.
*
* Docker uses a different strategy: the builder stage exports
* NEXT_PUBLIC_BRANDING_APP_NAME=__NEXT_PUBLIC_BRANDING_APP_NAME__ so the
* placeholder survives the build, and docker-entrypoint.sh substitutes the
* runtime value via sed at container start.
*
* public/sw.js is gitignored — the source of truth is the template.
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
const TEMPLATE_PATH = join(process.cwd(), 'public', 'sw.template.js')
const OUTPUT_PATH = join(process.cwd(), 'public', 'sw.js')
const PLACEHOLDER = '__NEXT_PUBLIC_BRANDING_APP_NAME__'
const value = process.env.NEXT_PUBLIC_BRANDING_APP_NAME || 'Gnubok'
if (!existsSync(TEMPLATE_PATH)) {
console.log(`[inject-public-branding] ${TEMPLATE_PATH} not found, skipping`)
process.exit(0)
}
const template = readFileSync(TEMPLATE_PATH, 'utf8')
const output = template.split(PLACEHOLDER).join(value)
writeFileSync(OUTPUT_PATH, output)
console.log(`[inject-public-branding] generated public/sw.js with brand "${value}"`)