diff --git a/WHITELABEL.md b/WHITELABEL.md new file mode 100644 index 00000000..ad3fc477 --- /dev/null +++ b/WHITELABEL.md @@ -0,0 +1,173 @@ +# Whitelabel fork checklist + +gnubok is whitelabel-friendly: every user-visible brand reference reads from a single `BrandingService` (`lib/branding/service.ts`). If you don't override anything, the app behaves exactly like upstream gnubok. To run your own brand on top of gnubok, fork the repo and override the values you care about. + +## Quick start + +```bash +# 1. Fork erp-mafia/gnubok on GitHub → you/your-brand +# 2. Clone and add upstream remote (one-time) +git clone https://github.com/you/your-brand +cd your-brand +git remote add upstream https://github.com/erp-mafia/gnubok + +# 3. Copy the example branding extension +cp -r extensions/general/_example-branding extensions/general/your-brand +# Edit extensions/general/your-brand/index.ts with your brand values + +# 4. (Optional) Set env vars instead of / in addition to the extension. See "Env vars" below. + +# 5. Enable the extension +# Edit extensions.config.json and add "your-brand" to the array. + +# 6. Run locally +npm run setup:extensions +npm run dev + +# 7. Deploy to your hosting (Vercel, Docker, etc.) +``` + +## Env vars + +All branding can be set via env vars. Public ones use `NEXT_PUBLIC_BRANDING_*` (build-time inlined, available in client components). Server-only ones use `BRANDING_*`. + +| Env var | Field | Default | +|---|---|---| +| `NEXT_PUBLIC_BRANDING_APP_NAME` | `appName` | `Gnubok` | +| `NEXT_PUBLIC_BRANDING_APP_DESCRIPTION` | `appDescription` | `Ekonomihantering` | +| `BRANDING_LEGAL_ENTITY` | `legalEntity` | `Arcim` | +| `BRANDING_SUPPORT_EMAIL` | `supportEmail` | `support@gnubok.se` | +| `BRANDING_PRIVACY_EMAIL` | `privacyEmail` | `privacy@gnubok.se` | +| `BRANDING_SECURITY_EMAIL` | `securityEmail` | `security@arcim.io` | +| `NEXT_PUBLIC_APP_URL` | `appUrl` | `https://app.gnubok.se` | +| `NEXT_PUBLIC_BRANDING_LOGO_PATH` | `logoPath` | `/gnubokiceon-removebg-preview.png` | +| `NEXT_PUBLIC_BRANDING_FAVICON_PATH` | `faviconPath` | `/favicon.ico` | +| `NEXT_PUBLIC_BRANDING_APPLE_ICON_PATH` | `appleTouchIconPath` | `/icons/icon-192.png` | +| `NEXT_PUBLIC_BRANDING_PWA_ICON_BASE` | `pwaIconBasePath` | `/icons` | +| `NEXT_PUBLIC_BRANDING_THEME_COLOR` | `themeColor` | `#304D83` | +| `NEXT_PUBLIC_BRANDING_MANIFEST_THEME_COLOR` | `manifestThemeColor` | `#1a1a1a` | +| `NEXT_PUBLIC_BRANDING_MANIFEST_BG_COLOR` | `manifestBackgroundColor` | `#ffffff` | + +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. + +## 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: + +| Identifier | Where | Why | +|---|---|---| +| `gnubok-company-id` | cookie | Active company context — renaming breaks logged-in sessions | +| `gnubok-invite-token` | cookie | Pre-auth invite token holding — renaming drops in-flight invites | +| `gnubok_sk_` | API key prefix | All issued API keys; existing clients fail validation | +| `gnubok_inv_` | invite token prefix | All sent invite links break | +| `gnubok_*` | MCP tool names (`gnubok_list_invoices`, etc.) | Published MCP API — Claude clients have these cached | +| `gnubok-mcp` | npm package name | Whitelabel users still install `npx gnubok-mcp`. Document `GNUBOK_URL=https://app.your-brand.se/api/extensions/ext/mcp-server/mcp` so they hit your endpoint | +| `GNUBOK_API_KEY` | env var read by `gnubok-mcp` package | Same reason — npm consumer expects this name | + +## What's outside this branding service + +A few things that look brand-related but are configured elsewhere: + +- **Supabase auth emails** (password reset, magic link) — set in the Supabase dashboard for your project, not in code. +- **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`). + +## Staying in sync with upstream + +Add this workflow at `.github/workflows/sync-upstream.yml` to your fork. It runs weekly and opens a PR with upstream changes: + +```yaml +name: Sync from upstream + +on: + schedule: + - cron: '0 3 * * 1' # Mondays 03:00 UTC + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Add upstream and fetch + run: | + git remote add upstream https://github.com/erp-mafia/gnubok + git fetch upstream main + + - name: Create sync branch and merge + id: merge + run: | + BRANCH="sync/upstream-$(date +%Y-%m-%d)" + git checkout -b "$BRANCH" + if git merge --no-edit upstream/main; then + echo "status=clean" >> "$GITHUB_OUTPUT" + else + echo "status=conflict" >> "$GITHUB_OUTPUT" + git merge --abort || true + fi + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + - name: Push and open PR (clean merge) + if: steps.merge.outputs.status == 'clean' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if git diff --quiet origin/main..HEAD; then + echo "Up to date with upstream — nothing to do." + exit 0 + fi + git push origin "${{ steps.merge.outputs.branch }}" + gh pr create \ + --base main \ + --head "${{ steps.merge.outputs.branch }}" \ + --title "Sync from upstream gnubok" \ + --body "Automated weekly sync from \`erp-mafia/gnubok@main\`." + + - name: Report conflict + if: steps.merge.outputs.status == 'conflict' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh issue create \ + --title "Upstream sync conflict ($(date +%Y-%m-%d))" \ + --label sync-conflict \ + --body "Automated upstream merge hit a conflict. Resolve manually: \`git fetch upstream && git merge upstream/main\`." +``` + +## Conflict avoidance + +The fork-friendliness of this design depends on you keeping changes confined to your branding extension folder. Every file you edit in `lib/`, `app/`, or `components/` becomes a potential conflict on the next upstream merge. If you find yourself wanting to override something the branding service doesn't expose, prefer: + +1. **Open an upstream issue** — the branding service is intentionally minimal; missing fields can be added. +2. **PR a hook upstream** — extending the service or adding a registry pattern keeps your fork clean. + +## Verifying your whitelabel + +After deploying: + +- [ ] Visit `/` — browser tab title shows your brand. +- [ ] Visit `/login` and `/register` — your logo renders. +- [ ] View source of `/manifest.webmanifest` — `name`, `short_name`, `theme_color` reflect your overrides. +- [ ] Trigger an invite email — From line says ` `, body uses your name. +- [ ] Visit `/dpa` and `/privacy` — legal entity and contact email are yours. +- [ ] Open OAuth flow (`/api/mcp-oauth/authorize?...`) from a test MCP client — consent page references your brand. +- [ ] Submit support form (Settings → Support) — internal subject prefix is `[ support]`. diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 518abeaa..22214df5 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -13,6 +13,9 @@ import Image from 'next/image' import { getErrorMessage } from '@/lib/errors/get-error-message' import { isBankIdEnabled } from '@/lib/auth/bankid' import { BankIdAuth } from '@/components/auth/BankIdAuth' +import { getBranding } from '@/lib/branding/service' + +const branding = getBranding() import type { BankIdResult } from '@/components/auth/BankIdAuth' export default function LoginPage() { @@ -335,8 +338,8 @@ export default function LoginPage() {
Gnubok
Gnubok
@@ -10,7 +12,7 @@ export default function BackupSettingsPage() {

Ladda ner en egen kopia av all räkenskapsinformation — SIE-filer, kvitton, underlag och behandlingshistorik — i en enda ZIP-fil. Säkerhetsbackupen är din - egen kopia för trygghet och portabilitet. gnubok arkiverar all + egen kopia för trygghet och portabilitet. {appName.toLowerCase()} arkiverar all räkenskapsinformation i minst 7 år enligt BFL 7 kap. 2 §, så din backup ersätter inte vårt lagkrav — den kompletterar det.

diff --git a/app/(public)/dpa/page.tsx b/app/(public)/dpa/page.tsx index 64832654..06285757 100644 --- a/app/(public)/dpa/page.tsx +++ b/app/(public)/dpa/page.tsx @@ -1,12 +1,16 @@ import type { Metadata } from 'next' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import Link from 'next/link' +import { getBranding } from '@/lib/branding/service' -export const metadata: Metadata = { - title: 'Personuppgiftsbitradesavtal - Gnubok', +export function generateMetadata(): Metadata { + return { + title: `Personuppgiftsbitradesavtal - ${getBranding().appName}`, + } } export default function DPAPage() { + const { appName, legalEntity, privacyEmail } = getBranding() return (
@@ -28,11 +32,11 @@ export default function DPAPage() { Detta personuppgiftsbitradesavtal ("DPA") ingår mellan:

    -
  • Personuppgiftsansvarig ("den Ansvarige"): Du som användare av Gnubok, +
  • Personuppgiftsansvarig ("den Ansvarige"): Du som användare av {appName}, i egenskap av ansvarig för de personuppgifter du registrerar i tjänsten (kunder, leverantörer, anställda m.fl.).
  • -
  • Personuppgiftsbiträde ("Biträdet"): Arcim, som tillhandahåller - Gnubok-tjänsten och behandlar personuppgifter på dina vägnar.
  • +
  • Personuppgiftsbiträde ("Biträdet"): {legalEntity}, som tillhandahåller + {' '}{appName}-tjänsten och behandlar personuppgifter på dina vägnar.
@@ -174,8 +178,8 @@ export default function DPAPage() {

Detta personuppgiftsbitradesavtal träder i kraft när du skapar ett konto på - Gnubok och gäller så länge du använder tjänsten. För frågor, kontakta oss - på privacy@gnubok.se. + {' '}{appName} och gäller så länge du använder tjänsten. För frågor, kontakta oss + på {privacyEmail}.

diff --git a/app/(public)/privacy/page.tsx b/app/(public)/privacy/page.tsx index 200d64ce..7e373d78 100644 --- a/app/(public)/privacy/page.tsx +++ b/app/(public)/privacy/page.tsx @@ -1,11 +1,15 @@ import type { Metadata } from 'next' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { getBranding } from '@/lib/branding/service' -export const metadata: Metadata = { - title: 'Integritetspolicy - Gnubok', +export function generateMetadata(): Metadata { + return { + title: `Integritetspolicy - ${getBranding().appName}`, + } } export default function PrivacyPolicyPage() { + const { appName, legalEntity, privacyEmail } = getBranding() return (
@@ -24,8 +28,8 @@ export default function PrivacyPolicyPage() {

- Arcim ("vi", "oss") är personuppgiftsansvarig för behandlingen av dina - personuppgifter i samband med användningen av Gnubok. Vi behandlar dina uppgifter i + {legalEntity} ("vi", "oss") är personuppgiftsansvarig för behandlingen av dina + personuppgifter i samband med användningen av {appName}. Vi behandlar dina uppgifter i enlighet med EU:s dataskyddsförordning (GDPR) och svensk dataskyddslagstiftning.

@@ -205,8 +209,8 @@ export default function PrivacyPolicyPage() { För frågor om behandlingen av dina personuppgifter, kontakta oss:

    -
  • Företag: Arcim
  • -
  • E-post: privacy@gnubok.se
  • +
  • Företag: {legalEntity}
  • +
  • E-post: {privacyEmail}

Du har även rätt att lämna klagomål till Integritetsskyddsmyndigheten (IMY), diff --git a/app/api/ai/inbox-items/[id]/request-receipt/route.ts b/app/api/ai/inbox-items/[id]/request-receipt/route.ts index fc3be1a0..6b2aec78 100644 --- a/app/api/ai/inbox-items/[id]/request-receipt/route.ts +++ b/app/api/ai/inbox-items/[id]/request-receipt/route.ts @@ -6,6 +6,7 @@ import { requireWritePermission } from '@/lib/auth/require-write' import { getEmailService } from '@/lib/email/service' import { appendProcessingHistory } from '@/lib/processing-history/append' import { gateAgentInbox } from '@/lib/ai/feature-flag' +import { getBranding } from '@/lib/branding/service' import type { InvoiceInboxItem } from '@/types' ensureInitialized() @@ -105,7 +106,7 @@ export async function POST( const currency = extracted?.receipt?.currency ?? 'SEK' const date = extracted?.receipt?.date ?? null - const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'https://gnubok.se' + const appUrl = getBranding().appUrl const deepLink = `${appUrl.replace(/\/$/, '')}/agent-inbox` const subject = `[${companyName}] Kvittobild behövs för bokföring` diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 9438b484..8f8d64df 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -11,6 +11,7 @@ import { } from '@/lib/email/consent-notification-templates' import { ensureInitialized } from '@/lib/init' import { verifyCronSecret } from '@/lib/auth/cron' +import { getBranding } from '@/lib/branding/service' import type { StoredAccount } from '@/extensions/general/enable-banking/types' ensureInitialized() @@ -298,7 +299,7 @@ async function sendConsentExpiryNotification( bankName: connection.bank_name as string, daysUntilExpiry: daysLeft, renewalUrl: `${baseUrl}/settings/banking`, - companyName: companySettings?.company_name || 'gnubok', + companyName: companySettings?.company_name || getBranding().appName.toLowerCase(), isExpired, } diff --git a/app/api/mcp-oauth/authorize/route.ts b/app/api/mcp-oauth/authorize/route.ts index 16ee8872..3945a80a 100644 --- a/app/api/mcp-oauth/authorize/route.ts +++ b/app/api/mcp-oauth/authorize/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { createAuthCode } from '@/lib/auth/oauth-codes' import { requireCompanyId } from '@/lib/company/context' +import { getBranding } from '@/lib/branding/service' /** * OAuth 2.0 Authorization Endpoint. @@ -100,6 +101,8 @@ export async function GET(request: Request) { const companyName = settings?.trade_name || settings?.company_name || user.email + const appNameLower = escapeHtml(getBranding().appName.toLowerCase()) + // Render consent page const html = ` @@ -107,7 +110,7 @@ export async function GET(request: Request) { - Anslut MCP-klient — gnubok + Anslut MCP-klient — ${appNameLower}