feat(white-label): byra white-label infrastructure: brands, cockpit, home domains, branded email (#1956)

* feat(white-label): brand and team-kind foundation

- brands table: one white-label identity per byra team (unique mutable
  domain, row presence = live, email sender identity, hex color CHECKs)
- teams.kind ('personal'|'byra'): ops-only kind changes, deterministic
  ensure_user_team (personal team only), AFTER UPDATE role re-sync so a
  demoted consultant loses admin in client books immediately
- resolveBrandByHost/resolveBrandForCompany with 60s TTL cache, derived
  chrome tone and WCAG contrast gate; no brand row = default appearance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): per-request brand theming, wordmark slot and source footer

- root layout resolves the brand from the Host header and injects a
  server-rendered style block (light + dark), font pair classes and a
  BrandProvider/useBranding context; default hosts render byte-identically
- BrandWordmark logo slot, host-aware manifest and favicon,
  images.remotePatterns for Supabase Storage logos
- curated font menu mechanism (font_key -> variable pair, preload:false
  for non-default entries)
- AGPL source-code footer link on login and public pages, both brands

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra team invites, member management and team billing

- team invites unfrozen behind a kind gate (byra teams only, owner/admin
  invite); members route handles multi-team membership; members/[id]
  unfrozen with last-owner protection; invite management UI in settings
- billing/status learns team-scoped grants and the settings page shows a
  read-only "part of the byra agreement" state instead of the upgrade pitch
- 30-day trial suppressed for companies created under a byra team

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): brand-aware outbound mail, auth email hook and public invoice branding

- every outbound mail is sent in the brand of the company it concerns:
  getSenderForCompany/getBaseUrlForCompany chain (verified brand domain,
  "via Accounted" fallback, canonical default) wired into invites,
  payslips, invoice deliveries and reminders
- Supabase Send Email hook endpoint (signature-verified with node:crypto,
  dormant until configured) renders auth mail per brand via redirect origin
- public invoice pages carry the company's brand mark
- snapshot suite per template class guards against wrong-brand mail

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra cockpit, home-domain rule and tab guard

- Klienter route: five urgency-sorted columns (company, unbooked, inbox,
  next deadline via the status engine, last booked) for byra team members,
  who land there after login on their home domain
- soft switch straight into a client and back; blocking two-exit tab
  guard against writes to the wrong active company
- client company creation admin-gated at the DB level (a created company
  is +1 on the byra invoice), bound to the byra team, no trial
- home-domain rule in the UI: switcher partitions companies by host,
  signpost page for companies homed elsewhere

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): brand-aware app name across UI strings

- 24 message keys per locale converted to the {appName} ICU parameter,
  27 call sites pass the active brand name (useBranding client-side,
  getRequestAppName server-side)
- 6 hardcoded JSX literals swept; statutory filing and API identity
  surfaces deliberately keep the Accounted name
- 34 new i18n keys for the cockpit, team invites, billing state, tab
  guard, signpost and source footer (sv/en parity verified)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(white-label): domain glossary and decision log entries

- CONTEXT.md: the white-label ubiquitous language (brand, byra team,
  home domain, signpost, umbrella subdomain, brand color, cockpit)
- DECISIONS.md entries from the build waves

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): lean byra cockpit sidebar with company-mode back link

Byra team members now get a two-mode sidebar: on cockpit routes (/clients
and the new /byra pages) only Hem, Klienter, Automationer and Nyckeltal
show; entering a client company brings back the full company sidebar with
a pinned back-to-clients link (expanded, rail and mobile). New pages: /byra
home with client count, needs-action count and per-client urgent deadlines
reusing the fetchClientOverview aggregation, plus designed empty states for
/byra/automations and /byra/kpi. Signpost gate allows the byra routes;
non-byra users are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): cockpit shows no active company and keeps lean sidebar under settings

In cockpit mode the bottom user widget no longer shows the active company
subline or the company-switcher flyout: the cockpit sits above the
companies and clients are entered through the Klienter list. The settings
modal previously flipped the sidebar to the full company nav behind it
because the pathname becomes /settings/*; the sidebar now keeps the mode
of the surface underneath.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): keep company picker in cockpit with nothing selected

The cockpit user menu gets the company-switcher flyout back, but neutral:
the row reads "Valj bolag", no company carries the check mark or active
styling, and picking any company (including the technically-active one)
enters it with a full navigation. Company mode is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(db): renumber white-label migrations past main and add byra settings scope

Renumber 20260801100000-120000 to 20260804110000-113000: main already
carries applied versions up to 20260803231000, and Supabase branching
refuses local migrations stamped before the remote head (the repo rule
from 5932632f5: keep new versions strictly newest). Comment references
updated in the pg tests, route docs and onboarding precheck.

Also ships the byra settings scope: settings opened from the cockpit
(?ctx=byra, honored only for byra team members) show account-level
sections only (Konto, Medlemmar och roller), hide company-scoped
sections and the company kicker, and the team section is registered in
SETTINGS_SECTIONS so Medlemmar och roller renders inside the settings
window. The cockpit user menu drops Abonnemang and carries the scope on
its links; section switches preserve it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(byra): cross-client nyckeltal view in the cockpit

Period presets and company chips in the URL, summary tiles, merged
monthly income/expense chart and a sortable per-client KPI table.
Numbers come from the existing get_kpi_report_aggregates RPC per
client (no new migrations); calendar months are the cross-client
axis since clients can have different fiscal years.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra self-service brand logo and app name

New Varumarke settings section (byra scope, owner/admin): logo
upload/remove and an editable app name; domain stays read-only.
brands has no write RLS by design, so writes go through
/api/byra/brand routes with the service client behind an explicit
owner/admin team check. Files land in logos/byra/{teamId}/. The
expanded sidebar shows the brand app name beside the logo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): route root layout through the shared brand resolver

app/layout.tsx carried a private copy of resolveRequestBrand, so it
and lib/branding/request-brand.ts could drift. The layout now uses
the shared function, which also gains a BRAND_DEV_DOMAIN override:
on literal localhost hosts only, resolve that brand so branding is
testable in local dev. Real domains are unaffected even if the
variable leaks into a deployment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(byra): automations roadmap teaser and cockpit i18n strings

The Automationer tab now previews the planned automation set
(Monday briefing, deadline watch, rule-driven bookkeeping,
connection watch, monthly checklist, report delivery) instead of a
bare empty state. Bundles the sv/en strings for the whole cockpit
wave (nyckeltal, varumarke, automations) and the decision-log
entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra owners/admins land in the cockpit, not an auto-picked company

After login "/" resolved the first-membership fallback and opened a client
company nobody chose, and the top-left brand mark always linked back to it.
Byra owners/admins now home to /byra: the logo links there always, and "/"
redirects there unless a company was explicitly picked this browser session.

The middleware writes the fallback company back to user_preferences, so the
DB cannot tell picked from auto-picked; setActiveCompany stamps a session
cookie (gnubok-company-picked) on every explicit switch instead. The byra
check on "/" reuses the layout's team_members query via a request-cached
helper, so it costs no extra round trip. Byra members and regular users are
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(white-label): drop brand color theming, keep monochrome everywhere

White-label is logo + app name + domain only (founder call): the
layout no longer injects brand color CSS variables, stamps
data-brand or colors the browser chrome. buildBrandVarsCss, its
WCAG gate and the brand_color/chrome_color columns stay dormant
for a future opt-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): arm SIE RPC statement_timeout via pgrst.db_pre_request hook

ALTER FUNCTION ... SET statement_timeout (20260629160100, 20260721144311)
never re-arms the running statement's timer, so large SIE imports still
died at the role default 8s. The pre-request hook runs as its own
statement before the main query, so set_config there is what the main
statement's timer is armed with. Scoped by request path to the three SIE
RPCs; every other request keeps 8s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(byra): drop the 'what's coming' tail from the automations intro

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): byra owners/admins with zero companies land in the empty cockpit

Both no-company gates (Edge middleware and the dashboard layout) sent
every company-less user to the onboarding wizard, which forced a fresh
byra owner to create a personal company before ever seeing the cockpit.
Byra owners/admins now pass through to cockpit routes (/byra, /clients,
/companies/new, /settings, /api) and are steered to /byra elsewhere.
Plain byra members and regular users keep the onboarding redirect.
The membership lookup runs only in the rare no-company state, so the
middleware hot path is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): auth wordmark shows the brand logo alone

Byra logos usually carry their own name, so logo + app name text on the
login/register hero read as a duplicate. Branded hosts with an uploaded
logo now render the logo only, with the app name as the image's alt
text. Hosts without a logo keep the text wordmark unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): per-brand favicon via brands.favicon_url

Branded hosts used logo_url as the tab icon, which squashes wide byra
lockups at 16px. New optional brands.favicon_url holds a square mark;
the root layout prefers it and falls back to logo_url as before.
Migration applied to staging (idempotent DDL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(auth): wire the villkor and integritetspolicy footer links

Both auth pages shipped with href="#" placeholders. Villkor now points
at the platform terms on the marketing site (accounted.se/terms; the
terms are the platform's even on branded byra hosts) and
integritetspolicy at the in-app /privacy page, host-relative so it
resolves on every branded domain. Both open in a new tab so the auth
form state survives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): styled popup for the team role dropdowns

The byra team panel's role pickers (member rows + invite form) were
native selects, so the opened list rendered as the unstylable OS menu.
Swapped to the Radix Select with the popup styled like every other
overlay; the trigger keeps the flat quiet SettingsSelect look.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(email): branded sender shows the brand name alone, no via-platform

Byra invite mail read "Willem via Accounted" in the From display name.
The tier-2 fallback (brand on the platform address) now renders just the
brand name; the platform stays visible in the actual From address until
the brand verifies its own sender domain (tier 1, unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): byra landing applies to every team member, not only owners/admins

An invited byra consultant (role member) still landed in an auto-picked
client company after signup. The cockpit landing rules ("/" redirect,
brand-mark home link, and both no-company gates) now key on byra team
MEMBERSHIP instead of the owner/admin role: anyone with cockpit access
homes to /byra. Regular users unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(email): branded team invite names the byra, not "ett team pa <platform>"

Subject, headline, body and text variant now read "Du har blivit
inbjuden till <Byra>" (brand casing kept) when the team has a brand.
Brandless teams keep the platform phrasing byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): sidebar keeps cockpit mode after refresh on settings

The sidebar's cockpit/company decision on /settings/* rested on React
state remembering the surface underneath, which a hard reload wipes: a
byra user refreshing settings opened from the cockpit got the full
company nav and read it as landing in a client company. The ?ctx=byra
marker already in the URL survives reloads, so the sidebar now honors
it as the cockpit signal alongside the in-session memory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): hide the active-company chip in byra-scoped settings

The full-page settings header (the hard-refresh fallback surface) showed
the ActiveCompanyBadge even under ?ctx=byra, so a byra user read the
auto-active client as "the company I am in". The chip now follows the
same byra-scope rule as the modal's kicker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): tab guard no longer fires in the tab that initiated the switch

BroadcastChannel delivers the company-switch broadcast to every listener in
the same tab too, so the cockpit tab raised its own WL-09 "switched in
another tab" dialog over the hard navigation into the clicked client.
performCompanySwitch now marks the switch as self-initiated; CompanyTabSync
suppresses only the dialog for that observation (stray writes still get
their 409) and clears the marker on bfcache restore so back-navigation
regains the full guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): styled popups for every settings dropdown

SettingsSelect rendered a native <select>, whose OS listbox cannot be
styled and clashes with the panel (same problem the team-panel role
dropdowns had). It now renders through Radix Select with the flat
dashed-underline trigger, keeping the native prop surface so all 13 call
sites work unchanged: value/defaultValue, onChange(e.target.value),
<option> children, and a hidden input that carries `name` into
SettingsFormWrapper's FormData read and raises the bubbling input event
its dirty tracking listens for. Empty-string option values map onto a
sentinel at the Radix boundary. The backup form's boxed fiscal-year
select moves to the shadcn Select with a placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): home-domain affinity redirect in middleware

Every signed-in user now homes on a domain: byra team members on their
brand's domain, everyone else on the platform app URL, except a byra's
client users, whose home is the byra domain their companies live under.
On any other product host the request redirects to the home domain's
root, where the user meets the RIGHT branded login (sessions are
per-domain by design). localhost, direct *.vercel.app hosts and IP
hosts are exempt; a 15-minute host-scoped cookie caches the "this is
home" verdict so the hot path costs zero extra queries; lookup failures
fail open. Complements the WL-01 signpost, which keeps handling
per-company homing inside a domain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): render hero brand logo at 64px on auth pages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(white-label): shareable invite link and re-send for byra team invites

A failed invite mail previously surfaced only as a toast description while
the invitation quietly waited for a mail that never arrived (the Arbore
case). The inviter now always has a recovery path:

- persistent share-link line after invite create/re-send: ochre attn line
  with a copy action when the mail did not go out, quiet muted line with
  the same action when it did
- POST /api/team/invite/[id] re-sends a pending invitation with a fresh
  token and expiry (same byra-only owner/admin gates as DELETE)
- brand mail sending extracted to lib/email/send-team-invite.ts, shared
  by create and re-send so the two paths cannot drift

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): sidebar shows uploaded brand logo alone, no app-name label

Byra logos usually carry their own name, so logo + text in the expanded
sidebar read as a duplicate (same founder call as BrandWordmark,
2026-08-05). The app-name label now renders only for branded hosts
without an uploaded logo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): close the four skeptic refutations before merge

- trial seed: migration 130300 now carries the seven-key PAID body from
  20260818170000 plus the byra guard, instead of silently reverting it;
  pg test pins the full key set against PAID_CAPABILITIES
- byra gate: new migration 130600 adds the owner/admin gate to
  create_company_for_user (v1 API + MCP path), and both surfaces resolve
  the default team personal-only, so a consultant's private company can
  never attach to the byra team
- home-domain: byra staff who also have canonical-homed companies are no
  longer redirected off the platform host; the signpost handles per-company
  homing (5 new middleware tests)
- settings selects: the Radix popup renders optgroup group headers again
  (ROT/RUT work-type picker)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(schema): re-baseline unresolvable-expression ceiling after #1954 catch-up merge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(white-label): pg-real rollback-safe assertions and deep-link-preserving affinity redirect

The byra company-creation pg test asserted persisted rows through the pool
after withUserContext, which always rolls back its transaction; the
assertions now run inside the transaction after RESET ROLE. The home-domain
affinity redirect carries the original path and query across the domain hop
(PR Agent finding), so invite links and deep links survive the correction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-26 16:56:39 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6f0f4d1eea
commit fdb5f6f891
160 changed files with 15125 additions and 504 deletions
+10
View File
@@ -3612,6 +3612,16 @@ export const DimensionTaggingApplySchema = z.object({
reason: z.string().trim().min(3).max(500),
})
/**
* Body for PATCH /api/byra/brand. The app name is byra-editable (WL-17):
* shown beside the sidebar logo and across branded chrome. Trimmed and
* capped so it stays a name, not a paragraph; domain and colors are NOT
* accepted here (ops-managed).
*/
export const ByraBrandUpdateSchema = z.object({
appName: z.string().trim().min(1).max(60),
})
// ============================================================
// Körjournal (mileage trips)
// ============================================================
+147
View File
@@ -0,0 +1,147 @@
import { describe, it, expect, vi } from 'vitest'
// brand-style imports the pure guards from resolve.ts, which pulls in the
// service-client module; mock it so the import chain is inert in node.
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn(),
}))
import { hexToHslTriplet, buildBrandVarsCss } from '@/lib/branding/brand-style'
import { deriveChromeColor } from '@/lib/branding/resolve'
// Siffra blue from the WL-02 prototype: passes the white-text WCAG gate.
// Lightness ~53%, i.e. above the dark-mode lift threshold (45%).
const ACCESSIBLE_BLUE = '#2563eb'
// White text on this yellow is nowhere near 4.5:1.
const INACCESSIBLE_YELLOW = '#ffd500'
// Deep green (L ~20%): passes the white-text gate but disappears against
// dark surfaces, so the dark theme must lift it.
const DARK_GREEN = '#14532d'
describe('hexToHslTriplet', () => {
it('converts hex to the space-separated triplet format globals.css uses', () => {
expect(hexToHslTriplet('#ffffff')).toBe('0 0% 100%')
expect(hexToHslTriplet('#000000')).toBe('0 0% 0%')
expect(hexToHslTriplet('#2563eb')).toBe('221 83% 53%')
})
it('accepts uppercase hex and surrounding whitespace', () => {
expect(hexToHslTriplet(' #2563EB ')).toBe('221 83% 53%')
})
it('returns null for anything that is not a 6-digit hex color', () => {
expect(hexToHslTriplet('#fff')).toBeNull()
expect(hexToHslTriplet('#12345')).toBeNull()
expect(hexToHslTriplet('2563eb')).toBeNull()
expect(hexToHslTriplet('blue')).toBeNull()
expect(hexToHslTriplet('')).toBeNull()
})
})
describe('buildBrandVarsCss', () => {
it('emits the brand variables in both the light and the dark block', () => {
const css = buildBrandVarsCss({ brandColor: ACCESSIBLE_BLUE, chromeColor: null })
expect(css).not.toBeNull()
// Light: html[data-brand]:not(.dark) at (0,2,1) beats :root (0,1,0) and
// :root[data-palette] (0,2,0). Dark: html[data-brand].dark at (0,2,1)
// beats .dark (0,1,0) and .dark[data-palette] (0,2,0).
expect(css).toContain('html[data-brand]:not(.dark) {')
expect(css).toContain('html[data-brand].dark {')
const brandTriplet = hexToHslTriplet(ACCESSIBLE_BLUE)
const lightBlock = css!.split('html[data-brand].dark')[0]
const darkBlock = css!.split('html[data-brand].dark')[1]
for (const block of [lightBlock, darkBlock]) {
expect(block).toContain(`--primary: ${brandTriplet};`)
expect(block).toContain('--primary-foreground: 0 0% 100%;')
expect(block).toContain(`--ring: ${brandTriplet};`)
}
})
it('sets --frame to the derived chrome when no override is stored', () => {
const css = buildBrandVarsCss({ brandColor: ACCESSIBLE_BLUE, chromeColor: null })
const chromeTriplet = hexToHslTriplet(deriveChromeColor(ACCESSIBLE_BLUE))
expect(css).toContain(`--frame: ${chromeTriplet};`)
})
it('sets --frame to the explicit chrome override when stored', () => {
const css = buildBrandVarsCss({ brandColor: ACCESSIBLE_BLUE, chromeColor: '#101820' })
expect(css).toContain(`--frame: ${hexToHslTriplet('#101820')};`)
expect(css).not.toContain(`--frame: ${hexToHslTriplet(deriveChromeColor(ACCESSIBLE_BLUE))};`)
})
it('scopes light sidebar text tokens to the frame sidebar', () => {
const css = buildBrandVarsCss({ brandColor: ACCESSIBLE_BLUE, chromeColor: null })
expect(css).toContain('html[data-brand] .bg-frame > aside {')
const sidebarBlock = css!.split('.bg-frame > aside')[1]
expect(sidebarBlock).toContain('--foreground: 0 0% 98%;')
expect(sidebarBlock).toContain('--muted-foreground: 0 0% 75%;')
expect(sidebarBlock).toContain('--primary: 0 0% 98%;')
// The solid-primary chip inside the sidebar inverts onto the chrome tone.
const chromeTriplet = hexToHslTriplet(deriveChromeColor(ACCESSIBLE_BLUE))
expect(sidebarBlock).toContain(`--primary-foreground: ${chromeTriplet};`)
expect(sidebarBlock).toContain('--secondary: ')
expect(sidebarBlock).toContain('--muted: ')
expect(sidebarBlock).toContain('--border: ')
})
it('lifts --primary/--ring in the dark block for a dark brand color', () => {
const css = buildBrandVarsCss({ brandColor: DARK_GREEN, chromeColor: null })!
const [lightBlock, darkBlock] = css.split('html[data-brand].dark')
const source = hexToHslTriplet(DARK_GREEN)!
// The stored color ends in its own (low) lightness; the lifted variant is
// the same hue and saturation at 62% lightness.
const lifted = source.replace(/\d+%$/, '62%')
expect(lifted).not.toBe(source)
expect(lightBlock).toContain(`--primary: ${source};`)
expect(lightBlock).toContain(`--ring: ${source};`)
expect(darkBlock).toContain(`--primary: ${lifted};`)
expect(darkBlock).toContain(`--ring: ${lifted};`)
expect(darkBlock).not.toContain(`--ring: ${source};`)
})
it('flips dark-mode button text dark when white fails on the lifted tone', () => {
const css = buildBrandVarsCss({ brandColor: DARK_GREEN, chromeColor: null })!
const [lightBlock, darkBlock] = css.split('html[data-brand].dark')
// Light theme keeps the gate's guarantee: white on the stored color.
expect(lightBlock).toContain('--primary-foreground: 0 0% 100%;')
// Lifted light green cannot carry white button text.
expect(darkBlock.split('.bg-frame')[0]).toContain('--primary-foreground: 0 0% 10%;')
})
it('keeps a light-enough brand color unchanged in dark mode (as today)', () => {
const css = buildBrandVarsCss({ brandColor: ACCESSIBLE_BLUE, chromeColor: null })!
const darkBlock = css.split('html[data-brand].dark')[1].split('.bg-frame')[0]
const brandTriplet = hexToHslTriplet(ACCESSIBLE_BLUE)
expect(darkBlock).toContain(`--primary: ${brandTriplet};`)
expect(darkBlock).toContain(`--ring: ${brandTriplet};`)
expect(darkBlock).toContain('--primary-foreground: 0 0% 100%;')
})
it('scopes deep-chrome tokens to the mobile bottom nav', () => {
const css = buildBrandVarsCss({ brandColor: ACCESSIBLE_BLUE, chromeColor: null })!
expect(css).toContain('html[data-brand] nav[data-mobile-nav] {')
const navBlock = css.split('nav[data-mobile-nav]')[1]
const chromeTriplet = hexToHslTriplet(deriveChromeColor(ACCESSIBLE_BLUE))
// The bar renders on --card with --border hairlines: chrome tone + the
// same calibrated light text as the sidebar.
expect(navBlock).toContain(`--card: ${chromeTriplet};`)
expect(navBlock).toContain('--muted-foreground: 0 0% 75%;')
expect(navBlock).toContain('--primary: 0 0% 98%;')
expect(navBlock).toContain(`--primary-foreground: ${chromeTriplet};`)
expect(navBlock).toContain('--border: ')
})
it('fails open (null) when the brand color fails the accessibility gate', () => {
expect(
buildBrandVarsCss({ brandColor: INACCESSIBLE_YELLOW, chromeColor: null }),
).toBeNull()
})
it('fails open (null) on invalid brand or chrome hex', () => {
expect(buildBrandVarsCss({ brandColor: 'not-a-color', chromeColor: null })).toBeNull()
expect(buildBrandVarsCss({ brandColor: '#12345', chromeColor: null })).toBeNull()
expect(
buildBrandVarsCss({ brandColor: ACCESSIBLE_BLUE, chromeColor: 'nonsense' }),
).toBeNull()
})
})
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { getBrandFontPair, DEFAULT_FONT_KEY } from '@/lib/branding/fonts'
describe('getBrandFontPair', () => {
it('returns null for the default key (no per-request override)', () => {
expect(getBrandFontPair(DEFAULT_FONT_KEY)).toBeNull()
})
it('falls back to the default pair for unknown keys', () => {
expect(getBrandFontPair('comic-sans')).toBeNull()
expect(getBrandFontPair('')).toBeNull()
})
it('resolves each curated menu entry to a serif display + sans body pair', () => {
expect(getBrandFontPair('lora')).toEqual({
display: 'var(--font-lora), Georgia, serif',
body: 'var(--font-source-sans), system-ui, sans-serif',
})
expect(getBrandFontPair('fraunces')).toEqual({
display: 'var(--font-fraunces), Georgia, serif',
body: 'var(--font-work-sans), system-ui, sans-serif',
})
expect(getBrandFontPair('playfair')).toEqual({
display: 'var(--font-playfair), Georgia, serif',
body: 'var(--font-public-sans), system-ui, sans-serif',
})
})
it('is not fooled by Object prototype property names', () => {
expect(getBrandFontPair('toString')).toBeNull()
expect(getBrandFontPair('hasOwnProperty')).toBeNull()
})
})
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest'
import { getBranding } from '@/lib/branding/service'
import {
toPublicBrand,
mergeClientBranding,
type PublicBrand,
} from '@/lib/branding/public-brand'
import type { Brand } from '@/lib/branding/resolve'
const fullBrand: Brand = {
id: 'brand-1',
teamId: 'team-1',
domain: 'app.siffra.se',
appName: 'Siffra',
logoUrl: 'https://cdn.example.com/storage/v1/object/public/brand-logos/siffra.png',
brandColor: '#2563eb',
chromeColor: null,
fontKey: 'default',
supportEmail: 'support@siffra.se',
authEmailFrom: null,
senderDomain: 'mail.siffra.se',
senderDomainStatus: 'verified',
resendDomainId: 'rd_123',
}
describe('toPublicBrand', () => {
it('keeps only the client-safe fields', () => {
const pub = toPublicBrand(fullBrand)
expect(pub).toEqual({
appName: 'Siffra',
logoUrl: fullBrand.logoUrl,
supportEmail: 'support@siffra.se',
authEmailFrom: null,
brandColor: '#2563eb',
fontKey: 'default',
domain: 'app.siffra.se',
})
// Ops-internal email plumbing must never cross to the client bundle.
expect(pub).not.toHaveProperty('senderDomain')
expect(pub).not.toHaveProperty('senderDomainStatus')
expect(pub).not.toHaveProperty('resendDomainId')
expect(pub).not.toHaveProperty('teamId')
})
})
describe('mergeClientBranding', () => {
const base = getBranding()
it('returns plain getBranding() values when there is no brand (the additive guarantee)', () => {
const merged = mergeClientBranding(base, null)
expect(merged.brand).toBeNull()
expect(merged.logoUrl).toBeNull()
// Every base field passes through untouched.
expect(merged).toMatchObject(base)
expect(merged.appName).toBe(base.appName)
expect(merged.supportEmail).toBe(base.supportEmail)
expect(merged.authEmailFrom).toBe(base.authEmailFrom)
expect(merged.themeColor).toBe(base.themeColor)
expect(merged.logoPath).toBe(base.logoPath)
})
it('layers brand values over the defaults when a brand is active', () => {
const pub = toPublicBrand(fullBrand)
const merged = mergeClientBranding(base, pub)
expect(merged.appName).toBe('Siffra')
expect(merged.supportEmail).toBe('support@siffra.se')
expect(merged.themeColor).toBe('#2563eb')
expect(merged.logoUrl).toBe(fullBrand.logoUrl)
expect(merged.brand).toEqual(pub)
// Untouched defaults survive the merge.
expect(merged.logoPath).toBe(base.logoPath)
expect(merged.appDescription).toBe(base.appDescription)
})
it('falls back to the default authEmailFrom when the brand has none', () => {
const withoutAuthFrom: PublicBrand = { ...toPublicBrand(fullBrand), authEmailFrom: null }
expect(mergeClientBranding(base, withoutAuthFrom).authEmailFrom).toBe(base.authEmailFrom)
const withAuthFrom: PublicBrand = {
...toPublicBrand(fullBrand),
authEmailFrom: 'noreply@siffra.se',
}
expect(mergeClientBranding(base, withAuthFrom).authEmailFrom).toBe('noreply@siffra.se')
})
})
+235
View File
@@ -0,0 +1,235 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
// Holder assigned in beforeEach; the mock factory closes over it so each test
// gets a fresh queued client without re-mocking the module.
const serviceClient = vi.hoisted(() => ({ current: null as unknown }))
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn(() => serviceClient.current),
}))
import {
resolveBrandByHost,
resolveBrandForCompany,
deriveChromeColor,
getEffectiveChrome,
isBrandColorAccessible,
normalizeHost,
clearBrandCache,
} from '@/lib/branding/resolve'
const brandRow = {
id: 'brand-1',
team_id: 'team-1',
domain: 'app.siffra.se',
app_name: 'Siffra',
logo_url: null,
favicon_url: null,
brand_color: '#2563eb',
chrome_color: null,
font_key: 'default',
support_email: 'support@siffra.se',
auth_email_from: null,
sender_domain: null,
sender_domain_status: 'unverified',
resend_domain_id: null,
}
let mock: ReturnType<typeof createQueuedMockSupabase>
beforeEach(() => {
vi.clearAllMocks()
clearBrandCache()
mock = createQueuedMockSupabase()
serviceClient.current = mock.supabase
})
afterEach(() => {
vi.useRealTimers()
})
describe('normalizeHost', () => {
it('lowercases and strips port and trailing dot', () => {
expect(normalizeHost('APP.SIFFRA.SE:3000')).toBe('app.siffra.se')
expect(normalizeHost(' app.siffra.se. ')).toBe('app.siffra.se')
expect(normalizeHost('app.siffra.se')).toBe('app.siffra.se')
})
})
describe('resolveBrandByHost', () => {
it('resolves a brand row and maps it to the camelCase Brand shape', async () => {
mock.enqueue({ data: brandRow })
const brand = await resolveBrandByHost('app.siffra.se')
expect(brand).toEqual({
id: 'brand-1',
teamId: 'team-1',
domain: 'app.siffra.se',
appName: 'Siffra',
logoUrl: null,
faviconUrl: null,
brandColor: '#2563eb',
chromeColor: null,
fontKey: 'default',
supportEmail: 'support@siffra.se',
authEmailFrom: null,
senderDomain: null,
senderDomainStatus: 'unverified',
resendDomainId: null,
})
expect(mock.findCall('brands', 'eq')).toEqual(['domain', 'app.siffra.se'])
})
it('normalizes the host before lookup and shares the cache entry across variants', async () => {
mock.enqueue({ data: brandRow })
const first = await resolveBrandByHost('APP.SIFFRA.SE:3000')
expect(first?.domain).toBe('app.siffra.se')
expect(mock.findCall('brands', 'eq')).toEqual(['domain', 'app.siffra.se'])
const second = await resolveBrandByHost('app.siffra.se')
expect(second?.id).toBe('brand-1')
// Cache hit: only the first call reached the database.
expect(mock.supabase.from).toHaveBeenCalledTimes(1)
})
it('returns null for unknown hosts and caches the miss', async () => {
mock.enqueue({ data: null })
expect(await resolveBrandByHost('app.gnubok.se')).toBeNull()
expect(await resolveBrandByHost('app.gnubok.se')).toBeNull()
expect(mock.supabase.from).toHaveBeenCalledTimes(1)
})
it('returns null without touching the database for an empty host', async () => {
expect(await resolveBrandByHost('')).toBeNull()
expect(mock.supabase.from).not.toHaveBeenCalled()
})
it('expires cache entries after the TTL', async () => {
vi.useFakeTimers()
mock.enqueueMany([{ data: brandRow }, { data: brandRow }])
await resolveBrandByHost('app.siffra.se')
vi.advanceTimersByTime(59_000)
await resolveBrandByHost('app.siffra.se')
expect(mock.supabase.from).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(2_000)
await resolveBrandByHost('app.siffra.se')
expect(mock.supabase.from).toHaveBeenCalledTimes(2)
})
it('does not cache a query error, so the next call retries', async () => {
mock.enqueue({ data: null, error: { message: 'boom' } })
expect(await resolveBrandByHost('app.siffra.se')).toBeNull()
mock.enqueue({ data: brandRow })
const brand = await resolveBrandByHost('app.siffra.se')
expect(brand?.id).toBe('brand-1')
expect(mock.supabase.from).toHaveBeenCalledTimes(2)
})
})
describe('resolveBrandForCompany', () => {
it('resolves companies.team_id -> brands.team_id', async () => {
mock.enqueueMany([{ data: { team_id: 'team-1' } }, { data: brandRow }])
const brand = await resolveBrandForCompany('company-1')
expect(brand?.teamId).toBe('team-1')
expect(mock.findCall('companies', 'eq')).toEqual(['id', 'company-1'])
expect(mock.findCall('brands', 'eq')).toEqual(['team_id', 'team-1'])
})
it('returns null and caches when the company has no team', async () => {
mock.enqueue({ data: { team_id: null } })
expect(await resolveBrandForCompany('company-1')).toBeNull()
expect(await resolveBrandForCompany('company-1')).toBeNull()
// One from('companies') call total; never reached brands.
expect(mock.supabase.from).toHaveBeenCalledTimes(1)
expect(mock.findCall('brands', 'eq')).toBeUndefined()
})
it('returns null when the company does not exist', async () => {
mock.enqueue({ data: null })
expect(await resolveBrandForCompany('missing')).toBeNull()
})
it('returns null when the team has no brand, and caches per company key', async () => {
mock.enqueueMany([{ data: { team_id: 'team-9' } }, { data: null }])
expect(await resolveBrandForCompany('company-9')).toBeNull()
expect(await resolveBrandForCompany('company-9')).toBeNull()
expect(mock.supabase.from).toHaveBeenCalledTimes(2)
})
})
describe('deriveChromeColor', () => {
it('derives a deterministic deep chrome tone from the brand color', () => {
expect(deriveChromeColor('#2563eb')).toBe('#1c263b')
expect(deriveChromeColor('#2563eb')).toBe('#1c263b')
expect(deriveChromeColor('#dc2626')).toBe('#3b1c1c')
expect(deriveChromeColor('#304D83')).toBe('#1c273b')
})
it('keeps near-achromatic brand colors neutral instead of tinting them red', () => {
// Pure gray input: saturating to 30% would produce a dark red (hue 0).
expect(deriveChromeColor('#1a1a1a')).toBe('#262626')
})
it('always emits a six-digit lowercase hex color', () => {
for (const input of ['#ffffff', '#000000', '#00ff00', '#ABCDEF']) {
expect(deriveChromeColor(input)).toMatch(/^#[0-9a-f]{6}$/)
}
})
it('throws on invalid input (format is CHECK-enforced upstream)', () => {
expect(() => deriveChromeColor('blue')).toThrow(/Invalid hex color/)
})
})
describe('getEffectiveChrome', () => {
it('prefers the explicit chrome_color override', () => {
expect(getEffectiveChrome({ brandColor: '#2563eb', chromeColor: '#101418' })).toBe('#101418')
})
it('derives from the brand color when no override is set', () => {
expect(getEffectiveChrome({ brandColor: '#2563eb', chromeColor: null })).toBe('#1c263b')
})
})
describe('isBrandColorAccessible', () => {
it('accepts colors where white text clears 4.5:1', () => {
expect(isBrandColorAccessible('#1a1a1a')).toBe(true)
expect(isBrandColorAccessible('#2563eb')).toBe(true)
})
it('rejects colors where white text fails 4.5:1', () => {
expect(isBrandColorAccessible('#ffff00')).toBe(false)
expect(isBrandColorAccessible('#ffffff')).toBe(false)
})
it('rejects invalid hex strings instead of throwing', () => {
expect(isBrandColorAccessible('blue')).toBe(false)
expect(isBrandColorAccessible('#12345')).toBe(false)
expect(isBrandColorAccessible('')).toBe(false)
})
})
describe('clearBrandCache', () => {
it('forces the next resolution back to the database', async () => {
mock.enqueueMany([{ data: brandRow }, { data: brandRow }])
await resolveBrandByHost('app.siffra.se')
clearBrandCache()
await resolveBrandByHost('app.siffra.se')
expect(mock.supabase.from).toHaveBeenCalledTimes(2)
})
})
+44
View File
@@ -0,0 +1,44 @@
'use client'
/**
* Per-request brand context (WL-12 slice A2).
*
* The root layout resolves the brand from the request host and provides its
* client-safe subset here. `useBranding()` is the client-side migration
* target for `getBranding()` call sites: it returns the brand merged over the
* sync defaults, and plain `getBranding()` values when the host has no brand,
* so an un-migrated call site and a migrated one agree on default hosts.
*
* Server components do not use this: they call `resolveBrandByHost()` (or the
* layout's already-resolved brand) directly.
*/
import { createContext, useContext, useMemo, type ReactNode } from 'react'
import { getBranding } from './service'
import {
mergeClientBranding,
type ClientBranding,
type PublicBrand,
} from './public-brand'
const BrandContext = createContext<PublicBrand | null>(null)
export function BrandProvider({
brand,
children,
}: {
brand: PublicBrand | null
children: ReactNode
}) {
return <BrandContext.Provider value={brand}>{children}</BrandContext.Provider>
}
/**
* The active branding for client components: per-request brand values merged
* over `getBranding()` defaults, falling back to plain defaults when the
* request host serves no brand.
*/
export function useBranding(): ClientBranding {
const brand = useContext(BrandContext)
return useMemo(() => mergeClientBranding(getBranding(), brand), [brand])
}
+211
View File
@@ -0,0 +1,211 @@
/**
* Brand style block builder (WL-12 slice A2).
*
* Pure functions that turn a brand's colors into the server-rendered
* <style id="brand-vars"> block the root layout injects on branded hosts.
* Kept free of rendering so the triplet conversion and the block builder
* are unit-testable in plain node.
*
* How the block wins over app/globals.css in both themes:
*
* - globals.css declares the light theme on `:root` (0,1,0) and the dark
* theme on `.dark` (0,1,0); the user palettes add `:root[data-palette]` /
* `.dark[data-palette]` at (0,2,0).
* - the layout stamps `data-brand` on <html> only when a brand renders, so
* `html[data-brand]:not(.dark)` and `html[data-brand].dark` are (0,2,1):
* they beat every one of the above in their own mode without relying on
* stylesheet order.
* - the sidebar block defines variables directly ON the sidebar element
* (`.bg-frame > aside`), and element-level custom properties always beat
* inherited ones, whatever the specificity of the root declarations.
*
* No brand (or a brand whose color fails the accessibility gate) means the
* layout renders no block and no `data-brand` attribute at all: the additive
* guarantee (default hosts stay byte-identical) lives in the callers, and the
* fail-open path lives here as a `null` return.
*/
import { getContrastRatio } from '@/lib/invoices/contrast-check'
import { getEffectiveChrome, hslToHex, isBrandColorAccessible, type Brand } from './resolve'
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/
interface Hsl {
h: number
s: number
l: number
}
function hexToHsl(hex: string): Hsl | null {
if (typeof hex !== 'string') return null
const trimmed = hex.trim()
if (!HEX_COLOR_RE.test(trimmed)) return null
const value = trimmed.slice(1)
const r = parseInt(value.slice(0, 2), 16) / 255
const g = parseInt(value.slice(2, 4), 16) / 255
const b = parseInt(value.slice(4, 6), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
const d = max - min
let h = 0
let s = 0
if (d !== 0) {
s = d / (1 - Math.abs(2 * l - 1))
if (max === r) h = ((g - b) / d) % 6
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
h *= 60
if (h < 0) h += 360
}
return { h, s, l }
}
/** Format an HSL value in the space-separated triplet globals.css uses. */
function formatTriplet(hsl: Hsl): string {
const h = Math.round(hsl.h) % 360
const s = Math.round(hsl.s * 100)
const l = Math.round(hsl.l * 100)
return `${h} ${s}% ${l}%`
}
/**
* Convert a `#rrggbb` hex color to the `H S% L%` triplet format the theme
* variables in app/globals.css use (e.g. `#2563eb` -> `221 83% 53%`).
* Returns null for anything that is not a 6-digit hex color.
*/
export function hexToHslTriplet(hex: string): string | null {
const hsl = hexToHsl(hex)
return hsl ? formatTriplet(hsl) : null
}
// Sidebar tokens on deep chrome (WL-02 round 2: deep-branded chrome needs
// light sidebar text). The sidebar consumes --foreground, --muted-foreground,
// --primary (active items), --primary-foreground, --secondary (hover/active
// chips), --muted (count badges) and --border (trial box, hairlines); this is
// the minimal set that keeps it readable on the deep tone. Lightness values
// are fixed (not relative to the chrome) so any tone inside the derivation
// bounds (L 15-17%) gets the same calibrated lift; hue and saturation follow
// the chrome so the chips read as the same material. A light chrome override
// would break this contract, which is why the onboarding gate (WL-02) rejects
// it there instead of us auto-correcting at render time.
const SIDEBAR_FOREGROUND = '0 0% 98%'
const SIDEBAR_MUTED_FOREGROUND = '0 0% 75%'
const SIDEBAR_SECONDARY_LIGHTNESS = 0.28
const SIDEBAR_MUTED_LIGHTNESS = 0.26
const SIDEBAR_BORDER_LIGHTNESS = 0.3
const SIDEBAR_SATURATION_MAX = 0.35
// Dark-mode ring/primary lift (WL-02 gap fix). Every accessible brand color
// is fairly dark (the white-text 4.5:1 gate demands it), and a LOW-lightness
// one (below ~45%) disappears against dark surfaces: invisible focus rings,
// murky primary buttons. The dark theme therefore lifts such colors to a
// lighter variant of the same hue (~62%, inside the 60-65% band the WL-02
// prototype used for its dark-mode brand variants). Colors at or above the
// threshold keep their stored value in dark mode, exactly as before.
const DARK_LIFT_LIGHTNESS_THRESHOLD = 0.45
const DARK_LIFT_TARGET_LIGHTNESS = 0.62
/** The brand tone the DARK theme renders: lifted when the source is dark. */
function darkModeBrandHsl(brandHsl: Hsl): Hsl {
if (brandHsl.l >= DARK_LIFT_LIGHTNESS_THRESHOLD) return brandHsl
return { ...brandHsl, l: DARK_LIFT_TARGET_LIGHTNESS }
}
/**
* Build the CSS text for the `<style id="brand-vars">` block: brand primary +
* ring + deep chrome frame in both the light and dark theme, plus the
* sidebar-scoped light-text tokens. Returns null (fail open to the default
* theme) when the brand color fails the WCAG gate or any color is not valid
* hex; the caller logs and renders without the block.
*/
export function buildBrandVarsCss(brand: Pick<Brand, 'brandColor' | 'chromeColor'>): string | null {
const brandHsl = hexToHsl(brand.brandColor)
if (!brandHsl) return null
if (!isBrandColorAccessible(brand.brandColor)) return null
const chromeHsl = hexToHsl(getEffectiveChrome(brand))
if (!chromeHsl) return null
const brandTriplet = formatTriplet(brandHsl)
const chromeTriplet = formatTriplet(chromeHsl)
const sidebarSaturation = Math.min(chromeHsl.s, SIDEBAR_SATURATION_MAX)
const sidebarTone = (lightness: number) =>
formatTriplet({ h: chromeHsl.h, s: sidebarSaturation, l: lightness })
// The gate guarantees 4.5:1 for white on the brand color, so
// --primary-foreground is white in the light theme.
const brandVars = [
` --primary: ${brandTriplet};`,
' --primary-foreground: 0 0% 100%;',
` --ring: ${brandTriplet};`,
` --frame: ${chromeTriplet};`,
].join('\n')
// Dark theme: a dark brand color is lifted (darkModeBrandHsl) so rings and
// primary buttons stay visible on dark surfaces. The white-text guarantee
// only holds for the STORED color; the lifted tone may need dark button
// text instead, so the foreground follows the same contrast math as the
// onboarding gate.
const darkHsl = darkModeBrandHsl(brandHsl)
const darkTriplet = formatTriplet(darkHsl)
const darkPrimaryForeground =
getContrastRatio('#ffffff', hslToHex(darkHsl.h, darkHsl.s, darkHsl.l)) >= 4.5
? '0 0% 100%'
: '0 0% 10%'
const darkBrandVars = [
` --primary: ${darkTriplet};`,
` --primary-foreground: ${darkPrimaryForeground};`,
` --ring: ${darkTriplet};`,
` --frame: ${chromeTriplet};`,
].join('\n')
const sidebarVars = [
` --foreground: ${SIDEBAR_FOREGROUND};`,
` --muted-foreground: ${SIDEBAR_MUTED_FOREGROUND};`,
` --primary: ${SIDEBAR_FOREGROUND};`,
` --primary-foreground: ${chromeTriplet};`,
` --secondary: ${sidebarTone(SIDEBAR_SECONDARY_LIGHTNESS)};`,
` --secondary-foreground: ${SIDEBAR_FOREGROUND};`,
` --muted: ${sidebarTone(SIDEBAR_MUTED_LIGHTNESS)};`,
` --border: ${sidebarTone(SIDEBAR_BORDER_LIGHTNESS)};`,
].join('\n')
// Mobile bottom nav (WL-02 gap fix): mobile keeps the pre-frame layout, so
// its chrome is the bottom bar, not the sidebar. Same deep-chrome
// treatment: the bar renders on --card with --border hairlines and
// --primary accents, so scoping those tokens to the bar re-tints it
// exactly like the sidebar. The hook is the data-mobile-nav attribute
// DashboardNav stamps on the bar (a class would couple this block to
// Tailwind utility names).
const mobileNavVars = [
` --card: ${chromeTriplet};`,
` --border: ${sidebarTone(SIDEBAR_BORDER_LIGHTNESS)};`,
` --muted-foreground: ${SIDEBAR_MUTED_FOREGROUND};`,
` --primary: ${SIDEBAR_FOREGROUND};`,
` --primary-foreground: ${chromeTriplet};`,
].join('\n')
// `.bg-frame > aside` is the dashboard sidebar: DashboardNav's <aside> is a
// direct child of the frame wrapper in every shell variant of
// app/(dashboard)/layout.tsx. `bg-frame` is a locked design-system class
// (.claude/rules/design.md), so the selector is stable.
return [
'html[data-brand]:not(.dark) {',
brandVars,
'}',
'html[data-brand].dark {',
darkBrandVars,
'}',
'html[data-brand] .bg-frame > aside {',
sidebarVars,
'}',
'html[data-brand] nav[data-mobile-nav] {',
mobileNavVars,
'}',
].join('\n')
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Curated brand font menu (WL-03).
*
* `next/font` is strictly build-time, so a brand can never bring an arbitrary
* font; it picks from this menu via `brands.font_key`. The delivery pattern
* (documented in WL-03's research) is zero-client-JS:
*
* - every menu font is registered in app/layout.tsx via `next/font` and its
* `.variable` class sits on <html> on every host,
* - the active pair is selected per request by setting `--font-display` /
* `--font-body` as inline style on <html>, which beats the `:root`
* declarations in app/globals.css.
*
* The menu: `'default'` is Hedvig Letters Serif + Geist, exactly today's
* pair, already declared in globals.css. `null` therefore means "no
* per-request override", which keeps default hosts byte-identical. The three
* curated pairs below (all Google Fonts, OFL) are registered in
* app/layout.tsx with `preload: false` (WL-03: only the default pair may
* preload; a preloading menu font would be downloaded by every visitor on
* every host), so a menu font costs nothing until a brand actually selects
* it and the browser resolves its `var()`.
*
* Adding a menu font is one entry + one registration:
* 1. register it in app/layout.tsx via `next/font` with `preload: false`,
* 2. append its `.variable` class to the <html> className list there,
* 3. add one entry here mapping the new font_key to its variable pair.
*/
export interface BrandFontPair {
/** Value for the `--font-display` CSS variable. */
display: string
/** Value for the `--font-body` CSS variable. */
body: string
}
export const DEFAULT_FONT_KEY = 'default'
const FONT_MENU: Record<string, BrandFontPair | null> = {
[DEFAULT_FONT_KEY]: null,
// Lora + Source Sans 3: warm bookish serif over a neutral workhorse sans;
// the closest in spirit to the default pair without echoing it.
lora: {
display: 'var(--font-lora), Georgia, serif',
body: 'var(--font-source-sans), system-ui, sans-serif',
},
// Fraunces + Work Sans: characterful old-style display with a plain
// geometric body; the most distinctive of the three.
fraunces: {
display: 'var(--font-fraunces), Georgia, serif',
body: 'var(--font-work-sans), system-ui, sans-serif',
},
// Playfair Display + Public Sans: high-contrast editorial serif over the
// sober USWDS sans; the most formal of the three.
playfair: {
display: 'var(--font-playfair), Georgia, serif',
body: 'var(--font-public-sans), system-ui, sans-serif',
},
}
/**
* Resolve a brand's font_key to the CSS variable pair to set on <html>.
* Returns null for the default pair and for unknown keys (fail open to the
* globals.css defaults; an unknown key must never break rendering).
*/
export function getBrandFontPair(fontKey: string): BrandFontPair | null {
if (Object.prototype.hasOwnProperty.call(FONT_MENU, fontKey)) {
return FONT_MENU[fontKey]
}
return FONT_MENU[DEFAULT_FONT_KEY]
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Client-safe brand shape + merge logic (WL-12 slice A2).
*
* The full `Brand` row carries ops-internal email plumbing (Resend domain id,
* sender verification state) that has no business in the client bundle. The
* root layout converts the resolved brand to this serializable subset before
* handing it to the BrandProvider.
*
* `mergeClientBranding` is the single place where per-request brand values
* are layered over the sync `getBranding()` defaults; it is pure so the
* fallback behavior is unit-testable without rendering. No brand in, defaults
* out untouched: that is the additive guarantee for un-branded hosts.
*/
import type { BrandingConfig } from './service'
import type { Brand } from './resolve'
/** Serializable subset of a Brand that may cross to the client. */
export interface PublicBrand {
appName: string
logoUrl: string | null
supportEmail: string
authEmailFrom: string | null
brandColor: string
fontKey: string
domain: string
}
export function toPublicBrand(brand: Brand): PublicBrand {
return {
appName: brand.appName,
logoUrl: brand.logoUrl,
supportEmail: brand.supportEmail,
authEmailFrom: brand.authEmailFrom,
brandColor: brand.brandColor,
fontKey: brand.fontKey,
domain: brand.domain,
}
}
export interface ClientBranding extends BrandingConfig {
/** Brand logo URL on a branded host; null on default hosts (use logoPath). */
logoUrl: string | null
/** The per-request brand, or null on default hosts. */
brand: PublicBrand | null
}
/**
* Layer the per-request brand over the sync branding defaults. With no brand
* every value is exactly `getBranding()`'s (defaults < env < extension), so
* un-migrated surfaces and default hosts render exactly as before.
*/
export function mergeClientBranding(
base: BrandingConfig,
brand: PublicBrand | null,
): ClientBranding {
if (!brand) {
return { ...base, logoUrl: null, brand: null }
}
return {
...base,
appName: brand.appName,
supportEmail: brand.supportEmail,
authEmailFrom: brand.authEmailFrom ?? base.authEmailFrom,
themeColor: brand.brandColor,
logoUrl: brand.logoUrl,
brand,
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'server-only'
/**
* Request-scoped brand helpers for SERVER components (WL-12 slice A4).
*
* Server components cannot use the client `useBranding()` hook; they resolve
* the brand from the request Host header instead, exactly like the root
* layout does. Unknown hosts (the canonical domain included) fall back to
* `getBranding()` defaults, which keeps default hosts byte-identical.
*
* Do NOT import this from client components or from host-less paths
* (cron, API-key routes): those use `resolveBrandForCompany()` directly.
*/
import { headers } from 'next/headers'
import { getBranding } from './service'
import { normalizeHost, resolveBrandByHost, type Brand } from './resolve'
/**
* Local dev override (WL-17): host-based resolution can never brand
* localhost (no brands row exists for it), which made branding untestable in
* local dev. Setting BRAND_DEV_DOMAIN=<brands.domain> in .env.local makes
* localhost render that brand. Guarded on the literal local hosts, so the
* variable can never rebrand a real domain even if it leaks into a deployed
* environment. UI-only: the middleware home-domain rule still sees the real
* host, so company partitioning stays un-overridden.
*/
const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1'])
/** The brand serving the current request, or null on default/unknown hosts. */
export async function resolveRequestBrand(): Promise<Brand | null> {
const requestHeaders = await headers()
const host = requestHeaders.get('host')
if (!host) return null
const devDomain = process.env.BRAND_DEV_DOMAIN
if (devDomain && LOCAL_DEV_HOSTS.has(normalizeHost(host))) {
return resolveBrandByHost(devDomain)
}
return resolveBrandByHost(host)
}
/**
* The app name the current request should display: the request host's brand
* name when one exists, else the `getBranding()` default. The server-side
* counterpart of `useBranding().appName`.
*/
export async function getRequestAppName(): Promise<string> {
const brand = await resolveRequestBrand()
return brand?.appName ?? getBranding().appName
}
+338
View File
@@ -0,0 +1,338 @@
/**
* Brand resolution for white-label domains.
*
* Two resolvers against the same small `brands` table (WL-12):
*
* - resolveBrandByHost(host): what the screen shows (root layout reads
* the request host)
* - resolveBrandForCompany(id): what outbound artifacts carry (email
* sender identity, mailed links) via
* companies.team_id -> brands.team_id,
* for cron/API paths that have no host
*
* Both use the cookieless service client and bypass RLS by design: anonymous
* visitors on a brand domain need the brand before login. Results are cached
* in-memory per key with a short TTL (~60s); ops-assisted onboarding
* tolerates that staleness, so there is no invalidation infrastructure in v1.
* Unknown hosts and brandless companies resolve to null, cached too, so the
* canonical-domain hot path costs one lookup per TTL window; callers fall
* back to getBranding() defaults (lib/branding/service.ts).
*/
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { getContrastRatio } from '@/lib/invoices/contrast-check'
/** Camelcase mirror of a public.brands row. */
export interface Brand {
id: string
teamId: string
domain: string
appName: string
logoUrl: string | null
/** Square tab icon; the layout prefers it over logoUrl for rel="icon". */
faviconUrl: string | null
brandColor: string
chromeColor: string | null
fontKey: string
supportEmail: string
authEmailFrom: string | null
senderDomain: string | null
senderDomainStatus: 'unverified' | 'pending' | 'verified' | 'failed'
resendDomainId: string | null
}
interface BrandRow {
id: string
team_id: string
domain: string
app_name: string
logo_url: string | null
favicon_url: string | null
brand_color: string
chrome_color: string | null
font_key: string
support_email: string
auth_email_from: string | null
sender_domain: string | null
sender_domain_status: string
resend_domain_id: string | null
}
function mapRow(row: BrandRow): Brand {
return {
id: row.id,
teamId: row.team_id,
domain: row.domain,
appName: row.app_name,
logoUrl: row.logo_url,
faviconUrl: row.favicon_url ?? null,
brandColor: row.brand_color,
chromeColor: row.chrome_color,
fontKey: row.font_key,
supportEmail: row.support_email,
authEmailFrom: row.auth_email_from,
senderDomain: row.sender_domain,
senderDomainStatus: row.sender_domain_status as Brand['senderDomainStatus'],
resendDomainId: row.resend_domain_id,
}
}
// ============================================================
// In-memory TTL cache (module-level; no external deps)
// ============================================================
const CACHE_TTL_MS = 60_000
interface CacheEntry {
value: Brand | null
expiresAt: number
}
const cache = new Map<string, CacheEntry>()
function readCache(key: string): CacheEntry | undefined {
const entry = cache.get(key)
if (!entry) return undefined
if (entry.expiresAt <= Date.now()) {
cache.delete(key)
return undefined
}
return entry
}
function writeCache(key: string, value: Brand | null): void {
cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS })
}
/** Test hook: drop every cached resolution. */
export function clearBrandCache(): void {
cache.clear()
}
// ============================================================
// Resolvers
// ============================================================
/**
* Normalize a Host header value to the shape stored in brands.domain:
* lowercase hostname, no port, no trailing dot.
*/
export function normalizeHost(host: string): string {
let h = host.trim().toLowerCase()
const colon = h.indexOf(':')
if (colon !== -1) h = h.slice(0, colon)
return h.replace(/\.+$/, '')
}
/**
* Look up the brand serving `host`. Null for unknown hosts (the canonical
* domain included): callers fall back to getBranding() defaults, which keeps
* the additive guarantee (unknown host = default Accounted, bit for bit).
*/
export async function resolveBrandByHost(host: string): Promise<Brand | null> {
const normalized = normalizeHost(host)
if (!normalized) return null
const key = `host:${normalized}`
const hit = readCache(key)
if (hit) return hit.value
const supabase = createServiceClientNoCookies()
const { data, error } = await supabase
.from('brands')
.select('*')
.eq('domain', normalized)
.maybeSingle()
if (error) {
// Transient failure: fall back to defaults without caching, so a live
// brand is not masked for a whole TTL window by one failed query.
return null
}
const brand = data ? mapRow(data as BrandRow) : null
writeCache(key, brand)
return brand
}
/**
* Look up the brand a company's outbound artifacts should carry:
* companies.team_id -> brands.team_id. Null when the company does not exist,
* has no team, or its team has no brand.
*/
export async function resolveBrandForCompany(companyId: string): Promise<Brand | null> {
if (!companyId) return null
const key = `company:${companyId}`
const hit = readCache(key)
if (hit) return hit.value
const supabase = createServiceClientNoCookies()
const { data: company, error: companyError } = await supabase
.from('companies')
.select('team_id')
.eq('id', companyId)
.maybeSingle()
if (companyError) return null
const teamId = (company as { team_id: string | null } | null)?.team_id ?? null
if (!teamId) {
writeCache(key, null)
return null
}
const { data, error } = await supabase
.from('brands')
.select('*')
.eq('team_id', teamId)
.maybeSingle()
if (error) return null
const brand = data ? mapRow(data as BrandRow) : null
writeCache(key, brand)
return brand
}
/**
* Look up the brand owned by a team directly (brands.team_id is unique).
* For team-scoped artifacts that concern no single company, e.g. the byrå
* team-invite mail. Null when the team has no brand; cached like the other
* resolvers.
*/
export async function resolveBrandForTeam(teamId: string): Promise<Brand | null> {
if (!teamId) return null
const key = `team:${teamId}`
const hit = readCache(key)
if (hit) return hit.value
const supabase = createServiceClientNoCookies()
const { data, error } = await supabase
.from('brands')
.select('*')
.eq('team_id', teamId)
.maybeSingle()
if (error) return null
const brand = data ? mapRow(data as BrandRow) : null
writeCache(key, brand)
return brand
}
// ============================================================
// Chrome derivation (WL-02: config model A standard, C opt-in)
// ============================================================
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/
// Deep chrome tone bounds (WL-02 round 2): keep the brand hue, clamp
// saturation to roughly 30-35% and lightness to roughly 15-17% so the
// frame/sidebar reads as a deep-branded tone in both light and dark mode.
const CHROME_SATURATION_MIN = 0.3
const CHROME_SATURATION_MAX = 0.35
const CHROME_LIGHTNESS_MIN = 0.15
const CHROME_LIGHTNESS_MAX = 0.17
// Below this saturation the hue carries no real information (HSL reports
// hue 0 for pure grays); boosting saturation would tint a neutral brand
// red-ish, so near-achromatic brand colors keep their neutrality.
const ACHROMATIC_SATURATION_THRESHOLD = 0.05
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
function hexToHsl(hex: string): { h: number; s: number; l: number } {
const m = HEX_COLOR_RE.exec(hex.trim())
if (!m) throw new Error(`Invalid hex color: ${hex}`)
const value = m[0].slice(1)
const r = parseInt(value.slice(0, 2), 16) / 255
const g = parseInt(value.slice(2, 4), 16) / 255
const b = parseInt(value.slice(4, 6), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
const d = max - min
let h = 0
let s = 0
if (d !== 0) {
s = d / (1 - Math.abs(2 * l - 1))
if (max === r) h = ((g - b) / d) % 6
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
h *= 60
if (h < 0) h += 360
}
return { h, s, l }
}
/** HSL (h in degrees, s/l as 0-1 fractions) to `#rrggbb`. Exported for the
* brand style block's dark-mode contrast check (lib/branding/brand-style.ts). */
export function hslToHex(h: number, s: number, l: number): string {
const c = (1 - Math.abs(2 * l - 1)) * s
const hp = h / 60
const x = c * (1 - Math.abs((hp % 2) - 1))
let r1 = 0
let g1 = 0
let b1 = 0
if (hp < 1) [r1, g1] = [c, x]
else if (hp < 2) [r1, g1] = [x, c]
else if (hp < 3) [g1, b1] = [c, x]
else if (hp < 4) [g1, b1] = [x, c]
else if (hp < 5) [r1, b1] = [x, c]
else [r1, b1] = [c, x]
const m = l - c / 2
const toHex = (v: number) =>
Math.round((v + m) * 255)
.toString(16)
.padStart(2, '0')
return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`
}
/**
* Derive the deep chrome tone from the brand color: same hue, saturation
* clamped to [30%, 35%], lightness clamped to [15%, 17%]. Deterministic.
* Near-achromatic inputs stay achromatic (a gray brand gets a neutral dark
* gray chrome, not a red tint).
*/
export function deriveChromeColor(brandColorHex: string): string {
const { h, s, l } = hexToHsl(brandColorHex)
const chromeS =
s < ACHROMATIC_SATURATION_THRESHOLD
? s
: clamp(s, CHROME_SATURATION_MIN, CHROME_SATURATION_MAX)
const chromeL = clamp(l, CHROME_LIGHTNESS_MIN, CHROME_LIGHTNESS_MAX)
return hslToHex(h, chromeS, chromeL)
}
/**
* The chrome color a brand actually renders with: the explicit ops override
* when set, else the tone derived from the brand color.
*/
export function getEffectiveChrome(brand: Pick<Brand, 'brandColor' | 'chromeColor'>): string {
return brand.chromeColor ?? deriveChromeColor(brand.brandColor)
}
// ============================================================
// Contrast guardrail (WL-02: reuse the invoice-branding check)
// ============================================================
/**
* Whether white text on the brand color clears WCAG 2.2 AA for normal text
* (4.5:1), i.e. the color is usable as a primary button background. Reuses
* the invoice-branding contrast math (lib/invoices/contrast-check.ts).
* Invalid hex input returns false rather than throwing: the gate rejects,
* it never crashes onboarding.
*/
export function isBrandColorAccessible(hex: string): boolean {
if (!HEX_COLOR_RE.test(hex.trim())) return false
return getContrastRatio('#ffffff', hex.trim()) >= 4.5
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Batched brand lookup per team, for the home-domain rule (WL-01).
*
* The dashboard layout needs "does team X have a brand, and on which domain"
* for every company the user belongs to, in one round trip: looping
* resolveBrandForCompany would cost 2 queries per company on a cold cache.
* Like lib/branding/resolve.ts this uses the cookieless service client and
* bypasses RLS BY DESIGN: an end client (Kalle) is not a member of the byrå
* team, so RLS would hide the brand row he needs for his "hanteras via
* app.siffra.se" signpost. Only domain + app name leave this module: no
* email identity or color config is exposed to that path.
*
* Results are cached in-memory per team id with the same ~60s TTL as the
* resolvers; ops-assisted onboarding tolerates that staleness.
*/
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import type { TeamBrandRef } from '@/lib/company/home-domain'
const CACHE_TTL_MS = 60_000
interface CacheEntry {
value: TeamBrandRef | null
expiresAt: number
}
const cache = new Map<string, CacheEntry>()
/** Test hook: drop every cached team brand. */
export function clearTeamBrandCache(): void {
cache.clear()
}
/**
* Resolve brands for a set of team ids. Returns a map containing ONLY teams
* that have a brand; brandless teams are absent (and negatively cached).
* On a transient query failure the uncached teams resolve as brandless for
* this call, without poisoning the cache: the canonical appearance is the
* safe fallback (the additive guarantee).
*/
export async function resolveBrandsForTeams(
teamIds: Array<string | null | undefined>,
): Promise<Map<string, TeamBrandRef>> {
const now = Date.now()
const result = new Map<string, TeamBrandRef>()
const misses: string[] = []
const unique = [...new Set(teamIds.filter((id): id is string => !!id))]
for (const teamId of unique) {
const entry = cache.get(teamId)
if (entry && entry.expiresAt > now) {
if (entry.value) result.set(teamId, entry.value)
continue
}
misses.push(teamId)
}
if (misses.length === 0) return result
const supabase = createServiceClientNoCookies()
const { data, error } = await supabase
.from('brands')
.select('team_id, domain, app_name')
.in('team_id', misses)
if (error) {
// Transient failure: treat the misses as brandless for this call only.
return result
}
const found = new Map<string, TeamBrandRef>()
for (const row of (data ?? []) as Array<{
team_id: string
domain: string
app_name: string
}>) {
found.set(row.team_id, { domain: row.domain, appName: row.app_name })
}
const expiresAt = Date.now() + CACHE_TTL_MS
for (const teamId of misses) {
const value = found.get(teamId) ?? null
cache.set(teamId, { value, expiresAt })
if (value) result.set(teamId, value)
}
return result
}
+185
View File
@@ -0,0 +1,185 @@
import { describe, it, expect } from 'vitest'
import {
filterBucketsToRange,
mergeMonthlySeries,
periodsInRange,
pickCurrentPeriod,
resolvePeriodPreset,
resultMargin,
sumBuckets,
type FiscalPeriodLite,
type MonthBucket,
} from '@/lib/byra/kpi-aggregate'
const bucket = (
year: number,
month0: number,
income = 0,
expenses = 0,
): MonthBucket => ({ year, month0, income, expenses })
const period = (
id: string,
start: string,
end: string,
ob: string | null = null,
): FiscalPeriodLite => ({
id,
company_id: 'c1',
period_start: start,
period_end: end,
opening_balance_entry_id: ob,
})
describe('resolvePeriodPreset', () => {
const today = new Date(2026, 7, 4) // 2026-08-04
it('resolves month to the first of the current month', () => {
expect(resolvePeriodPreset('month', today).range).toEqual({
fromDate: '2026-08-01',
toDate: '2026-08-04',
})
})
it('resolves quarter to the quarter start', () => {
expect(resolvePeriodPreset('quarter', today).range).toEqual({
fromDate: '2026-07-01',
toDate: '2026-08-04',
})
})
it('resolves ytd to January 1', () => {
expect(resolvePeriodPreset('ytd', today).range).toEqual({
fromDate: '2026-01-01',
toDate: '2026-08-04',
})
})
it('resolves 12m across the year boundary', () => {
expect(resolvePeriodPreset('12m', today).range).toEqual({
fromDate: '2025-09-01',
toDate: '2026-08-04',
})
})
it('falls back to ytd on unknown or missing input', () => {
expect(resolvePeriodPreset('bogus', today).preset).toBe('ytd')
expect(resolvePeriodPreset(undefined, today).preset).toBe('ytd')
})
it('handles a January today for quarter and 12m', () => {
const january = new Date(2026, 0, 15)
expect(resolvePeriodPreset('quarter', january).range.fromDate).toBe('2026-01-01')
expect(resolvePeriodPreset('12m', january).range.fromDate).toBe('2025-02-01')
})
})
describe('pickCurrentPeriod', () => {
const fy25 = period('p25', '2025-01-01', '2025-12-31')
const fy26 = period('p26', '2026-01-01', '2026-12-31')
const fy27 = period('p27', '2027-01-01', '2027-12-31')
it('picks the period containing today', () => {
expect(pickCurrentPeriod([fy25, fy26, fy27], '2026-08-04')?.id).toBe('p26')
})
it('falls back to the most recent past period', () => {
expect(pickCurrentPeriod([fy25, fy26], '2027-06-01')?.id).toBe('p26')
})
it('falls back to the earliest future period when all are ahead', () => {
expect(pickCurrentPeriod([fy26, fy27], '2024-06-01')?.id).toBe('p26')
})
it('returns null for no periods', () => {
expect(pickCurrentPeriod([], '2026-08-04')).toBeNull()
})
it('prefers the latest start when periods overlap today', () => {
const short = period('pShort', '2026-07-01', '2026-12-31')
expect(pickCurrentPeriod([fy26, short], '2026-08-04')?.id).toBe('pShort')
})
})
describe('periodsInRange', () => {
it('keeps periods overlapping the range and drops the rest', () => {
const fy25 = period('p25', '2025-01-01', '2025-12-31')
const fy26 = period('p26', '2026-01-01', '2026-12-31')
const range = { fromDate: '2025-09-01', toDate: '2026-08-04' }
expect(periodsInRange([fy25, fy26], range).map((p) => p.id)).toEqual(['p25', 'p26'])
expect(
periodsInRange([fy25], { fromDate: '2026-01-01', toDate: '2026-08-04' }),
).toEqual([])
})
})
describe('filterBucketsToRange', () => {
it('keeps whole months inside the range, inclusive of partial edge months', () => {
const buckets = [
bucket(2025, 7),
bucket(2025, 8),
bucket(2026, 7),
bucket(2026, 8),
]
const range = { fromDate: '2025-09-01', toDate: '2026-08-04' }
expect(filterBucketsToRange(buckets, range)).toEqual([
bucket(2025, 8),
bucket(2026, 7),
])
})
})
describe('sumBuckets and resultMargin', () => {
it('sums revenue, expenses and result with öre rounding', () => {
const sums = sumBuckets([
bucket(2026, 0, 100.005, 50.004),
bucket(2026, 1, 200, 25),
])
expect(sums.revenue).toBe(300.01)
expect(sums.expenses).toBe(75.0)
expect(sums.result).toBe(225.01)
})
it('returns zeros for no buckets', () => {
expect(sumBuckets([])).toEqual({ revenue: 0, expenses: 0, result: 0 })
})
it('computes margin in percent and nulls out on zero revenue', () => {
expect(resultMargin(200, 50)).toBe(25)
expect(resultMargin(300, 100)).toBe(33.33)
expect(resultMargin(0, -500)).toBeNull()
})
})
describe('mergeMonthlySeries', () => {
it('zero-fills every month of the range and sums across companies', () => {
const range = { fromDate: '2026-01-01', toDate: '2026-03-31' }
const months = mergeMonthlySeries(
[bucket(2026, 0, 100, 40), bucket(2026, 0, 50, 10), bucket(2026, 2, 30, 5)],
range,
)
expect(months).toEqual([
{ label: 'Jan', income: 150, expenses: 50 },
{ label: 'Feb', income: 0, expenses: 0 },
{ label: 'Mar', income: 30, expenses: 5 },
])
})
it('adds a year suffix when the range spans calendar years', () => {
const range = { fromDate: '2025-12-01', toDate: '2026-01-31' }
const months = mergeMonthlySeries([bucket(2025, 11, 10, 0)], range)
expect(months.map((m) => m.label)).toEqual(['Dec 25', 'Jan 26'])
})
it('ignores buckets outside the range', () => {
const range = { fromDate: '2026-01-01', toDate: '2026-01-31' }
const months = mergeMonthlySeries([bucket(2025, 11, 999, 999)], range)
expect(months).toEqual([{ label: 'Jan', income: 0, expenses: 0 }])
})
it('returns empty for an inverted range', () => {
expect(
mergeMonthlySeries([], { fromDate: '2026-03-01', toDate: '2026-01-31' }),
).toEqual([])
})
})
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server'
import { createServiceClient } from '@/lib/supabase/server'
import { getByraMembership } from '@/lib/clients/fetch-client-overview'
/**
* Shared authorization for byrå brand write routes (WL-17).
*
* brands has no write RLS (rows are ops-managed), so every brand write goes
* through the service client behind this explicit owner/admin check on the
* caller's byrå team: the same authorization idiom as /api/team/members.
*/
export interface ByraBrandAccess {
teamId: string
serviceClient: ReturnType<typeof createServiceClient>
errorResponse: NextResponse | null
}
/** Resolve the caller's byrå team and require owner/admin, or an error response. */
export async function requireByraBrandAccess(userId: string): Promise<ByraBrandAccess> {
const serviceClient = createServiceClient()
const membership = await getByraMembership(serviceClient, userId)
if (!membership) {
return {
teamId: '',
serviceClient,
errorResponse: NextResponse.json({ error: 'Endast för byråteam.' }, { status: 403 }),
}
}
if (membership.role !== 'owner' && membership.role !== 'admin') {
return {
teamId: membership.teamId,
serviceClient,
errorResponse: NextResponse.json(
{ error: 'Endast ägare och administratörer kan ändra varumärket.' },
{ status: 403 },
),
}
}
return { teamId: membership.teamId, serviceClient, errorResponse: null }
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Pure aggregation helpers for the byrå cockpit Nyckeltal page (WL-16).
*
* The fetch layer (lib/byra/kpi-overview.ts) pulls per-fiscal-period KPI
* aggregates for every client company; everything computable without I/O
* lives here so it is unit-testable: period-preset resolution, fiscal-period
* selection, calendar-month bucket filtering, and the merged cross-client
* monthly series for the chart.
*
* Clients can have different fiscal years, so the cross-client axis is
* CALENDAR months (a deliberate v1 choice; see DECISIONS.md). Balance-side
* numbers (cash, VAT) are as-of the current fiscal period's activity, never
* range-filtered: balances do not decompose over date ranges.
*/
import { roundOre } from '@/lib/money'
import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart'
/** Swedish month labels, mirrors MONTH_LABELS in lib/reports/monthly-breakdown.ts
* (not imported: that module pulls server-side report code, and this helper is
* bundled into the client via ByraKpiView). */
const MONTH_LABELS = [
'Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun',
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec',
]
export type KpiPeriodPreset = 'month' | 'quarter' | 'ytd' | '12m'
export const KPI_PERIOD_PRESETS: KpiPeriodPreset[] = ['month', 'quarter', 'ytd', '12m']
/** Inclusive ISO date range (yyyy-MM-dd). */
export interface KpiRange {
fromDate: string
toDate: string
}
/** One month of P&L activity for one company (0-based month, JS convention). */
export interface MonthBucket {
year: number
month0: number
income: number
expenses: number
}
export interface FiscalPeriodLite {
id: string
company_id: string
period_start: string
period_end: string
opening_balance_entry_id: string | null
}
function isoDate(year: number, month0: number, day: number): string {
return `${year}-${String(month0 + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
/** Year and 0-based month straight from the ISO string (timezone-proof). */
function isoYearMonth0(iso: string): { year: number; month0: number } {
return { year: Number(iso.slice(0, 4)), month0: Number(iso.slice(5, 7)) - 1 }
}
/** Absolute month index for range comparisons. */
function monthIndex(year: number, month0: number): number {
return year * 12 + month0
}
/**
* Resolve the period preset from the URL into a calendar date range ending
* today. Unknown or missing values fall back to 'ytd'. `today` is injected
* so the resolution is deterministic in tests.
*/
export function resolvePeriodPreset(
raw: string | undefined,
today: Date,
): { preset: KpiPeriodPreset; range: KpiRange } {
const preset: KpiPeriodPreset = (KPI_PERIOD_PRESETS as string[]).includes(raw ?? '')
? (raw as KpiPeriodPreset)
: 'ytd'
const y = today.getFullYear()
const m = today.getMonth()
const toDate = isoDate(y, m, today.getDate())
let fromDate: string
switch (preset) {
case 'month':
fromDate = isoDate(y, m, 1)
break
case 'quarter':
fromDate = isoDate(y, Math.floor(m / 3) * 3, 1)
break
case '12m': {
const start = monthIndex(y, m) - 11
fromDate = isoDate(Math.floor(start / 12), start % 12, 1)
break
}
case 'ytd':
fromDate = isoDate(y, 0, 1)
break
}
return { preset, range: { fromDate, toDate } }
}
/**
* The fiscal period whose closing balances represent "now" for a company:
* the period containing today (latest start wins if periods overlap), else
* the most recent past period, else the earliest future one. Null only when
* the company has no periods at all.
*/
export function pickCurrentPeriod(
periods: FiscalPeriodLite[],
todayIso: string,
): FiscalPeriodLite | null {
if (periods.length === 0) return null
const containing = periods
.filter((p) => p.period_start <= todayIso && p.period_end >= todayIso)
.sort((a, b) => b.period_start.localeCompare(a.period_start))
if (containing.length > 0) return containing[0]
const past = periods
.filter((p) => p.period_end < todayIso)
.sort((a, b) => b.period_end.localeCompare(a.period_end))
if (past.length > 0) return past[0]
return [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))[0]
}
/** Fiscal periods whose span overlaps the range (inclusive on both ends). */
export function periodsInRange(
periods: FiscalPeriodLite[],
range: KpiRange,
): FiscalPeriodLite[] {
return periods.filter(
(p) => p.period_start <= range.toDate && p.period_end >= range.fromDate,
)
}
/**
* Keep buckets whose calendar month falls inside the range. Month
* granularity: a range starting or ending mid-month includes that whole
* month's bucket (presets start on month boundaries, and the current month's
* bucket only ever contains entries up to today).
*/
export function filterBucketsToRange(buckets: MonthBucket[], range: KpiRange): MonthBucket[] {
const from = isoYearMonth0(range.fromDate)
const to = isoYearMonth0(range.toDate)
const fromIdx = monthIndex(from.year, from.month0)
const toIdx = monthIndex(to.year, to.month0)
return buckets.filter((b) => {
const idx = monthIndex(b.year, b.month0)
return idx >= fromIdx && idx <= toIdx
})
}
export interface RangeSums {
revenue: number
expenses: number
result: number
}
/** Sum a company's in-range buckets into revenue/expenses/result. */
export function sumBuckets(buckets: MonthBucket[]): RangeSums {
let income = 0
let expenses = 0
for (const b of buckets) {
income += b.income
expenses += b.expenses
}
const revenue = roundOre(income)
const rounded = roundOre(expenses)
return { revenue, expenses: rounded, result: roundOre(revenue - rounded) }
}
/**
* Net result margin in percent (two decimals), null when there is no
* revenue to divide by. Mirrors the percent idiom in lib/reports/kpi.ts.
*/
export function resultMargin(revenue: number, result: number): number | null {
if (revenue === 0) return null
return Math.round((result / revenue) * 10000) / 100
}
/**
* Merge every selected company's in-range buckets into one chart series:
* all months of the range zero-initialized, bucket sums added, Swedish
* month labels with a two-digit year suffix when the range spans years.
*/
export function mergeMonthlySeries(buckets: MonthBucket[], range: KpiRange): MonthlyDataPoint[] {
const from = isoYearMonth0(range.fromDate)
const to = isoYearMonth0(range.toDate)
const fromIdx = monthIndex(from.year, from.month0)
const toIdx = monthIndex(to.year, to.month0)
if (toIdx < fromIdx) return []
const sums = new Map<number, { income: number; expenses: number }>()
for (let idx = fromIdx; idx <= toIdx; idx++) {
sums.set(idx, { income: 0, expenses: 0 })
}
for (const b of buckets) {
const target = sums.get(monthIndex(b.year, b.month0))
if (!target) continue
target.income += b.income
target.expenses += b.expenses
}
const spansYears = from.year !== to.year
const months: MonthlyDataPoint[] = []
for (let idx = fromIdx; idx <= toIdx; idx++) {
const year = Math.floor(idx / 12)
const month0 = idx % 12
const label = spansYears
? `${MONTH_LABELS[month0]} ${String(year).slice(-2)}`
: MONTH_LABELS[month0]
const sum = sums.get(idx)!
months.push({
label,
income: roundOre(sum.income),
expenses: roundOre(sum.expenses),
})
}
return months
}
+299
View File
@@ -0,0 +1,299 @@
/**
* Byrå cockpit Nyckeltal fetch layer (WL-16): cross-client KPI numbers.
*
* Read-first like the rest of the cockpit (WL-09): every query is a
* cross-membership READ that RLS already allows via user_company_ids().
* Per client company the numbers come from the get_kpi_report_aggregates
* RPC (SECURITY INVOKER, one SQL pass per fiscal period; the same fast
* path the in-company KPI route uses), so nothing here scans journal
* lines through PostgREST.
*
* One RPC call per (company, fiscal period): with the byrå scale this is
* designed for (~10 clients, 1-2 periods per range) that is a couple of
* dozen parallel round trips. Past ~15 clients the right move is a new
* RPC taking uuid[] (see DECISIONS.md).
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
fetchClientOverview,
type ClientOverview,
} from '@/lib/clients/fetch-client-overview'
import type { ClientDeadline } from '@/lib/clients/aggregate'
import {
fetchKpiAggregates,
buildOpeningBalances,
buildTrialBalanceRows,
} from '@/lib/reports/kpi-aggregates'
import { calculateCashPosition, calculateVatLiability } from '@/lib/reports/kpi'
import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart'
import {
filterBucketsToRange,
mergeMonthlySeries,
periodsInRange,
pickCurrentPeriod,
resolvePeriodPreset,
resultMargin,
sumBuckets,
type FiscalPeriodLite,
type KpiPeriodPreset,
type KpiRange,
type MonthBucket,
} from '@/lib/byra/kpi-aggregate'
const log = createLogger('byra:kpi-overview')
/** Max ids per PostgREST .in() filter (mirrors lib/clients/fetch-client-overview.ts). */
const IN_CLAUSE_CHUNK = 150
function chunked<T>(items: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
return out
}
export interface ByraKpiClientRow {
companyId: string
name: string
orgNumber: string | null
revenue: number
expenses: number
result: number
/** Net margin in percent, null when revenue is 0. */
margin: number | null
/** Closing balance of 19xx accounts for the current fiscal period. */
cash: number
/** Net 26xx position, positive = att betala (mirrors ruta 49). */
vatLiability: number
unbookedCount: number
inboxCount: number
nextDeadline: ClientDeadline | null
/** True when this client's numbers could not be fetched; render dashes, not zeros. */
failed: boolean
}
export interface ByraKpiOverview {
team: { id: string; name: string }
role: ClientOverview['role']
preset: KpiPeriodPreset
range: KpiRange
/** Full roster for the company filter chips (unfiltered). */
allClients: Array<{ companyId: string; name: string }>
/** Ids the current filter selects (equals the full roster when unfiltered). */
selectedIds: string[]
/** One row per selected client, revenue descending. */
rows: ByraKpiClientRow[]
/** Merged monthly income/expense series across the selected clients. */
months: MonthlyDataPoint[]
}
interface CompanyKpiNumbers {
revenue: number
expenses: number
result: number
margin: number | null
cash: number
vatLiability: number
failed: boolean
buckets: MonthBucket[]
}
const FAILED_NUMBERS: CompanyKpiNumbers = {
revenue: 0,
expenses: 0,
result: 0,
margin: null,
cash: 0,
vatLiability: 0,
failed: true,
buckets: [],
}
/**
* Fetch one company's numbers: monthly P&L buckets clipped to the range,
* plus cash and VAT from the current fiscal period's closing balances.
* Account names are irrelevant here (nothing displays them), so the trial
* balance rows are built with an empty account map.
*/
async function fetchCompanyNumbers(
supabase: SupabaseClient,
companyId: string,
periods: FiscalPeriodLite[],
range: KpiRange,
todayIso: string,
): Promise<CompanyKpiNumbers> {
const current = pickCurrentPeriod(periods, todayIso)
const inRange = periodsInRange(periods, range)
const needed = new Map<string, FiscalPeriodLite>()
for (const p of inRange) needed.set(p.id, p)
if (current) needed.set(current.id, current)
if (needed.size === 0) {
return { ...FAILED_NUMBERS, failed: false }
}
const [aggEntries, priorResult] = await Promise.all([
Promise.all(
[...needed.values()].map(async (period) => ({
period,
agg: await fetchKpiAggregates(
supabase,
companyId,
period.id,
period.opening_balance_entry_id,
),
})),
),
// Opening balances without an OB entry fall back to the server-side
// prior-period aggregate, exactly like the in-company KPI route.
current && !current.opening_balance_entry_id
? supabase.rpc('compute_prior_opening_balances', {
p_company_id: companyId,
p_period_start: current.period_start,
})
: Promise.resolve(null),
])
if (priorResult?.error) {
throw new Error(priorResult.error.message)
}
const inRangeIds = new Set(inRange.map((p) => p.id))
const buckets: MonthBucket[] = []
for (const { period, agg } of aggEntries) {
if (!inRangeIds.has(period.id)) continue
for (const m of agg.monthly) {
buckets.push({ year: m.year, month0: m.month - 1, income: m.income, expenses: m.expenses })
}
}
const rangeBuckets = filterBucketsToRange(buckets, range)
const sums = sumBuckets(rangeBuckets)
let cash = 0
let vatLiability = 0
if (current) {
const currentAgg = aggEntries.find((e) => e.period.id === current.id)!.agg
const openingBalances = buildOpeningBalances(
currentAgg,
current.opening_balance_entry_id ? null : (priorResult?.data ?? []),
)
const tbRows = buildTrialBalanceRows(openingBalances, currentAgg.tb, new Map())
cash = calculateCashPosition(tbRows)
vatLiability = calculateVatLiability(tbRows)
}
return {
...sums,
margin: resultMargin(sums.revenue, sums.result),
cash,
vatLiability,
failed: false,
buckets: rangeBuckets,
}
}
/**
* Aggregate the Nyckeltal overview for the caller's byrå team. Returns null
* when the user is not a byrå team member (the page redirects). A failing
* client is marked `failed` and zero-weighted rather than failing the page.
*/
export async function fetchByraKpiOverview(
supabase: SupabaseClient,
userId: string,
opts: { preset?: string; companyIds?: string[]; today?: Date } = {},
): Promise<ByraKpiOverview | null> {
const overview = await fetchClientOverview(supabase, userId)
if (!overview) return null
const today = opts.today ?? new Date()
const { preset, range } = resolvePeriodPreset(opts.preset, today)
const todayIso = range.toDate
const roster = overview.clients
const requested = new Set(opts.companyIds ?? [])
const validRequested = roster.filter((c) => requested.has(c.companyId))
const selected = validRequested.length > 0 ? validRequested : roster
const base: ByraKpiOverview = {
team: overview.team,
role: overview.role,
preset,
range,
allClients: roster.map((c) => ({ companyId: c.companyId, name: c.name })),
selectedIds: selected.map((c) => c.companyId),
rows: [],
months: [],
}
if (selected.length === 0) return base
const selectedIds = base.selectedIds
const periodRows: FiscalPeriodLite[] = []
for (const chunk of chunked(selectedIds, IN_CLAUSE_CHUNK)) {
const rows = await fetchAllRows<FiscalPeriodLite>(({ from, to }) =>
supabase
.from('fiscal_periods')
.select('id, company_id, period_start, period_end, opening_balance_entry_id')
.in('company_id', chunk)
.order('id', { ascending: true })
.range(from, to),
)
periodRows.push(...rows)
}
const periodsByCompany = new Map<string, FiscalPeriodLite[]>()
for (const p of periodRows) {
const list = periodsByCompany.get(p.company_id)
if (list) list.push(p)
else periodsByCompany.set(p.company_id, [p])
}
const numbers = await Promise.all(
selected.map(async (client) => {
try {
return await fetchCompanyNumbers(
supabase,
client.companyId,
periodsByCompany.get(client.companyId) ?? [],
range,
todayIso,
)
} catch (err) {
log.error('byra-kpi client fetch failed', err, {
companyId: client.companyId,
operation: 'byra.kpi_overview',
})
return FAILED_NUMBERS
}
}),
)
const rows: ByraKpiClientRow[] = selected.map((client, i) => {
const n = numbers[i]
return {
companyId: client.companyId,
name: client.name,
orgNumber: client.orgNumber,
revenue: n.revenue,
expenses: n.expenses,
result: n.result,
margin: n.margin,
cash: n.cash,
vatLiability: n.vatLiability,
unbookedCount: client.unbookedCount,
inboxCount: client.inboxCount,
nextDeadline: client.nextDeadline,
failed: n.failed,
}
})
rows.sort((a, b) => b.revenue - a.revenue || a.name.localeCompare(b.name, 'sv'))
return {
...base,
rows,
months: mergeMonthlySeries(
numbers.flatMap((n) => n.buckets),
range,
),
}
}
+215
View File
@@ -0,0 +1,215 @@
import { describe, it, expect } from 'vitest'
import {
countByCompany,
deadlineUrgency,
pickNextDeadlines,
sortClientRows,
filterClientRows,
type ClientOverviewRow,
} from '../aggregate'
function isoDaysFromNow(days: number): string {
const d = new Date()
d.setHours(0, 0, 0, 0)
d.setDate(d.getDate() + days)
// Local date parts, not toISOString(): UTC conversion would shift the day
// in timezones ahead of UTC and skew the day math under test.
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function makeRow(overrides: Partial<ClientOverviewRow> = {}): ClientOverviewRow {
return {
companyId: 'c-1',
name: 'Alpha AB',
orgNumber: null,
unbookedCount: 0,
inboxCount: 0,
nextDeadline: null,
lastBookedDate: null,
...overrides,
}
}
describe('countByCompany', () => {
it('counts rows per company id', () => {
const counts = countByCompany([
{ company_id: 'a' },
{ company_id: 'b' },
{ company_id: 'a' },
{ company_id: 'a' },
])
expect(counts.get('a')).toBe(3)
expect(counts.get('b')).toBe(1)
expect(counts.get('c')).toBeUndefined()
})
it('returns an empty map for no rows', () => {
expect(countByCompany([]).size).toBe(0)
})
})
describe('deadlineUrgency', () => {
it('flags overdue on status overdue', () => {
expect(
deadlineUrgency({ due_date: isoDaysFromNow(30), status: 'overdue' }),
).toBe('overdue')
})
it('flags overdue on a passed due date even before the cron sweeps', () => {
expect(
deadlineUrgency({ due_date: isoDaysFromNow(-1), status: 'upcoming' }),
).toBe('overdue')
})
it('flags action_needed inside the 14-day window (status engine semantics)', () => {
expect(
deadlineUrgency({ due_date: isoDaysFromNow(14), status: 'upcoming' }),
).toBe('action_needed')
expect(
deadlineUrgency({ due_date: isoDaysFromNow(1), status: 'in_progress' }),
).toBe('action_needed')
})
it('keeps far-future deadlines as upcoming', () => {
expect(
deadlineUrgency({ due_date: isoDaysFromNow(15), status: 'upcoming' }),
).toBe('upcoming')
})
})
describe('pickNextDeadlines', () => {
it('picks the earliest due date per company from unsorted input', () => {
const next = pickNextDeadlines([
{
company_id: 'a',
title: 'Later',
due_date: isoDaysFromNow(40),
tax_deadline_type: null,
status: 'upcoming',
},
{
company_id: 'a',
title: 'Sooner',
due_date: isoDaysFromNow(2),
tax_deadline_type: 'vat_declaration',
status: 'action_needed',
},
{
company_id: 'b',
title: 'Only',
due_date: isoDaysFromNow(60),
tax_deadline_type: null,
status: 'upcoming',
},
])
expect(next.get('a')).toEqual({
title: 'Sooner',
dueDate: isoDaysFromNow(2),
taxDeadlineType: 'vat_declaration',
urgency: 'action_needed',
})
expect(next.get('b')?.urgency).toBe('upcoming')
})
})
describe('sortClientRows', () => {
it('puts overdue-deadline companies first, then sorts by unbooked count', () => {
const rows = [
makeRow({ companyId: 'calm', name: 'Calm AB', unbookedCount: 50 }),
makeRow({
companyId: 'late',
name: 'Late AB',
unbookedCount: 0,
nextDeadline: {
title: 'Moms',
dueDate: isoDaysFromNow(-5),
taxDeadlineType: 'vat_declaration',
urgency: 'overdue',
},
}),
makeRow({ companyId: 'busy', name: 'Busy AB', unbookedCount: 120 }),
]
expect(sortClientRows(rows).map((r) => r.companyId)).toEqual([
'late',
'busy',
'calm',
])
})
it('orders overdue companies by earliest due date', () => {
const rows = [
makeRow({
companyId: 'less-late',
name: 'A',
nextDeadline: {
title: 'Moms',
dueDate: isoDaysFromNow(-2),
taxDeadlineType: null,
urgency: 'overdue',
},
}),
makeRow({
companyId: 'most-late',
name: 'B',
nextDeadline: {
title: 'AGI',
dueDate: isoDaysFromNow(-30),
taxDeadlineType: null,
urgency: 'overdue',
},
}),
]
expect(sortClientRows(rows).map((r) => r.companyId)).toEqual([
'most-late',
'less-late',
])
})
it('tie-breaks by name so the list is stable', () => {
const rows = [
makeRow({ companyId: 'b', name: 'Beta AB', unbookedCount: 3 }),
makeRow({ companyId: 'a', name: 'Alfa AB', unbookedCount: 3 }),
]
expect(sortClientRows(rows).map((r) => r.companyId)).toEqual(['a', 'b'])
})
it('does not mutate the input array', () => {
const rows = [
makeRow({ companyId: 'x', unbookedCount: 1 }),
makeRow({ companyId: 'y', unbookedCount: 9 }),
]
const before = rows.map((r) => r.companyId)
sortClientRows(rows)
expect(rows.map((r) => r.companyId)).toEqual(before)
})
})
describe('filterClientRows', () => {
const rows = [
makeRow({ companyId: 'a', name: 'Alfa Bygg AB', orgNumber: '556012-5790' }),
makeRow({ companyId: 'b', name: 'Beta Konsult AB', orgNumber: '5591234567' }),
]
it('returns everything for an empty query', () => {
expect(filterClientRows(rows, ' ')).toHaveLength(2)
})
it('matches case-insensitively on name', () => {
expect(filterClientRows(rows, 'alfa').map((r) => r.companyId)).toEqual(['a'])
})
it('matches org numbers ignoring separators', () => {
expect(filterClientRows(rows, '5560125790').map((r) => r.companyId)).toEqual(['a'])
expect(filterClientRows(rows, '559123').map((r) => r.companyId)).toEqual(['b'])
})
it('returns nothing on a miss', () => {
expect(filterClientRows(rows, 'gamma')).toHaveLength(0)
})
})
+131
View File
@@ -0,0 +1,131 @@
/**
* Pure aggregation helpers for the byrå cockpit client list (WL-14).
*
* The fetch layer (lib/clients/fetch-client-overview.ts) runs a handful of
* grouped queries across all client companies; everything that can be
* computed without I/O lives here so it is unit-testable: per-company
* counting, next-deadline selection with the 14-day ACTION_NEEDED semantics
* from lib/deadlines/status-engine.ts, and the urgency sort (overdue
* deadlines first, then largest unbooked pile).
*/
import {
ACTION_NEEDED_THRESHOLD_DAYS,
daysUntilDeadline,
} from '@/lib/deadlines/status-engine'
export type DeadlineUrgency = 'overdue' | 'action_needed' | 'upcoming'
export interface ClientDeadline {
title: string
dueDate: string
taxDeadlineType: string | null
urgency: DeadlineUrgency
}
export interface ClientOverviewRow {
companyId: string
name: string
orgNumber: string | null
/** Canonical "att bokföra" count (lib/worklist book_transaction predicate). */
unbookedCount: number
/** Unconsumed inbox documents (lib/worklist inbox_document predicate). */
inboxCount: number
nextDeadline: ClientDeadline | null
/** Latest posted verifikat date; null = nothing booked yet. */
lastBookedDate: string | null
}
/** Count rows per company_id from a flat grouped-query result. */
export function countByCompany(rows: Array<{ company_id: string }>): Map<string, number> {
const counts = new Map<string, number>()
for (const row of rows) {
counts.set(row.company_id, (counts.get(row.company_id) ?? 0) + 1)
}
return counts
}
export interface DeadlineRowInput {
company_id: string
title: string
due_date: string
tax_deadline_type: string | null
status: string
}
/**
* Deadline urgency per the status engine: `overdue` status (or a passed due
* date the cron has not swept yet) flags overdue; within the 14-day
* ACTION_NEEDED window flags action_needed; everything else is upcoming.
* Computing from the due date as well as the stored status keeps the cockpit
* honest between cron runs.
*/
export function deadlineUrgency(row: Pick<DeadlineRowInput, 'due_date' | 'status'>): DeadlineUrgency {
const days = daysUntilDeadline(row.due_date)
if (row.status === 'overdue' || days < 0) return 'overdue'
if (row.status === 'action_needed' || days <= ACTION_NEEDED_THRESHOLD_DAYS) {
return 'action_needed'
}
return 'upcoming'
}
/**
* Pick each company's next deadline (earliest due date) from a flat list of
* open deadlines. Input does not need to be pre-sorted.
*/
export function pickNextDeadlines(rows: DeadlineRowInput[]): Map<string, ClientDeadline> {
const next = new Map<string, { dueDate: string; row: DeadlineRowInput }>()
for (const row of rows) {
const current = next.get(row.company_id)
if (!current || row.due_date < current.dueDate) {
next.set(row.company_id, { dueDate: row.due_date, row })
}
}
const result = new Map<string, ClientDeadline>()
for (const [companyId, { row }] of next) {
result.set(companyId, {
title: row.title,
dueDate: row.due_date,
taxDeadlineType: row.tax_deadline_type,
urgency: deadlineUrgency(row),
})
}
return result
}
/**
* Urgency sort per the WL-14 resolution: companies with an OVERDUE deadline
* first (earliest due date wins within the band), then everyone else by
* unbooked count descending. Name is the stable tie-break so the list does
* not jitter between renders.
*/
export function sortClientRows(rows: ClientOverviewRow[]): ClientOverviewRow[] {
const byName = (a: ClientOverviewRow, b: ClientOverviewRow) =>
a.name.localeCompare(b.name, 'sv')
return [...rows].sort((a, b) => {
const aOverdue = a.nextDeadline?.urgency === 'overdue'
const bOverdue = b.nextDeadline?.urgency === 'overdue'
if (aOverdue !== bOverdue) return aOverdue ? -1 : 1
if (aOverdue && bOverdue) {
const byDue = a.nextDeadline!.dueDate.localeCompare(b.nextDeadline!.dueDate)
if (byDue !== 0) return byDue
}
if (a.unbookedCount !== b.unbookedCount) return b.unbookedCount - a.unbookedCount
return byName(a, b)
})
}
/** Case-insensitive free-text filter on name and org number. */
export function filterClientRows(rows: ClientOverviewRow[], query: string): ClientOverviewRow[] {
const q = query.trim().toLowerCase()
if (!q) return rows
const qDigits = q.replace(/[^0-9]/g, '')
return rows.filter((row) => {
if (row.name.toLowerCase().includes(q)) return true
const org = row.orgNumber ?? ''
if (org.toLowerCase().includes(q)) return true
// "5560-1257" should match "5560125790": compare digits to digits.
return qDigits.length > 0 && org.replace(/[^0-9]/g, '').includes(qDigits)
})
}
+253
View File
@@ -0,0 +1,253 @@
/**
* Byrå cockpit aggregation (WL-14): one overview row per client company.
*
* Read-first by design (WL-09): every query here is a cross-membership READ,
* which RLS already allows via user_company_ids(); nothing writes. Queries
* are GROUPED across all client company ids (a handful of queries total,
* never N-per-company):
*
* 1. byrå team membership (teams.kind = 'byra')
* 2. client companies (companies.team_id = byrå team)
* 3. display names/org numbers (company_settings)
* 4. unbooked transactions (canonical lib/worklist book_transaction predicate)
* 5. unconsumed inbox documents (lib/worklist inbox_document predicate,
* including the document_attachments backstop)
* 6. open deadlines (status-engine semantics)
* 7. latest posted verifikat per company (embedded order+limit, one query)
*
* fetchAllRows guards every query that can exceed PostgREST's 1000-row cap.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
countByCompany,
pickNextDeadlines,
sortClientRows,
type ClientOverviewRow,
type DeadlineRowInput,
} from '@/lib/clients/aggregate'
export interface ClientOverview {
team: { id: string; name: string }
/** The caller's role in the byrå team: gates the create action (WL-15). */
role: 'owner' | 'admin' | 'member'
clients: ClientOverviewRow[]
}
/** Max ids per PostgREST .in() filter (mirrors lib/worklist IN_CLAUSE_CHUNK). */
const IN_CLAUSE_CHUNK = 150
function chunked<T>(items: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
return out
}
interface ByraMembershipRow {
team_id: string
role: string
teams: { id: string; name: string; kind: string } | null
}
/**
* The caller's byrå team membership, or null for non-byrå users. When a user
* somehow belongs to several byrå teams the earliest-created team wins
* (deterministic, mirrors ensure_user_team's tie-break).
*/
export async function getByraMembership(
supabase: SupabaseClient,
userId: string,
): Promise<{ teamId: string; teamName: string; role: ClientOverview['role'] } | null> {
const { data, error } = await supabase
.from('team_members')
.select('team_id, role, teams:team_id!inner(id, name, kind, created_at)')
.eq('user_id', userId)
.eq('teams.kind', 'byra')
.order('created_at', { ascending: true })
if (error || !data || data.length === 0) return null
const row = data[0] as unknown as ByraMembershipRow
const role = row.role === 'owner' || row.role === 'admin' ? row.role : 'member'
return {
teamId: row.team_id,
teamName: row.teams?.name ?? '',
role,
}
}
/**
* Aggregate the client overview for the caller's byrå team. Returns null when
* the user is not a byrå team member (the route maps that to 403, the page to
* a redirect).
*/
export async function fetchClientOverview(
supabase: SupabaseClient,
userId: string,
): Promise<ClientOverview | null> {
const membership = await getByraMembership(supabase, userId)
if (!membership) return null
// 2. Client companies: everything hanging on the byrå team, archived
// excluded. RLS additionally scopes this to companies the caller can read
// (team sync makes every byrå member a member of every client company).
const companies = await fetchAllRows<{
id: string
name: string
org_number: string | null
}>(({ from, to }) =>
supabase
.from('companies')
.select('id, name, org_number')
.eq('team_id', membership.teamId)
.is('archived_at', null)
.order('id', { ascending: true })
.range(from, to),
)
if (companies.length === 0) {
return {
team: { id: membership.teamId, name: membership.teamName },
role: membership.role,
clients: [],
}
}
const companyIds = companies.map((c) => c.id)
const idChunks = chunked(companyIds, IN_CLAUSE_CHUNK)
// 3. Current display names + settings org numbers (companies.name can be
// stale; the dashboard layout uses the same source).
const settingsRows: Array<{
company_id: string
company_name: string | null
org_number: string | null
}> = []
for (const chunk of idChunks) {
const { data, error } = await supabase
.from('company_settings')
.select('company_id, company_name, org_number')
.in('company_id', chunk)
if (error) throw new Error(error.message)
settingsRows.push(...(data ?? []))
}
const settingsByCompany = new Map(settingsRows.map((s) => [s.company_id, s]))
// 4. Unbooked transactions: the canonical predicate (is_business IS NULL
// AND is_ignored = false; see lib/worklist/types.ts book_transaction).
const unbookedRows: Array<{ company_id: string }> = []
for (const chunk of idChunks) {
const rows = await fetchAllRows<{ id: string; company_id: string }>(({ from, to }) =>
supabase
.from('transactions')
.select('id, company_id')
.in('company_id', chunk)
.is('is_business', null)
.eq('is_ignored', false)
.order('id', { ascending: true })
.range(from, to),
)
unbookedRows.push(...rows)
}
const unbookedByCompany = countByCompany(unbookedRows)
// 5. Unconsumed inbox documents (lib/worklist inbox_document): items with a
// file that became nothing yet, whose document is still unlinked.
const inboxRows: Array<{ company_id: string; document_id: string | null }> = []
for (const chunk of idChunks) {
const rows = await fetchAllRows<{
id: string
company_id: string
document_id: string | null
}>(({ from, to }) =>
supabase
.from('invoice_inbox_items')
.select('id, company_id, document_id')
.in('company_id', chunk)
.not('document_id', 'is', null)
.is('created_supplier_invoice_id', null)
.is('created_journal_entry_id', null)
.is('matched_transaction_id', null)
.order('id', { ascending: true })
.range(from, to),
)
inboxRows.push(...rows)
}
const docIds = [
...new Set(inboxRows.map((r) => r.document_id).filter((id): id is string => !!id)),
]
const unlinkedDocRows: Array<{ company_id: string }> = []
for (const chunk of chunked(docIds, IN_CLAUSE_CHUNK)) {
const { data, error } = await supabase
.from('document_attachments')
.select('id, company_id')
.in('id', chunk)
.is('journal_entry_id', null)
.eq('is_current_version', true)
if (error) throw new Error(error.message)
unlinkedDocRows.push(...(data ?? []))
}
const inboxByCompany = countByCompany(unlinkedDocRows)
// 6. Open deadlines across all clients, then the earliest per company with
// the 14-day ACTION_NEEDED semantics (lib/deadlines/status-engine.ts).
const deadlineRows: DeadlineRowInput[] = []
for (const chunk of idChunks) {
const rows = await fetchAllRows<DeadlineRowInput & { id: string }>(({ from, to }) =>
supabase
.from('deadlines')
.select('id, company_id, title, due_date, tax_deadline_type, status')
.in('company_id', chunk)
.eq('is_completed', false)
.is('dismissed_at', null)
.in('status', ['upcoming', 'action_needed', 'in_progress', 'overdue'])
.order('id', { ascending: true })
.range(from, to),
)
deadlineRows.push(...rows)
}
const nextDeadlineByCompany = pickNextDeadlines(deadlineRows)
// 7. Latest posted verifikat date per company: ONE query using PostgREST's
// per-parent embedded order + limit (lateral join under the hood), instead
// of N latest-entry queries.
const lastBookedByCompany = new Map<string, string>()
for (const chunk of idChunks) {
const { data, error } = await supabase
.from('companies')
.select('id, journal_entries(entry_date)')
.in('id', chunk)
.eq('journal_entries.status', 'posted')
.order('entry_date', { referencedTable: 'journal_entries', ascending: false })
.limit(1, { referencedTable: 'journal_entries' })
if (error) throw new Error(error.message)
for (const row of (data ?? []) as Array<{
id: string
journal_entries: Array<{ entry_date: string }> | null
}>) {
const entryDate = row.journal_entries?.[0]?.entry_date
if (entryDate) lastBookedByCompany.set(row.id, entryDate)
}
}
const clients: ClientOverviewRow[] = companies.map((company) => {
const settings = settingsByCompany.get(company.id)
return {
companyId: company.id,
name: settings?.company_name || company.name,
orgNumber: company.org_number ?? settings?.org_number ?? null,
unbookedCount: unbookedByCompany.get(company.id) ?? 0,
inboxCount: inboxByCompany.get(company.id) ?? 0,
nextDeadline: nextDeadlineByCompany.get(company.id) ?? null,
lastBookedDate: lastBookedByCompany.get(company.id) ?? null,
}
})
return {
team: { id: membership.teamId, name: membership.teamName },
role: membership.role,
clients: sortClientRows(clients),
}
}
+90
View File
@@ -211,6 +211,96 @@ describe('createCompanyFromOnboarding: org_number validation', () => {
})
})
describe('createCompanyFromOnboarding: byrå team gating (WL-15)', () => {
const baseParams = {
settings: {
entity_type: 'aktiebolag' as const,
company_name: 'Klient AB',
},
fiscalPeriod: {
startDate: '2026-01-01',
endDate: '2026-12-31',
name: 'Räkenskapsår 2026',
},
}
it('refuses a plain byrå MEMBER (creation is +1 on the byrå invoice)', async () => {
const { supabase } = buildSupabase({
user: { id: 'user-1' },
results: {
teams: { maybeSingle: { data: { kind: 'byra' } } },
team_members: { maybeSingle: { data: { role: 'member' } } },
},
rpcResults: { create_company_with_owner: { data: 'should-not-happen' } },
})
mockCreateClient.mockResolvedValue(supabase as never)
const result = await createCompanyFromOnboarding({
teamId: 'byra-team',
...baseParams,
})
expect(result.error).toBe(
'Endast byråns ägare och administratörer kan skapa klientbolag.',
)
const rpcCreate = supabase.rpc.mock.calls.find(
([name]) => name === 'create_company_with_owner',
)
expect(rpcCreate).toBeUndefined()
})
it('lets a byrå ADMIN create, with the explicit byrå team binding', async () => {
const { supabase } = buildSupabase({
user: { id: 'user-1' },
results: {
teams: { maybeSingle: { data: { kind: 'byra' } } },
team_members: { maybeSingle: { data: { role: 'admin' } } },
},
rpcResults: {
create_company_with_owner: { data: 'client-company-id' },
seed_chart_of_accounts: { data: null },
},
})
mockCreateClient.mockResolvedValue(supabase as never)
const result = await createCompanyFromOnboarding({
teamId: 'byra-team',
...baseParams,
})
// Response shape: the created company id, no error.
expect(result).toEqual({ companyId: 'client-company-id' })
// Team binding present: the RPC received the byrå team explicitly
// (never ensure_user_team arbitrariness, WL-08/WL-15).
const rpcCreate = supabase.rpc.mock.calls.find(
([name]) => name === 'create_company_with_owner',
)
expect(rpcCreate?.[1]).toMatchObject({ p_team_id: 'byra-team' })
})
it('keeps personal-team creation untouched (no role gate)', async () => {
const { supabase } = buildSupabase({
user: { id: 'user-1' },
results: {
teams: { maybeSingle: { data: { kind: 'personal' } } },
},
rpcResults: {
create_company_with_owner: { data: 'personal-company-id' },
seed_chart_of_accounts: { data: null },
},
})
mockCreateClient.mockResolvedValue(supabase as never)
const result = await createCompanyFromOnboarding({
teamId: 'personal-team',
...baseParams,
})
expect(result).toEqual({ companyId: 'personal-company-id' })
})
})
describe('createCompanyFromOnboarding: TIC snapshot persistence', () => {
it('persists the supplied ticLookup to companies.tic_snapshot', async () => {
const { supabase, calls } = buildSupabase({
+11 -2
View File
@@ -112,7 +112,7 @@ describe('setActiveCompany', () => {
expect(mockCookieSet).not.toHaveBeenCalled()
})
it('sets the cookie only after the write is verified', async () => {
it('sets the cookies only after the write is verified', async () => {
const { supabase, calls } = buildSupabase({
company_members: { single: { data: { company_id: 'company-2' } } },
user_preferences: { single: { data: { active_company_id: 'company-2' } } },
@@ -122,12 +122,21 @@ describe('setActiveCompany', () => {
const upsert = calls.find((c) => c.table === 'user_preferences' && c.method === 'upsert')
expect(upsert?.args[0]).toEqual({ user_id: 'user-1', active_company_id: 'company-2' })
expect(mockCookieSet).toHaveBeenCalledTimes(1)
expect(mockCookieSet).toHaveBeenCalledTimes(2)
expect(mockCookieSet).toHaveBeenCalledWith(
'gnubok-company-id',
'company-2',
expect.objectContaining({ httpOnly: true, path: '/' }),
)
// The explicit-choice marker (byrå landing): must be a SESSION cookie
// (no maxAge), so a new browser session starts unpicked and byrå
// owners/admins land in the cockpit again.
const picked = mockCookieSet.mock.calls.find((c) => c[0] === 'gnubok-company-picked')
expect(picked?.[1]).toBe('1')
expect(picked?.[2]).toEqual(
expect.objectContaining({ httpOnly: true, path: '/', sameSite: 'lax' }),
)
expect(picked?.[2]).not.toHaveProperty('maxAge')
})
})
+182
View File
@@ -0,0 +1,182 @@
import { describe, it, expect } from 'vitest'
import {
partitionCompaniesByHomeDomain,
isCompanyHomedOnHost,
resolveLandingPath,
type TeamBrandRef,
} from '../home-domain'
interface Entry {
id: string
teamId: string | null
}
const CANONICAL = {
canonicalDomain: 'app.gnubok.se',
canonicalAppName: 'Accounted',
}
const siffraBrand: TeamBrandRef = { domain: 'app.siffra.se', appName: 'Siffra' }
function partition(opts: {
companies: Entry[]
brandByTeam?: Map<string, TeamBrandRef>
hostBrandTeamId?: string | null
}) {
return partitionCompaniesByHomeDomain<Entry>({
companies: opts.companies,
getTeamId: (c) => c.teamId,
brandByTeam: opts.brandByTeam ?? new Map(),
hostBrandTeamId: opts.hostBrandTeamId ?? null,
...CANONICAL,
})
}
describe('partitionCompaniesByHomeDomain', () => {
it('canonical host with no brands anywhere: everything visible (additive guarantee)', () => {
const result = partition({
companies: [
{ id: 'own', teamId: 'personal-1' },
{ id: 'other', teamId: null },
],
})
expect(result.visible.map((c) => c.id)).toEqual(['own', 'other'])
expect(result.foreign).toHaveLength(0)
})
it('canonical host: branded companies become foreign under their brand domain', () => {
const result = partition({
companies: [
{ id: 'own-firma', teamId: 'personal-1' },
{ id: 'client', teamId: 'byra-1' },
],
brandByTeam: new Map([['byra-1', siffraBrand]]),
})
expect(result.visible.map((c) => c.id)).toEqual(['own-firma'])
expect(result.foreign).toEqual([
{ item: { id: 'client', teamId: 'byra-1' }, domain: 'app.siffra.se', appName: 'Siffra' },
])
})
it("brand host: only the brand team's companies are visible", () => {
const result = partition({
companies: [
{ id: 'own-firma', teamId: 'personal-1' },
{ id: 'client-a', teamId: 'byra-1' },
{ id: 'client-b', teamId: 'byra-1' },
],
brandByTeam: new Map([['byra-1', siffraBrand]]),
hostBrandTeamId: 'byra-1',
})
expect(result.visible.map((c) => c.id)).toEqual(['client-a', 'client-b'])
// The consultant's own firma points home to the canonical domain.
expect(result.foreign).toEqual([
{
item: { id: 'own-firma', teamId: 'personal-1' },
domain: 'app.gnubok.se',
appName: 'Accounted',
},
])
})
it('brand host: a company under ANOTHER brand points at that brand domain', () => {
const otherBrand: TeamBrandRef = { domain: 'app.other.se', appName: 'Other' }
const result = partition({
companies: [{ id: 'other-client', teamId: 'byra-2' }],
brandByTeam: new Map([
['byra-1', siffraBrand],
['byra-2', otherBrand],
]),
hostBrandTeamId: 'byra-1',
})
expect(result.visible).toHaveLength(0)
expect(result.foreign[0]!.domain).toBe('app.other.se')
})
})
describe('isCompanyHomedOnHost', () => {
const brandByTeam = new Map([['byra-1', siffraBrand]])
it('brandless company is homed on the canonical host', () => {
expect(
isCompanyHomedOnHost({
companyTeamId: 'personal-1',
brandByTeam,
hostBrandTeamId: null,
}),
).toBe(true)
})
it('branded company is NOT homed on the canonical host', () => {
expect(
isCompanyHomedOnHost({
companyTeamId: 'byra-1',
brandByTeam,
hostBrandTeamId: null,
}),
).toBe(false)
})
it('branded company is homed on its own brand host', () => {
expect(
isCompanyHomedOnHost({
companyTeamId: 'byra-1',
brandByTeam,
hostBrandTeamId: 'byra-1',
}),
).toBe(true)
})
it('brandless company is NOT homed on a brand host', () => {
expect(
isCompanyHomedOnHost({
companyTeamId: 'personal-1',
brandByTeam,
hostBrandTeamId: 'byra-1',
}),
).toBe(false)
})
})
describe('resolveLandingPath', () => {
it("byrå staff on their brand host land in the cockpit", () => {
expect(
resolveLandingPath({
hostBrandTeamId: 'byra-1',
byraTeams: [{ teamId: 'byra-1', hasBrand: true }],
}),
).toBe('/clients')
})
it("someone else's brand host never lands in the cockpit", () => {
expect(
resolveLandingPath({
hostBrandTeamId: 'byra-2',
byraTeams: [{ teamId: 'byra-1', hasBrand: true }],
}),
).toBe('/')
})
it('brandless byrå homes its cockpit on the canonical host (WL-01)', () => {
expect(
resolveLandingPath({
hostBrandTeamId: null,
byraTeams: [{ teamId: 'byra-1', hasBrand: false }],
}),
).toBe('/clients')
})
it('a BRANDED byrå does not land in the cockpit on the canonical host', () => {
expect(
resolveLandingPath({
hostBrandTeamId: null,
byraTeams: [{ teamId: 'byra-1', hasBrand: true }],
}),
).toBe('/')
})
it('non-byrå users always land on / (byte-identical flow)', () => {
expect(resolveLandingPath({ hostBrandTeamId: null, byraTeams: [] })).toBe('/')
expect(resolveLandingPath({ hostBrandTeamId: 'byra-1', byraTeams: [] })).toBe('/')
})
})
+246
View File
@@ -0,0 +1,246 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
decodeStorageValue,
encodeStorageValue,
guardBrowserWrite,
guardStore,
isTabMismatch,
markCompanySwitchInFlight,
markSelfSwitchTarget,
requestHasNextActionHeader,
shouldBlockMutation,
} from '../tab-guard'
const ORIGIN = 'http://localhost:3000'
describe('encode/decodeStorageValue', () => {
it('round-trips a company id', () => {
expect(decodeStorageValue(encodeStorageValue('company-2'))).toBe('company-2')
})
it('reads malformed payloads as unknown', () => {
expect(decodeStorageValue(null)).toBeNull()
expect(decodeStorageValue('not json')).toBeNull()
expect(decodeStorageValue(JSON.stringify({ companyId: 42 }))).toBeNull()
})
})
describe('isTabMismatch', () => {
it('mismatches only on positive evidence of a different company', () => {
expect(isTabMismatch('company-1', 'company-2')).toBe(true)
expect(isTabMismatch('company-1', 'company-1')).toBe(false)
})
it('never mismatches on unknown observations', () => {
expect(isTabMismatch('company-1', null)).toBe(false)
expect(isTabMismatch('company-1', undefined)).toBe(false)
})
it('never mismatches when the tab has no company', () => {
expect(isTabMismatch(null, 'company-2')).toBe(false)
})
})
describe('shouldBlockMutation', () => {
const mismatch = {
pageOrigin: ORIGIN,
tabCompanyId: 'company-1',
observedCompanyId: 'company-2',
}
it('blocks a mutating same-origin /api request from a stale tab', () => {
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'post']) {
expect(
shouldBlockMutation({ ...mismatch, method, url: '/api/invoices' }),
).toBe(true)
}
expect(
shouldBlockMutation({
...mismatch,
method: 'POST',
url: `${ORIGIN}/api/transactions/tx-1/categorize`,
}),
).toBe(true)
})
it('never blocks reads', () => {
expect(
shouldBlockMutation({ ...mismatch, method: 'GET', url: '/api/invoices' }),
).toBe(false)
expect(
shouldBlockMutation({ ...mismatch, method: undefined, url: '/api/invoices' }),
).toBe(false)
})
it('never blocks while tab and active company agree', () => {
expect(
shouldBlockMutation({
pageOrigin: ORIGIN,
tabCompanyId: 'company-1',
observedCompanyId: 'company-1',
method: 'POST',
url: '/api/invoices',
}),
).toBe(false)
})
it('never blocks on unknown active company (no positive evidence)', () => {
expect(
shouldBlockMutation({
pageOrigin: ORIGIN,
tabCompanyId: 'company-1',
observedCompanyId: null,
method: 'POST',
url: '/api/invoices',
}),
).toBe(false)
})
it('only guards same-origin /api paths for plain fetches', () => {
// Cross-origin (e.g. browser-direct Supabase) is outside the seam.
expect(
shouldBlockMutation({
...mismatch,
method: 'POST',
url: 'https://xyz.supabase.co/rest/v1/transactions',
}),
).toBe(false)
// A plain (non-action) POST to a page route is not guarded.
expect(
shouldBlockMutation({ ...mismatch, method: 'POST', url: '/clients' }),
).toBe(false)
// Prefix trickery: /apiary is not /api/.
expect(
shouldBlockMutation({ ...mismatch, method: 'POST', url: '/apiary/things' }),
).toBe(false)
})
it('blocks a server-action POST to a page route from a stale tab', () => {
expect(
shouldBlockMutation({
...mismatch,
method: 'POST',
url: '/transactions',
isServerAction: true,
}),
).toBe(true)
})
it('lets the sanctioned company-switch action through while in flight', () => {
expect(
shouldBlockMutation({
...mismatch,
method: 'POST',
url: '/clients',
isServerAction: true,
companySwitchInFlight: true,
}),
).toBe(false)
})
it('never blocks a server action while tab and active company agree', () => {
expect(
shouldBlockMutation({
pageOrigin: ORIGIN,
tabCompanyId: 'company-1',
observedCompanyId: 'company-1',
method: 'POST',
url: '/clients',
isServerAction: true,
}),
).toBe(false)
})
it('never blocks a cross-origin request even with the action marker', () => {
expect(
shouldBlockMutation({
...mismatch,
method: 'POST',
url: 'https://evil.example/steal',
isServerAction: true,
}),
).toBe(false)
})
})
describe('requestHasNextActionHeader', () => {
it('detects the header on init.headers as a plain record (Next 16 shape)', () => {
expect(
requestHasNextActionHeader('/clients', {
headers: { Accept: 'text/x-component', 'Next-Action': 'abc123' },
}),
).toBe(true)
expect(
requestHasNextActionHeader('/clients', { headers: { 'next-action': 'abc123' } }),
).toBe(true)
})
it('detects the header on Headers instances and entry arrays', () => {
expect(
requestHasNextActionHeader('/clients', {
headers: new Headers({ 'Next-Action': 'abc123' }),
}),
).toBe(true)
expect(
requestHasNextActionHeader('/clients', { headers: [['Next-Action', 'abc123']] }),
).toBe(true)
})
it('detects the header on a Request input when init has none', () => {
const req = new Request('http://localhost/clients', {
method: 'POST',
headers: { 'Next-Action': 'abc123' },
})
expect(requestHasNextActionHeader(req)).toBe(true)
})
it('is false for ordinary requests', () => {
expect(requestHasNextActionHeader('/api/invoices')).toBe(false)
expect(
requestHasNextActionHeader('/api/invoices', {
headers: { 'Content-Type': 'application/json' },
}),
).toBe(false)
expect(requestHasNextActionHeader(new URL('http://localhost/x'))).toBe(false)
})
})
describe('markSelfSwitchTarget', () => {
it('sets and clears the self-switch marker on the store', () => {
markSelfSwitchTarget('company-2')
expect(guardStore.selfSwitchTargetId).toBe('company-2')
markSelfSwitchTarget(null)
expect(guardStore.selfSwitchTargetId).toBeNull()
})
})
describe('guardBrowserWrite', () => {
beforeEach(() => {
guardStore.tabCompanyId = null
guardStore.observedCompanyId = null
guardStore.notifyBlocked = null
markCompanySwitchInFlight(false)
})
it('allows the write when tab and observation agree (or nothing observed)', () => {
guardStore.tabCompanyId = 'company-1'
expect(guardBrowserWrite()).toBe(true)
guardStore.observedCompanyId = 'company-1'
expect(guardBrowserWrite()).toBe(true)
})
it('refuses the write and raises the dialog on positive mismatch evidence', () => {
let notified = 0
guardStore.tabCompanyId = 'company-1'
guardStore.observedCompanyId = 'company-2'
guardStore.notifyBlocked = () => {
notified += 1
}
expect(guardBrowserWrite()).toBe(false)
expect(notified).toBe(1)
})
it('allows the write when no guard is mounted (no tab belief)', () => {
expect(guardBrowserWrite()).toBe(true)
})
})
+25
View File
@@ -95,6 +95,31 @@ async function createCompanyFromOnboardingImpl(params: {
const companyName = (params.settings.company_name as string | undefined) || 'Mitt företag'
// Creating a company under a BYRÅ team is admin-gated (WL-15): every
// created client company is +1 on the byrå's monthly invoice, so only team
// owner/admin may do it. Personal-team creation is untouched. The
// create_company_with_owner RPC enforces the same rule in the database
// (migration 20260826130400); this check exists to return a readable error
// instead of a raw 42501. A team the caller cannot read via RLS resolves
// to null kind here and falls through to the RPC's own membership check.
const { data: teamRow } = await supabase
.from('teams')
.select('kind')
.eq('id', params.teamId)
.maybeSingle()
if ((teamRow as { kind?: string } | null)?.kind === 'byra') {
const { data: teamMemberRow } = await supabase
.from('team_members')
.select('role')
.eq('team_id', params.teamId)
.eq('user_id', user.id)
.maybeSingle()
const teamRole = (teamMemberRow as { role?: string } | null)?.role
if (teamRole !== 'owner' && teamRole !== 'admin') {
return { error: 'Endast byråns ägare och administratörer kan skapa klientbolag.' }
}
}
// Steps 1-5 (company + owner via RPC, org number, TIC snapshot, chart,
// settings, fiscal period, tax deadlines, with rollback) are shared with
// the MCP and v1 creation paths: lib/company/create-company.ts.
+18
View File
@@ -11,6 +11,16 @@ export { CompanyContextError, getActiveCompanyId }
const COMPANY_COOKIE = 'gnubok-company-id'
/**
* Session marker for an EXPLICIT company choice (WL byrå landing). Set only
* by setActiveCompany, which every deliberate switch funnels through
* (company switcher, cockpit client entry, onboarding). The middleware's
* first-membership fallback write-back upserts user_preferences directly and
* never sets this, so "/" can tell a picked company from an auto-resolved
* one. Session-scoped on purpose: a new browser session starts unpicked.
*/
export const COMPANY_PICKED_COOKIE = 'gnubok-company-picked'
/**
* Resolve a company's effective entity type.
*
@@ -175,6 +185,14 @@ export async function setActiveCompany(
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365, // 1 year
})
// Explicit-choice marker (see COMPANY_PICKED_COOKIE). No maxAge: a session
// cookie, gone when the browser closes.
cookieStore.set(COMPANY_PICKED_COOKIE, '1', {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
})
} catch {
// Sealed cookie store (render phase): the DB write is what matters.
}
+117
View File
@@ -0,0 +1,117 @@
/**
* The home-domain rule (WL-01, locked): every company has exactly one home
* domain: its byrå team's brand domain when the team has a brand, else the
* canonical domain. A company is opened and worked in ONLY on its home
* domain; everywhere else the UI shows a signpost, never the company.
*
* Explicitly a NAVIGATION rule, not a security boundary: access is governed
* solely by company membership and RLS, unchanged. These helpers are pure so
* the rule is unit-testable; the dashboard layout feeds them the host brand
* (resolveBrandByHost) and the per-team brand map (resolveBrandsForTeams).
*/
export interface TeamBrandRef {
domain: string
appName: string
}
export interface ForeignCompanyRef {
id: string
name: string
/** The host where this company is actually worked in. */
domain: string
/** Brand app name for the signpost copy; canonical app name when brandless. */
appName: string
}
export interface HomeDomainPartition<T> {
/** Companies homed on the current host: the only ones the switcher offers. */
visible: T[]
/** Companies homed elsewhere: shown as non-clickable "Hanteras via" entries. */
foreign: Array<{ item: T; domain: string; appName: string }>
}
/**
* Partition a user's companies by home domain for the current host.
*
* - On a brand host (hostBrandTeamId set): visible = companies owned by that
* brand's team; everything else is foreign (its own brand domain, or the
* canonical domain for brandless companies).
* - On the canonical host (hostBrandTeamId null): visible = companies WITHOUT
* a brand; branded companies are foreign under their brand domain.
*
* A company whose team has no entry in brandByTeam is brandless and homed on
* the canonical domain, so the canonical hot path (no brands anywhere) yields
* visible = everything and foreign = [] : byte-identical behavior.
*/
export function partitionCompaniesByHomeDomain<T>(opts: {
companies: T[]
getTeamId: (company: T) => string | null
brandByTeam: Map<string, TeamBrandRef>
hostBrandTeamId: string | null
canonicalDomain: string
canonicalAppName: string
}): HomeDomainPartition<T> {
const { companies, getTeamId, brandByTeam, hostBrandTeamId } = opts
const visible: T[] = []
const foreign: HomeDomainPartition<T>['foreign'] = []
for (const company of companies) {
const teamId = getTeamId(company)
const brand = teamId ? brandByTeam.get(teamId) : undefined
if (hostBrandTeamId !== null) {
if (teamId === hostBrandTeamId) {
visible.push(company)
} else {
foreign.push({
item: company,
domain: brand?.domain ?? opts.canonicalDomain,
appName: brand?.appName ?? opts.canonicalAppName,
})
}
} else if (!brand) {
visible.push(company)
} else {
foreign.push({ item: company, domain: brand.domain, appName: brand.appName })
}
}
return { visible, foreign }
}
/**
* Whether the active company is homed on the current host (its brand domain
* equals the host's brand domain; null equals null on canonical). When false
* the layout renders the signpost instead of the company's dashboard.
*/
export function isCompanyHomedOnHost(opts: {
companyTeamId: string | null
brandByTeam: Map<string, TeamBrandRef>
hostBrandTeamId: string | null
}): boolean {
const brand = opts.companyTeamId ? opts.brandByTeam.get(opts.companyTeamId) : undefined
if (opts.hostBrandTeamId === null) return !brand
return opts.companyTeamId === opts.hostBrandTeamId
}
/**
* Post-login landing (WL-14): byrå staff land in the cockpit on the byrå's
* home domain: the brand domain when the team has a brand, else the canonical
* domain (a byrå team without white label is a valid state, WL-01). Everyone
* else keeps today's '/' byte-identically.
*/
export function resolveLandingPath(opts: {
/** teams.id of the brand serving the current host, null on canonical. */
hostBrandTeamId: string | null
/** The user's byrå team memberships with brand presence. */
byraTeams: Array<{ teamId: string; hasBrand: boolean }>
}): '/clients' | '/' {
if (opts.hostBrandTeamId !== null) {
return opts.byraTeams.some((t) => t.teamId === opts.hostBrandTeamId)
? '/clients'
: '/'
}
// Canonical host: only a brandless byrå homes its cockpit here.
return opts.byraTeams.some((t) => !t.hasBrand) ? '/clients' : '/'
}
+26
View File
@@ -0,0 +1,26 @@
'use client'
/**
* Post-login landing (WL-14): asks the server where this session should land
* when the auth flow has no explicit destination. Byrå staff on their byrå's
* home domain get '/clients' (the cockpit); everyone else '/': and ANY
* failure (offline, 4xx, malformed payload) degrades to '/', so the
* non-byrå flow can never be worse than today's hardcoded push.
*
* The answer is whitelisted client-side: only the two known destinations are
* ever returned, so a compromised or confused response cannot redirect the
* user anywhere else.
*/
export async function resolvePostLoginDestination(): Promise<'/clients' | '/'> {
try {
const res = await fetch('/api/clients/landing', {
cache: 'no-store',
credentials: 'same-origin',
})
if (!res.ok) return '/'
const payload = (await res.json()) as { data?: { destination?: unknown } }
return payload?.data?.destination === '/clients' ? '/clients' : '/'
} catch {
return '/'
}
}
+47 -10
View File
@@ -1,30 +1,67 @@
'use client'
import { switchCompany } from '@/lib/company/actions'
import {
TAB_SYNC_CHANNEL,
TAB_SYNC_STORAGE_KEY,
encodeStorageValue,
markCompanySwitchInFlight,
markSelfSwitchTarget,
} from '@/lib/company/tab-guard'
/**
* Client-side company switch: persists the new active company via the
* server action, notifies sibling tabs, then hard-reloads onto the new
* company. Shared by CompanySwitcher (mobile sheet) and the sidebar
* user-menu flyout so both use the exact same mechanism.
* company. Shared by CompanySwitcher (mobile sheet), the sidebar user-menu
* flyout and the byrå cockpit so all use the exact same mechanism.
*
* `destination` (default '/') is where THIS tab lands after the switch: the
* cockpit's soft switch (WL-09) enters a client directly on the target page
* instead of bouncing via the start page. It is always a full-page load: the
* hard navigation is what tears down React state, router cache, in-flight
* fetches and blob URLs so nothing from the previous company survives.
*
* Returns an error code instead of navigating when the switch fails.
*/
export async function performCompanySwitch(
companyId: string,
options: { destination?: string } = {},
): Promise<{ error: string } | void> {
const result = await switchCompany(companyId)
// The tab guard blocks server-action POSTs from a mismatched tab, but the
// switch action is the sanctioned exception: it is both dialog exits and
// the deliberate re-switch from a stale tab. Flag it around the call so the
// patched fetch lets exactly this action through.
markCompanySwitchInFlight(true)
let result: { error?: string }
try {
result = await switchCompany(companyId)
} finally {
markCompanySwitchInFlight(false)
}
if (result.error) {
return { error: result.error }
}
// Notify every other open tab of the same user so they hard-reload
// onto the new company. BroadcastChannel is best-effort: if the
// browser doesn't support it (very old) we still hard-reload
// ourselves, and other tabs will self-correct via the visibilitychange
// / pageshow listeners in CompanyTabSync on their next focus event.
// This tab's own switch: the broadcast below loops back into this tab's
// CompanyTabSync (BroadcastChannel delivers to every same-name channel in
// the same context except the posting object), and without this marker the
// guard would raise its blocking dialog over the hard navigation already
// in flight. The marker suppresses only the dialog; stray mutations from
// this tab are still blocked until the reload lands.
markSelfSwitchTarget(companyId)
// Notify every other open tab of the same user so their tab guard can
// react (blocking dialog, WL-09). BroadcastChannel is the live signal;
// the localStorage write is the fallback (storage events fire in every
// other same-origin tab even where BroadcastChannel is unavailable) and
// the synchronous last-known value the mutation guard reads.
try {
window.localStorage.setItem(TAB_SYNC_STORAGE_KEY, encodeStorageValue(companyId))
} catch {
// Storage may be unavailable (private mode); BroadcastChannel and the
// focus-time server checks in CompanyTabSync still cover the other tabs.
}
if (typeof BroadcastChannel !== 'undefined') {
try {
const channel = new BroadcastChannel('gnubok-company-switch')
const channel = new BroadcastChannel(TAB_SYNC_CHANNEL)
channel.postMessage({ companyId })
channel.close()
} catch {
@@ -34,5 +71,5 @@ export async function performCompanySwitch(
// Hard navigation: tears down React state, router cache, in-flight
// fetches, blob URLs, etc. This is the whole point: nothing from the
// previous company can survive the switch.
window.location.assign('/')
window.location.assign(options.destination ?? '/')
}
+219
View File
@@ -0,0 +1,219 @@
/**
* Cross-tab active-company guard logic (WL-09, tab guard).
*
* The active company is ONE user_preferences row per user, shared by every
* tab and session. Two tabs on two clients therefore silently write both
* tabs' actions into the LAST switched company's books. The guard gives each
* tab a memory of the company it is rendering (server-rendered at mount) and
* blocks the tab as soon as another tab switches: a blocking dialog whose
* only exits are "switch back" or "reload as the new company". Deliberately
* NO continue-anyway: continuing would post into the wrong company's books
* (this is not the soft-guard pattern).
*
* Pure logic lives here so it is unit-testable; the DOM wiring (channel,
* storage events, fetch wrapping, the dialog) lives in
* components/dashboard/CompanyTabSync.tsx.
*/
/** Broadcast channel name: pre-existing wire identifier, do not rename. */
export const TAB_SYNC_CHANNEL = 'gnubok-company-switch'
/**
* localStorage key doubling as the BroadcastChannel fallback: writing it
* fires `storage` events in every OTHER tab of the same origin. The value is
* JSON: { companyId, at } (the timestamp makes re-switching to the same
* company still produce a new value, so the event always fires).
*/
export const TAB_SYNC_STORAGE_KEY = 'gnubok-active-company'
/** Methods that mutate state and must not run from a stale tab. */
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
/**
* The header Next.js puts on every client-invoked server action POST
* (verified against next@16.2.12: fetchServerAction in
* dist/client/components/router-reducer/reducers/server-action-reducer.js
* calls the bare global `fetch` with `{ 'next-action': actionId }`, so these
* requests DO pass through the patched window.fetch).
*/
export const NEXT_ACTION_HEADER = 'next-action'
export interface TabSyncMessage {
companyId: string | null
}
/**
* Shared guard state: one writer (CompanyTabSync, the single dashboard-shell
* mount) and two readers (the patched fetch and guardBrowserWrite below).
* Module-level so browser-direct write sites can consult the tab's belief
* without threading React context through every mutation helper.
*/
export const guardStore: {
/** The company this tab is rendering (server-rendered at mount). */
tabCompanyId: string | null
/** Last observed active company from another tab's switch broadcast. */
observedCompanyId: string | null
/** Raises the blocking dialog when a mutation is refused. */
notifyBlocked: (() => void) | null
/**
* True while performCompanySwitch awaits the sanctioned switchCompany
* server action: the ONE server action that must pass from a mismatched
* tab, because it IS both dialog exits ("switch back") and the deliberate
* user-driven re-switch. Action ids are opaque per-build hashes, so the
* sanctioned call is identified by this flag rather than by id.
*/
companySwitchInFlight: boolean
/**
* Set when THIS tab initiated a switch to the given company and is
* hard-navigating there. The switch broadcast loops back into the
* initiating tab (BroadcastChannel delivers to every same-name channel in
* the same context except the posting object), which would raise the
* "switched in another tab" dialog over the tab's own navigation. While
* set, CompanyTabSync suppresses the dialog; mutation blocking stays
* active. Cleared on bfcache restore (pageshow persisted): a restored page
* renders the OLD company and must get the full guard back.
*/
selfSwitchTargetId: string | null
} = {
tabCompanyId: null,
observedCompanyId: null,
notifyBlocked: null,
companySwitchInFlight: false,
selfSwitchTargetId: null,
}
/** Flagged by performCompanySwitch around the sanctioned switch action. */
export function markCompanySwitchInFlight(inFlight: boolean): void {
guardStore.companySwitchInFlight = inFlight
}
/**
* Flagged by performCompanySwitch after the switch persisted, right before
* it broadcasts and hard-navigates: this tab's own switch, not a foreign one.
*/
export function markSelfSwitchTarget(companyId: string | null): void {
guardStore.selfSwitchTargetId = companyId
}
/**
* Whether a fetch call carries the server-action marker header. Pure over the
* (input, init) pair so it is unit-testable; Headers, record and entries
* shapes all occur (Next uses a plain record, but the seam is generic).
*/
export function requestHasNextActionHeader(
input: { headers?: unknown } | string | URL,
init?: { headers?: unknown },
): boolean {
const hasHeader = (headers: unknown): boolean => {
if (!headers) return false
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
return headers.has(NEXT_ACTION_HEADER)
}
if (Array.isArray(headers)) {
return headers.some(
(entry) =>
Array.isArray(entry) &&
typeof entry[0] === 'string' &&
entry[0].toLowerCase() === NEXT_ACTION_HEADER,
)
}
if (typeof headers === 'object') {
return Object.keys(headers as Record<string, unknown>).some(
(key) => key.toLowerCase() === NEXT_ACTION_HEADER,
)
}
return false
}
if (init && hasHeader(init.headers)) return true
if (typeof input === 'object' && input !== null && !(input instanceof URL)) {
return hasHeader((input as { headers?: unknown }).headers)
}
return false
}
/** Serialize the storage payload written on every switch. */
export function encodeStorageValue(companyId: string | null): string {
return JSON.stringify({ companyId, at: Date.now() })
}
/** Parse a storage payload; malformed input reads as "unknown" (null). */
export function decodeStorageValue(raw: string | null): string | null {
if (!raw) return null
try {
const parsed = JSON.parse(raw) as { companyId?: unknown }
return typeof parsed.companyId === 'string' ? parsed.companyId : null
} catch {
return null
}
}
/**
* Whether an observed active-company id conflicts with the company this tab
* is rendering. Unknown observations (null) are never a mismatch: the guard
* only blocks on positive evidence of a switch.
*/
export function isTabMismatch(
tabCompanyId: string | null,
observedCompanyId: string | null | undefined,
): boolean {
if (!tabCompanyId) return false
if (observedCompanyId === null || observedCompanyId === undefined) return false
return observedCompanyId !== tabCompanyId
}
/**
* Whether a fetch about to leave this tab must be blocked. Guards mutating
* same-origin requests on two shapes:
*
* - /api paths: the fetch-based mutation surface.
* - server-action POSTs (isServerAction: the request carries the
* `next-action` header; they POST to page routes through the same
* patched window.fetch). The one sanctioned exception is the company
* switch itself (companySwitchInFlight): it is both dialog exits and the
* deliberate re-switch from a stale tab, so blocking it would deadlock
* the guard's own resolution.
*
* Browser-direct Supabase calls go to another origin and are outside this
* seam; their write sites call guardBrowserWrite() instead.
*/
export function shouldBlockMutation(opts: {
method: string | undefined
url: string
pageOrigin: string
tabCompanyId: string | null
observedCompanyId: string | null | undefined
/** The request carries the next-action header (a server action POST). */
isServerAction?: boolean
/** The sanctioned company-switch action is in flight; let it through. */
companySwitchInFlight?: boolean
}): boolean {
const method = (opts.method ?? 'GET').toUpperCase()
if (!MUTATING_METHODS.has(method)) return false
if (!isTabMismatch(opts.tabCompanyId, opts.observedCompanyId)) return false
let parsed: URL
try {
parsed = new URL(opts.url, opts.pageOrigin)
} catch {
return false
}
if (parsed.origin !== opts.pageOrigin) return false
if (opts.isServerAction) return !opts.companySwitchInFlight
return parsed.pathname === '/api' || parsed.pathname.startsWith('/api/')
}
/**
* Guard for browser-direct Supabase mutations (they go straight to the
* Supabase origin, so the patched window.fetch's /api check never sees them).
* Call before executing the write: returns true when the write may proceed.
* On positive evidence of a cross-tab switch it raises the blocking dialog
* (the same only-exits-are-switch-or-reload dialog, WL-09) and returns false;
* the caller simply returns without writing.
*/
export function guardBrowserWrite(): boolean {
if (!isTabMismatch(guardStore.tabCompanyId, guardStore.observedCompanyId)) {
return true
}
guardStore.notifyBlocked?.()
return false
}
@@ -0,0 +1,97 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`buildAuthEmail > renders the branded recovery mail (snapshot) 1`] = `
"
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Återställ ditt lösenord</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f5f5f5;">
<div style="max-width: 520px; margin: 0 auto; padding: 40px 20px;">
<div style="background: #ffffff; border-radius: 12px; padding: 40px 32px; border: 1px solid #e5e5e5;">
<!-- Header -->
<div style="margin-bottom: 28px;">
<p style="margin: 0 0 4px 0; font-size: 13px; color: #888; letter-spacing: 0.05em;">SIFFRA</p>
<h1 style="margin: 0 0 8px 0; font-size: 22px; font-weight: 600; color: #111;">
Återställ ditt lösenord
</h1>
<p style="margin: 0; color: #666; font-size: 15px;">
Vi har tagit emot en begäran om att återställa lösenordet för ditt konto hos Siffra. Klicka på knappen nedan för att välja ett nytt lösenord.
</p>
</div>
<div style="margin: 28px 0;">
<a href="https://app.siffra.se/auth/callback?token_hash=abc&amp;type=recovery" style="display: inline-block; background: #111; color: #fff; text-decoration: none; padding: 12px 28px; border-radius: 8px; font-size: 14px; font-weight: 500;">
Återställ lösenord
</a>
</div>
<!-- Info -->
<p style="margin: 0; color: #999; font-size: 13px;">
Om du inte begärde detta kan du bortse från det här meddelandet.
</p>
</div>
</div>
</body>
</html>"
`;
exports[`buildAuthEmail > renders the branded recovery mail (snapshot) 2`] = `
"Återställ ditt lösenord
Vi har tagit emot en begäran om att återställa lösenordet för ditt konto hos Siffra. Klicka på knappen nedan för att välja ett nytt lösenord.
Återställ lösenord: https://app.siffra.se/auth/callback?token_hash=abc&type=recovery
Om du inte begärde detta kan du bortse från det här meddelandet."
`;
exports[`buildAuthEmail > renders the platform default without a brand 1`] = `
"
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Återställ ditt lösenord</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f5f5f5;">
<div style="max-width: 520px; margin: 0 auto; padding: 40px 20px;">
<div style="background: #ffffff; border-radius: 12px; padding: 40px 32px; border: 1px solid #e5e5e5;">
<!-- Header -->
<div style="margin-bottom: 28px;">
<p style="margin: 0 0 4px 0; font-size: 13px; color: #888; letter-spacing: 0.05em;">ACCOUNTED</p>
<h1 style="margin: 0 0 8px 0; font-size: 22px; font-weight: 600; color: #111;">
Återställ ditt lösenord
</h1>
<p style="margin: 0; color: #666; font-size: 15px;">
Vi har tagit emot en begäran om att återställa lösenordet för ditt konto hos Accounted. Klicka på knappen nedan för att välja ett nytt lösenord.
</p>
</div>
<div style="margin: 28px 0;">
<a href="https://app.gnubok.se/auth/callback?token_hash=abc&amp;type=recovery" style="display: inline-block; background: #111; color: #fff; text-decoration: none; padding: 12px 28px; border-radius: 8px; font-size: 14px; font-weight: 500;">
Återställ lösenord
</a>
</div>
<!-- Info -->
<p style="margin: 0; color: #999; font-size: 13px;">
Om du inte begärde detta kan du bortse från det här meddelandet.
</p>
</div>
</div>
</body>
</html>"
`;
exports[`buildAuthEmail > renders the platform default without a brand 2`] = `
"Återställ ditt lösenord
Vi har tagit emot en begäran om att återställa lösenordet för ditt konto hos Accounted. Klicka på knappen nedan för att välja ett nytt lösenord.
Återställ lösenord: https://app.gnubok.se/auth/callback?token_hash=abc&type=recovery
Om du inte begärde detta kan du bortse från det här meddelandet."
`;
@@ -0,0 +1,336 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`invite mail (template class: invite) > branded: brand sender, brand-domain link, no Accounted leakage 1`] = `
"
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inbjudan till Kund AB</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f5f5f5;">
<div style="max-width: 520px; margin: 0 auto; padding: 40px 20px;">
<div style="background: #ffffff; border-radius: 12px; padding: 40px 32px; border: 1px solid #e5e5e5;">
<!-- Header -->
<div style="margin-bottom: 28px;">
<p style="margin: 0 0 4px 0; font-size: 13px; color: #888; letter-spacing: 0.05em;">SIFFRA</p>
<h1 style="margin: 0 0 8px 0; font-size: 22px; font-weight: 600; color: #111;">
Du har blivit inbjuden
</h1>
<p style="margin: 0; color: #666; font-size: 15px;">
<strong>sara@siffra.se</strong> har bjudit in dig till <strong>Kund AB</strong> på siffra.
</p>
</div>
<!-- CTA -->
<div style="margin: 28px 0;">
<a href="https://app.siffra.se/invite/tok-1" style="display: inline-block; background: #111; color: #fff; text-decoration: none; padding: 12px 28px; border-radius: 8px; font-size: 14px; font-weight: 500;">
Acceptera inbjudan
</a>
</div>
<!-- Info -->
<p style="margin: 0; color: #999; font-size: 13px;">
Länken är giltig i 7 dagar. Om du inte förväntade dig denna inbjudan kan du ignorera detta meddelande.
</p>
</div>
</div>
</body>
</html>"
`;
exports[`invite mail (template class: invite) > unbranded: byte-identical to the un-overridden template on the canonical URL 1`] = `
"
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inbjudan till Kund AB</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f5f5f5;">
<div style="max-width: 520px; margin: 0 auto; padding: 40px 20px;">
<div style="background: #ffffff; border-radius: 12px; padding: 40px 32px; border: 1px solid #e5e5e5;">
<!-- Header -->
<div style="margin-bottom: 28px;">
<p style="margin: 0 0 4px 0; font-size: 13px; color: #888; letter-spacing: 0.05em;">ACCOUNTED</p>
<h1 style="margin: 0 0 8px 0; font-size: 22px; font-weight: 600; color: #111;">
Du har blivit inbjuden
</h1>
<p style="margin: 0; color: #666; font-size: 15px;">
<strong>sara@siffra.se</strong> har bjudit in dig till <strong>Kund AB</strong> på accounted.
</p>
</div>
<!-- CTA -->
<div style="margin: 28px 0;">
<a href="https://app.gnubok.se/invite/tok-1" style="display: inline-block; background: #111; color: #fff; text-decoration: none; padding: 12px 28px; border-radius: 8px; font-size: 14px; font-weight: 500;">
Acceptera inbjudan
</a>
</div>
<!-- Info -->
<p style="margin: 0; color: #999; font-size: 13px;">
Länken är giltig i 7 dagar. Om du inte förväntade dig denna inbjudan kan du ignorera detta meddelande.
</p>
</div>
</div>
</body>
</html>"
`;
exports[`invoice mail (template class: invoice) > unbranded: template output unchanged and platform-free 1`] = `
"
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Faktura F-1001</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<!-- Header -->
<div style="margin-bottom: 30px; border-bottom: 2px solid #1a1a1a; padding-bottom: 16px;">
<h1 style="margin: 0 0 10px 0; font-size: 24px; font-weight: 600; color: #1a1a1a;">
Faktura från Kund AB
</h1>
<p style="margin: 0; color: #666; font-size: 14px;">
Fakturanummer: F-1001
</p>
</div>
<!-- Greeting -->
<div style="margin-bottom: 30px;">
<p style="margin: 0 0 15px 0;">
Hej Erik,
</p>
<p style="margin: 0;">
Tack för ditt förtroende! Bifogat hittar du din faktura.
</p>
</div>
<!-- Summary Box -->
<div style="background: #f8f9fa; border-radius: 8px; padding: 25px; margin-bottom: 30px;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Fakturanummer:</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">F-1001</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Fakturadatum:</td>
<td style="padding: 8px 0; text-align: right;">2026-07-01</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Förfallodatum:</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500; color: #e11d48;">
2026-07-31
</td>
</tr>
<tr>
<td colspan="2" style="padding: 15px 0 8px 0; border-top: 1px solid #e5e7eb;"></td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">Att betala:</td>
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: #1a1a1a;">
1 250,00 SEK
</td>
</tr>
</table>
</div>
<!-- Pay-online button: only when the user pasted a payment link on this
invoice. The URL is schema-validated (https-only) but still escaped
for the attribute context: a URL may legally contain quotes. -->
<!-- Payment Details -->
<div style="margin-bottom: 30px;">
<h2 style="margin: 0 0 15px 0; font-size: 16px; font-weight: 600; color: #1a1a1a;">
Betalningsinformation
</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">Meddelande:</td>
<td style="padding: 6px 0; font-weight: 500;">F-1001</td>
</tr>
</table>
</div>
<!-- Footer -->
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">
Har du frågor om fakturan? Svara direkt på detta mejl så hjälper vi dig.
</p>
<p style="margin: 0; color: #666; font-size: 14px;">
Med vänliga hälsningar,<br>
<strong style="color: #1a1a1a;">Kund AB</strong>
</p>
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
Org.nr: 556000-0000
| Innehar F-skattsedel
</p>
</div>
</div>
</body>
</html>
"
`;
exports[`payslip mail (template class: payslip) > branded: brand sender and brand-domain link 1`] = `
"<p>Hej Anna,</p>
<p>Din lönespecifikation för juni 2026 finns att hämta via länken nedan.</p>
<p><a href="https://app.siffra.se/payslip/tok-1">Visa lönespecifikation</a></p>
<p>Utbetalningsdag: 2026-06-25<br>
Länken är personlig och giltig i 90 dagar.</p>
<p>Med vänliga hälsningar,<br>Kund AB</p>"
`;
exports[`payslip mail (template class: payslip) > unbranded: canonical link, platform sender, unchanged body 1`] = `
"<p>Hej Anna,</p>
<p>Din lönespecifikation för juni 2026 finns att hämta via länken nedan.</p>
<p><a href="https://app.gnubok.se/payslip/tok-1">Visa lönespecifikation</a></p>
<p>Utbetalningsdag: 2026-06-25<br>
Länken är personlig och giltig i 90 dagar.</p>
<p>Med vänliga hälsningar,<br>Kund AB</p>"
`;
exports[`reminder mail (template class: reminder) > unbranded: canonical action link and unchanged output 1`] = `
"
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vänlig påminnelse - Faktura F-1001</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f9fafb;">
<div style="max-width: 600px; margin: 0 auto; padding: 40px 20px;">
<div style="background: white; border-radius: 12px; padding: 40px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<!-- Header -->
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: #2563eb15; color: #2563eb; padding: 8px 16px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">
Vänlig påminnelse
</div>
</div>
<!-- Title -->
<h1 style="margin: 0 0 20px 0; font-size: 22px; font-weight: 600; color: #111; text-align: center;">
Faktura F-1001 förföll för 20 dagar sedan
</h1>
<!-- Greeting and Message -->
<div style="margin-bottom: 30px;">
<p style="margin: 0 0 15px 0;">
Hej Erik,
</p>
<p style="margin: 0 0 15px 0;">
Vi vill påminna dig om att faktura F-1001 förföll till betalning den 2026-06-01.
Om du redan har betalat kan du bortse från denna påminnelse.
</p>
</div>
<!-- Invoice Summary Box -->
<div style="background: #f8f9fa; border-radius: 8px; padding: 25px; margin-bottom: 30px; border-left: 4px solid #2563eb;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Fakturanummer:</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">F-1001</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Fakturadatum:</td>
<td style="padding: 8px 0; text-align: right;">2026-05-01</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Förfallodatum:</td>
<td style="padding: 8px 0; text-align: right; color: #dc2626; font-weight: 500;">
2026-06-01
</td>
</tr>
<tr>
<td colspan="2" style="padding: 15px 0 8px 0; border-top: 1px solid #e5e7eb;"></td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Ursprungligt belopp:</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">1 250 kr</td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">Att betala:</td>
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: #2563eb;">
1 250 kr
</td>
</tr>
</table>
</div>
<!-- Payment Details -->
<div style="margin-bottom: 30px;">
<h2 style="margin: 0 0 15px 0; font-size: 16px; font-weight: 600; color: #111;">
Betalningsinformation
</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">Meddelande:</td>
<td style="padding: 6px 0; font-weight: 500;">F-1001</td>
</tr>
</table>
</div>
<!-- Action Buttons -->
<div style="text-align: center; margin-bottom: 30px;">
<p style="margin: 0 0 20px 0; color: #666; font-size: 14px;">
Har du redan betalat eller har frågor om fakturan?
</p>
<a href="https://app.gnubok.se/invoice-action/tok-1" style="display: inline-block; background: #2563eb; color: white; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 500; font-size: 14px;">
Hantera faktura
</a>
</div>
<!-- Footer -->
<div style="padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">
Har du frågor? Svara direkt på detta mejl så hjälper vi dig.
</p>
<p style="margin: 0; color: #666; font-size: 14px;">
Med vänliga hälsningar,<br>
<strong>Kund AB</strong>
</p>
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
Org.nr: 199001011234
</p>
</div>
</div>
<!-- Unsubscribe note -->
<p style="text-align: center; margin-top: 20px; color: #999; font-size: 12px;">
Detta är ett automatiskt meddelande angående en obetald faktura.
</p>
</div>
</body>
</html>
"
`;
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { buildAuthEmail } from '../auth-templates'
const URL_EXAMPLE = 'https://app.siffra.se/auth/callback?token_hash=abc&type=recovery'
// In HTML the URL lands in an attribute and is entity-escaped.
const URL_EXAMPLE_HTML = URL_EXAMPLE.replace(/&/g, '&amp;')
describe('buildAuthEmail', () => {
it.each([
['signup', 'Bekräfta din e-postadress'],
['recovery', 'Återställ ditt lösenord'],
['magiclink', 'Din inloggningslänk'],
['invite', 'Du har blivit inbjuden'],
['email_change', 'Bekräfta din nya e-postadress'],
['email_change_current', 'Godkänn ändrad e-postadress'],
] as const)('renders %s with the brand app name and action link', (actionType, subject) => {
const mail = buildAuthEmail({
actionType,
appName: 'Siffra',
actionUrl: URL_EXAMPLE,
})
expect(mail.subject).toBe(subject)
expect(mail.html).toContain('Siffra')
expect(mail.html).toContain(URL_EXAMPLE_HTML)
expect(mail.html).not.toMatch(/accounted/i)
expect(mail.text).toContain(URL_EXAMPLE)
expect(mail.text).not.toMatch(/accounted/i)
})
it('renders the platform default without a brand', () => {
const mail = buildAuthEmail({
actionType: 'recovery',
appName: 'Accounted',
actionUrl: 'https://app.gnubok.se/auth/callback?token_hash=abc&type=recovery',
})
expect(mail.html).toContain('ACCOUNTED')
expect(mail.html).toContain('https://app.gnubok.se/auth/callback')
expect(mail.html).toMatchSnapshot()
expect(mail.text).toMatchSnapshot()
})
it('renders the branded recovery mail (snapshot)', () => {
const mail = buildAuthEmail({
actionType: 'recovery',
appName: 'Siffra',
actionUrl: URL_EXAMPLE,
})
expect(mail.html).toMatchSnapshot()
expect(mail.text).toMatchSnapshot()
})
it('renders the reauthentication code without a link', () => {
const mail = buildAuthEmail({
actionType: 'reauthentication',
appName: 'Siffra',
otpCode: '123456',
})
expect(mail.subject).toBe('Din verifieringskod')
expect(mail.html).toContain('123456')
expect(mail.html).not.toContain('<a href')
expect(mail.text).toContain('Kod: 123456')
})
it('falls back to a generic mail for unknown action types', () => {
const mail = buildAuthEmail({
actionType: 'some_future_type',
appName: 'Siffra',
actionUrl: URL_EXAMPLE,
})
expect(mail.subject).toBe('Bekräfta din åtgärd')
expect(mail.html).toContain(URL_EXAMPLE_HTML)
})
it('escapes HTML in the app name', () => {
const mail = buildAuthEmail({
actionType: 'recovery',
appName: '<script>x</script>',
actionUrl: URL_EXAMPLE,
})
expect(mail.html).not.toContain('<script>')
})
})
@@ -0,0 +1,264 @@
/**
* WL-13 brand-mail snapshot suite: for every template class (invite, payslip,
* invoice, reminder) assert the three sender states:
*
* - branded company -> brand sender + brand-domain links + no platform
* ("Accounted") leakage in the mail body
* - unbranded company-> today's output, unchanged
* - unverified sender domain -> platform-address fallback (fromName set,
* no fromAddress; the Resend service renders the brand
* name alone over the platform address)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Brand } from '@/lib/branding/resolve'
import { makeCustomer, makeInvoice, makeCompanySettings } from '@/tests/helpers'
const resolveBrandForCompanyMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/branding/resolve', () => ({
resolveBrandForCompany: resolveBrandForCompanyMock,
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appName: 'Accounted', appUrl: 'https://app.gnubok.se' }),
}))
import { getSenderForCompany, getBaseUrlForBrand } from '../brand-sender'
import {
generateInviteEmailSubject,
generateInviteEmailHtml,
generateInviteEmailText,
} from '../invite-templates'
import {
generateReminderEmailHtml,
generateReminderEmailText,
type ReminderEmailData,
} from '../reminder-templates'
import { generateInvoiceEmailHtml } from '../invoice-templates'
import { buildPayslipLinkEmail } from '@/lib/salary/payslips/email-template'
function makeBrand(overrides: Partial<Brand> = {}): Brand {
return {
id: 'brand-1',
teamId: 'team-1',
domain: 'app.siffra.se',
appName: 'Siffra',
logoUrl: null,
brandColor: '#123456',
chromeColor: null,
fontKey: 'default',
supportEmail: 'support@siffra.se',
authEmailFrom: 'noreply@post.siffra.se',
senderDomain: 'post.siffra.se',
senderDomainStatus: 'verified',
resendDomainId: 'rd-1',
...overrides,
}
}
const VERIFIED = makeBrand()
const UNVERIFIED = makeBrand({ senderDomainStatus: 'pending' })
beforeEach(() => {
vi.clearAllMocks()
resolveBrandForCompanyMock.mockResolvedValue(null)
})
describe('invite mail (template class: invite)', () => {
async function composeInvite() {
const sender = await getSenderForCompany('company-1')
const baseUrl = getBaseUrlForBrand(sender.brand)
const data = {
companyName: 'Kund AB',
inviterEmail: 'sara@siffra.se',
inviteUrl: `${baseUrl}/invite/tok-1`,
appName: sender.brand?.appName,
}
return {
sender,
subject: generateInviteEmailSubject(data),
html: generateInviteEmailHtml(data),
text: generateInviteEmailText(data),
}
}
it('branded: brand sender, brand-domain link, no Accounted leakage', async () => {
resolveBrandForCompanyMock.mockResolvedValue(VERIFIED)
const mail = await composeInvite()
expect(mail.sender).toMatchObject({
fromName: 'Siffra',
fromAddress: 'noreply@post.siffra.se',
replyTo: 'support@siffra.se',
})
expect(mail.html).toContain('https://app.siffra.se/invite/tok-1')
expect(mail.subject).toContain('siffra')
expect(mail.html).not.toMatch(/accounted/i)
expect(mail.text).not.toMatch(/accounted/i)
expect(mail.html).toMatchSnapshot()
})
it('unbranded: byte-identical to the un-overridden template on the canonical URL', async () => {
const mail = await composeInvite()
expect(mail.sender).toEqual({ fromName: null, fromAddress: null, replyTo: null, brand: null })
expect(mail.html).toContain('https://app.gnubok.se/invite/tok-1')
const todays = generateInviteEmailHtml({
companyName: 'Kund AB',
inviterEmail: 'sara@siffra.se',
inviteUrl: 'https://app.gnubok.se/invite/tok-1',
})
expect(mail.html).toBe(todays)
expect(mail.html).toMatchSnapshot()
})
it('unverified sender domain: via-Accounted fallback (fromName without fromAddress)', async () => {
resolveBrandForCompanyMock.mockResolvedValue(UNVERIFIED)
const mail = await composeInvite()
expect(mail.sender.fromName).toBe('Siffra')
expect(mail.sender.fromAddress).toBeNull()
// Links and body still carry the brand even in the fallback.
expect(mail.html).toContain('https://app.siffra.se/invite/tok-1')
expect(mail.html).not.toMatch(/accounted/i)
})
})
describe('payslip mail (template class: payslip)', () => {
async function composePayslip() {
const sender = await getSenderForCompany('company-1')
const baseUrl = getBaseUrlForBrand(sender.brand)
return {
sender,
...buildPayslipLinkEmail({
employeeFirstName: 'Anna',
companyName: 'Kund AB',
periodYear: 2026,
periodMonth: 6,
paymentDate: '2026-06-25',
url: `${baseUrl}/payslip/tok-1`,
}),
}
}
it('branded: brand sender and brand-domain link', async () => {
resolveBrandForCompanyMock.mockResolvedValue(VERIFIED)
const mail = await composePayslip()
expect(mail.sender).toMatchObject({
fromName: 'Siffra',
fromAddress: 'noreply@post.siffra.se',
})
expect(mail.html).toContain('https://app.siffra.se/payslip/tok-1')
expect(mail.html).not.toMatch(/accounted/i)
expect(mail.html).toMatchSnapshot()
})
it('unbranded: canonical link, platform sender, unchanged body', async () => {
const mail = await composePayslip()
expect(mail.sender.fromName).toBeNull()
expect(mail.sender.fromAddress).toBeNull()
expect(mail.html).toContain('https://app.gnubok.se/payslip/tok-1')
expect(mail.html).toMatchSnapshot()
})
it('unverified sender domain: via-Accounted fallback', async () => {
resolveBrandForCompanyMock.mockResolvedValue(UNVERIFIED)
const mail = await composePayslip()
expect(mail.sender.fromName).toBe('Siffra')
expect(mail.sender.fromAddress).toBeNull()
})
})
describe('invoice mail (template class: invoice)', () => {
const company = makeCompanySettings({
company_name: 'Kund AB',
org_number: '556000-0000',
})
const customer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' })
const invoice = makeInvoice({
invoice_number: 'F-1001',
invoice_date: '2026-07-01',
due_date: '2026-07-31',
total: 1250,
currency: 'SEK',
})
it('branded: mail rides the brand sender domain, COMPANY stays the display sender', async () => {
resolveBrandForCompanyMock.mockResolvedValue(VERIFIED)
const sender = await getSenderForCompany('company-1')
// The call site (sendTrackedInvoiceEmail) keeps fromName = company name
// and only picks up fromAddress from the brand.
expect(sender.fromAddress).toBe('noreply@post.siffra.se')
const html = generateInvoiceEmailHtml({ invoice, customer, company })
// Template content is company-branded and platform-free by design;
// the brand must not inject itself into the customer-facing body.
expect(html).not.toMatch(/accounted/i)
expect(html).not.toContain('Siffra')
expect(html).toContain('Kund AB')
})
it('unbranded: template output unchanged and platform-free', async () => {
const sender = await getSenderForCompany('company-1')
expect(sender.fromAddress).toBeNull()
const html = generateInvoiceEmailHtml({ invoice, customer, company })
expect(html).not.toMatch(/accounted/i)
expect(html).toMatchSnapshot()
})
it('unverified sender domain: no fromAddress, so the From header stays byte-identical', async () => {
resolveBrandForCompanyMock.mockResolvedValue(UNVERIFIED)
const sender = await getSenderForCompany('company-1')
expect(sender.fromAddress).toBeNull()
})
})
describe('reminder mail (template class: reminder)', () => {
function makeReminderData(actionUrl: string): ReminderEmailData {
return {
invoice: makeInvoice({
invoice_number: 'F-1001',
invoice_date: '2026-05-01',
due_date: '2026-06-01',
total: 1250,
currency: 'SEK',
}),
customer: makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se' }),
company: makeCompanySettings({ company_name: 'Kund AB' }),
reminderLevel: 1,
daysOverdue: 20,
actionUrl,
interestAmount: 0,
interestRate: 0,
interestFromDate: '2026-06-02',
interestDays: 0,
reminderFee: 0,
}
}
it('branded: action link on the brand home domain, no Accounted leakage', async () => {
resolveBrandForCompanyMock.mockResolvedValue(VERIFIED)
const sender = await getSenderForCompany('company-1')
const baseUrl = getBaseUrlForBrand(sender.brand)
const data = makeReminderData(`${baseUrl}/invoice-action/tok-1`)
const html = generateReminderEmailHtml(data)
const text = generateReminderEmailText(data)
expect(sender.fromAddress).toBe('noreply@post.siffra.se')
expect(html).toContain('https://app.siffra.se/invoice-action/tok-1')
expect(html).not.toMatch(/accounted/i)
expect(text).toContain('https://app.siffra.se/invoice-action/tok-1')
})
it('unbranded: canonical action link and unchanged output', async () => {
const sender = await getSenderForCompany('company-1')
const baseUrl = getBaseUrlForBrand(sender.brand)
const data = makeReminderData(`${baseUrl}/invoice-action/tok-1`)
const html = generateReminderEmailHtml(data)
expect(sender.fromAddress).toBeNull()
expect(html).toContain('https://app.gnubok.se/invoice-action/tok-1')
expect(html).not.toMatch(/accounted/i)
expect(html).toMatchSnapshot()
})
it('unverified sender domain: brand link but platform From address', async () => {
resolveBrandForCompanyMock.mockResolvedValue(UNVERIFIED)
const sender = await getSenderForCompany('company-1')
expect(sender.fromAddress).toBeNull()
expect(getBaseUrlForBrand(sender.brand)).toBe('https://app.siffra.se')
})
})
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Brand } from '@/lib/branding/resolve'
const resolveBrandForCompanyMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/branding/resolve', () => ({
resolveBrandForCompany: resolveBrandForCompanyMock,
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appName: 'Accounted', appUrl: 'https://app.gnubok.se' }),
}))
import {
getSenderForBrand,
getSenderForCompany,
getBaseUrlForBrand,
getBaseUrlForCompany,
} from '../brand-sender'
function makeBrand(overrides: Partial<Brand> = {}): Brand {
return {
id: 'brand-1',
teamId: 'team-1',
domain: 'app.siffra.se',
appName: 'Siffra',
logoUrl: null,
brandColor: '#123456',
chromeColor: null,
fontKey: 'default',
supportEmail: 'support@siffra.se',
authEmailFrom: 'noreply@post.siffra.se',
senderDomain: 'post.siffra.se',
senderDomainStatus: 'verified',
resendDomainId: 'rd-1',
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('getSenderForBrand', () => {
it('returns platform defaults (all null) for no brand', () => {
expect(getSenderForBrand(null)).toEqual({
fromName: null,
fromAddress: null,
replyTo: null,
brand: null,
})
})
it('sends from the brand address when the sender domain is verified', () => {
const brand = makeBrand()
expect(getSenderForBrand(brand)).toEqual({
fromName: 'Siffra',
fromAddress: 'noreply@post.siffra.se',
replyTo: 'support@siffra.se',
brand,
})
})
it('falls back to the via-platform pattern when the domain is unverified', () => {
const brand = makeBrand({ senderDomainStatus: 'pending' })
expect(getSenderForBrand(brand)).toEqual({
fromName: 'Siffra',
fromAddress: null,
replyTo: 'support@siffra.se',
brand,
})
})
it('falls back when the domain is verified but authEmailFrom is missing', () => {
const brand = makeBrand({ authEmailFrom: null })
const sender = getSenderForBrand(brand)
expect(sender.fromAddress).toBeNull()
expect(sender.fromName).toBe('Siffra')
})
})
describe('getSenderForCompany', () => {
it('resolves through resolveBrandForCompany', async () => {
const brand = makeBrand()
resolveBrandForCompanyMock.mockResolvedValue(brand)
const sender = await getSenderForCompany('company-1')
expect(resolveBrandForCompanyMock).toHaveBeenCalledWith('company-1')
expect(sender.fromAddress).toBe('noreply@post.siffra.se')
})
it('returns platform defaults for a brandless company', async () => {
resolveBrandForCompanyMock.mockResolvedValue(null)
const sender = await getSenderForCompany('company-1')
expect(sender).toEqual({ fromName: null, fromAddress: null, replyTo: null, brand: null })
})
})
describe('base URLs', () => {
it('uses the brand home domain when a brand exists', () => {
expect(getBaseUrlForBrand(makeBrand())).toBe('https://app.siffra.se')
})
it('uses the canonical app URL without a brand', () => {
expect(getBaseUrlForBrand(null)).toBe('https://app.gnubok.se')
})
it('getBaseUrlForCompany resolves the brand by company', async () => {
resolveBrandForCompanyMock.mockResolvedValue(makeBrand())
await expect(getBaseUrlForCompany('company-1')).resolves.toBe('https://app.siffra.se')
resolveBrandForCompanyMock.mockResolvedValue(null)
await expect(getBaseUrlForCompany('company-2')).resolves.toBe('https://app.gnubok.se')
})
})
@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest'
import { createHmac, randomBytes } from 'node:crypto'
import { verifyStandardWebhookSignature } from '../standard-webhook'
const KEY = randomBytes(24)
const SECRET = `v1,whsec_${KEY.toString('base64')}`
function sign(id: string, timestamp: string, payload: string, key: Buffer = KEY): string {
const sig = createHmac('sha256', key)
.update(`${id}.${timestamp}.${payload}`)
.digest('base64')
return `v1,${sig}`
}
const NOW_MS = 1_754_000_000_000
const NOW_SECONDS = String(Math.floor(NOW_MS / 1000))
describe('verifyStandardWebhookSignature', () => {
const payload = JSON.stringify({ hello: 'world' })
it('accepts a valid v1 signature', () => {
expect(
verifyStandardWebhookSignature({
secret: SECRET,
payload,
id: 'msg_1',
timestamp: NOW_SECONDS,
signature: sign('msg_1', NOW_SECONDS, payload),
nowMs: NOW_MS,
}),
).toBe(true)
})
it('accepts the whsec_-only secret format', () => {
expect(
verifyStandardWebhookSignature({
secret: `whsec_${KEY.toString('base64')}`,
payload,
id: 'msg_1',
timestamp: NOW_SECONDS,
signature: sign('msg_1', NOW_SECONDS, payload),
nowMs: NOW_MS,
}),
).toBe(true)
})
it('accepts a space-separated signature list with one matching entry', () => {
const other = sign('msg_1', NOW_SECONDS, payload, randomBytes(24))
const good = sign('msg_1', NOW_SECONDS, payload)
expect(
verifyStandardWebhookSignature({
secret: SECRET,
payload,
id: 'msg_1',
timestamp: NOW_SECONDS,
signature: `${other} ${good}`,
nowMs: NOW_MS,
}),
).toBe(true)
})
it('rejects a signature computed with a different key', () => {
expect(
verifyStandardWebhookSignature({
secret: SECRET,
payload,
id: 'msg_1',
timestamp: NOW_SECONDS,
signature: sign('msg_1', NOW_SECONDS, payload, randomBytes(24)),
nowMs: NOW_MS,
}),
).toBe(false)
})
it('rejects a tampered payload', () => {
expect(
verifyStandardWebhookSignature({
secret: SECRET,
payload: payload + 'x',
id: 'msg_1',
timestamp: NOW_SECONDS,
signature: sign('msg_1', NOW_SECONDS, payload),
nowMs: NOW_MS,
}),
).toBe(false)
})
it('rejects missing headers', () => {
expect(
verifyStandardWebhookSignature({
secret: SECRET,
payload,
id: null,
timestamp: NOW_SECONDS,
signature: sign('msg_1', NOW_SECONDS, payload),
nowMs: NOW_MS,
}),
).toBe(false)
})
it('rejects a timestamp outside the replay tolerance', () => {
const stale = String(Math.floor(NOW_MS / 1000) - 3600)
expect(
verifyStandardWebhookSignature({
secret: SECRET,
payload,
id: 'msg_1',
timestamp: stale,
signature: sign('msg_1', stale, payload),
nowMs: NOW_MS,
}),
).toBe(false)
})
it('ignores non-v1 signature versions', () => {
const sig = sign('msg_1', NOW_SECONDS, payload).slice(3)
expect(
verifyStandardWebhookSignature({
secret: SECRET,
payload,
id: 'msg_1',
timestamp: NOW_SECONDS,
signature: `v1a,${sig}`,
nowMs: NOW_MS,
}),
).toBe(false)
})
})
+176
View File
@@ -0,0 +1,176 @@
/**
* Auth mail templates for the Supabase Send Email hook (WL-05, WL-13).
*
* Swedish-only, like the other user-facing mail templates in lib/email/
* (see .claude/rules/i18n.md): these are transactional mails, not UI chrome,
* so they deliberately do not go through next-intl.
*
* The appName parameter carries the brand of the requesting host (resolved
* via resolveBrandByHost from the redirect_to origin); the default is the
* platform name, so unbranded hosts render canonical mail.
*/
import { escapeHtml, sanitizeSubjectLine } from './user-text'
export type AuthEmailActionType =
| 'signup'
| 'recovery'
| 'magiclink'
| 'invite'
| 'email_change'
| 'email_change_current'
| 'reauthentication'
interface AuthEmailContent {
subject: string
heading: string
body: (appName: string) => string
cta: string
}
const CONTENT: Record<AuthEmailActionType, AuthEmailContent> = {
signup: {
subject: 'Bekräfta din e-postadress',
heading: 'Bekräfta din e-postadress',
body: (appName) =>
`Klicka på knappen nedan för att bekräfta din e-postadress och slutföra din registrering hos ${appName}.`,
cta: 'Bekräfta e-postadress',
},
recovery: {
subject: 'Återställ ditt lösenord',
heading: 'Återställ ditt lösenord',
body: (appName) =>
`Vi har tagit emot en begäran om att återställa lösenordet för ditt konto hos ${appName}. Klicka på knappen nedan för att välja ett nytt lösenord.`,
cta: 'Återställ lösenord',
},
magiclink: {
subject: 'Din inloggningslänk',
heading: 'Logga in',
body: (appName) => `Klicka på knappen nedan för att logga in hos ${appName}.`,
cta: 'Logga in',
},
invite: {
subject: 'Du har blivit inbjuden',
heading: 'Du har blivit inbjuden',
body: (appName) =>
`Du har blivit inbjuden till ${appName}. Klicka på knappen nedan för att skapa ditt konto.`,
cta: 'Acceptera inbjudan',
},
email_change: {
subject: 'Bekräfta din nya e-postadress',
heading: 'Bekräfta din nya e-postadress',
body: (appName) =>
`Klicka på knappen nedan för att bekräfta din nya e-postadress hos ${appName}.`,
cta: 'Bekräfta ny e-postadress',
},
email_change_current: {
subject: 'Godkänn ändrad e-postadress',
heading: 'Godkänn ändrad e-postadress',
body: (appName) =>
`En ändring av e-postadressen för ditt konto hos ${appName} har begärts. Klicka på knappen nedan för att godkänna ändringen från din nuvarande adress.`,
cta: 'Godkänn ändringen',
},
reauthentication: {
subject: 'Din verifieringskod',
heading: 'Din verifieringskod',
body: (appName) => `Ange koden nedan för att bekräfta din identitet hos ${appName}.`,
cta: '',
},
}
// Availability first: an action type this module does not know (Supabase can
// add new mail classes) still produces a usable mail with the verify link
// rather than dropping the send.
const FALLBACK_CONTENT: AuthEmailContent = {
subject: 'Bekräfta din åtgärd',
heading: 'Bekräfta din åtgärd',
body: (appName) => `Klicka på knappen nedan för att fortsätta hos ${appName}.`,
cta: 'Fortsätt',
}
const IGNORE_NOTE = 'Om du inte begärde detta kan du bortse från det här meddelandet.'
export interface AuthEmailInput {
actionType: string
appName: string
/** Verify URL on the originating host (token_hash flow). */
actionUrl?: string
/** One-time code, for reauthentication mail (no link). */
otpCode?: string
}
export interface AuthEmail {
subject: string
html: string
text: string
}
function contentFor(actionType: string): AuthEmailContent {
return (CONTENT as Record<string, AuthEmailContent>)[actionType] ?? FALLBACK_CONTENT
}
export function buildAuthEmail(input: AuthEmailInput): AuthEmail {
const content = contentFor(input.actionType)
const appName = input.appName
const safeAppName = escapeHtml(appName)
const body = content.body(appName)
const action = input.otpCode
? `
<div style="margin: 28px 0;">
<span style="display: inline-block; background: #f5f5f5; border: 1px solid #e5e5e5; border-radius: 8px; padding: 12px 28px; font-size: 22px; font-weight: 600; letter-spacing: 0.3em; color: #111;">${escapeHtml(input.otpCode)}</span>
</div>`
: input.actionUrl
? `
<div style="margin: 28px 0;">
<a href="${escapeHtml(input.actionUrl)}" style="display: inline-block; background: #111; color: #fff; text-decoration: none; padding: 12px 28px; border-radius: 8px; font-size: 14px; font-weight: 500;">
${escapeHtml(content.cta)}
</a>
</div>`
: ''
const html = `
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(content.subject)}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f5f5f5;">
<div style="max-width: 520px; margin: 0 auto; padding: 40px 20px;">
<div style="background: #ffffff; border-radius: 12px; padding: 40px 32px; border: 1px solid #e5e5e5;">
<!-- Header -->
<div style="margin-bottom: 28px;">
<p style="margin: 0 0 4px 0; font-size: 13px; color: #888; letter-spacing: 0.05em;">${safeAppName.toUpperCase()}</p>
<h1 style="margin: 0 0 8px 0; font-size: 22px; font-weight: 600; color: #111;">
${escapeHtml(content.heading)}
</h1>
<p style="margin: 0; color: #666; font-size: 15px;">
${escapeHtml(body)}
</p>
</div>
${action}
<!-- Info -->
<p style="margin: 0; color: #999; font-size: 13px;">
${IGNORE_NOTE}
</p>
</div>
</div>
</body>
</html>`
let text = `${content.heading}\n\n${body}\n\n`
if (input.otpCode) {
text += `Kod: ${input.otpCode}\n\n`
} else if (input.actionUrl) {
text += `${content.cta}: ${input.actionUrl}\n\n`
}
text += IGNORE_NOTE
return {
subject: sanitizeSubjectLine(content.subject),
html,
text,
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Brand-aware outbound mail identity (WL-13).
*
* THE RULE: every outbound mail is sent in the brand of the COMPANY it
* concerns, resolved via resolveBrandForCompany (WL-12). These helpers are
* the ONLY entry points call sites use for sender identity and link base
* URLs; a company without a brand gets exactly today's values, byte for byte.
*
* Sender chain (WL-04):
* 1. Brand with a VERIFIED Resend sender domain and an authEmailFrom
* address: send from the brand address with the brand app name.
* 2. Brand without a verified sender domain: ride the platform default
* address with the brand name alone as the display name (fromName set,
* no fromAddress; no "via <platform>", founder call 2026-08-05).
* DMARC-clean: the From domain stays ours.
* 3. No brand: platform defaults (fromName/fromAddress/replyTo all null,
* the email service renders "<Platform> <RESEND_FROM_EMAIL>").
*/
import { getBranding } from '@/lib/branding/service'
import { resolveBrandForCompany, type Brand } from '@/lib/branding/resolve'
export interface BrandSender {
/** Display name for the From header; null = platform default. */
fromName: string | null
/**
* Explicit From address; only set when the brand's sender domain is
* verified in Resend. Null = platform default address.
*/
fromAddress: string | null
/** Reply-To; the brand's support address when a brand exists, else null. */
replyTo: string | null
/** The resolved brand, for callers that also need appName/domain/logo. */
brand: Brand | null
}
/** Pure mapping from a resolved brand to sender identity. */
export function getSenderForBrand(brand: Brand | null): BrandSender {
if (!brand) {
return { fromName: null, fromAddress: null, replyTo: null, brand: null }
}
if (brand.senderDomainStatus === 'verified' && brand.authEmailFrom) {
return {
fromName: brand.appName,
fromAddress: brand.authEmailFrom,
replyTo: brand.supportEmail || null,
brand,
}
}
// Unverified/pending/failed sender domain: "via Accounted" fallback.
return {
fromName: brand.appName,
fromAddress: null,
replyTo: brand.supportEmail || null,
brand,
}
}
/**
* Sender identity for mail concerning `companyId`. Host-independent and
* cron-safe: resolution goes company -> team -> brand.
*/
export async function getSenderForCompany(companyId: string): Promise<BrandSender> {
return getSenderForBrand(await resolveBrandForCompany(companyId))
}
/** Base URL mailed links should point at, given an already-resolved brand. */
export function getBaseUrlForBrand(brand: Brand | null): string {
return brand ? `https://${brand.domain}` : getBranding().appUrl
}
/**
* Base URL for links in mail concerning `companyId`: the brand's home domain
* when the company's team has a brand, else the canonical app URL
* (NEXT_PUBLIC_APP_URL via getBranding().appUrl).
*/
export async function getBaseUrlForCompany(companyId: string): Promise<string> {
return getBaseUrlForBrand(await resolveBrandForCompany(companyId))
}
+42 -11
View File
@@ -4,16 +4,22 @@ export interface InviteEmailData {
companyName: string
inviterEmail: string
inviteUrl: string
/**
* Brand override (WL-13): the app name of the brand of the company the
* invite concerns. Absent = platform default from getBranding(), which
* keeps unbranded companies byte-identical to before.
*/
appName?: string
}
export function generateInviteEmailSubject(data: InviteEmailData): string {
const { appName } = getBranding()
const appName = data.appName ?? getBranding().appName
return `Du har bjudits in till ${data.companyName} på ${appName.toLowerCase()}`
}
export function generateInviteEmailHtml(data: InviteEmailData): string {
const { companyName, inviterEmail, inviteUrl } = data
const { appName } = getBranding()
const appName = data.appName ?? getBranding().appName
return `
<!DOCTYPE html>
@@ -55,7 +61,7 @@ export function generateInviteEmailHtml(data: InviteEmailData): string {
}
export function generateInviteEmailText(data: InviteEmailData): string {
const { appName } = getBranding()
const appName = data.appName ?? getBranding().appName
return `Du har bjudits in till ${data.companyName} på ${appName.toLowerCase()} av ${data.inviterEmail}.
Acceptera inbjudan: ${data.inviteUrl}
@@ -70,16 +76,35 @@ Länken är giltig i 7 dagar.`
export interface TeamInviteEmailData {
inviterEmail: string
inviteUrl: string
/**
* Brand override (WL-13): the app name of the byrå team's brand. Absent =
* platform default from getBranding(), which keeps brandless teams
* byte-identical to before.
*/
appName?: string
}
export function generateTeamInviteEmailSubject(): string {
const { appName } = getBranding()
return `Du har bjudits in till ett team på ${appName.toLowerCase()}`
export function generateTeamInviteEmailSubject(data?: Pick<TeamInviteEmailData, 'appName'>): string {
// Branded byrå: the invite is to THE BYRÅ, by name and in its own casing
// ("Du har blivit inbjuden till Willem"), no platform wording. Brandless
// teams keep the platform phrasing byte-identical.
if (data?.appName) {
return `Du har blivit inbjuden till ${data.appName}`
}
return `Du har bjudits in till ett team på ${getBranding().appName.toLowerCase()}`
}
export function generateTeamInviteEmailHtml(data: TeamInviteEmailData): string {
const { inviterEmail, inviteUrl } = data
const { appName } = getBranding()
const appName = data.appName ?? getBranding().appName
// Branded byrå: headline and body name the byrå itself; brandless teams
// keep the generic team wording.
const headline = data.appName
? `Du har blivit inbjuden till ${data.appName}`
: 'Du har blivit inbjuden till ett team'
const bodyLine = data.appName
? `<strong>${inviterEmail}</strong> har bjudit in dig till <strong>${data.appName}</strong>. Du får tillgång till alla företag i teamet.`
: `<strong>${inviterEmail}</strong> har bjudit in dig som konsult. Du får tillgång till alla företag i teamet.`
return `
<!DOCTYPE html>
@@ -96,10 +121,10 @@ export function generateTeamInviteEmailHtml(data: TeamInviteEmailData): string {
<div style="margin-bottom: 28px;">
<p style="margin: 0 0 4px 0; font-size: 13px; color: #888; letter-spacing: 0.05em;">${appName.toUpperCase()}</p>
<h1 style="margin: 0 0 8px 0; font-size: 22px; font-weight: 600; color: #111;">
Du har blivit inbjuden till ett team
${headline}
</h1>
<p style="margin: 0; color: #666; font-size: 15px;">
<strong>${inviterEmail}</strong> har bjudit in dig som konsult. Du får tillgång till alla företag i teamet.
${bodyLine}
</p>
</div>
@@ -121,8 +146,14 @@ export function generateTeamInviteEmailHtml(data: TeamInviteEmailData): string {
}
export function generateTeamInviteEmailText(data: TeamInviteEmailData): string {
const { appName } = getBranding()
return `Du har bjudits in som konsult till ett team på ${appName.toLowerCase()} av ${data.inviterEmail}. Du får tillgång till alla företag i teamet.
if (data.appName) {
return `Du har blivit inbjuden till ${data.appName} av ${data.inviterEmail}. Du får tillgång till alla företag i teamet.
Acceptera inbjudan: ${data.inviteUrl}
Länken är giltig i 7 dagar.`
}
return `Du har bjudits in som konsult till ett team på ${getBranding().appName.toLowerCase()} av ${data.inviterEmail}. Du får tillgång till alla företag i teamet.
Acceptera inbjudan: ${data.inviteUrl}
+73
View File
@@ -0,0 +1,73 @@
import { getEmailService } from '@/lib/email/service'
import { getSenderForBrand, getBaseUrlForBrand } from '@/lib/email/brand-sender'
import { resolveBrandForTeam } from '@/lib/branding/resolve'
import {
generateTeamInviteEmailSubject,
generateTeamInviteEmailHtml,
generateTeamInviteEmailText,
} from '@/lib/email/invite-templates'
import { createLogger } from '@/lib/logger'
const log = createLogger('team-invite-mail')
export interface TeamInviteMailResult {
/** The accept link, always produced so the inviter can share it directly. */
inviteUrl: string
/** False when the service is unconfigured or the provider send failed. */
emailSent: boolean
}
/**
* Send a byrå-team invitation mail in the team's brand (WL-13): sender
* identity via getSenderForBrand(resolveBrandForTeam(teamId)) and the accept
* link on the brand's home domain, canonical for brandless teams.
*
* Shared by invite creation and re-send so the two paths cannot drift. A send
* failure never throws: the invitation stays valid and the caller surfaces
* emailSent: false together with the link.
*/
export async function sendTeamInviteMail(params: {
teamId: string
email: string
inviterEmail: string
token: string
}): Promise<TeamInviteMailResult> {
const { teamId, email, inviterEmail, token } = params
const brand = await resolveBrandForTeam(teamId)
const sender = getSenderForBrand(brand)
const appUrl = getBaseUrlForBrand(brand)
const inviteUrl = `${appUrl}/invite/${token}`
let emailSent = false
const emailService = getEmailService()
if (emailService.isConfigured()) {
const emailData = {
inviterEmail,
inviteUrl,
appName: sender.brand?.appName,
}
const result = await emailService.sendEmail({
to: email,
subject: generateTeamInviteEmailSubject(emailData),
html: generateTeamInviteEmailHtml(emailData),
text: generateTeamInviteEmailText(emailData),
fromName: sender.fromName ?? undefined,
fromAddress: sender.fromAddress ?? undefined,
replyTo: sender.replyTo ?? undefined,
})
if (result.success) {
emailSent = true
log.info('team invite email sent', { to: email, teamId, messageId: result.messageId })
} else {
log.error('team invite email send failed', new Error(result.error ?? 'unknown'), {
to: email,
teamId,
})
}
} else {
log.warn('email service not configured: team invite email skipped', { to: email, teamId })
}
return { inviteUrl, emailSent }
}
+7
View File
@@ -15,6 +15,13 @@ export interface SendEmailOptions {
text?: string
replyTo?: string
fromName?: string
/**
* Explicit From address. Only ever set by lib/email/brand-sender.ts for
* brands whose Resend sender domain is VERIFIED. When absent, the provider
* sends from its default address (RESEND_FROM_EMAIL) with fromName as the
* display name.
*/
fromAddress?: string
/**
* Explicit From identity (company's own verified sending domain). When
* set, the provider sends as "<name> <address>" instead of the platform
+78
View File
@@ -0,0 +1,78 @@
/**
* Standard Webhooks signature verification (https://www.standardwebhooks.com/),
* implemented with node:crypto only: the Supabase Send Email hook signs its
* requests in this format and the endpoint must stay dependency-free.
*
* Scheme: the secret is base64 material, presented as "v1,whsec_<base64>"
* (Supabase dashboard) or "whsec_<base64>". The signed content is
* "<msg-id>.<timestamp>.<raw-body>", HMAC-SHA256 over the decoded secret,
* base64-encoded. The webhook-signature header carries a space-separated
* list of "<version>,<signature>" entries; only symmetric v1 entries are
* considered.
*/
import { createHmac, timingSafeEqual } from 'node:crypto'
const DEFAULT_TOLERANCE_SECONDS = 300
export interface StandardWebhookVerificationInput {
/** Raw secret ("v1,whsec_...", "whsec_..." or bare base64). */
secret: string
/** Raw request body, byte-identical to what was signed. */
payload: string
/** webhook-id header. */
id: string | null
/** webhook-timestamp header (unix seconds). */
timestamp: string | null
/** webhook-signature header. */
signature: string | null
/** Replay window in seconds; default 300. */
toleranceSeconds?: number
/** Test hook: current time in ms. */
nowMs?: number
}
function decodeSecret(secret: string): Buffer {
let s = secret.trim()
if (s.startsWith('v1,')) s = s.slice(3)
if (s.startsWith('whsec_')) s = s.slice(6)
return Buffer.from(s, 'base64')
}
/**
* True only when the signature verifies and the timestamp is within the
* replay tolerance. Never throws: any malformed input is a failed
* verification.
*/
export function verifyStandardWebhookSignature(
input: StandardWebhookVerificationInput,
): boolean {
const { secret, payload, id, timestamp, signature } = input
if (!secret || !id || !timestamp || !signature) return false
const ts = Number(timestamp)
if (!Number.isFinite(ts)) return false
const nowSeconds = (input.nowMs ?? Date.now()) / 1000
const tolerance = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS
if (Math.abs(nowSeconds - ts) > tolerance) return false
const key = decodeSecret(secret)
if (key.length === 0) return false
const expected = createHmac('sha256', key)
.update(`${id}.${timestamp}.${payload}`)
.digest()
for (const part of signature.split(/\s+/)) {
const commaIndex = part.indexOf(',')
if (commaIndex === -1) continue
const version = part.slice(0, commaIndex)
const candidateBase64 = part.slice(commaIndex + 1)
if (version !== 'v1' || !candidateBase64) continue
const candidate = Buffer.from(candidateBase64, 'base64')
if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) {
return true
}
}
return false
}
+64
View File
@@ -0,0 +1,64 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* WL-10 billing honesty: a company attached to a byrå team whose team holds
* an active manual (partner-agreement) grant is covered by the byrå's
* agreement. The billing surface shows an "Ingår i <byråns namn>s avtal"
* state instead of the upgrade pitch for these companies.
*/
export interface TeamAgreement {
teamName: string
}
/**
* Resolve whether `companyId` is covered by a byrå-team agreement.
*
* The v1 partner deal is manual team-scoped grant rows: source='manual',
* team_id set, expires_at = contract end or NULL (WL-10 resolution). Any such
* unexpired row on the company's team counts; grace handling is simply ops
* setting expires_at, after which this returns null and the company falls
* back to the standard non-paying behavior.
*
* MUST be called with the SERVICE client: end clients are never members of
* the byrå team (WL-08), so RLS hides both the team row and the team-scoped
* grant rows from their session. The caller is responsible for having
* authorized companyId (requireCompanyId) before passing it here.
*/
export async function getTeamAgreement(
serviceClient: SupabaseClient,
companyId: string,
): Promise<TeamAgreement | null> {
const { data: company } = await serviceClient
.from('companies')
.select('team_id')
.eq('id', companyId)
.maybeSingle()
const teamId = (company as { team_id: string | null } | null)?.team_id ?? null
if (!teamId) return null
const { data: team } = await serviceClient
.from('teams')
.select('name, kind')
.eq('id', teamId)
.maybeSingle()
const teamRow = team as { name: string; kind: string } | null
if (!teamRow || teamRow.kind !== 'byra') return null
const { data: grants, error: grantsError } = await serviceClient
.from('capability_grants')
.select('expires_at')
.eq('team_id', teamId)
.eq('source', 'manual')
// Fail-closed on a read error: no fabricated agreement state.
if (grantsError) return null
const now = Date.now()
const active = ((grants ?? []) as { expires_at: string | null }[]).some(
(g) => g.expires_at === null || new Date(g.expires_at).getTime() > now,
)
return active ? { teamName: teamRow.name } : null
}
@@ -5,6 +5,7 @@ import type { EmailService } from '@/lib/email/service'
const mockUploadDocument = vi.fn()
const mockDeleteDocument = vi.fn()
const mockCreateServiceClient = vi.fn()
const mockGetSenderForCompany = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
deleteDocument: (...args: unknown[]) => mockDeleteDocument(...args),
@@ -12,6 +13,9 @@ vi.mock('@/lib/core/documents/document-service', () => ({
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => mockCreateServiceClient(),
}))
vi.mock('@/lib/email/brand-sender', () => ({
getSenderForCompany: (...args: unknown[]) => mockGetSenderForCompany(...args),
}))
import {
InvoiceDeliverySnapshotError,
@@ -97,6 +101,13 @@ describe('invoice delivery tracking', () => {
sha256_hash: 'sha256-exact-pdf',
})
mockDeleteDocument.mockResolvedValue({ ok: true })
// Default: unbranded company (today's sender, byte for byte).
mockGetSenderForCompany.mockResolvedValue({
fromName: null,
fromAddress: null,
replyTo: null,
brand: null,
})
})
it('persists the exact payload before sending and records provider success', async () => {
@@ -152,6 +163,56 @@ describe('invoice delivery tracking', () => {
})
})
it('keeps the From header unbranded for a company without a brand', async () => {
const { supabase } = makeSupabase()
const sendEmail = vi.fn().mockResolvedValue({ success: true, provider: 'resend' })
await sendTrackedInvoiceEmail(makeInput(supabase, makeEmailService(sendEmail)))
expect(mockGetSenderForCompany).toHaveBeenCalledWith('company-1')
const options = sendEmail.mock.calls[0][0]
expect(options.fromName).toBe('Example AB')
expect(options.fromAddress).toBeUndefined()
expect(options.replyTo).toBe('sender@example.com')
})
it('rides the verified brand sender domain while keeping the company display name', async () => {
mockGetSenderForCompany.mockResolvedValue({
fromName: 'Siffra',
fromAddress: 'noreply@post.siffra.se',
replyTo: 'support@siffra.se',
brand: { appName: 'Siffra' },
})
const { supabase } = makeSupabase()
const sendEmail = vi.fn().mockResolvedValue({ success: true, provider: 'resend' })
await sendTrackedInvoiceEmail(makeInput(supabase, makeEmailService(sendEmail)))
const options = sendEmail.mock.calls[0][0]
// The COMPANY stays the displayed sender; only the address is the brand's.
expect(options.fromName).toBe('Example AB')
expect(options.fromAddress).toBe('noreply@post.siffra.se')
// Reply-to stays the company's own address, not the brand support box.
expect(options.replyTo).toBe('sender@example.com')
})
it('does not attach a From address for an unverified brand sender domain', async () => {
mockGetSenderForCompany.mockResolvedValue({
fromName: 'Siffra',
fromAddress: null,
replyTo: 'support@siffra.se',
brand: { appName: 'Siffra' },
})
const { supabase } = makeSupabase()
const sendEmail = vi.fn().mockResolvedValue({ success: true, provider: 'resend' })
await sendTrackedInvoiceEmail(makeInput(supabase, makeEmailService(sendEmail)))
const options = sendEmail.mock.calls[0][0]
expect(options.fromName).toBe('Example AB')
expect(options.fromAddress).toBeUndefined()
})
it('does not call the provider when the immutable snapshot cannot be saved', async () => {
const { supabase } = makeSupabase({
snapshotData: null,
@@ -70,6 +70,10 @@ vi.mock('@/lib/email/service', () => ({
vi.mock('@/lib/bookkeeping/reminder-fee-entries', () => ({
createReminderFeeEntry: mockCreateReminderFeeEntry,
}))
vi.mock('@/lib/email/brand-sender', () => ({
getSenderForCompany: vi.fn().mockResolvedValue({ brand: null }),
getBaseUrlForBrand: vi.fn().mockReturnValue('https://app.accounted.test'),
}))
import { processOverdueReminders } from '../reminder-processor'
@@ -28,16 +28,24 @@ vi.mock('@supabase/ssr', () => {
}
})
const sendEmailSpy = vi.hoisted(() => vi.fn())
vi.mock('@/lib/email/invoice-sender', () => ({
resolveInvoiceSender: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({
sendEmail: vi.fn().mockResolvedValue({ success: true }),
sendEmail: sendEmailSpy,
}),
}))
const brandSenderMock = vi.hoisted(() => ({
getSenderForCompany: vi.fn(),
getBaseUrlForBrand: vi.fn(),
}))
vi.mock('@/lib/email/brand-sender', () => brandSenderMock)
import {
processOverdueReminders,
determineReminderLevel,
@@ -47,6 +55,15 @@ import {
import { makeCompanySettings, makeCustomer, makeInvoice } from '@/tests/helpers'
import { getReminderDaysConfig } from '@/lib/email/reminder-templates'
sendEmailSpy.mockResolvedValue({ success: true })
brandSenderMock.getSenderForCompany.mockResolvedValue({
fromName: null,
fromAddress: null,
replyTo: null,
brand: null,
})
brandSenderMock.getBaseUrlForBrand.mockReturnValue('https://app.gnubok.se')
describe('determineReminderLevel', () => {
it('returns null below the level-1 threshold', () => {
expect(determineReminderLevel(10, [])).toBeNull()
@@ -176,6 +193,94 @@ describe('processOverdueReminders: credit-note filter', () => {
})
})
describe('sendReminder: brand mail (WL-13)', () => {
const invoice = Object.assign(
makeInvoice({
company_id: 'company-1',
invoice_number: 'F-1001',
due_date: '2026-06-01',
total: 1250,
currency: 'SEK',
}),
{ customer: makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se' }) },
)
// bankgiro satisfies the payment-account gate; without a usable SEK
// account sendReminder refuses before ever reaching the mail path.
const company = makeCompanySettings({
company_name: 'Kund AB',
email: 'faktura@kund.se',
bankgiro: '123-4567',
})
const surcharges = {
interestAmount: 0,
interestRate: 0,
interestFromDate: '2026-06-02',
interestDays: 0,
reminderFee: 0,
}
beforeEach(() => {
sendEmailSpy.mockClear()
sendEmailSpy.mockResolvedValue({ success: true })
brandSenderMock.getSenderForCompany.mockResolvedValue({
fromName: null,
fromAddress: null,
replyTo: null,
brand: null,
})
brandSenderMock.getBaseUrlForBrand.mockReturnValue('https://app.gnubok.se')
})
it('unbranded: canonical action link and today\'s From fields', async () => {
await sendReminder(invoice, company, 1, 'tok-1', surcharges)
const options = sendEmailSpy.mock.calls[0][0]
expect(options.text).toContain('https://app.gnubok.se/invoice-action/tok-1')
expect(options.fromName).toBe('Kund AB')
expect(options.replyTo).toBe('faktura@kund.se')
expect(options.fromAddress).toBeUndefined()
})
it('branded: action link on the brand domain, mail rides the verified brand sender', async () => {
const brand = { appName: 'Siffra', domain: 'app.siffra.se' }
brandSenderMock.getSenderForCompany.mockResolvedValue({
fromName: 'Siffra',
fromAddress: 'noreply@post.siffra.se',
replyTo: 'support@siffra.se',
brand,
})
brandSenderMock.getBaseUrlForBrand.mockReturnValue('https://app.siffra.se')
await sendReminder(invoice, company, 1, 'tok-1', surcharges)
expect(brandSenderMock.getSenderForCompany).toHaveBeenCalledWith('company-1')
expect(brandSenderMock.getBaseUrlForBrand).toHaveBeenCalledWith(brand)
const options = sendEmailSpy.mock.calls[0][0]
expect(options.text).toContain('https://app.siffra.se/invoice-action/tok-1')
// The COMPANY stays the displayed sender; only the address is the brand's.
expect(options.fromName).toBe('Kund AB')
expect(options.fromAddress).toBe('noreply@post.siffra.se')
expect(options.replyTo).toBe('faktura@kund.se')
expect(options.html).not.toMatch(/accounted/i)
})
it('unverified brand sender domain: brand link but no From address override', async () => {
brandSenderMock.getSenderForCompany.mockResolvedValue({
fromName: 'Siffra',
fromAddress: null,
replyTo: 'support@siffra.se',
brand: { appName: 'Siffra', domain: 'app.siffra.se' },
})
brandSenderMock.getBaseUrlForBrand.mockReturnValue('https://app.siffra.se')
await sendReminder(invoice, company, 1, 'tok-1', surcharges)
const options = sendEmailSpy.mock.calls[0][0]
expect(options.text).toContain('https://app.siffra.se/invoice-action/tok-1')
expect(options.fromAddress).toBeUndefined()
})
})
describe('sendReminder payment-account gate', () => {
const customer = makeCustomer({ email: 'kund@example.se' })
const surcharges = { interestAmount: 0, interestRate: 0.1, interestDays: 0, reminderFee: 60 }
+10
View File
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { EmailService, SendEmailOptions, SendEmailResult } from '@/lib/email/service'
import { getSenderForCompany } from '@/lib/email/brand-sender'
import { deleteDocument, uploadDocument } from '@/lib/core/documents/document-service'
import { createServiceClient } from '@/lib/supabase/server'
import type { InvoiceDelivery } from '@/types'
@@ -145,6 +146,12 @@ export async function sendTrackedInvoiceEmail(
)
}
// Brand mail (WL-13): the invoice mail keeps the COMPANY as the displayed
// sender exactly as today, but rides the brand's verified sender domain
// when the company's team has one. No brand, or an unverified sender
// domain, leaves the From header byte-identical to before.
const brandSender = await getSenderForCompany(companyId)
const emailOptions: SendEmailOptions = {
to,
cc,
@@ -154,6 +161,9 @@ export async function sendTrackedInvoiceEmail(
text,
replyTo,
fromName,
...(brandSender.fromAddress ? { fromAddress: brandSender.fromAddress } : {}),
// The company's own verified sender wins over the brand address
// (buildFromHeader gives `from` precedence).
from,
attachments: [
{
+10 -2
View File
@@ -1,5 +1,6 @@
import { createServerClient } from '@supabase/ssr'
import { getEmailService } from '@/lib/email/service'
import { getSenderForCompany, getBaseUrlForBrand } from '@/lib/email/brand-sender'
import { resolveInvoiceSender, type InvoiceSenderIdentity } from '@/lib/email/invoice-sender'
import {
generateReminderEmailHtml,
@@ -139,8 +140,12 @@ export async function sendReminder(
const daysOverdue = calculateDaysOverdue(invoice.due_date)
// Build action URL (public page for customer response)
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
// Brand mail (WL-13): the action link points at the company's home domain
// and the mail rides the brand's verified sender domain, while the COMPANY
// stays the displayed sender exactly as today. No brand = canonical URL
// and today's From header.
const brandSender = await getSenderForCompany(invoice.company_id)
const baseUrl = getBaseUrlForBrand(brandSender.brand)
const actionUrl = `${baseUrl}/invoice-action/${actionToken}`
const emailData = {
@@ -160,6 +165,9 @@ export async function sendReminder(
text: generateReminderEmailText(emailData),
replyTo: company.email || undefined,
fromName: company.company_name || undefined,
...(brandSender.fromAddress ? { fromAddress: brandSender.fromAddress } : {}),
// The company's own verified sender wins over the brand address
// (buildFromHeader gives `from` precedence).
from: sender,
})
+275 -1
View File
@@ -27,6 +27,20 @@ const state = vi.hoisted(() => ({
}>
error: unknown
},
// Rows the team_members query resolves to (the byrå exception in the
// no-company branch and the home-domain guard both await eq-terminated
// chains, so the from() mock makes exactly that table thenable).
byraMemberships: [] as Array<{
role?: string
teams: { kind: string; brands?: { domain: string } | Array<{ domain: string }> | null }
}>,
// Rows any awaited company_members filter chain resolves to: the Rule 2
// home-domain client lookup AND the Rule 1 canonical personal-company
// exemption both land here (each scenario exercises only one of them).
clientMemberships: [] as Array<Record<string, unknown>>,
clientMembershipsError: null as unknown,
// What the mocked resolveBrandByHost returns for the request host.
hostBrand: null as null | { teamId: string },
signOut: vi.fn(async () => ({ error: null })),
// Row returned for user_preferences reads (the auto_logout mint lookup).
userPreferences: null as null | { auto_logout: boolean },
@@ -54,7 +68,23 @@ vi.mock('@supabase/ssr', () => ({
const chain: Record<string, unknown> = {}
const self = new Proxy(chain, {
get: (_t, prop) => {
if (prop === 'then') return undefined
if (prop === 'then') {
// The byrå-membership lookup and the home-domain client lookup
// await their filter chains directly (no .maybeSingle terminal),
// so those tables must be thenable.
if (table === 'team_members') {
return (resolve: (v: unknown) => void) =>
resolve({ data: state.byraMemberships, error: null })
}
if (table === 'company_members') {
return (resolve: (v: unknown) => void) =>
resolve({
data: state.clientMembershipsError ? null : state.clientMemberships,
error: state.clientMembershipsError,
})
}
return undefined
}
if (prop === 'maybeSingle' || prop === 'single') {
return async () => ({
data:
@@ -86,6 +116,13 @@ vi.mock('@/lib/logger', () => {
return { createLogger: () => logger }
})
vi.mock('@/lib/branding/resolve', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/branding/resolve')>()),
resolveBrandByHost: vi.fn(async () =>
state.hostBrand ? { teamId: state.hostBrand.teamId } : null,
),
}))
import { updateSession } from '../middleware'
import {
createSessionTimeoutState,
@@ -105,6 +142,10 @@ function run(path: string, init?: RequestInit) {
return updateSession(new NextRequest(`${ORIGIN}${path}`, init))
}
function runAt(origin: string, path: string, headers?: Record<string, string>) {
return updateSession(new NextRequest(`${origin}${path}`, { headers }))
}
describe('updateSession redirect destinations', () => {
const envBackup = {
require: process.env.NEXT_PUBLIC_REQUIRE_MFA,
@@ -128,6 +169,10 @@ describe('updateSession redirect destinations', () => {
data: [{ company_id: 'company-1', locale: 'sv', used_fallback: false }],
error: null,
}
state.byraMemberships = []
state.clientMemberships = []
state.clientMembershipsError = null
state.hostBrand = null
state.userPreferences = null
state.userPreferencesError = null
delete process.env.NEXT_PUBLIC_REQUIRE_MFA
@@ -577,6 +622,235 @@ describe('updateSession redirect destinations', () => {
})
})
// ── No-company branch: the byrå cockpit exception ─────────────────────
describe('byrå team members with zero companies', () => {
beforeEach(() => {
state.user = SIGNED_IN
// No resolvable company at all (fresh byrå, no client memberships).
state.company = {
data: [{ company_id: null, locale: 'sv', used_fallback: true }],
error: null,
}
})
it('steers a byrå owner from the dashboard root to the empty cockpit, not onboarding', async () => {
state.byraMemberships = [{ role: 'owner', teams: { kind: 'byra' } }]
const response = await run('/')
expect(new URL(locationOf(response)!).pathname).toBe('/byra')
})
it('lets a byrå admin through to cockpit routes', async () => {
state.byraMemberships = [{ role: 'admin', teams: { kind: 'byra' } }]
for (const path of ['/byra', '/clients', '/companies/new-client', '/settings/brand']) {
const response = await run(path)
expect(response.status).toBe(200)
}
})
it('steers a plain byrå member to the cockpit too (any role counts)', async () => {
state.byraMemberships = [{ role: 'member', teams: { kind: 'byra' } }]
const response = await run('/')
expect(new URL(locationOf(response)!).pathname).toBe('/byra')
})
it('keeps the onboarding redirect for non-byrå users', async () => {
state.byraMemberships = []
const response = await run('/')
expect(new URL(locationOf(response)!).pathname).toBe('/onboarding')
})
})
// ── Home-domain affinity (WL): the domain corrects itself ─────────────
describe('home-domain affinity', () => {
const ARBORE = 'https://arbore.accounted.se'
const ACOUNT = 'https://acount.accounted.se'
beforeEach(() => {
state.user = SIGNED_IN
})
it('redirects a byrå member on a foreign byrå domain to their own domain', async () => {
state.byraMemberships = [
{ teams: { kind: 'byra', brands: { domain: 'acount.accounted.se' } } },
]
const response = await runAt(ARBORE, '/')
expect(locationOf(response)).toBe(`${ACOUNT}/`)
})
it('redirects a byrå member on the platform domain to their byrå domain', async () => {
state.byraMemberships = [
{ teams: { kind: 'byra', brands: { domain: 'arbore.accounted.se' } } },
]
const response = await runAt('https://app.gnubok.se', '/')
expect(locationOf(response)).toBe(`${ARBORE}/`)
})
it('preserves path and query across the affinity redirect', async () => {
state.byraMemberships = [
{ teams: { kind: 'byra', brands: { domain: 'arbore.accounted.se' } } },
]
const response = await runAt('https://app.gnubok.se', '/invoices/abc?tab=payments')
expect(locationOf(response)).toBe(`${ARBORE}/invoices/abc?tab=payments`)
})
it('lets a byrå member through on their own domain and caches the verdict', async () => {
state.byraMemberships = [
{ teams: { kind: 'byra', brands: { domain: 'arbore.accounted.se' } } },
]
const response = await runAt(ARBORE, '/byra')
expect(response.status).toBe(200)
expect(response.headers.get('set-cookie')).toContain(
'gnubok-home-ok=arbore.accounted.se',
)
})
it('keeps a byrå member with a brandless personal company on the canonical host', async () => {
state.byraMemberships = [
{ teams: { kind: 'byra', brands: { domain: 'arbore.accounted.se' } } },
]
// A personal company with no team at all: homed on canonical.
state.clientMemberships = [{ companies: { team_id: null, teams: null } }]
const response = await runAt('https://app.gnubok.se', '/')
expect(response.status).toBe(200)
expect(locationOf(response)).toBeNull()
expect(response.headers.get('set-cookie')).toContain(
'gnubok-home-ok=app.gnubok.se',
)
})
it('counts a company whose team has no brand as canonical-homed too', async () => {
state.byraMemberships = [
{ teams: { kind: 'byra', brands: { domain: 'arbore.accounted.se' } } },
]
state.clientMemberships = [
{ companies: { team_id: 'team-personal', teams: { brands: null } } },
]
const response = await runAt('https://app.gnubok.se', '/')
expect(response.status).toBe(200)
expect(response.headers.get('set-cookie')).toContain(
'gnubok-home-ok=app.gnubok.se',
)
})
it('still redirects a byrå member whose companies are all brand-homed', async () => {
state.byraMemberships = [
{ teams: { kind: 'byra', brands: { domain: 'arbore.accounted.se' } } },
]
state.clientMemberships = [
{ companies: { team_id: 'team-arbore', teams: { brands: { id: 'brand-1' } } } },
]
const response = await runAt('https://app.gnubok.se', '/')
expect(locationOf(response)).toBe(`${ARBORE}/`)
})
it('still redirects off a FOREIGN byrå domain even with a canonical personal company', async () => {
state.byraMemberships = [
{ teams: { kind: 'byra', brands: { domain: 'acount.accounted.se' } } },
]
state.clientMemberships = [{ companies: { team_id: null, teams: null } }]
const response = await runAt(ARBORE, '/')
expect(locationOf(response)).toBe(`${ACOUNT}/`)
})
it('stays put without caching when the canonical-company lookup fails', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
state.byraMemberships = [
{ teams: { kind: 'byra', brands: { domain: 'arbore.accounted.se' } } },
]
state.clientMembershipsError = { message: 'connection reset' }
const response = await runAt('https://app.gnubok.se', '/')
// Fail open: no redirect, and no home-ok cookie so the next request
// re-runs the check instead of freezing the error verdict for the TTL.
expect(response.status).toBe(200)
expect(response.headers.get('set-cookie') ?? '').not.toContain('gnubok-home-ok')
expect(errorSpy).toHaveBeenCalled()
errorSpy.mockRestore()
})
it('tolerates the array shape for the embedded brand', async () => {
state.byraMemberships = [
{ teams: { kind: 'byra', brands: [{ domain: 'acount.accounted.se' }] } },
]
const response = await runAt(ARBORE, '/')
expect(locationOf(response)).toBe(`${ACOUNT}/`)
})
it('redirects a user with no byrå ties off a brand domain to the platform', async () => {
state.hostBrand = { teamId: 'team-arbore' }
const response = await runAt(ARBORE, '/')
expect(locationOf(response)).toBe('https://app.gnubok.se/')
})
it('keeps a byrå client user on the byrå domain their company lives under', async () => {
state.hostBrand = { teamId: 'team-arbore' }
state.clientMemberships = [{ company_id: 'company-1' }]
const response = await runAt(ARBORE, '/')
expect(response.status).toBe(200)
})
it('does nothing on the platform domain for regular users', async () => {
const response = await runAt('https://app.gnubok.se', '/')
expect(response.status).toBe(200)
})
it('does nothing on localhost and direct Vercel hosts', async () => {
state.byraMemberships = [
{ teams: { kind: 'byra', brands: { domain: 'arbore.accounted.se' } } },
]
for (const origin of [ORIGIN, 'https://erp-base-abc123.vercel.app']) {
const response = await runAt(origin, '/byra')
expect(response.status).toBe(200)
}
})
it('skips the check while the host-scoped OK cookie is fresh', async () => {
state.byraMemberships = [
{ teams: { kind: 'byra', brands: { domain: 'acount.accounted.se' } } },
]
const response = await runAt(ARBORE, '/', {
cookie: 'gnubok-home-ok=arbore.accounted.se',
})
expect(response.status).toBe(200)
})
})
// ── MFA semantics that must not change ────────────────────────────────
describe('per-request timing header and log line', () => {
+227
View File
@@ -15,6 +15,7 @@ import { apiPathSkipsMfaGate } from '@/lib/auth/api-mfa-gate'
import { DEFAULT_LOCALE, LOCALE_COOKIE, isLocale } from '@/i18n/config'
import { userHasPassword } from '@/lib/auth/has-password'
import { safeReturnTo } from '@/lib/auth/safe-return-to'
import { normalizeHost, resolveBrandByHost } from '@/lib/branding/resolve'
import {
apiRequestSkipsSessionTimeout,
createSessionTimeoutState,
@@ -39,6 +40,14 @@ import {
const log = createLogger('proxy')
/**
* Host-scoped marker that the signed-in user is on their home domain, so the
* affinity check below costs zero queries on the hot path. Expiry re-runs the
* check, which bounds staleness after team-membership changes.
*/
const HOME_DOMAIN_OK_COOKIE = 'gnubok-home-ok'
const HOME_DOMAIN_OK_MAX_AGE = 15 * 60
/**
* Auth proxy entry point. Wraps the real work so every response carries a
* per-phase timing header and emits one structured log line, mirroring what
@@ -320,6 +329,35 @@ async function updateSessionInner(
return bounceToAuth(request, '/login')
}
// ── Home-domain affinity (WL, founder call 2026-08-05) ──────────────────
// Every signed-in user has a home domain: byrå team members home on their
// brand's domain, everyone else on the platform app URL, except a byrå's
// client users, whose home is the byrå domain their companies live under.
// On a mismatch the request is redirected to the home domain's root:
// sessions are per domain, so the user lands on the RIGHT branded login
// and signs in there ("the domain corrects itself"). This complements the
// WL-01 signpost, which handles per-company homing INSIDE a domain and
// stays the answer for multi-domain company rosters. Exemption: byrå
// staff who also have canonical-homed companies stay put on the canonical
// host; the signpost handles per-company homing.
const homeOutcome = await resolveHomeDomainOutcome(supabase, user.id, request)
if (homeOutcome.redirectTo) {
return NextResponse.redirect(homeOutcome.redirectTo)
}
if (homeOutcome.cacheOk) {
supabaseResponse.cookies.set(
HOME_DOMAIN_OK_COOKIE,
normalizeHost(request.nextUrl.hostname),
{
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: HOME_DOMAIN_OK_MAX_AGE,
},
)
}
// /mfa/enroll: gate behind has-password. BankID-only users who reach this
// page can lock themselves out: Supabase requires AAL2 to change password
// or unenroll MFA, and AAL2 needs a prior password sign-in. Force them to
@@ -461,6 +499,34 @@ async function updateSessionInner(
return supabaseResponse
}
// Byrå team members (any role: widened from owner/admin, founder call
// 2026-08-05) with zero client companies (a fresh byrå) home to the
// EMPTY cockpit, never to the company onboarding wizard: clients are
// created from the cockpit, and forcing the wizard here would make a
// byrå user create a personal company just to get in. Cockpit-shaped
// paths pass through (the dashboard layout renders its no-company shell);
// everything else is steered to /byra. API requests pass through so the
// routes' own guards answer with JSON instead of an HTML redirect.
// The query runs only in the rare no-company state: zero hot-path cost.
const { data: byraRows } = await supabase
.from('team_members')
.select('role, teams:team_id!inner(kind)')
.eq('user_id', user.id)
.eq('teams.kind', 'byra')
const isByraMember = (byraRows ?? []).length > 0
if (isByraMember) {
const isByraNoCompanyAllowed =
pathname.startsWith('/byra') ||
pathname.startsWith('/clients') ||
pathname.startsWith('/companies/new') ||
pathname.startsWith('/settings') ||
pathname.startsWith('/api/')
if (isByraNoCompanyAllowed) {
return supabaseResponse
}
return NextResponse.redirect(new URL('/byra', request.url))
}
// Enrichment lives in the user-keyed `bankid_enrichment` table (migration
// 20260506160000), it cannot live in extension_data, which is
// company-scoped, and the user has no company yet on this path.
@@ -650,6 +716,167 @@ function bounceToAuth(
return NextResponse.redirect(url)
}
/**
* Hosts that carry no brand affinity: local dev and direct Vercel
* deployment URLs must never bounce a signed-in user to a product domain.
*/
function isAffinityExemptHost(host: string): boolean {
return (
!host ||
host === 'localhost' ||
host.endsWith('.localhost') ||
host.endsWith('.vercel.app') ||
/^\d{1,3}(\.\d{1,3}){3}$/.test(host)
)
}
/**
* Decide whether this signed-in request sits on the user's home domain.
*
* Rules (founder call 2026-08-05):
* 1. A byrå team member's home is their brand's domain: any other product
* host (a foreign byrå domain OR the platform domain) redirects there,
* EXCEPT on the canonical platform host when the member also has a
* company homed there (a company whose team has no brand): they stay,
* and the WL-01 signpost handles per-company homing.
* 2. A non-member on a brand domain redirects to the platform app URL,
* UNLESS one of their companies is homed under that brand's team (the
* byrå's own client users log in on the byrå domain).
* 3. Everyone else stays put.
*
* `cacheOk` marks a positive "this is home" verdict, cached in a host-scoped
* cookie by the caller. Query failures fail open with no caching, so a
* transient error neither locks anyone out nor sticks for a TTL window.
*/
async function resolveHomeDomainOutcome(
supabase: ReturnType<typeof createServerClient>,
userId: string,
request: NextRequest,
): Promise<{ redirectTo: URL | null; cacheOk: boolean }> {
const stay = { redirectTo: null, cacheOk: false }
const host = normalizeHost(request.nextUrl.hostname)
if (isAffinityExemptHost(host)) return stay
if (request.cookies.get(HOME_DOMAIN_OK_COOKIE)?.value === host) return stay
// Rule 1: the user's own byrå brand domains (RLS: members read their brand).
const { data: byraRows, error: byraError } = await supabase
.from('team_members')
.select('teams:team_id!inner(kind, brands(domain))')
.eq('user_id', userId)
.eq('teams.kind', 'byra')
if (byraError) {
console.error('[middleware] home-domain byrå lookup failed', byraError)
return stay
}
const byraDomains: string[] = []
for (const row of byraRows ?? []) {
const teams = (row as { teams?: { brands?: unknown } | null }).teams
const brands = teams?.brands
// One brand per team (brands.team_id unique): PostgREST returns an
// object, but tolerate the array shape too.
const list = Array.isArray(brands) ? brands : brands ? [brands] : []
for (const entry of list) {
const domain = (entry as { domain?: unknown }).domain
if (typeof domain === 'string' && domain) byraDomains.push(normalizeHost(domain))
}
}
if (byraDomains.length > 0) {
if (byraDomains.includes(host)) return { redirectTo: null, cacheOk: true }
// Exemption: a byrå member who ALSO belongs to a company homed on the
// canonical domain (its team has no brand, or no team at all) must be
// able to reach that company somewhere, and the canonical host is its
// only home. Redirecting them off canonical made their own company
// deterministically unreachable: on the brand host it renders as a
// non-clickable signpost pointing right back at canonical. So on the
// canonical host, stay when such a company exists (host-stable verdict,
// so it is cacheable); a foreign brand host still redirects, since
// nothing of the user's is homed there. Cost: one extra query, only for
// byrå members on the canonical host without a fresh home-ok cookie.
const canonicalHost = normalizeHost(
new URL(process.env.NEXT_PUBLIC_APP_URL || 'https://app.gnubok.se').hostname,
)
if (host === canonicalHost) {
const { data: companyRows, error: companyError } = await supabase
.from('company_members')
.select('companies!inner(team_id, teams(brands(id)))')
.eq('user_id', userId)
if (companyError) {
console.error(
'[middleware] home-domain canonical-company lookup failed',
companyError,
)
return stay
}
if ((companyRows ?? []).some(rowHasCanonicalHomedCompany)) {
return { redirectTo: null, cacheOk: true }
}
}
// Carry the original path and query across the hop so deep links (invite
// accepts, direct object URLs) survive the domain correction.
return {
redirectTo: new URL(
`${request.nextUrl.pathname}${request.nextUrl.search}`,
`https://${byraDomains[0]}`,
),
cacheOk: false,
}
}
// Rule 2: not a byrå member, so only a brand host can be foreign.
const hostBrand = await resolveBrandByHost(host)
if (!hostBrand) return { redirectTo: null, cacheOk: true }
const { data: clientRows, error: clientError } = await supabase
.from('company_members')
.select('company_id, companies!inner(team_id)')
.eq('user_id', userId)
.eq('companies.team_id', hostBrand.teamId)
.limit(1)
if (clientError) {
console.error('[middleware] home-domain client lookup failed', clientError)
return stay
}
if ((clientRows ?? []).length > 0) return { redirectTo: null, cacheOk: true }
const platformUrl = new URL(process.env.NEXT_PUBLIC_APP_URL || 'https://app.gnubok.se')
if (normalizeHost(platformUrl.hostname) === host) return { redirectTo: null, cacheOk: true }
// Preserve path + query for the same deep-link reason as the byrå hop.
return {
redirectTo: new URL(
`${request.nextUrl.pathname}${request.nextUrl.search}`,
platformUrl.origin,
),
cacheOk: false,
}
}
/**
* Whether a company_members row (carrying the companies → teams → brands
* embed) points at a company homed on the canonical domain: no team at all,
* or a team without a brands row. The brand null-check happens HERE in JS on
* purpose: a PostgREST `.is()` filter on an embedded resource does not
* filter the parent rows, so filtering server-side would silently match
* every membership. Embeds arrive as object or array depending on the
* relationship shape, so both are tolerated (same as the byraRows parsing).
*/
function rowHasCanonicalHomedCompany(row: unknown): boolean {
const companies = (row as { companies?: unknown }).companies
const companyList = Array.isArray(companies) ? companies : companies ? [companies] : []
for (const company of companyList) {
const teams = (company as { teams?: unknown }).teams
if (!teams) return true
const teamList = Array.isArray(teams) ? teams : [teams]
for (const team of teamList) {
const brands = (team as { brands?: unknown }).brands
const brandList = Array.isArray(brands) ? brands : brands ? [brands] : []
if (brandList.length === 0) return true
}
}
return false
}
/**
* Resolve the active company for the authenticated user.
*