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
parent 6f0f4d1eea
commit fdb5f6f891
160 changed files with 15125 additions and 504 deletions
+44
View File
@@ -0,0 +1,44 @@
# Accounted
Swedish accounting SaaS: double-entry bookkeeping under Bokföringslagen for sole traders and limited companies, sold directly and (from 2026) resold white-label by accounting firms under their own brand.
## Language
### White label
**Brand**:
The host-resolved white-label identity: one row per partner domain carrying app name, logo, palette, font, and email sender identity. Owned by a byrå team; a request's Host header resolves to at most one brand, and no match means the default Accounted appearance.
_Avoid_: Tenant (already means company-level multi-tenancy here), theme, skin
**Byrå team**:
A team that represents an accounting firm: named, multi-member, invitable, the anchor for brand ownership, client companies, and billing.
_Avoid_: Agency, firm team
**Personal team**:
The silent auto-created team every user gets (`ensure_user_team()`); an implementation detail of company ownership, never a byrå.
**Partner**:
Prose word for the commercial relationship: a byrå with an agreement and usually a brand. Not an entity in the schema.
**Canonical domain**:
app.accounted.se (and legacy app.gnubok.se): the domains carrying the default Accounted brand, external OAuth flows, and cron entry points.
**Home domain**:
The one domain a company is opened and worked in: its byrå's brand domain if the company's team has a brand, else the canonical domain. Everywhere else the UI shows a signpost to the home domain, never the company. A navigation rule, not a security boundary: access is still governed solely by company membership and RLS.
**Signpost**:
The screen shown when a logged-in user's company lives on a different host ("Kalles Bygg AB hanteras via app.siffra.se"), with a link there. Never a silent redirect: sessions are per domain, so the user must log in again on the home domain.
**Umbrella subdomain**:
A brand domain hosted under our own zone (siffra.accounted.se): the starter shape, zero partner DNS work. A custom domain (app.siffra.se) is the upgrade; the brand model treats both as the same single hostname.
**Invoice branding**:
The per-company logo and colors printed on that company's customer invoices. Unrelated to a byrå brand; never shorten to "branding".
_Avoid_: Branding (unqualified)
**Brand color**:
The single partner-supplied color a brand is themed from: primary buttons, focus rings, links, and active nav take it directly; the chrome (frame and sidebar) is derived from it unless the partner explicitly overrides the chrome color. Semantic colors (success, warning, destructive) never follow it.
**Cockpit**:
The byrå dashboard: the read-first client overview for byrå staff, aggregating status across all client companies. Acting on a client happens inside the client after a switch (or via the agent path); the cockpit itself never writes to client books.
_Avoid_: Byrå portal, partner dashboard
+22
View File
@@ -746,6 +746,28 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-01] MCP page offsets are declared as non-negative integers and defensively floored before PostgREST range calls: fractional offsets cannot name a stable row boundary and can produce invalid range bounds when execution bypasses schema validation.
[2026-08-01] Paginated MCP invoice tools fetch one lookahead row and use it when Supabase omits the exact count: returning a conservative next_offset avoids falsely declaring the current page terminal and silently truncating callers, while exact-count responses and page sizes remain unchanged.
[2026-08-01] Brand sidebar re-tint via CSS selector (html[data-brand] .bg-frame > aside) instead of editing DashboardNav markup: element-level custom properties beat any :root/.dark specificity, DashboardNav stays untouched for the WL-02 deep-chrome light-text override, and default hosts get no selector at all (data-brand only stamped when a brand renders).
[2026-08-01] SourceCodeFooter (WL-06) uses t.has('source_code') with a hardcoded sv/en fallback: the key lands in messages/*.json only in the wave-2 i18n merge (dev_docs/white-label/build/i18n-keys-branding.md), and rendering a raw key path on the login page in the interim was unacceptable.
[2026-08-01] Mail slices (WL-13/WL-05): auth mail via the Send Email hook stays Swedish-only in lib/email/auth-templates.ts (Supabase's payload carries no recipient locale, and mail templates sit outside next-intl per .claude/rules/i18n.md); the reminder cron's env-less base-URL fallback moved from the literal app.erp-base.se to getBranding().appUrl (canonical NEXT_PUBLIC_APP_URL, identical whenever the env var is set, which prod always does).
[2026-08-01] Cockpit landing (WL-14) decided at the login + mfa/verify pages via GET /api/clients/landing, not middleware or '/': a '/' redirect would break the soft switch's enter-client navigation (which targets '/'), middleware cannot afford brand+membership lookups per request, and a server action from /login is intercepted by the middleware's logged-in-user redirect; the fetch helper whitelists '/clients'|'/' and degrades to '/' so non-byrå flows stay byte-identical.
[2026-08-01] Home-domain signpost gate (WL-01) lives in the dashboard layout, not middleware: the layout already has memberships + one cached brand query, middleware on Edge would pay brand lookups on every request, and the middleware active-company write-back is host-agnostic by design (navigation rule, not security); /settings/account and /clients stay reachable through the gate.
[2026-08-01] Soft switch (WL-09) keeps a full-page load but to the TARGET page (performCompanySwitch destination param): the hard navigation is the deliberate cross-company leak-wipe (React state, router cache, in-flight fetches); soft client routing would need auditing every company-scoped cache and was explicitly allowed to stay a reload by the resolution as long as no start-page bounce remains.
[2026-08-01] Tab-guard mutation seam (WL-09): window.fetch wrapped while the dashboard shell is mounted, guarding mutating same-origin /api requests only; there is no shared client fetch helper in the codebase to wrap, server actions POST to page routes (the blocking dialog covers them), and browser-direct Supabase mutations are already required to be server-routed per project rule.
[2026-08-01] Byrå creation gate (WL-15) enforced in create_company_with_owner itself (migration 20260804113000) plus a readable-error precheck in createCompanyFromOnboarding: the RPC is SECURITY DEFINER and EXECUTE-granted to authenticated, so any app-level-only gate is bypassable via PostgREST.
[2026-08-01] Phantom-columns unresolvable ceiling raised 360 -> 370 (count 361 after the white-label waves): no single wave file introduced an identifiable rewritable dynamic expression (the wave-window files' unresolved entries all predate the build), so the guard's own escape hatch (raise with documented reason) beats speculative refactors of working query code.
[2026-08-01] appName sweep (WL-12 A4) left 5 literal classes as "Accounted": arsredovisning direktinlamning copy + DigitalInlamning personnummer note (statutory filing surface, the Bolagsverket agreement holder is legal identity), llms.txt + connect-claude docs + v1 API doc strings (API/MCP identity per the map's out-of-scope list), and Accounted:-prefixed storage keys/event names (wire format). When in doubt the sweep left it and reported, per WL-12.
[2026-08-01] Tab-guard server-action seam (gap-fix): verified next@16.2.12 invokes client server actions via the bare global fetch (server-action-reducer.js) so the patched window.fetch DOES see them; the guard now blocks mutating action POSTs on mismatch, with the company-switch action allowed through via a markCompanySwitchInFlight flag (action ids are opaque per-build hashes, so a flag around the one sanctioned call beats id matching); blocking it would deadlock the dialog's own "switch back" exit.
[2026-08-01] Browser-direct Supabase writes (gap-fix): exactly 3 sites existed (invoice status, SRU code, calendar deadlines); all routed through guardBrowserWrite() returning boolean instead of throwing, so call sites stay one-line and the blocking dialog (not a toast) is the user feedback. The guardStore moved from CompanyTabSync module scope into lib/company/tab-guard.ts so write sites share the same belief without React context threading.
[2026-08-01] team/members/[id] uses withRouteContext without requireWrite: requireWrite gates on the ACTIVE COMPANY role, the wrong dimension for a team-scoped mutation (a byra owner may have any company active); the team owner/admin check in the handler is the authorization. Error bodies stay the team-family plain-string shape ({ error: string }) for consistency with the sibling invite/accept/members routes and their shared client parsing.
[2026-08-01] Dark-mode ring lift (gap-fix, WL-02): lift threshold L < 45%, target L 62% (inside the prototype's 60-65% band); dark-mode --primary-foreground flips to near-black when white loses 4.5:1 on the lifted tone (the onboarding gate only guarantees white on the STORED color). Light-enough brands keep byte-identical dark blocks.
[2026-08-01] Curated font menu (gap-fix, WL-03): lora/fraunces/playfair with Source Sans 3/Work Sans/Public Sans bodies, all Google Fonts OFL, no new npm deps (next/font/google is built-in); registered preload:false so menu fonts cost nothing on default hosts.
[2026-08-04] SIE timeout fix via pgrst.db_pre_request hook, not ALTER FUNCTION SET statement_timeout: proved empirically that function-scoped statement_timeout never re-arms the already-running statement's timer (the 20260629160100/20260721144311 fixes were no-ops); pre-request runs as its own statement before the main query so set_config there works. Chose path-scoped hook over chunked import (smaller, fixes all three SIE RPCs at once, keeps 8s for everything else).
[2026-08-04] Byra Nyckeltal v1 (WL-16) fetches per-client KPI via N parallel get_kpi_report_aggregates calls, not a new uuid[] RPC: zero migrations on the white-label branch and fine at the ~10-client byra scale; an array RPC is the scale path past ~15 clients. Cross-client comparison axis is CALENDAR months (clients have different fiscal years); cash/VAT are as-of the current fiscal period, never range-filtered.
[2026-08-04] Byra brand self-service (WL-17) limited to the LOGO and APP NAME: domain and colors stay ops-managed (white-glove decision stands; a broken byra-set color/domain could brick their cockpit). brands has no write RLS by design, so writes go through /api/byra/brand/logo with service client + explicit owner/admin check, files under logos/byra/{teamId}/. Sidebar label beside the logo = app_name, byra-editable via PATCH /api/byra/brand (Emil re-decided from domain-prefix same day).
[2026-08-04] Byra landing uses a session cookie (gnubok-company-picked), not a DB flag: middleware writes the fallback company back to user_preferences on first request, so the DB cannot distinguish picked from auto-picked.
[2026-08-04] Brand COLOR theming removed from the request path (founder call): white-label = logo + app name + domain only, every host keeps the standard monochrome theme. buildBrandVarsCss, the WCAG gate and brands.brand_color/chrome_color stay in the codebase dormant for a future opt-in; the layout no longer stamps data-brand or renders brand-vars CSS.
[2026-08-05] User-level home-domain affinity = middleware redirect (not signpost): byrå members bounce to their brand domain, brandless users bounce off brand domains to the platform URL; founder chose redirect knowing sessions are per-domain (one extra login at the right branded login). WL-01 signpost stays for per-company homing inside a domain.
[2026-08-03] Issue #563 automatic bank and skattekonto sync resolves company and firm capability grants in bulk before applying the 50-item run cap: limiting raw connection rows first let 50 expired trials permanently starve paying companies. Kept this in the shared TypeScript entitlement layer instead of adding a scheduling RPC or migration because the existing grant, expiry, and explicit-disable semantics already live there and no schema change is required.
[2026-08-01] Out-of-order SIE IB activity is bounded by the target fiscal-period end, not its start: this excludes later-first imports while preserving same-period continuation suppression; successor IB resync checks the current error state plus a real target-period entry because result.success is finalized later, keeping replacement on a new engine voucher plus storno without letting a no-op import succeed through resync alone.
[2026-08-01] Successor SIE IB replacement uses a specialized engine RPC instead of loosening the owner-only generic relink RPC: non-viewer members and scoped service-role imports are supported, while one period-row lock and expected-pointer CAS make the replacement voucher, storno, reversal status, pointer swap, and voucher sequence increments commit or roll back together.
+29 -5
View File
@@ -25,9 +25,11 @@ import {
import { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid-flags'
import { getBranding } from '@/lib/branding/service'
import { useBranding } from '@/lib/branding/brand-context'
import { SourceCodeFooter } from '@/components/branding/SourceCodeFooter'
import { detectWebmailHint } from '@/lib/auth/webmail-search'
import { safeReturnTo } from '@/lib/auth/safe-return-to'
import { resolvePostLoginDestination } from '@/lib/company/post-login-landing'
import {
consumeInviteCookie,
INVITE_PROBLEM_MESSAGE_KEYS,
@@ -53,7 +55,6 @@ import {
type SessionTimeoutReason,
} from '@/lib/auth/session-timeout-shared'
const branding = getBranding()
import type { BankIdResult } from '@/components/auth/BankIdAuth'
const BankIdAuth = dynamic(
@@ -111,6 +112,9 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
const registerHref = nextPath === '/' ? '/register' : `/register?next=${encodeURIComponent(nextPath)}`
const supabase = createClient()
const bankIdEnabled = isBankIdEnabled()
// Per-request brand merged over getBranding() defaults (WL-12): identical
// values on default hosts, brand values on branded hosts.
const branding = useBranding()
const googleAuthEnabled = isGoogleAuthEnabled()
const tAuth = useTranslations('auth')
const tCommon = useTranslations('common')
@@ -330,7 +334,10 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
return
}
router.push('/')
// Byrå staff land in the cockpit on their byrå's home domain (WL-14);
// everyone else resolves to '/' and keeps today's flow byte-identically
// (any failure inside the helper also degrades to '/').
router.push(await resolvePostLoginDestination())
router.refresh()
} catch (error) {
setFormError({
@@ -784,15 +791,32 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
<p className="mt-3 text-center text-xs text-muted-foreground/80 leading-relaxed">
{tAuth('terms_prefix')}{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
{/* The platform's terms live on the marketing site; the privacy
policy is the in-app /privacy page (host-relative, so it works
on branded byrå domains too). New tab: don't lose login state. */}
<a
href="https://accounted.se/terms"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
{tAuth('terms_link')}
</a>{' '}
{tAuth('terms_and')}{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
<a
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
{tAuth('privacy_link')}
</a>
.
</p>
{/* AGPL section 13 source offer (WL-06): renders on both default and
branded hosts; never gate this on a brand. */}
<SourceCodeFooter className="mt-4" />
</div>
</div>
)
+7 -1
View File
@@ -11,6 +11,7 @@ import { useToast } from '@/components/ui/use-toast'
import { Loader2, ShieldCheck, LogOut } from 'lucide-react'
import { SupportLink } from '@/components/ui/support-link'
import { safeReturnTo } from '@/lib/auth/safe-return-to'
import { resolvePostLoginDestination } from '@/lib/company/post-login-landing'
import {
consumeInviteCookie,
INVITE_PROBLEM_MESSAGE_KEYS,
@@ -151,7 +152,12 @@ function MfaVerifyContent() {
return
}
router.push(returnTo)
// Hosted byrå staff hit MFA before any dashboard, so the cockpit
// landing (WL-14) resolves here too: only when no explicit step-up
// destination was requested. Everyone else keeps returnTo/'/' exactly
// as before (the helper degrades to '/' on any failure). The session
// is AAL2 at this point, so the /api MFA gate passes.
router.push(returnTo === '/' ? await resolvePostLoginDestination() : returnTo)
router.refresh()
} catch {
toast({
+18 -5
View File
@@ -16,7 +16,7 @@ import { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid-flags'
import type { BankIdResult } from '@/components/auth/BankIdAuth'
import { getBranding } from '@/lib/branding/service'
import { useBranding } from '@/lib/branding/brand-context'
import { detectWebmailHint } from '@/lib/auth/webmail-search'
import {
consumeInviteCookie,
@@ -39,8 +39,6 @@ import { persistLoginMethodHint, type LoginMethod } from '@/lib/auth/login-metho
import { safeReturnTo } from '@/lib/auth/safe-return-to'
import { cn } from '@/lib/utils'
const branding = getBranding()
const BankIdAuth = dynamic(
() => import('@/components/auth/BankIdAuth').then((module) => module.BankIdAuth),
{ ssr: false },
@@ -99,6 +97,9 @@ function RegisterPageContent() {
const router = useRouter()
const supabase = createClient()
const bankIdEnabled = isBankIdEnabled()
// Per-request brand merged over getBranding() defaults (WL-12): identical
// values on default hosts, brand values on branded hosts.
const branding = useBranding()
const googleAuthEnabled = isGoogleAuthEnabled()
const t = useTranslations('register')
const tAuth = useTranslations('auth')
@@ -861,11 +862,23 @@ function RegisterPageContent() {
<p className="mt-3 text-center text-xs text-muted-foreground/80 leading-relaxed">
{t('terms_prefix')}{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
{/* Same targets as the login page: platform terms on the marketing
site, in-app /privacy (host-relative for branded domains). */}
<a
href="https://accounted.se/terms"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
{t('terms_link')}
</a>{' '}
{t('terms_and')}{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
<a
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
{t('privacy_link')}
</a>
.
+60
View File
@@ -0,0 +1,60 @@
import { redirect } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { BellRing, ListChecks, Mail, PlugZap, Send, Wand2 } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
import { getByraMembership } from '@/lib/clients/fetch-client-overview'
import { getDashboardAuthContext } from '../../request-context'
export const dynamic = 'force-dynamic'
/**
* Byrå cockpit: Automationer. Nothing is buildable yet; instead of a bare
* empty state the page shows the planned automation set (the byrå-tools
* research shortlist) so the surface sells the roadmap. All static: no
* per-card chips (same state on every card = no chip, design rule 5), one
* intro line carries the "coming" message.
*/
const CARDS = [
{ key: 'digest', icon: Mail },
{ key: 'deadlines', icon: BellRing },
{ key: 'rules', icon: Wand2 },
{ key: 'connections', icon: PlugZap },
{ key: 'checklist', icon: ListChecks },
{ key: 'reports', icon: Send },
] as const
export default async function ByraAutomationsPage() {
const { supabase, user } = await getDashboardAuthContext()
if (!user) {
redirect('/login')
}
const membership = await getByraMembership(supabase, user.id)
if (!membership) {
redirect('/')
}
const t = await getTranslations('byra')
return (
<div className="space-y-8">
<PageHeader title={t('automations_title')} />
<p className="max-w-2xl text-sm text-muted-foreground">{t('automations_intro')}</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 stagger-enter">
{CARDS.map(({ key, icon: Icon }) => (
<div key={key} className="rounded-lg border border-border p-6">
<Icon className="h-4 w-4 text-muted-foreground" />
<h2 className="mt-3 text-sm font-medium">{t(`automations_card_${key}_title`)}</h2>
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
{t(`automations_card_${key}_desc`)}
</p>
</div>
))}
</div>
<p className="text-[13px] text-muted-foreground">{t('automations_footer')}</p>
</div>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { redirect } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { TrendingUp } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
import { EmptyState } from '@/components/ui/empty-state'
import { fetchByraKpiOverview } from '@/lib/byra/kpi-overview'
import ByraKpiView from '@/components/byra/ByraKpiView'
import { getDashboardAuthContext } from '../../request-context'
export const dynamic = 'force-dynamic'
/**
* Byrå cockpit: Nyckeltal (WL-16). Aggregated cross-client KPI view: period
* preset + company chips in the URL (shareable, server-refetched), summary
* tiles, merged monthly chart, and a sortable per-client table. Byrå team
* members only, like the rest of the cockpit.
*/
export default async function ByraKpiPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const { supabase, user } = await getDashboardAuthContext()
if (!user) {
redirect('/login')
}
const params = await searchParams
const period = typeof params.period === 'string' ? params.period : undefined
const companiesParam = typeof params.companies === 'string' ? params.companies : undefined
const companyIds = companiesParam
? companiesParam.split(',').filter((id) => id.length > 0)
: undefined
const overview = await fetchByraKpiOverview(supabase, user.id, {
preset: period,
companyIds,
})
if (!overview) {
redirect('/')
}
const t = await getTranslations('byra')
return (
<div className="space-y-8">
<PageHeader title={t('kpi_title')} />
{overview.allClients.length === 0 ? (
<EmptyState
icon={TrendingUp}
title={t('kpi_empty_title')}
description={t('kpi_empty_description')}
/>
) : (
<ByraKpiView
preset={overview.preset}
allClients={overview.allClients}
selectedIds={overview.selectedIds}
rows={overview.rows}
months={overview.months}
/>
)}
</div>
)
}
+117
View File
@@ -0,0 +1,117 @@
import { redirect } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { PageHeader } from '@/components/ui/page-header'
import { Badge } from '@/components/ui/badge'
import { cn, formatDate } from '@/lib/utils'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { fetchClientOverview } from '@/lib/clients/fetch-client-overview'
import { getDashboardAuthContext } from '../request-context'
export const dynamic = 'force-dynamic'
/**
* Byrå cockpit home (Hem): the general overview the lean sidebar opens on.
* Aggregates the same client-overview data as /clients into a glance:
* client count, companies needing action, and the nearest deadline per
* client. Byrå team members only, like the rest of the cockpit.
*/
export default async function ByraHomePage() {
const { supabase, user } = await getDashboardAuthContext()
if (!user) {
redirect('/login')
}
const overview = await fetchClientOverview(supabase, user.id)
if (!overview) {
redirect('/')
}
const t = await getTranslations('byra')
const tClients = await getTranslations('clients')
const clients = overview.clients
const needsAction = clients.filter((c) => c.unbookedCount > 0 || c.inboxCount > 0).length
const urgentDeadlines = clients.filter(
(c) => c.nextDeadline && c.nextDeadline.urgency !== 'upcoming',
).length
// One row per client with an open deadline, most urgent first.
const urgencyRank = { overdue: 0, action_needed: 1, upcoming: 2 } as const
const deadlineRows = clients
.filter((c) => c.nextDeadline)
.sort((a, b) => {
const rank = urgencyRank[a.nextDeadline!.urgency] - urgencyRank[b.nextDeadline!.urgency]
if (rank !== 0) return rank
return a.nextDeadline!.dueDate.localeCompare(b.nextDeadline!.dueDate)
})
.slice(0, 5)
const stats = [
{ label: t('stats_clients'), value: clients.length },
{ label: t('stats_attention'), value: needsAction },
{ label: t('stats_deadlines'), value: urgentDeadlines },
]
return (
<div className="space-y-8">
<PageHeader title={overview.team.name || t('home_title')} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{stats.map((stat) => (
<div key={stat.label} className="rounded-lg border border-border p-4">
<div className="text-xs text-muted-foreground">{stat.label}</div>
<div className="mt-1 font-display text-xl tabular-nums">{stat.value}</div>
</div>
))}
</div>
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('deadlines_title')}
</h2>
{deadlineRows.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('deadlines_empty')}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={TH_CLASS}>{tClients('col_company')}</th>
<th className={TH_CLASS}>{tClients('col_next_deadline')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{deadlineRows.map((row) => (
<tr key={row.companyId} className="transition-colors duration-150 hover:bg-secondary/35">
<td className={cn(TD_CLASS, 'font-medium text-foreground')}>{row.name}</td>
<td className={TD_CLASS}>
<span className="flex items-center gap-2">
{row.nextDeadline!.urgency === 'overdue' && (
<Badge variant="destructive">{tClients('deadline_overdue')}</Badge>
)}
{row.nextDeadline!.urgency === 'action_needed' && (
<Badge variant="warning">{tClients('deadline_action_needed')}</Badge>
)}
<span
className={cn(
'truncate',
row.nextDeadline!.urgency === 'upcoming' && 'text-muted-foreground',
)}
>
{row.nextDeadline!.title}
</span>
<span className="text-muted-foreground tabular-nums">
{formatDate(row.nextDeadline!.dueDate)}
</span>
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
)
}
+163
View File
@@ -0,0 +1,163 @@
'use client'
import { useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Loader2, Search } from 'lucide-react'
import { cn, formatDate } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { useToast } from '@/components/ui/use-toast'
import { performCompanySwitch } from '@/lib/company/switch-client'
import { filterClientRows, type ClientOverviewRow } from '@/lib/clients/aggregate'
/**
* The cockpit client list (WL-14): dry-table, urgency-sorted server-side,
* free-text filter client-side. Entering a client is the WL-09 soft switch:
* sets the active company and lands DIRECTLY on that company's overview via
* performCompanySwitch (one full-page load to the target, no start-page
* bounce). The way back is the Klienter nav entry, which needs no switch.
*/
export default function ClientsTable({ clients }: { clients: ClientOverviewRow[] }) {
const t = useTranslations('clients')
const { toast } = useToast()
const [query, setQuery] = useState('')
const [pendingId, setPendingId] = useState<string | null>(null)
const rows = useMemo(() => filterClientRows(clients, query), [clients, query])
const handleEnter = async (companyId: string) => {
if (pendingId) return
setPendingId(companyId)
const result = await performCompanySwitch(companyId, { destination: '/' })
if (result?.error) {
setPendingId(null)
toast({ title: t('enter_failed'), variant: 'destructive' })
}
}
if (clients.length === 0) {
return (
<p className="text-sm text-muted-foreground">{t('empty')}</p>
)
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 max-w-xs rounded-lg border border-border px-3 py-2">
<Search className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('filter_placeholder')}
aria-label={t('filter_placeholder')}
className="w-full bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/60 focus:outline-none"
/>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={TH_CLASS}>{t('col_company')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('col_unbooked')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('col_inbox')}</th>
<th className={TH_CLASS}>{t('col_next_deadline')}</th>
<th className={TH_CLASS}>{t('col_last_booked')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{rows.length === 0 && (
<tr>
<td className={cn(TD_CLASS, 'text-muted-foreground')} colSpan={5}>
{t('no_matches')}
</td>
</tr>
)}
{rows.map((row) => (
<tr
key={row.companyId}
onClick={() => void handleEnter(row.companyId)}
className={cn(
'cursor-pointer transition-colors duration-150 hover:bg-secondary/35',
pendingId && pendingId !== row.companyId && 'opacity-50',
)}
>
<td className={TD_CLASS}>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
void handleEnter(row.companyId)
}}
disabled={pendingId !== null}
className="flex items-center gap-2 text-left"
>
<span className="font-medium text-foreground">{row.name}</span>
{row.orgNumber && (
<span className="text-xs text-muted-foreground tabular-nums">
{row.orgNumber}
</span>
)}
{pendingId === row.companyId && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
)}
</button>
</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>
{row.unbookedCount > 0 ? (
row.unbookedCount
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>
{row.inboxCount > 0 ? (
row.inboxCount
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
<td className={TD_CLASS}>
{row.nextDeadline ? (
<span className="flex items-center gap-2">
{/* Chips mark exceptions: only overdue/action_needed
deviate; an ordinary upcoming deadline is muted text. */}
{row.nextDeadline.urgency === 'overdue' && (
<Badge variant="destructive">{t('deadline_overdue')}</Badge>
)}
{row.nextDeadline.urgency === 'action_needed' && (
<Badge variant="warning">{t('deadline_action_needed')}</Badge>
)}
<span
className={cn(
'truncate',
row.nextDeadline.urgency === 'upcoming' && 'text-muted-foreground',
)}
>
{row.nextDeadline.title}
</span>
<span className="text-muted-foreground tabular-nums">
{formatDate(row.nextDeadline.dueDate)}
</span>
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className={cn(TD_CLASS, 'tabular-nums')}>
{row.lastBookedDate ? (
<span className="text-muted-foreground">
{formatDate(row.lastBookedDate)}
</span>
) : (
<span className="text-muted-foreground">{t('never_booked')}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,61 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
/**
* "Nytt klientbolag" (WL-15): header primary action on the cockpit, rendered
* only for byrå owner/admin (the page checks; the create RPC enforces the
* same gate in the database). Confirm up front (design convention 10): the
* dialog states that the company is added to the byrå's agreement (+1 on the
* monthly invoice) BEFORE routing into today's company creation flow with
* the explicit byrå team binding (/companies/new-client).
*/
export default function NewClientCompanyButton() {
const t = useTranslations('clients')
const router = useRouter()
const [open, setOpen] = useState(false)
const [navigating, setNavigating] = useState(false)
return (
<>
<Button onClick={() => setOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t('new_client_company')}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('new_client_company')}</DialogTitle>
<DialogDescription>{t('added_to_agreement')}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={navigating}>
{t('cancel')}
</Button>
<Button
disabled={navigating}
onClick={() => {
setNavigating(true)
router.push('/companies/new-client')
}}
>
{t('continue')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { PageHeader } from '@/components/ui/page-header'
import { fetchClientOverview } from '@/lib/clients/fetch-client-overview'
import { getDashboardAuthContext } from '../request-context'
import ClientsTable from './ClientsTable'
import NewClientCompanyButton from './NewClientCompanyButton'
export const dynamic = 'force-dynamic'
/**
* Byrå cockpit: the client list (WL-14). Five urgency-sorted columns per
* client company; byrå team members only: everyone else is redirected to the
* dashboard (the API mirrors this with 403). Read-first (WL-09): this page
* only aggregates; acting on a client happens by jumping in, which switches
* the active company.
*/
export default async function ClientsPage() {
const { supabase, user } = await getDashboardAuthContext()
if (!user) {
redirect('/login')
}
const overview = await fetchClientOverview(supabase, user.id)
if (!overview) {
// Not a byrå team member: the cockpit is byrå-exclusive in v1.
redirect('/')
}
const t = await getTranslations('clients')
const canCreate = overview.role === 'owner' || overview.role === 'admin'
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
action={canCreate ? <NewClientCompanyButton /> : undefined}
/>
<ClientsTable clients={overview.clients} />
</div>
)
}
@@ -9,6 +9,7 @@ import {
sectorNameKey,
} from '@/lib/extensions/i18n'
import type { SectorSlug } from '@/lib/extensions/types'
import { getRequestAppName } from '@/lib/branding/request-brand'
import CategoryBadge from '@/components/extensions/CategoryBadge'
import { WORKSPACES } from '@/lib/extensions/_generated/workspace-map'
import { Button } from '@/components/ui/button'
@@ -27,13 +28,16 @@ export default async function ExtensionDetailPage({
const sector = getSector(sectorSlug as SectorSlug)
const t = await getTranslations('extensions')
// Some long descriptions carry the {appName} ICU parameter (WL-12 appName
// sweep); passing it unconditionally is harmless for messages without it.
const appName = await getRequestAppName()
const nameKey = extensionNameKey(definition.slug)
const descriptionKey = extensionDescriptionKey(definition.slug)
const longDescriptionKey = extensionLongDescriptionKey(definition.slug)
const extensionName = nameKey ? t(nameKey) : definition.name
const extensionDescription = descriptionKey ? t(descriptionKey) : definition.description
const extensionLongDescription = longDescriptionKey ? t(longDescriptionKey) : definition.longDescription
const extensionDescription = descriptionKey ? t(descriptionKey, { appName }) : definition.description
const extensionLongDescription = longDescriptionKey ? t(longDescriptionKey, { appName }) : definition.longDescription
const sectorLabel = (() => {
if (!sector) return sectorSlug
+14 -6
View File
@@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { guardBrowserWrite } from '@/lib/company/tab-guard'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { DetailSection, DefRow, DefEmpty } from '@/components/ui/detail-section'
@@ -80,6 +81,9 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { useBranding } from '@/lib/branding/brand-context'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
/** Minimized Peppol delivery projection from GET /api/invoices/[id]/peppol/deliveries. */
interface PeppolDeliveryView {
@@ -97,8 +101,6 @@ const PEPPOL_STATUS_KEYS = new Set([
'no_route', 'failed',
])
const PEPPOL_SENDABLE_STATUSES = new Set<InvoiceStatus>(['draft', 'sent', 'overdue'])
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton'
// Why the downloaded file is not the invoice the customer received. One key
// per reason: "no archived copy exists" and "the archive could not be reached"
@@ -152,6 +154,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const { toast } = useToast()
const supabase = createClient()
const t = useTranslations('invoice_detail')
const { appName } = useBranding()
// Begäran status labels are shared with the payout dialog on the list page.
const tInvoices = useTranslations('invoices')
const tCommon = useTranslations('common')
@@ -573,6 +576,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
async function updateStatus(status: InvoiceStatus) {
if (!invoice) return
// Cross-tab guard (WL-09): the direct Supabase branches below bypass the
// patched window.fetch, so consult the tab guard explicitly. On a
// mismatch the blocking dialog raised by guardBrowserWrite is the user
// feedback; nothing is written.
if (!guardBrowserWrite()) return
setIsUpdating(true)
@@ -713,7 +721,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
if (caveat) {
toast({
title: t('pdf_rerender_downloaded_title'),
description: t(RERENDER_CAVEAT_KEYS[caveat]),
description: t(RERENDER_CAVEAT_KEYS[caveat], { appName }),
})
} else {
toast({
@@ -992,7 +1000,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
if (!window.open(url, '_blank', 'noopener,noreferrer')) {
toast({
title: t('pdf_preview_blocked_title'),
description: t('pdf_preview_blocked_description'),
description: t('pdf_preview_blocked_description', { appName }),
variant: 'destructive',
})
return
@@ -1002,7 +1010,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
if (caveat) {
toast({
title: t('pdf_rerender_preview_title'),
description: t(RERENDER_CAVEAT_KEYS[caveat]),
description: t(RERENDER_CAVEAT_KEYS[caveat], { appName }),
})
}
}
@@ -2315,7 +2323,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<DialogDescription>
{pdfArchiveIssue === 'document'
? t('pdf_archive_issue_document_desc')
: t('pdf_archive_issue_history_desc')}
: t('pdf_archive_issue_history_desc', { appName })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
+137 -21
View File
@@ -13,16 +13,24 @@ import { SessionTimeoutController } from '@/components/auth/SessionTimeoutContro
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import TrialExpiredDialog from '@/components/billing/TrialExpiredDialog'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
import { CompanyProvider, type ByraTeamRef } from '@/contexts/CompanyContext'
import { ReferenceDataSeed } from '@/components/providers/ReferenceDataSeed'
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
import { getDashboardNavFlags } from '@/lib/dashboard/nav-flags'
import { getBranding } from '@/lib/branding/service'
import { resolveBrandByHost } from '@/lib/branding/resolve'
import { resolveBrandsForTeams } from '@/lib/branding/team-brands'
import {
partitionCompaniesByHomeDomain,
isCompanyHomedOnHost,
} from '@/lib/company/home-domain'
import HomeDomainSignpost from '@/components/dashboard/HomeDomainSignpost'
import type { AccountingFramework, EntityType, CompanyRole, Team } from '@/types'
import {
getDashboardAuthContext,
getDashboardCompanyId,
getDashboardSettings,
getDashboardTeamMemberships,
getResolvedDashboardAgentProfile,
} from './request-context'
@@ -67,16 +75,16 @@ export default async function DashboardLayout({
// `getActiveCompanyId` reads from user_preferences, matching what RLS
// sees via `current_active_company_id()`. Keeping both sides on the same
// source avoids cross-tab / cookie divergence.
// Team membership (with the team row embedded) only depends on user.id,
// so it resolves in parallel, this layout is on the critical path of
// every dashboard page, so sequential round-trips are wall-clock time.
// Team memberships come from the request-cached getDashboardTeamMemberships
// (ALL rows: multi-team is the supported shape after WL-08) so the home
// page's byrå landing redirect reuses the same single query.
// Wave 1: everything keyed on the user alone runs alongside the company
// resolution. The memberships join carries the active company's row and
// role too, so wave 2 no longer re-reads companies / company_members.
const [
companyId,
headerStore,
{ data: teamMembership },
teamMemberships,
{ data: userProfile },
{ data: userPrefs },
{ data: allMemberships },
@@ -84,12 +92,7 @@ export default async function DashboardLayout({
getDashboardCompanyId(),
// Read the pathname forwarded by middleware so we can branch on it.
headers(),
supabase
.from('team_members')
.select('team_id, role, teams:team_id(*)')
.eq('user_id', user.id)
.limit(1)
.maybeSingle(),
getDashboardTeamMemberships(),
// The signed-in user's profile, shown in the bottom-left account
// popover (full_name + initial) so it's clear which user is logged
// in, distinct from the active company shown at the top.
@@ -106,15 +109,46 @@ export default async function DashboardLayout({
pathname.startsWith(p)
)
// Team now carries `kind` directly (types/index.ts, WL-08).
const membershipRows = teamMemberships
const byraMembership = membershipRows.find((m) => m.teams?.kind === 'byra') ?? null
// Byrå team membership gates the cockpit ("Klienter" nav + /clients).
const byraTeam: ByraTeamRef | null = byraMembership?.teams
? {
id: byraMembership.teams.id,
name: byraMembership.teams.name,
role:
byraMembership.role === 'owner' || byraMembership.role === 'admin'
? byraMembership.role
: 'member',
}
: null
// Legacy single-team fields: the byrå team when present (the one consumers
// care about), else the first membership: same value as before for
// single-team users.
const team: Team | null =
(teamMembership?.teams as unknown as Team | null) ?? null
const isTeamMember = !!teamMembership
(byraMembership?.teams as Team | null) ??
(membershipRows[0]?.teams as Team | null) ??
null
const isTeamMember = membershipRows.length > 0
// No companies: redirect to onboarding, except for allowed escape-hatch
// routes (so the user can still reach /settings/account to delete their
// account after archiving their last company).
// account after archiving their last company) and byrå team members (any
// role, founder call 2026-08-05), whose home is the EMPTY cockpit (mirrors
// the middleware's byrå exception): they render the no-company shell on
// cockpit routes and are steered to /byra everywhere else, never to the
// company wizard.
if (!companyId) {
if (!isNoCompanyAllowed) {
const isByraMember = !!byraTeam
const isByraShellPath =
pathname.startsWith('/byra') ||
pathname.startsWith('/clients') ||
pathname.startsWith('/settings')
if (!isNoCompanyAllowed && !(isByraMember && isByraShellPath)) {
if (isByraMember) {
redirect('/byra')
}
redirect('/onboarding')
}
@@ -126,6 +160,8 @@ export default async function DashboardLayout({
companies: [],
isTeamMember,
team,
byraTeam,
foreignCompanies: [],
isSandbox: false,
capabilities: [],
trialEndsAt: null,
@@ -167,6 +203,14 @@ export default async function DashboardLayout({
const companyRow = (activeMembership?.companies as unknown as import('@/types').Company | null) ?? null
const memberRow = activeMembership ? { role: activeMembership.role } : null
// Home-domain rule (WL-01): which brand serves this host, and which brand
// (if any) each membership company's team owns. Both resolvers are ~60s
// cached; unknown hosts and brandless teams resolve to null/absent, so the
// canonical no-brands hot path stays byte-identical. Both only depend on
// wave-1 data, so they ride in the wave-2 batch below.
const hostHeader =
headerStore.get('x-forwarded-host') ?? headerStore.get('host') ?? ''
// Wave 2: everything keyed on the company. Nav badge counts are NOT fetched
// here: DashboardNav loads them client-side after mount
// (lib/hooks/use-worklist-badges). The four nav-visibility probes
@@ -179,6 +223,8 @@ export default async function DashboardLayout({
navFlags,
{ data: seedFiscalPeriods },
{ data: seedCashAccounts },
hostBrand,
brandByTeam,
] = await Promise.all([
getDashboardSettings(),
// Agent identity, name + avatar, surfaced on the FAB and chat
@@ -210,10 +256,24 @@ export default async function DashboardLayout({
.eq('company_id', companyId)
.order('is_primary', { ascending: false })
.order('ledger_account', { ascending: true }),
hostHeader ? resolveBrandByHost(hostHeader) : Promise.resolve(null),
resolveBrandsForTeams(
(allMemberships || []).map(
(m) => (m.companies as { team_id?: string | null } | null)?.team_id ?? null,
),
),
])
const hasWebshop = navFlags.hasWebshop
const hasMileageTrips = navFlags.hasMileageTrips
const canonicalDomain = (() => {
try {
return new URL(getBranding().appUrl).hostname
} catch {
return ''
}
})()
// company_id -> current display name for every company the user belongs to.
const nameByCompany = new Map(
(allSettingsNames || []).map((s) => [s.company_id, s.company_name as string | null]),
@@ -234,6 +294,8 @@ export default async function DashboardLayout({
}),
isTeamMember,
team,
byraTeam,
foreignCompanies: [],
isSandbox: false,
capabilities: [],
trialEndsAt: null,
@@ -304,10 +366,9 @@ export default async function DashboardLayout({
const uiState = (userPrefs?.ui_state ?? {}) as import('@/types').UserUiState
const navCollapsed = uiState.nav_collapsed === true
const companyContextValue = {
company: companyWithName,
role: memberRow.role as CompanyRole,
companies: (allMemberships || []).map((m) => {
const allCompanyEntries = (allMemberships || [])
.filter((m) => m.companies)
.map((m) => {
const c = m.companies as unknown as import('@/types').Company
// Current display name for every company (company_settings.company_name,
// falling back to the frozen companies.name) so non-active switcher rows
@@ -316,9 +377,34 @@ export default async function DashboardLayout({
company: { ...c, name: nameByCompany.get(c.id) || c.name },
role: m.role as CompanyRole,
}
}),
})
// Home-domain rule (WL-01): the switcher offers only companies homed on
// THIS host; companies homed elsewhere become "Hanteras via <domain>"
// signpost entries. With no brands anywhere both lists reduce to
// visible = everything, foreign = [] : the additive guarantee.
const homePartition = partitionCompaniesByHomeDomain({
companies: allCompanyEntries,
getTeamId: (entry) => entry.company.team_id ?? null,
brandByTeam,
hostBrandTeamId: hostBrand?.teamId ?? null,
canonicalDomain,
canonicalAppName: getBranding().appName,
})
const foreignCompanies = homePartition.foreign.map((f) => ({
id: f.item.company.id,
name: f.item.company.name,
domain: f.domain,
}))
const companyContextValue = {
company: companyWithName,
role: memberRow.role as CompanyRole,
companies: homePartition.visible,
isTeamMember,
team,
byraTeam,
foreignCompanies,
isSandbox,
capabilities: entitlements.capabilities,
trialEndsAt: entitlements.trialEndsAt,
@@ -326,6 +412,23 @@ export default async function DashboardLayout({
trialExpiredAt: entitlements.trialExpiredAt,
}
// Signpost gate (WL-01): a company is opened ONLY on its home domain. When
// the active company is homed elsewhere, the dashboard body is replaced by
// the signpost (never the wrong company's data), except on account-level
// and byrå-cockpit routes, which are not company surfaces. Navigation rule
// only: RLS/membership remain the security boundary, and middleware is
// untouched (the layout is the clean seam: brand lookups don't belong on
// the Edge hot path).
const activeCompanyHomed = isCompanyHomedOnHost({
companyTeamId: (companyRow.team_id as string | null) ?? null,
brandByTeam,
hostBrandTeamId: hostBrand?.teamId ?? null,
})
const SIGNPOST_ALLOWED_PATHS = ['/settings/account', '/clients', '/byra']
const showSignpost =
!activeCompanyHomed &&
!SIGNPOST_ALLOWED_PATHS.some((p) => pathname.startsWith(p))
return (
<CompanyProvider value={companyContextValue}>
<ReferenceDataSeed
@@ -374,7 +477,20 @@ export default async function DashboardLayout({
initialUiState={uiState}
/>
<main id="main-content" className={MAIN_PANEL_CLASS} role="main">
<MainContainer companyId={companyId}>{children}</MainContainer>
<MainContainer companyId={companyId}>
{showSignpost ? (
<HomeDomainSignpost
activeCompanyName={displayName}
homedCompanies={homePartition.visible.map((entry) => ({
id: entry.company.id,
name: entry.company.name,
}))}
foreignCompanies={foreignCompanies}
/>
) : (
children
)}
</MainContainer>
</main>
<AgentTrigger hidden={userPrefs?.hide_assistant_fab === true} />
{/* One-time expired-trial notice. Sandbox/anonymous demo users have
+22
View File
@@ -1,11 +1,14 @@
import { Suspense } from 'react'
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import DashboardContent from '@/components/dashboard/DashboardContent'
import { ChecklistSkeleton, PanesSkeleton } from '@/components/dashboard/HemSkeletons'
import { COMPANY_PICKED_COOKIE } from '@/lib/company/context'
import {
getDashboardAuthContext,
getDashboardCompanyId,
getDashboardSettings,
getDashboardTeamMemberships,
getResolvedDashboardAgentProfile,
} from './request-context'
import { HemChecklistSection, HemNoticesSection, HemPanesSection } from './hem-sections'
@@ -40,6 +43,25 @@ export default async function DashboardPage() {
redirect('/onboarding')
}
// Byrå landing: every byrå team member (owner, admin AND member: widened
// from owner/admin on the founder's call 2026-08-05, so invited consultants
// land right too) homes to the cockpit, not to an auto-resolved client
// company. companyId above can be the middleware's first-membership
// fallback (which it also writes back to user_preferences, so the DB can't
// tell picked from auto-picked); the session cookie stamped by
// setActiveCompany is the explicit-choice signal. Once they enter a client
// this session, "/" is that company's Hem again. Memberships are
// request-cached and shared with the layout.
const [cookieStore, teamMemberships] = await Promise.all([
cookies(),
getDashboardTeamMemberships(),
])
if (!cookieStore.has(COMPANY_PICKED_COOKIE)) {
if (teamMemberships.some((m) => m.teams?.kind === 'byra')) {
redirect('/byra')
}
}
const now = new Date()
const [settingsRes, { data: profile }, agentProfile, { count: skatteverketTokenCount }] =
+29
View File
@@ -6,6 +6,7 @@ import { createClient } from '@/lib/supabase/server'
import { claimsPinned, userFromClaims } from '@/lib/auth/claims'
import { getActiveCompanyId } from '@/lib/company/context'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import type { Team } from '@/types'
/**
* Request-local dashboard auth context. React cache shares the Supabase client
@@ -48,6 +49,34 @@ export const getDashboardCompanyId = cache(async () => {
return user ? getActiveCompanyId(supabase, user.id) : null
})
export interface DashboardTeamMembership {
team_id: string
role: string
teams: Team | null
}
/**
* Request-local team memberships with the team row embedded. ALL memberships:
* multi-team membership (own personal team + byrå team) is the supported
* shape after WL-08; a `.limit(1)` would pick an arbitrary row and could hide
* a consultant's byrå membership. Shared between the dashboard layout (byrå
* cockpit gate) and the home page (byrå landing redirect), so the byrå check
* costs no extra query.
*/
export const getDashboardTeamMemberships = cache(
async (): Promise<DashboardTeamMembership[]> => {
const { supabase, user } = await getDashboardAuthContext()
if (!user) return []
const { data } = await supabase
.from('team_members')
.select('team_id, role, teams:team_id(*)')
.eq('user_id', user.id)
return (data ?? []) as unknown as DashboardTeamMembership[]
},
)
export const getDashboardSettings = cache(async () => {
const [{ supabase }, companyId] = await Promise.all([
getDashboardAuthContext(),
+7
View File
@@ -0,0 +1,7 @@
'use client'
import { BrandSettingsContent } from '@/components/settings/sections/BrandSettingsContent'
export default function BrandSettingsPage() {
return <BrandSettingsContent />
}
+5
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import Link from 'next/link'
import { SourceCodeFooter } from '@/components/branding/SourceCodeFooter'
import { getBranding } from '@/lib/branding/service'
export function generateMetadata(): Metadata {
@@ -249,6 +250,10 @@ export default function DPAPage() {
</p>
</CardContent>
</Card>
{/* AGPL section 13 source offer (WL-06): renders on both default and
branded hosts; never gate this on a brand. */}
<SourceCodeFooter className="pt-2" />
</div>
</div>
)
+33 -1
View File
@@ -38,6 +38,33 @@ interface InvoiceData {
reminderFeeCurrency?: string
totalDue: number
feeDueSeparately?: number
// White-label brand of the invoice's COMPANY (WL-13), resolved server-side
// by the action API regardless of host. Null/absent = today's page.
brand?: {
appName: string
logoUrl: string | null
brandColor: string
} | null
}
// Discreet brand mark shown when the invoice's company has a white-label
// brand: logo when one exists, else the brand name in the brand color.
function BrandMark({ brand }: { brand: NonNullable<InvoiceData['brand']> }) {
return (
<div className="flex justify-center mb-6">
{brand.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={brand.logoUrl} alt={brand.appName} className="h-8 w-auto" />
) : (
<span
className="text-sm font-semibold tracking-wide"
style={{ color: brand.brandColor }}
>
{brand.appName}
</span>
)}
</div>
)
}
export default function InvoiceActionPage({ params }: { params: Promise<{ token: string }> }) {
@@ -133,7 +160,9 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
if (invoice.alreadyResponded || successMessage) {
return (
<div className="min-h-dvh bg-gradient-to-b from-slate-50 to-white flex items-center justify-center p-4">
<Card className="max-w-md w-full">
<div className="max-w-md w-full">
{invoice.brand && <BrandMark brand={invoice.brand} />}
<Card className="w-full">
<CardContent className="pt-6 text-center">
<CheckCircle className="h-12 w-12 text-success mx-auto mb-4" />
<h2 className="text-lg mb-2">Tack för ditt svar!</h2>
@@ -151,6 +180,7 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
</CardContent>
</Card>
</div>
</div>
)
}
@@ -168,6 +198,8 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
return (
<div className="min-h-dvh bg-gradient-to-b from-slate-50 to-white py-12 px-4">
<div className="max-w-lg mx-auto">
{/* Brand chrome (WL-13): only when the invoice's company has a brand */}
{invoice.brand && <BrandMark brand={invoice.brand} />}
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-2xl text-foreground mb-2">
+5
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SourceCodeFooter } from '@/components/branding/SourceCodeFooter'
import { getBranding } from '@/lib/branding/service'
export function generateMetadata(): Metadata {
@@ -310,6 +311,10 @@ export default function PrivacyPolicyPage() {
</p>
</CardContent>
</Card>
{/* AGPL section 13 source offer (WL-06): renders on both default and
branded hosts; never gate this on a brand. */}
<SourceCodeFooter className="pt-2" />
</div>
</div>
)
@@ -0,0 +1,240 @@
/**
* Tests for POST /api/auth/email-hook (Supabase Send Email hook).
*
* Unauthenticated by design: the guard is the Standard Webhooks signature.
* Signature material is computed with node:crypto exactly like Supabase does.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createHmac, randomBytes } from 'node:crypto'
import type { Brand } from '@/lib/branding/resolve'
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const resolveBrandByHostMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/branding/resolve', () => ({
resolveBrandByHost: resolveBrandByHostMock,
// Imported by lib/email/brand-sender (not called on the hook path).
resolveBrandForCompany: vi.fn(),
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appName: 'Accounted', appUrl: 'https://app.gnubok.se' }),
}))
const sendEmailMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ sendEmail: sendEmailMock, isConfigured: () => true }),
}))
import { POST } from '../route'
const KEY = randomBytes(24)
const SECRET = `v1,whsec_${KEY.toString('base64')}`
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,
}
}
function signedRequest(rawBody: string, opts?: { badSignature?: boolean; headers?: Record<string, string> }): Request {
const id = 'msg_1'
const timestamp = String(Math.floor(Date.now() / 1000))
const signature = createHmac('sha256', KEY)
.update(`${id}.${timestamp}.${rawBody}`)
.digest('base64')
return new Request('http://localhost:3000/api/auth/email-hook', {
method: 'POST',
body: rawBody,
headers: {
'content-type': 'application/json',
'webhook-id': id,
'webhook-timestamp': timestamp,
'webhook-signature': opts?.badSignature ? 'v1,AAAA' : `v1,${signature}`,
...opts?.headers,
},
})
}
function hookPayload(overrides?: {
user?: Record<string, unknown>
email_data?: Record<string, unknown>
}): string {
return JSON.stringify({
user: { email: 'user@example.se', ...overrides?.user },
email_data: {
token: '123456',
token_hash: 'hash-1',
redirect_to: 'https://app.gnubok.se/auth/callback?next=/reset-password',
email_action_type: 'recovery',
site_url: 'https://app.gnubok.se',
...overrides?.email_data,
},
})
}
beforeEach(() => {
vi.clearAllMocks()
process.env.SUPABASE_SEND_EMAIL_HOOK_SECRET = SECRET
resolveBrandByHostMock.mockResolvedValue(null)
sendEmailMock.mockResolvedValue({ success: true, messageId: 'msg-1' })
})
afterEach(() => {
delete process.env.SUPABASE_SEND_EMAIL_HOOK_SECRET
})
describe('POST /api/auth/email-hook', () => {
it('returns 500 when the hook secret is not configured', async () => {
delete process.env.SUPABASE_SEND_EMAIL_HOOK_SECRET
const res = await POST(signedRequest(hookPayload()))
expect(res.status).toBe(500)
expect(sendEmailMock).not.toHaveBeenCalled()
})
it('returns 401 for an invalid signature', async () => {
const res = await POST(signedRequest(hookPayload(), { badSignature: true }))
expect(res.status).toBe(401)
expect(sendEmailMock).not.toHaveBeenCalled()
})
it('returns 400 for a signed but malformed payload', async () => {
const res = await POST(signedRequest('not-json'))
expect(res.status).toBe(400)
expect(sendEmailMock).not.toHaveBeenCalled()
})
it('sends canonical recovery mail linking to the originating host (no brand)', async () => {
const res = await POST(signedRequest(hookPayload()))
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({})
expect(sendEmailMock).toHaveBeenCalledTimes(1)
const options = sendEmailMock.mock.calls[0][0]
expect(options.to).toBe('user@example.se')
expect(options.subject).toBe('Återställ ditt lösenord')
expect(options.fromName).toBeUndefined()
expect(options.fromAddress).toBeUndefined()
expect(options.replyTo).toBeUndefined()
// token_hash + verifyOtp pattern on the originating host, preserving the
// existing next=/reset-password query.
expect(options.text).toContain('https://app.gnubok.se/auth/callback?next=%2Freset-password')
expect(options.text).toContain('token_hash=hash-1')
expect(options.text).toContain('type=recovery')
})
it('brands the mail from the redirect_to host and rides the verified brand sender', async () => {
resolveBrandByHostMock.mockResolvedValue(makeBrand())
const res = await POST(
signedRequest(
hookPayload({
email_data: {
email_action_type: 'signup',
redirect_to: 'https://app.siffra.se/auth/callback',
},
}),
),
)
expect(res.status).toBe(200)
expect(resolveBrandByHostMock).toHaveBeenCalledWith('app.siffra.se')
const options = sendEmailMock.mock.calls[0][0]
expect(options.fromName).toBe('Siffra')
expect(options.fromAddress).toBe('noreply@post.siffra.se')
expect(options.replyTo).toBe('support@siffra.se')
expect(options.html).toContain('Siffra')
expect(options.html).not.toMatch(/accounted/i)
expect(options.text).toContain('https://app.siffra.se/auth/callback?token_hash=hash-1')
expect(options.text).toContain('type=signup')
})
it('uses the via-fallback for a brand without a verified sender domain', async () => {
resolveBrandByHostMock.mockResolvedValue(makeBrand({ senderDomainStatus: 'pending' }))
await POST(
signedRequest(
hookPayload({
email_data: {
email_action_type: 'magiclink',
redirect_to: 'https://app.siffra.se/auth/callback',
},
}),
),
)
const options = sendEmailMock.mock.calls[0][0]
expect(options.fromName).toBe('Siffra')
expect(options.fromAddress).toBeUndefined()
})
it('sends two mails for a secure email change', async () => {
await POST(
signedRequest(
hookPayload({
user: { email: 'current@example.se', new_email: 'new@example.se' },
email_data: {
email_action_type: 'email_change',
token_hash: 'hash-new-address',
token_hash_new: 'hash-current-address',
redirect_to: 'https://app.gnubok.se/auth/callback',
},
}),
),
)
expect(sendEmailMock).toHaveBeenCalledTimes(2)
const first = sendEmailMock.mock.calls[0][0]
const second = sendEmailMock.mock.calls[1][0]
// token_hash confirms at the NEW address, token_hash_new at the current.
expect(first.to).toBe('new@example.se')
expect(first.text).toContain('token_hash=hash-new-address')
expect(second.to).toBe('current@example.se')
expect(second.text).toContain('token_hash=hash-current-address')
})
it('sends the OTP code for reauthentication without a link', async () => {
await POST(
signedRequest(
hookPayload({
email_data: { email_action_type: 'reauthentication', token: '424242' },
}),
),
)
const options = sendEmailMock.mock.calls[0][0]
expect(options.subject).toBe('Din verifieringskod')
expect(options.text).toContain('424242')
expect(options.text).not.toContain('token_hash=')
})
it('builds the callback URL when redirect_to points at a plain path', async () => {
await POST(
signedRequest(
hookPayload({
email_data: {
email_action_type: 'magiclink',
redirect_to: 'https://app.gnubok.se/settings/account',
},
}),
),
)
const options = sendEmailMock.mock.calls[0][0]
expect(options.text).toContain('https://app.gnubok.se/auth/callback?next=%2Fsettings%2Faccount')
expect(options.text).toContain('type=magiclink')
})
it('returns 500 when the email provider fails, so Supabase retries', async () => {
sendEmailMock.mockResolvedValue({ success: false, error: 'provider down' })
const res = await POST(signedRequest(hookPayload()))
expect(res.status).toBe(500)
})
})
+209
View File
@@ -0,0 +1,209 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { createLogger } from '@/lib/logger'
import { getEmailService } from '@/lib/email/service'
import { getBranding } from '@/lib/branding/service'
import { resolveBrandByHost } from '@/lib/branding/resolve'
import { getSenderForBrand } from '@/lib/email/brand-sender'
import { buildAuthEmail } from '@/lib/email/auth-templates'
import { verifyStandardWebhookSignature } from '@/lib/email/standard-webhook'
// Loads the email extension so getEmailService() returns the Resend
// implementation instead of the noop default.
ensureInitialized()
const log = createLogger('auth-email-hook')
/**
* POST /api/auth/email-hook: Supabase Auth "Send Email" hook (WL-05, WL-13).
*
* When enabled in Supabase (Auth > Hooks > Send Email, pointing at this URL
* with the shared secret in SUPABASE_SEND_EMAIL_HOOK_SECRET), Supabase stops
* sending auth mail itself and this endpoint sends every auth mail (signup
* confirmation, recovery, magic link, invite, email change, reauthentication)
* through the platform email service, branded per the requesting host: the
* brand is resolved from the redirect_to origin via resolveBrandByHost, so a
* reset requested on app.partner.se is sent in the partner's brand and links
* back to app.partner.se. Unknown hosts get canonical platform mail.
*
* Unauthenticated by design (server-to-server): authenticity comes from the
* Standard Webhooks signature, not a session, exactly like the Stripe and
* Resend webhook routes. The raw body is verified byte-for-byte before
* parsing. This endpoint is availability-critical once the hook is enabled:
* any internal failure returns 500 so Supabase retries (up to 3 times within
* a 5 second budget); success returns 200 {} fast.
*
* Until the hook is switched on in Supabase this route is dormant and auth
* mail keeps flowing from Supabase unchanged.
*/
// verifyOtp types our /auth/callback confirm route accepts. Unknown action
// types fall back to the generic 'email' type rather than dropping the mail.
const VERIFY_TYPES = new Set([
'signup',
'recovery',
'magiclink',
'invite',
'email_change',
'email',
])
interface SendEmailHookPayload {
user?: {
email?: string | null
new_email?: string | null
email_new?: string | null
} | null
email_data?: {
token?: string
token_hash?: string
token_new?: string
token_hash_new?: string
redirect_to?: string
email_action_type?: string
site_url?: string
} | null
}
/**
* Build the verify URL on the ORIGINATING host using the token_hash +
* verifyOtp pattern (browser- and host-independent, per the WL-05 research):
* /auth/callback consumes token_hash + type server-side and then honors the
* `next` path. If redirect_to already points at /auth/callback (our client
* flows do), its query (e.g. next=/reset-password) is preserved.
*/
function buildActionUrl(
redirectUrl: URL | null,
tokenHash: string,
actionType: string,
): string {
const verifyType = VERIFY_TYPES.has(actionType) ? actionType : 'email'
let url: URL
if (redirectUrl && redirectUrl.pathname === '/auth/callback') {
url = new URL(redirectUrl.toString())
} else {
url = new URL('/auth/callback', redirectUrl ? redirectUrl.origin : getBranding().appUrl)
if (redirectUrl) {
const next = redirectUrl.pathname + redirectUrl.search
if (next && next !== '/') url.searchParams.set('next', next)
}
}
url.searchParams.set('token_hash', tokenHash)
url.searchParams.set('type', verifyType)
return url.toString()
}
export async function POST(request: Request) {
const secret = process.env.SUPABASE_SEND_EMAIL_HOOK_SECRET
if (!secret) {
log.error('SUPABASE_SEND_EMAIL_HOOK_SECRET is not configured', undefined)
return NextResponse.json({ error: 'Hook not configured' }, { status: 500 })
}
const rawBody = await request.text()
const verified = verifyStandardWebhookSignature({
secret,
payload: rawBody,
id: request.headers.get('webhook-id'),
timestamp: request.headers.get('webhook-timestamp'),
signature: request.headers.get('webhook-signature'),
})
if (!verified) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
let payload: SendEmailHookPayload
try {
payload = JSON.parse(rawBody) as SendEmailHookPayload
} catch {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
}
const emailData = payload.email_data ?? {}
const actionType = emailData.email_action_type || ''
const recipient = payload.user?.email || null
if (!recipient) {
return NextResponse.json({ error: 'Missing recipient' }, { status: 400 })
}
// Brand from the requesting host: redirect_to carries the tenant origin.
let redirectUrl: URL | null = null
if (emailData.redirect_to) {
try {
redirectUrl = new URL(emailData.redirect_to)
} catch {
redirectUrl = null
}
}
const brand = redirectUrl ? await resolveBrandByHost(redirectUrl.hostname) : null
const sender = getSenderForBrand(brand)
const appName = brand?.appName ?? getBranding().appName
// Compose the mail(s) for this hook invocation.
const mails: Array<{ to: string; actionType: string; actionUrl?: string; otpCode?: string }> = []
if (actionType === 'reauthentication') {
if (!emailData.token) {
return NextResponse.json({ error: 'Missing token' }, { status: 400 })
}
mails.push({ to: recipient, actionType, otpCode: emailData.token })
} else if (actionType === 'email_change') {
// Secure email change sends TWO mails from one invocation. Documented
// reversal: token_hash confirms at the NEW address, token_hash_new at
// the CURRENT one.
if (!emailData.token_hash) {
return NextResponse.json({ error: 'Missing token_hash' }, { status: 400 })
}
const newEmail = payload.user?.new_email || payload.user?.email_new || recipient
mails.push({
to: newEmail,
actionType: 'email_change',
actionUrl: buildActionUrl(redirectUrl, emailData.token_hash, 'email_change'),
})
if (emailData.token_hash_new) {
mails.push({
to: recipient,
actionType: 'email_change_current',
actionUrl: buildActionUrl(redirectUrl, emailData.token_hash_new, 'email_change'),
})
}
} else {
if (!emailData.token_hash) {
return NextResponse.json({ error: 'Missing token_hash' }, { status: 400 })
}
mails.push({
to: recipient,
actionType,
actionUrl: buildActionUrl(redirectUrl, emailData.token_hash, actionType),
})
}
const emailService = getEmailService()
for (const mail of mails) {
const built = buildAuthEmail({
actionType: mail.actionType,
appName,
actionUrl: mail.actionUrl,
otpCode: mail.otpCode,
})
const result = await emailService.sendEmail({
to: mail.to,
subject: built.subject,
html: built.html,
text: built.text,
fromName: sender.fromName ?? undefined,
fromAddress: sender.fromAddress ?? undefined,
replyTo: sender.replyTo ?? undefined,
})
if (!result.success) {
// Non-2xx makes Supabase retry, which is the recovery we want: auth
// mail must not be silently dropped.
log.error('auth mail send failed', new Error(result.error ?? 'unknown'), {
actionType: mail.actionType,
})
return NextResponse.json({ error: 'Send failed' }, { status: 500 })
}
}
return NextResponse.json({})
}
+106
View File
@@ -42,12 +42,22 @@ vi.mock('@/lib/sandbox/guard', () => ({
isSandboxCompany: vi.fn().mockResolvedValue(false),
}))
// Service client for the WL-10 team-agreement lookup (end clients cannot read
// the byrå team or its grants under RLS, so the route uses the service role).
let serviceByTable: Record<string, TableResult> = {}
const createServiceClientMock = vi.fn(() => makeSupabase(serviceByTable))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => createServiceClientMock(),
createClient: vi.fn(),
}))
import { GET } from '../status/route'
interface StatusBody {
isPaying: boolean
trialEndsAt: string | null
isDemo: boolean
teamAgreement?: { teamName: string }
}
function authAs(byTable: Record<string, TableResult>) {
@@ -60,6 +70,7 @@ function authAs(byTable: Record<string, TableResult>) {
beforeEach(() => {
vi.clearAllMocks()
serviceByTable = {}
})
describe('GET /api/billing/status', () => {
@@ -108,3 +119,98 @@ describe('GET /api/billing/status', () => {
expect(body.isPaying).toBe(false)
})
})
// WL-10 billing honesty: a non-paying company under a byrå team with an
// active team-scoped manual grant gets the additive teamAgreement field.
describe('GET /api/billing/status team agreement', () => {
it('returns teamAgreement for a byrå-covered non-paying company', async () => {
authAs({
company_subscriptions: { data: null },
capability_grants: { data: null },
})
serviceByTable = {
companies: { data: { team_id: 'team-1' } },
teams: { data: { name: 'Siffran AB', kind: 'byra' } },
capability_grants: { data: [{ expires_at: null }] },
}
const { status, body } = await parseJsonResponse<StatusBody>(await GET())
expect(status).toBe(200)
expect(body.isPaying).toBe(false)
expect(body.teamAgreement).toEqual({ teamName: 'Siffran AB' })
})
it('accepts a future-dated grant expiry', async () => {
authAs({
company_subscriptions: { data: null },
capability_grants: { data: null },
})
serviceByTable = {
companies: { data: { team_id: 'team-1' } },
teams: { data: { name: 'Siffran AB', kind: 'byra' } },
capability_grants: { data: [{ expires_at: '2099-01-01T00:00:00Z' }] },
}
const { body } = await parseJsonResponse<StatusBody>(await GET())
expect(body.teamAgreement).toEqual({ teamName: 'Siffran AB' })
})
it('ignores an expired team grant (grace lapsed: standard paywall)', async () => {
authAs({
company_subscriptions: { data: null },
capability_grants: { data: null },
})
serviceByTable = {
companies: { data: { team_id: 'team-1' } },
teams: { data: { name: 'Siffran AB', kind: 'byra' } },
capability_grants: { data: [{ expires_at: '2020-01-01T00:00:00Z' }] },
}
const { body } = await parseJsonResponse<StatusBody>(await GET())
expect(body.isPaying).toBe(false)
expect(body.teamAgreement).toBeUndefined()
})
it('ignores a personal team even with a manual grant', async () => {
authAs({
company_subscriptions: { data: null },
capability_grants: { data: null },
})
serviceByTable = {
companies: { data: { team_id: 'team-1' } },
teams: { data: { name: 'Personal', kind: 'personal' } },
capability_grants: { data: [{ expires_at: null }] },
}
const { body } = await parseJsonResponse<StatusBody>(await GET())
expect(body.teamAgreement).toBeUndefined()
})
it('leaves a teamless company unchanged', async () => {
authAs({
company_subscriptions: { data: null },
capability_grants: { data: { expires_at: '2099-01-01T00:00:00Z' } },
})
serviceByTable = {
companies: { data: { team_id: null } },
}
const { body } = await parseJsonResponse<StatusBody>(await GET())
expect(body.isPaying).toBe(false)
expect(body.trialEndsAt).toBe('2099-01-01T00:00:00Z')
expect(body.teamAgreement).toBeUndefined()
expect('teamAgreement' in (body as object)).toBe(false)
})
it('never consults the team path for a paying company', async () => {
authAs({
company_subscriptions: { data: { status: 'active' } },
capability_grants: { data: null },
})
const { body } = await parseJsonResponse<StatusBody>(await GET())
expect(body.isPaying).toBe(true)
expect(body.teamAgreement).toBeUndefined()
expect(createServiceClientMock).not.toHaveBeenCalled()
})
})
+23 -1
View File
@@ -1,14 +1,21 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { createServiceClient } from '@/lib/supabase/server'
import { requireCompanyId } from '@/lib/company/context'
import { isStripeConfigured } from '@/lib/stripe/client'
import { isSandboxCompany } from '@/lib/sandbox/guard'
import { getTeamAgreement, type TeamAgreement } from '@/lib/entitlements/team-agreement'
/**
* Billing status for the client-rendered billing section (which lives inside the
* settings modal Dialog and can't read the DB server-side). Returns whether the
* company is paying, whether Stripe checkout is configured, and the trial expiry
* (for the days-left urgency banner). Read-only.
*
* WL-10: a non-paying company covered by its byrå team's agreement (active
* team-scoped manual grant) additionally gets `teamAgreement: { teamName }`,
* which the settings surface renders as "Ingår i <byråns namn>s avtal"
* instead of the upgrade pitch. Additive field: absent for everyone else.
*/
export async function GET() {
const { user, supabase, error } = await requireAuth()
@@ -30,6 +37,7 @@ export async function GET() {
let isPaying = false
let trialEndsAt: string | null = null
let teamAgreement: TeamAgreement | null = null
if (companyId) {
const { data: sub } = await supabase
.from('company_subscriptions')
@@ -53,7 +61,21 @@ export async function GET() {
.limit(1)
.maybeSingle()
trialEndsAt = (trial as { expires_at: string | null } | null)?.expires_at ?? null
// Team entitlement (WL-10): only consulted when the company isn't paying
// on its own subscription. Service client by necessity: end clients are
// not members of the byrå team, so RLS hides the team and its grants
// from the user's session.
if (!isPaying) {
teamAgreement = await getTeamAgreement(createServiceClient(), companyId)
}
}
return NextResponse.json({ isPaying, configured: isStripeConfigured(), trialEndsAt, isDemo })
return NextResponse.json({
isPaying,
configured: isStripeConfigured(),
trialEndsAt,
isDemo,
...(teamAgreement ? { teamAgreement } : {}),
})
}
+179
View File
@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { parseJsonResponse } from '@/tests/helpers'
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
const getByraMembershipMock = vi.fn()
vi.mock('@/lib/clients/fetch-client-overview', () => ({
getByraMembership: (...args: unknown[]) => getByraMembershipMock(...args),
}))
const clearBrandCacheMock = vi.fn()
vi.mock('@/lib/branding/resolve', () => ({
clearBrandCache: (...args: unknown[]) => clearBrandCacheMock(...args),
}))
interface BrandRow {
domain?: string
app_name?: string
logo_url?: string | null
id?: string
}
let brandRow: BrandRow | null = null
let brandReadError: { message: string } | null = null
let updateError: { message: string } | null = null
const updateEqMock = vi.fn(async () => ({ error: updateError }))
const updateMock = vi.fn(() => ({ eq: updateEqMock }))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => ({
from: () => ({
select: () => ({
eq: () => ({
maybeSingle: async () => ({ data: brandRow, error: brandReadError }),
}),
}),
update: updateMock,
}),
}),
}))
import { GET, PATCH } from '../route'
function patchRequest(body: unknown): Request {
return new Request('http://localhost/api/byra/brand', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
function authed() {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, error: null })
}
function unauthed() {
requireAuthMock.mockResolvedValue({
user: null,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
}
beforeEach(() => {
vi.clearAllMocks()
brandRow = null
brandReadError = null
updateError = null
})
describe('GET /api/byra/brand', () => {
it('returns 401 when unauthenticated', async () => {
unauthed()
const res = await GET()
expect(res.status).toBe(401)
})
it('returns 403 for a user without a byrå team', async () => {
authed()
getByraMembershipMock.mockResolvedValue(null)
const res = await GET()
expect(res.status).toBe(403)
})
it('returns hasBrand false when the team has no brand row', async () => {
authed()
getByraMembershipMock.mockResolvedValue({ teamId: 'team-1', teamName: 'Siffra', role: 'owner' })
const res = await GET()
const { status, body } = await parseJsonResponse<{
data: { hasBrand: boolean; canEdit: boolean }
}>(res)
expect(status).toBe(200)
expect(body.data).toMatchObject({ hasBrand: false, canEdit: true })
})
it('returns the brand with canEdit false for a plain member', async () => {
authed()
getByraMembershipMock.mockResolvedValue({ teamId: 'team-1', teamName: 'Siffra', role: 'member' })
brandRow = { domain: 'willem.accounted.se', app_name: 'Siffra', logo_url: null }
const res = await GET()
const { status, body } = await parseJsonResponse<{
data: { hasBrand: boolean; domain: string; appName: string; logoUrl: string | null; canEdit: boolean }
}>(res)
expect(status).toBe(200)
expect(body.data).toEqual({
hasBrand: true,
domain: 'willem.accounted.se',
appName: 'Siffra',
logoUrl: null,
canEdit: false,
})
})
it('returns 500 when the brand read fails', async () => {
authed()
getByraMembershipMock.mockResolvedValue({ teamId: 'team-1', teamName: 'Siffra', role: 'admin' })
brandReadError = { message: 'boom' }
const res = await GET()
expect(res.status).toBe(500)
})
})
describe('PATCH /api/byra/brand', () => {
it('returns 401 when unauthenticated', async () => {
unauthed()
const res = await PATCH(patchRequest({ appName: 'Willem' }))
expect(res.status).toBe(401)
})
it('returns 403 for a plain member', async () => {
authed()
getByraMembershipMock.mockResolvedValue({ teamId: 'team-1', teamName: 'Siffra', role: 'member' })
const res = await PATCH(patchRequest({ appName: 'Willem' }))
expect(res.status).toBe(403)
})
it('returns 400 for an empty app name', async () => {
authed()
getByraMembershipMock.mockResolvedValue({ teamId: 'team-1', teamName: 'Siffra', role: 'owner' })
const res = await PATCH(patchRequest({ appName: ' ' }))
expect(res.status).toBe(400)
expect(updateMock).not.toHaveBeenCalled()
})
it('returns 404 when the team has no brand row', async () => {
authed()
getByraMembershipMock.mockResolvedValue({ teamId: 'team-1', teamName: 'Siffra', role: 'owner' })
brandRow = null
const res = await PATCH(patchRequest({ appName: 'Willem' }))
expect(res.status).toBe(404)
})
it('trims, updates app_name and clears the brand cache', async () => {
authed()
getByraMembershipMock.mockResolvedValue({ teamId: 'team-1', teamName: 'Siffra', role: 'admin' })
brandRow = { id: 'brand-1' }
const res = await PATCH(patchRequest({ appName: ' Willem ' }))
const { status, body } = await parseJsonResponse<{ data: { app_name: string } }>(res)
expect(status).toBe(200)
expect(body.data.app_name).toBe('Willem')
expect(updateMock).toHaveBeenCalledWith({ app_name: 'Willem' })
expect(clearBrandCacheMock).toHaveBeenCalled()
})
it('returns 500 when the update fails', async () => {
authed()
getByraMembershipMock.mockResolvedValue({ teamId: 'team-1', teamName: 'Siffra', role: 'owner' })
brandRow = { id: 'brand-1' }
updateError = { message: 'nope' }
const res = await PATCH(patchRequest({ appName: 'Willem' }))
expect(res.status).toBe(500)
expect(clearBrandCacheMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { parseJsonResponse } from '@/tests/helpers'
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
const getByraMembershipMock = vi.fn()
vi.mock('@/lib/clients/fetch-client-overview', () => ({
getByraMembership: (...args: unknown[]) => getByraMembershipMock(...args),
}))
const clearBrandCacheMock = vi.fn()
vi.mock('@/lib/branding/resolve', () => ({
clearBrandCache: (...args: unknown[]) => clearBrandCacheMock(...args),
}))
let brandRow: { id: string; logo_url?: string | null } | null = null
let updateError: { message: string } | null = null
const updateEqMock = vi.fn(async () => ({ error: updateError }))
const updateMock = vi.fn(() => ({ eq: updateEqMock }))
const storageListMock = vi.fn(async () => ({ data: [{ name: 'logo-old.png' }] }))
const storageRemoveMock = vi.fn(async () => ({ error: null }))
const storageUploadMock = vi.fn(async () => ({ error: null }))
const storageGetPublicUrlMock = vi.fn(() => ({
data: { publicUrl: 'https://cdn.test/logos/byra/team-1/logo-2.png' },
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => ({
from: () => ({
select: () => ({
eq: () => ({
maybeSingle: async () => ({ data: brandRow, error: null }),
}),
}),
update: updateMock,
}),
storage: {
from: () => ({
list: storageListMock,
remove: storageRemoveMock,
upload: storageUploadMock,
getPublicUrl: storageGetPublicUrlMock,
}),
},
}),
}))
import { POST, DELETE } from '../route'
function authed() {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, error: null })
}
function unauthed() {
requireAuthMock.mockResolvedValue({
user: null,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
}
function ownerMembership() {
getByraMembershipMock.mockResolvedValue({ teamId: 'team-1', teamName: 'Siffra', role: 'owner' })
}
function uploadRequest(type = 'image/png', size = 128): Request {
const file = new File([new Uint8Array(size)], 'logo.png', { type })
const formData = new FormData()
formData.append('file', file)
return new Request('http://localhost/api/byra/brand/logo', { method: 'POST', body: formData })
}
beforeEach(() => {
vi.clearAllMocks()
brandRow = { id: 'brand-1', logo_url: null }
updateError = null
})
describe('POST /api/byra/brand/logo', () => {
it('returns 401 when unauthenticated', async () => {
unauthed()
const res = await POST(uploadRequest())
expect(res.status).toBe(401)
})
it('returns 403 for a user without a byrå team', async () => {
authed()
getByraMembershipMock.mockResolvedValue(null)
const res = await POST(uploadRequest())
expect(res.status).toBe(403)
})
it('returns 403 for a plain member', async () => {
authed()
getByraMembershipMock.mockResolvedValue({ teamId: 'team-1', teamName: 'Siffra', role: 'member' })
const res = await POST(uploadRequest())
expect(res.status).toBe(403)
})
it('returns 404 when the team has no brand row', async () => {
authed()
ownerMembership()
brandRow = null
const res = await POST(uploadRequest())
expect(res.status).toBe(404)
})
it('returns 400 when no file is attached', async () => {
authed()
ownerMembership()
const formData = new FormData()
const res = await POST(
new Request('http://localhost/api/byra/brand/logo', { method: 'POST', body: formData }),
)
expect(res.status).toBe(400)
})
it('returns 400 for a disallowed file type', async () => {
authed()
ownerMembership()
const res = await POST(uploadRequest('application/pdf'))
expect(res.status).toBe(400)
})
it('uploads, purges old files, updates brands.logo_url and clears the cache', async () => {
authed()
ownerMembership()
const res = await POST(uploadRequest())
const { status, body } = await parseJsonResponse<{ data: { logo_url: string } }>(res)
expect(status).toBe(200)
expect(body.data.logo_url).toBe('https://cdn.test/logos/byra/team-1/logo-2.png')
expect(storageRemoveMock).toHaveBeenCalledWith(['byra/team-1/logo-old.png'])
expect(storageUploadMock).toHaveBeenCalledWith(
expect.stringMatching(/^byra\/team-1\/logo-\d+\.png$/),
expect.any(Buffer),
{ contentType: 'image/png', upsert: true },
)
expect(updateMock).toHaveBeenCalledWith({
logo_url: 'https://cdn.test/logos/byra/team-1/logo-2.png',
})
expect(clearBrandCacheMock).toHaveBeenCalled()
})
it('returns 500 when the brands update fails', async () => {
authed()
ownerMembership()
updateError = { message: 'nope' }
const res = await POST(uploadRequest())
expect(res.status).toBe(500)
expect(clearBrandCacheMock).not.toHaveBeenCalled()
})
})
describe('DELETE /api/byra/brand/logo', () => {
it('returns 403 for a plain member', async () => {
authed()
getByraMembershipMock.mockResolvedValue({ teamId: 'team-1', teamName: 'Siffra', role: 'member' })
const res = await DELETE()
expect(res.status).toBe(403)
})
it('purges files, nulls logo_url and clears the cache', async () => {
authed()
ownerMembership()
brandRow = { id: 'brand-1', logo_url: 'https://cdn.test/old.png' }
const res = await DELETE()
const { status, body } = await parseJsonResponse<{ data: { logo_url: null } }>(res)
expect(status).toBe(200)
expect(body.data.logo_url).toBeNull()
expect(storageRemoveMock).toHaveBeenCalledWith(['byra/team-1/logo-old.png'])
expect(updateMock).toHaveBeenCalledWith({ logo_url: null })
expect(clearBrandCacheMock).toHaveBeenCalled()
})
})
+140
View File
@@ -0,0 +1,140 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { createServiceClient } from '@/lib/supabase/server'
import { requireByraBrandAccess } from '@/lib/byra/brand-access'
import { clearBrandCache } from '@/lib/branding/resolve'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { LOGO_UPLOAD_MAX_BYTES, LOGO_UPLOAD_MAX_MB } from '@/lib/invoices/branding-constants'
/**
* POST/DELETE /api/byra/brand/logo
* Byrå self-service brand logo (the ONE brand field byråer edit themselves;
* domain, app name and colors stay ops-managed, see DECISIONS.md).
*
* brands has no write RLS (ops-managed rows), so writes go through the
* service client behind an explicit owner/admin check on the caller's byrå
* team: the same authorization idiom as /api/team/members. Files live in
* the public `logos` bucket under byra/{teamId}/ (company invoice logos use
* {companyId}/ top-level; the byra/ prefix keeps the namespaces apart).
*
* The timestamped filename doubles as a cache-buster; the 60s brand cache
* in lib/branding/resolve.ts means other lambda instances can serve the old
* logo_url for up to a minute after a change. clearBrandCache() covers the
* instance that handled the write.
*/
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
async function purgeLogoFiles(
serviceClient: ReturnType<typeof createServiceClient>,
teamId: string,
): Promise<void> {
const prefix = `byra/${teamId}`
const { data: existing } = await serviceClient.storage.from('logos').list(prefix)
if (existing && existing.length > 0) {
await serviceClient.storage
.from('logos')
.remove(existing.map((f) => `${prefix}/${f.name}`))
}
}
export async function POST(request: Request) {
const { user, error } = await requireAuth()
if (error) return error
const { teamId, serviceClient, errorResponse } = await requireByraBrandAccess(user.id)
if (errorResponse) return errorResponse
const { data: brand } = await serviceClient
.from('brands')
.select('id')
.eq('team_id', teamId)
.maybeSingle()
if (!brand) {
return NextResponse.json({ error: 'Ingen varumärkesprofil är aktiverad ännu.' }, { status: 404 })
}
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'Ingen fil angiven' }, { status: 400 })
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' },
{ status: 400 },
)
}
if (file.size > LOGO_UPLOAD_MAX_BYTES) {
return NextResponse.json(
{ error: `Filen är för stor (max ${LOGO_UPLOAD_MAX_MB} MB).` },
{ status: 400 },
)
}
const buffer = Buffer.from(await file.arrayBuffer())
const mimeToExt: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/svg+xml': 'svg',
'image/webp': 'webp',
}
const ext = mimeToExt[file.type] ?? 'png'
const storagePath = `byra/${teamId}/logo-${Date.now()}.${ext}`
await purgeLogoFiles(serviceClient, teamId)
const { error: uploadError } = await serviceClient.storage
.from('logos')
.upload(storagePath, buffer, { contentType: file.type, upsert: true })
if (uploadError) {
return NextResponse.json(
{ error: `Uppladdning misslyckades: ${getUserErrorMessage(uploadError)}` },
{ status: 500 },
)
}
const { data: urlData } = serviceClient.storage.from('logos').getPublicUrl(storagePath)
const { error: updateError } = await serviceClient
.from('brands')
.update({ logo_url: urlData.publicUrl })
.eq('team_id', teamId)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera varumärket.' }, { status: 500 })
}
clearBrandCache()
return NextResponse.json({ data: { logo_url: urlData.publicUrl } })
}
export async function DELETE() {
const { user, error } = await requireAuth()
if (error) return error
const { teamId, serviceClient, errorResponse } = await requireByraBrandAccess(user.id)
if (errorResponse) return errorResponse
const { data: brand } = await serviceClient
.from('brands')
.select('id, logo_url')
.eq('team_id', teamId)
.maybeSingle()
if (!brand) {
return NextResponse.json({ error: 'Ingen varumärkesprofil är aktiverad ännu.' }, { status: 404 })
}
await purgeLogoFiles(serviceClient, teamId)
const { error: updateError } = await serviceClient
.from('brands')
.update({ logo_url: null })
.eq('team_id', teamId)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera varumärket.' }, { status: 500 })
}
clearBrandCache()
return NextResponse.json({ data: { logo_url: null } })
}
+96
View File
@@ -0,0 +1,96 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { createServiceClient } from '@/lib/supabase/server'
import { getByraMembership } from '@/lib/clients/fetch-client-overview'
import { requireByraBrandAccess } from '@/lib/byra/brand-access'
import { clearBrandCache } from '@/lib/branding/resolve'
import { validateBody } from '@/lib/api/validate'
import { ByraBrandUpdateSchema } from '@/lib/api/schemas'
/**
* GET /api/byra/brand
* The caller's byrå brand profile for the Varumärke settings section.
*
* Team-scoped, not company-scoped: uses requireAuth() directly (like
* /api/team/members) because the byrå cockpit has no active-company
* context. requireAuth still enforces MFA (AAL2) on hosted.
*
* Reads the brands row directly through the service client instead of the
* cached resolveBrandForTeam(): the settings form must show what is stored
* NOW, not a value up to 60s stale (lib/branding/resolve.ts cache).
*/
export async function GET() {
const { user, error } = await requireAuth()
if (error) return error
const serviceClient = createServiceClient()
const membership = await getByraMembership(serviceClient, user.id)
if (!membership) {
return NextResponse.json({ error: 'Endast för byråteam.' }, { status: 403 })
}
const { data: brand, error: brandError } = await serviceClient
.from('brands')
.select('domain, app_name, logo_url')
.eq('team_id', membership.teamId)
.maybeSingle()
if (brandError) {
return NextResponse.json({ error: 'Kunde inte hämta varumärket.' }, { status: 500 })
}
const canEdit = membership.role === 'owner' || membership.role === 'admin'
if (!brand) {
return NextResponse.json({
data: { hasBrand: false, domain: null, appName: null, logoUrl: null, canEdit },
})
}
return NextResponse.json({
data: {
hasBrand: true,
domain: brand.domain,
appName: brand.app_name,
logoUrl: brand.logo_url,
canEdit,
},
})
}
/**
* PATCH /api/byra/brand
* Byrå self-service app name (WL-17): the name shown beside the sidebar
* logo and across branded chrome. Owner/admin only; domain and colors stay
* ops-managed. Same service-client write path as the logo route.
*/
export async function PATCH(request: Request) {
const { user, error } = await requireAuth()
if (error) return error
const { teamId, serviceClient, errorResponse } = await requireByraBrandAccess(user.id)
if (errorResponse) return errorResponse
const validation = await validateBody(request, ByraBrandUpdateSchema)
if (!validation.success) return validation.response
const { data: brand } = await serviceClient
.from('brands')
.select('id')
.eq('team_id', teamId)
.maybeSingle()
if (!brand) {
return NextResponse.json({ error: 'Ingen varumärkesprofil är aktiverad ännu.' }, { status: 404 })
}
const { error: updateError } = await serviceClient
.from('brands')
.update({ app_name: validation.data.appName })
.eq('team_id', teamId)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera varumärket.' }, { status: 500 })
}
clearBrandCache()
return NextResponse.json({ data: { app_name: validation.data.appName } })
}
+201
View File
@@ -0,0 +1,201 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
} from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
import { GET } from '../route'
const noParams = { params: Promise.resolve({}) }
function authed() {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
}
function unauthed() {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
}
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 urgency 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}`
}
beforeEach(() => {
vi.clearAllMocks()
reset()
})
describe('GET /api/clients', () => {
it('returns 401 when unauthenticated', async () => {
unauthed()
const res = await GET(createMockRequest('/api/clients'), noParams)
expect(res.status).toBe(401)
})
it('returns 403 for a user without a byrå team', async () => {
authed()
// Query 1: byrå team membership lookup: no rows.
enqueue({ data: [] })
const res = await GET(createMockRequest('/api/clients'), noParams)
const { status, body } = await parseJsonResponse<{
error: { code: string }
}>(res)
expect(status).toBe(403)
expect(body.error.code).toBe('FORBIDDEN')
})
it('aggregates one urgency-sorted row per client company', async () => {
authed()
// 1. byrå membership
enqueue({
data: [
{
team_id: 'team-1',
role: 'admin',
teams: { id: 'team-1', name: 'Siffra', kind: 'byra' },
},
],
})
// 2. client companies
enqueue({
data: [
{ id: 'c1', name: 'Alpha AB', org_number: '5560125790' },
{ id: 'c2', name: 'Beta AB', org_number: null },
],
})
// 3. company_settings (display names)
enqueue({
data: [{ company_id: 'c1', company_name: 'Alpha Redovisning AB', org_number: null }],
})
// 4. unbooked transactions (canonical predicate)
enqueue({
data: [
{ id: 't1', company_id: 'c1' },
{ id: 't2', company_id: 'c1' },
{ id: 't3', company_id: 'c1' },
{ id: 't4', company_id: 'c2' },
],
})
// 5. inbox items with a document that became nothing
enqueue({ data: [{ id: 'i1', company_id: 'c1', document_id: 'd1' }] })
// 6. document backstop: d1 still unlinked
enqueue({ data: [{ id: 'd1', company_id: 'c1' }] })
// 7. open deadlines: c2 has an overdue one
enqueue({
data: [
{
id: 'dl1',
company_id: 'c2',
title: 'Momsdeklaration',
due_date: isoDaysFromNow(-5),
tax_deadline_type: 'vat_declaration',
status: 'overdue',
},
],
})
// 8. latest posted verifikat per company (embedded)
enqueue({
data: [
{ id: 'c1', journal_entries: [{ entry_date: '2026-07-15' }] },
{ id: 'c2', journal_entries: [] },
],
})
const res = await GET(createMockRequest('/api/clients'), noParams)
const { status, body } = await parseJsonResponse<{
data: {
team: { id: string; name: string }
role: string
clients: Array<{
companyId: string
name: string
orgNumber: string | null
unbookedCount: number
inboxCount: number
nextDeadline: { urgency: string; dueDate: string } | null
lastBookedDate: string | null
}>
}
}>(res)
expect(status).toBe(200)
expect(body.data.team).toEqual({ id: 'team-1', name: 'Siffra' })
expect(body.data.role).toBe('admin')
// Urgency sort: c2 (overdue deadline) before c1 (bigger unbooked pile).
expect(body.data.clients.map((c) => c.companyId)).toEqual(['c2', 'c1'])
const [c2, c1] = body.data.clients
expect(c2).toMatchObject({
name: 'Beta AB',
unbookedCount: 1,
inboxCount: 0,
lastBookedDate: null,
})
expect(c2.nextDeadline).toMatchObject({
urgency: 'overdue',
dueDate: isoDaysFromNow(-5),
})
expect(c1).toMatchObject({
// company_settings display name wins over the frozen companies.name
name: 'Alpha Redovisning AB',
orgNumber: '5560125790',
unbookedCount: 3,
inboxCount: 1,
nextDeadline: null,
lastBookedDate: '2026-07-15',
})
})
it('returns an empty client list for a byrå without companies', async () => {
authed()
enqueue({
data: [
{
team_id: 'team-1',
role: 'member',
teams: { id: 'team-1', name: 'Siffra', kind: 'byra' },
},
],
})
enqueue({ data: [] }) // no client companies
const res = await GET(createMockRequest('/api/clients'), noParams)
const { status, body } = await parseJsonResponse<{
data: { role: string; clients: unknown[] }
}>(res)
expect(status).toBe(200)
expect(body.data.role).toBe('member')
expect(body.data.clients).toEqual([])
})
})
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { resolveBrandByHost } from '@/lib/branding/resolve'
import { resolveBrandsForTeams } from '@/lib/branding/team-brands'
import { resolveLandingPath } from '@/lib/company/home-domain'
/**
* GET /api/clients/landing
*
* Post-login landing decision (WL-14): byrå staff land in the cockpit
* ('/clients') when the current host is their byrå's home domain: the byrå's
* brand domain, or the canonical domain for a byrå without white label
* (WL-01). Everyone else gets '/' so their flow stays byte-identical. Called
* by the login and MFA-verify pages when no explicit destination was
* requested; any failure degrades to '/' at the caller.
*/
export const GET = withRouteContext('clients.landing', async (request, ctx) => {
const host =
request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? ''
const hostBrand = host ? await resolveBrandByHost(host) : null
const { data: memberships } = await ctx.supabase
.from('team_members')
.select('team_id, teams:team_id!inner(kind)')
.eq('user_id', ctx.user.id)
.eq('teams.kind', 'byra')
const byraTeamIds = (memberships ?? []).map((m) => m.team_id as string)
if (byraTeamIds.length === 0) {
return NextResponse.json({ data: { destination: '/' } })
}
const brandByTeam = await resolveBrandsForTeams(byraTeamIds)
const destination = resolveLandingPath({
hostBrandTeamId: hostBrand?.teamId ?? null,
byraTeams: byraTeamIds.map((id) => ({ teamId: id, hasBrand: brandByTeam.has(id) })),
})
return NextResponse.json({ data: { destination } })
})
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { fetchClientOverview } from '@/lib/clients/fetch-client-overview'
/**
* GET /api/clients
*
* Byrå cockpit aggregate (WL-14): one row per client company on the caller's
* byrå team (teams.kind = 'byra') with the four at-a-glance signals: unbooked
* transaction count, unconsumed inbox document count, next deadline
* (status-engine semantics, 14-day ACTION_NEEDED window) and last booked
* (latest posted verifikat date). Rows arrive urgency-sorted: overdue
* deadlines first, then largest unbooked pile.
*
* Read-only across memberships (WL-09 read-first): RLS already allows these
* reads via user_company_ids(); the active company is untouched.
*
* Non-byrå users get 403 FORBIDDEN: the cockpit is byrå-exclusive in v1
* (multi-company self-users keep the company switcher).
*/
export const GET = withRouteContext('clients.list', async (_request, ctx) => {
const overview = await fetchClientOverview(ctx.supabase, ctx.user.id)
if (!overview) {
return errorResponseFromCode('FORBIDDEN', ctx.log, {
requestId: ctx.requestId,
reason: 'not a byrå team member',
messageSv: 'Klientlistan är endast tillgänglig för byråteam.',
messageEn: 'The client list is only available to byrå team members.',
})
}
return NextResponse.json({ data: overview })
})
@@ -46,10 +46,18 @@ vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ isConfigured: isConfiguredMock, sendEmail: sendEmailMock }),
}))
const generateInviteEmailHtmlMock = vi.fn(() => '<p>html</p>')
const brandSenderMock = vi.hoisted(() => ({
getSenderForCompany: vi.fn(),
getBaseUrlForBrand: vi.fn(),
}))
vi.mock('@/lib/email/brand-sender', () => brandSenderMock)
const generateInviteEmailHtmlMock = vi.hoisted(() =>
vi.fn((data: { inviteUrl: string }) => `<p>${data.inviteUrl}</p>`),
)
vi.mock('@/lib/email/invite-templates', () => ({
generateInviteEmailSubject: () => 'subject',
generateInviteEmailHtml: (...args: unknown[]) => generateInviteEmailHtmlMock(...args),
generateInviteEmailHtml: generateInviteEmailHtmlMock,
generateInviteEmailText: () => 'text',
}))
@@ -81,6 +89,13 @@ beforeEach(() => {
requireWriteMock.mockResolvedValue({ ok: true })
isConfiguredMock.mockReturnValue(true)
sendEmailMock.mockResolvedValue({ success: true, messageId: 'msg-1' })
brandSenderMock.getSenderForCompany.mockResolvedValue({
fromName: null,
fromAddress: null,
replyTo: null,
brand: null,
})
brandSenderMock.getBaseUrlForBrand.mockReturnValue('http://localhost:3000')
inviteUserByEmailMock.mockResolvedValue({ data: { user: { id: 'new-user' } }, error: null })
})
@@ -150,6 +165,65 @@ describe('POST /api/company/members/invite', () => {
)
})
it('sends the branded invite: brand link base, brand appName, brand sender', async () => {
brandSenderMock.getSenderForCompany.mockResolvedValue({
fromName: 'Siffra',
fromAddress: 'noreply@post.siffra.se',
replyTo: 'support@siffra.se',
brand: { appName: 'Siffra', domain: 'app.siffra.se' },
})
brandSenderMock.getBaseUrlForBrand.mockReturnValue('https://app.siffra.se')
enqueue({ data: { role: 'owner' } })
enqueue({ data: [] })
enqueue({ data: null })
enqueue({ data: { name: 'Kund AB' } })
enqueue({ data: null })
const { status } = await parseJsonResponse(
await post({ email: 'client@example.com' })
)
expect(status).toBe(200)
expect(generateInviteEmailHtmlMock).toHaveBeenCalledWith(
expect.objectContaining({
inviteUrl: 'https://app.siffra.se/invite/tok-plain',
appName: 'Siffra',
})
)
expect(sendEmailMock).toHaveBeenCalledWith(
expect.objectContaining({
fromName: 'Siffra',
fromAddress: 'noreply@post.siffra.se',
replyTo: 'support@siffra.se',
})
)
})
it('keeps the canonical link and platform sender for an unbranded company', async () => {
enqueue({ data: { role: 'owner' } })
enqueue({ data: [] })
enqueue({ data: null })
enqueue({ data: { name: 'Kund AB' } })
enqueue({ data: null })
const { status } = await parseJsonResponse(
await post({ email: 'client@example.com' })
)
expect(status).toBe(200)
expect(generateInviteEmailHtmlMock).toHaveBeenCalledWith(
expect.objectContaining({
inviteUrl: 'https://app.accounted.test/invite/tok-plain',
appName: undefined,
})
)
const options = sendEmailMock.mock.calls[0][0]
expect(options.fromName).toBeUndefined()
expect(options.fromAddress).toBeUndefined()
expect(options.replyTo).toBeUndefined()
})
it('reports email_sent=false when the send fails (invite still created)', async () => {
enqueue({ data: { role: 'owner' } })
enqueue({ data: [] })
+13 -2
View File
@@ -7,6 +7,7 @@ import { validateBody } from '@/lib/api/validate'
import { generateInviteToken, getInviteExpiry } from '@/lib/auth/invite-tokens'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { getEmailService } from '@/lib/email/service'
import { getSenderForCompany, getBaseUrlForBrand } from '@/lib/email/brand-sender'
import {
generateInviteEmailSubject,
generateInviteEmailHtml,
@@ -213,15 +214,22 @@ export const POST = withRouteContext(
// Send email. email_sent is surfaced in the response so the UI can tell
// the user when the invitation exists but the mail never went out:
// previously a send failure was invisible (invite looked sent).
// Brand mail (WL-13): sender identity and the invite link follow the
// brand of the company the invite concerns; a company without a brand
// uses the validated request origin (appOrigin) and sender exactly as
// before.
const sender = await getSenderForCompany(companyId)
const appUrl = sender.brand ? getBaseUrlForBrand(sender.brand) : appOrigin
const emailService = getEmailService()
let emailSent = false
if (emailService.isConfigured()) {
const inviteUrl = `${appOrigin}/invite/${token}`
const inviteUrl = `${appUrl}/invite/${token}`
const emailData = {
companyName: company?.name || 'Företag',
inviterEmail: user.email || '',
inviteUrl,
appName: sender.brand?.appName,
}
const result = await emailService.sendEmail({
@@ -229,6 +237,9 @@ export const POST = withRouteContext(
subject: generateInviteEmailSubject(emailData),
html: generateInviteEmailHtml(emailData),
text: generateInviteEmailText(emailData),
fromName: sender.fromName ?? undefined,
fromAddress: sender.fromAddress ?? undefined,
replyTo: sender.replyTo ?? undefined,
})
if (result.success) {
@@ -243,7 +254,7 @@ export const POST = withRouteContext(
// In development, return the invite URL directly (no email service)
const isDev = process.env.NODE_ENV === 'development'
const devInviteUrl = isDev ? `${appOrigin}/invite/${token}` : undefined
const devInviteUrl = isDev ? `${appUrl}/invite/${token}` : undefined
return NextResponse.json({
data: {
@@ -31,6 +31,11 @@ vi.mock('@supabase/ssr', () => {
}
})
const resolveBrandForCompanyMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/branding/resolve', () => ({
resolveBrandForCompany: resolveBrandForCompanyMock,
}))
import { GET } from '../route'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
@@ -42,11 +47,13 @@ interface ActionPayload {
reminderFeeCurrency: string
totalDue: number
feeDueSeparately: number
brand: { appName: string; logoUrl: string | null; brandColor: string } | null
}
function makeReminderRow(currency: string, total: number) {
return {
id: 'reminder-1',
company_id: 'company-1',
reminder_level: 1,
sent_at: '2026-05-20T08:00:00Z',
response_type: null,
@@ -81,6 +88,7 @@ async function callGet() {
describe('GET /api/invoices/reminders/action', () => {
beforeEach(() => {
vi.clearAllMocks()
resolveBrandForCompanyMock.mockResolvedValue(null)
})
it('returns 400 without a token', async () => {
@@ -128,4 +136,28 @@ describe('GET /api/invoices/reminders/action', () => {
expect(body.reminderFee).toBe(60)
expect(body.totalDue).toBe(510)
})
it('returns the company brand for the public page when one exists (WL-13)', async () => {
resolveBrandForCompanyMock.mockResolvedValue({
appName: 'Siffra',
logoUrl: 'https://cdn.example/siffra.png',
brandColor: '#123456',
domain: 'app.siffra.se',
})
queued = { data: makeReminderRow('SEK', 10_000), error: null }
const { body } = await callGet()
expect(resolveBrandForCompanyMock).toHaveBeenCalledWith('company-1')
expect(body.brand).toEqual({
appName: 'Siffra',
logoUrl: 'https://cdn.example/siffra.png',
brandColor: '#123456',
})
})
it('returns brand: null for a brandless company (page unchanged)', async () => {
queued = { data: makeReminderRow('SEK', 10_000), error: null }
const { body } = await callGet()
expect(body.brand).toBeNull()
})
})
@@ -4,6 +4,7 @@ import {
calculateReminderAmounts,
REMINDER_FEE_CURRENCY,
} from '@/lib/email/reminder-templates'
import { resolveBrandForCompany } from '@/lib/branding/resolve'
// Create a service client (no auth needed - public endpoint with token validation)
function createServiceClient() {
@@ -120,6 +121,7 @@ export async function GET(request: Request) {
.from('invoice_reminders')
.select(`
id,
company_id,
reminder_level,
sent_at,
response_type,
@@ -183,6 +185,12 @@ export async function GET(request: Request) {
currency: invoice.currency,
})
// Brand of the COMPANY the invoice concerns (WL-13): the public page styles
// itself by this regardless of host. Null (no brand) = today's page.
const brand = reminder.company_id
? await resolveBrandForCompany(reminder.company_id as string)
: null
return NextResponse.json({
invoiceNumber: invoice.invoice_number,
invoiceDate: invoice.invoice_date,
@@ -207,5 +215,9 @@ export async function GET(request: Request) {
totalDue: amounts.totalDue,
/** In SEK. Non-zero only when the fee must be demanded outside `totalDue`. */
feeDueSeparately: amounts.feeDueSeparately,
/** White-label brand of the invoice's company; null keeps today's page. */
brand: brand
? { appName: brand.appName, logoUrl: brand.logoUrl, brandColor: brand.brandColor }
: null,
})
}
@@ -35,6 +35,11 @@ vi.mock('@/lib/entitlements/has-capability', () => ({
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appUrl: 'https://app.example.test' }),
}))
const brandSenderMock = vi.hoisted(() => ({
getSenderForCompany: vi.fn(),
getBaseUrlForBrand: vi.fn(),
}))
vi.mock('@/lib/email/brand-sender', () => brandSenderMock)
vi.mock('@/lib/salary/payslips/links', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/salary/payslips/links')>()
return {
@@ -80,6 +85,13 @@ describe('POST /api/salary/runs/[id]/payslips/send', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(rotateLinkForEmployee).mockResolvedValue({ token: 'T'.repeat(43) })
brandSenderMock.getSenderForCompany.mockResolvedValue({
fromName: null,
fromAddress: null,
replyTo: null,
brand: null,
})
brandSenderMock.getBaseUrlForBrand.mockReturnValue('https://app.example.test')
})
it('returns 401 when unauthenticated', async () => {
@@ -228,6 +240,44 @@ describe('POST /api/salary/runs/[id]/payslips/send', () => {
expect(emailArgs.subject).toContain('Ny Firma AB')
})
it('sends the branded payslip mail: brand link base + brand sender (WL-13)', async () => {
brandSenderMock.getSenderForCompany.mockResolvedValue({
fromName: 'Siffra',
fromAddress: 'noreply@post.siffra.se',
replyTo: 'support@siffra.se',
brand: { appName: 'Siffra', domain: 'app.siffra.se' },
})
brandSenderMock.getBaseUrlForBrand.mockReturnValue('https://app.siffra.se')
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
const sendEmail = mockEmail({ success: true, messageId: 'msg-1' })
enqueueMany([
{ data: RUN },
{ data: { name: 'Bolaget AB', org_number: '5560000000' } },
{
data: [
{
employee_id: 'emp-1',
employee: { first_name: 'Anna', last_name: 'A', email: 'anna@example.test' },
},
],
},
])
const request = createMockRequest('/api/salary/runs/run-1/payslips/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
expect(response.status).toBe(200)
expect(brandSenderMock.getSenderForCompany).toHaveBeenCalledWith('company-1')
const emailArgs = sendEmail.mock.calls[0][0]
expect(emailArgs.html).toContain(`https://app.siffra.se/payslip/${'T'.repeat(43)}`)
expect(emailArgs.fromName).toBe('Siffra')
expect(emailArgs.fromAddress).toBe('noreply@post.siffra.se')
expect(emailArgs.replyTo).toBe('support@siffra.se')
})
it('records provider failures without failing the whole batch', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
@@ -3,7 +3,7 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getEmailService } from '@/lib/email/service'
import { getBranding } from '@/lib/branding/service'
import { getSenderForCompany, getBaseUrlForBrand } from '@/lib/email/brand-sender'
import { rotateLinkForEmployee } from '@/lib/salary/payslips/links'
import { buildPayslipLinkEmail } from '@/lib/salary/payslips/email-template'
import { getCompanyDisplayName } from '@/lib/company/context'
@@ -77,7 +77,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
return errorResponseFromCode('SALARY_PAYSLIPS_NO_EMPLOYEES', log, { requestId })
}
const appUrl = getBranding().appUrl
// Brand mail (WL-13): payslip links and the sender identity follow the
// company's brand; no brand = canonical URL and platform sender as before.
const sender = await getSenderForCompany(companyId)
const appUrl = getBaseUrlForBrand(sender.brand)
let sent = 0
let skipped = 0
@@ -128,6 +131,9 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
subject: email.subject,
html: email.html,
text: email.text,
fromName: sender.fromName ?? undefined,
fromAddress: sender.fromAddress ?? undefined,
replyTo: sender.replyTo ?? undefined,
})
if (!sendResult.success) {
+184 -3
View File
@@ -1,11 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { NextRequest, NextResponse } from 'next/server'
import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
// team/accept uses the service client for all DB work (invite acceptance can
// run before the user has any company membership). requireAuth only gates the
// caller's identity + MFA.
const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase()
const { supabase: serviceSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
@@ -20,7 +20,7 @@ vi.mock('@/lib/auth/invite-tokens', () => ({
hashInviteToken: (t: string) => `hash-${t}`,
}))
import { POST } from '../route'
import { GET, POST } from '../route'
const mockUser = { id: 'user-1', email: 'invitee@test.se' }
@@ -101,3 +101,184 @@ describe('POST /api/team/accept', () => {
expect(body.error).toContain('matchar inte')
})
})
// WL-08 invite unfreeze: the same token endpoint accepts byrå-team
// invitations. from() consumption order for the team path:
// 1. company_invitations lookup (miss)
// 2. team_invitations lookup
// 3. team_members insert
// 4. user_preferences read (active company?)
// 5. companies read (first team company) [only when no active company]
// 6. user_preferences upsert [only when a company was found]
// 7. team_invitations update -> accepted
describe('POST /api/team/accept (byrå-team invitations)', () => {
const future = new Date(Date.now() + 86_400_000).toISOString()
const teamInvite = (overrides: Record<string, unknown> = {}) => ({
id: 'tinv-1',
team_id: 'team-byra',
email: 'invitee@test.se',
role: 'member',
status: 'pending',
expires_at: future,
teams: { name: 'Siffran AB', kind: 'byra' },
...overrides,
})
it('accepts a valid byrå invite and points a company-less user at a team company', async () => {
enqueue({ data: null, error: { code: 'PGRST116' } }) // no company invite
enqueue({ data: teamInvite() })
enqueue({ error: null }) // team_members insert
enqueue({ data: null }) // user_preferences read: no active company
enqueue({ data: { id: 'company-9' } }) // first team company
enqueue({ error: null }) // user_preferences upsert
enqueue({ error: null }) // invitation -> accepted
const res = await POST(makeReq({ token: 'abc' }))
const { status, body } = await parseJsonResponse<{
data: { type: string; teamId: string; teamName: string }
}>(res)
expect(status).toBe(200)
expect(body.data).toEqual({ type: 'team', teamId: 'team-byra', teamName: 'Siffran AB' })
// The membership row is what triggers the DB-side company sync.
const inserts = findCalls('team_members', 'insert')
expect(inserts).toHaveLength(1)
expect(inserts[0]![0]).toEqual({
team_id: 'team-byra',
user_id: 'user-1',
role: 'member',
})
expect(findCalls('user_preferences', 'upsert')).toHaveLength(1)
})
it('never hijacks an existing active company', async () => {
enqueue({ data: null, error: { code: 'PGRST116' } })
enqueue({ data: teamInvite() })
enqueue({ error: null }) // team_members insert
enqueue({ data: { active_company_id: 'my-own-firma' } }) // has active company
enqueue({ error: null }) // invitation -> accepted
const res = await POST(makeReq({ token: 'abc' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(200)
expect(findCalls('user_preferences', 'upsert')).toHaveLength(0)
})
it('never mints a team owner from an invitation row', async () => {
enqueue({ data: null, error: { code: 'PGRST116' } })
enqueue({ data: teamInvite({ role: 'owner' }) }) // hand-edited row
enqueue({ error: null })
enqueue({ data: { active_company_id: 'my-own-firma' } })
enqueue({ error: null })
const res = await POST(makeReq({ token: 'abc' }))
expect(res.status).toBe(200)
const inserts = findCalls('team_members', 'insert')
expect((inserts[0]![0] as { role: string }).role).toBe('member')
})
it('rejects a personal-team invitation token as invalid (kind gate)', async () => {
enqueue({ data: null, error: { code: 'PGRST116' } })
enqueue({ data: teamInvite({ teams: { name: 'Personal', kind: 'personal' } }) })
const res = await POST(makeReq({ token: 'abc' }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(400)
expect(body.error).toBe('Inbjudan är ogiltig.')
})
it('returns 403 when the team invite email does not match the user', async () => {
enqueue({ data: null, error: { code: 'PGRST116' } })
enqueue({ data: teamInvite({ email: 'someone-else@test.se' }) })
const res = await POST(makeReq({ token: 'abc' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(403)
})
it('returns 410 and marks an expired team invite', async () => {
const past = new Date(Date.now() - 86_400_000).toISOString()
enqueue({ data: null, error: { code: 'PGRST116' } })
enqueue({ data: teamInvite({ expires_at: past }) })
enqueue({ error: null }) // invitation -> expired
const res = await POST(makeReq({ token: 'abc' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(410)
const updates = findCalls('team_invitations', 'update')
expect(updates[0]![0]).toEqual({ status: 'expired' })
})
it('returns 409 when already a team member', async () => {
enqueue({ data: null, error: { code: 'PGRST116' } })
enqueue({ data: teamInvite() })
enqueue({ error: { code: '23505' } }) // duplicate membership
const res = await POST(makeReq({ token: 'abc' }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(409)
expect(body.error).toBe('Du är redan medlem.')
})
})
describe('GET /api/team/accept (byrå-team invitations)', () => {
function makeGetReq(token: string) {
return new NextRequest(`http://localhost/api/team/accept?token=${token}`)
}
it('returns team invite info with companyName doubling as the team name', async () => {
const future = new Date(Date.now() + 86_400_000).toISOString()
enqueue({ data: null, error: { code: 'PGRST116' } }) // no company invite
enqueue({
data: {
id: 'tinv-1',
team_id: 'team-byra',
email: 'invitee@test.se',
role: 'member',
status: 'pending',
expires_at: future,
teams: { name: 'Siffran AB', kind: 'byra' },
},
})
enqueue({ data: true }) // rpc check_email_exists
const res = await GET(makeGetReq('abc'))
const { status, body } = await parseJsonResponse<{
data: {
type: string
companyName: string
teamName: string
email: string
expired: boolean
alreadyHasAccount: boolean
}
}>(res)
expect(status).toBe(200)
expect(body.data.type).toBe('team')
expect(body.data.teamName).toBe('Siffran AB')
// The invite page renders companyName as "what you are joining".
expect(body.data.companyName).toBe('Siffran AB')
expect(body.data.expired).toBe(false)
expect(body.data.alreadyHasAccount).toBe(true)
})
it('treats a personal-team invitation token as not found', async () => {
enqueue({ data: null, error: { code: 'PGRST116' } })
enqueue({
data: {
id: 'tinv-1',
team_id: 'team-p',
email: 'invitee@test.se',
role: 'member',
status: 'pending',
expires_at: new Date(Date.now() + 86_400_000).toISOString(),
teams: { name: 'Personal', kind: 'personal' },
},
})
const res = await GET(makeGetReq('abc'))
const { status } = await parseJsonResponse(res)
expect(status).toBe(404)
})
})
+172 -9
View File
@@ -3,10 +3,22 @@ import { NextResponse, type NextRequest } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { hashInviteToken } from '@/lib/auth/invite-tokens'
interface TeamInviteRow {
id: string
team_id: string
email: string
role: string
status: string
expires_at: string
teams: { name: string; kind: string } | null
}
/**
* GET /api/team/accept?token=xxx
* Validates an invite token and returns invite info (for the invite page).
* Only company invitations are supported: team invitations are disabled.
* Handles both company invitations and byrå-team invitations (WL-08 invite
* unfreeze). Team invitations resolve only for teams with kind='byra':
* personal teams are uninvitable, so a token pointing at one is invalid.
* No auth required: this is a public endpoint.
*/
export async function GET(request: NextRequest) {
@@ -24,10 +36,7 @@ export async function GET(request: NextRequest) {
.eq('token_hash', tokenHash)
.single()
if (!companyInvite) {
return NextResponse.json({ error: 'Inbjudan hittades inte eller är ogiltig.' }, { status: 404 })
}
if (companyInvite) {
if (companyInvite.status !== 'pending') {
return NextResponse.json({ error: 'Inbjudan har redan använts.' }, { status: 410 })
}
@@ -49,10 +58,55 @@ export async function GET(request: NextRequest) {
})
}
// No company invitation for this token: try byrå-team invitations.
const { data: teamInviteRaw } = await serviceClient
.from('team_invitations')
.select('id, team_id, email, role, status, expires_at, teams:team_id(name, kind)')
.eq('token_hash', tokenHash)
.single()
const teamInvite = teamInviteRaw as unknown as TeamInviteRow | null
// Kind gate: invitations exist for byrå teams only. A personal-team token
// (or a team whose kind was reverted after issue) is indistinguishable from
// an invalid token on purpose.
if (!teamInvite || teamInvite.teams?.kind !== 'byra') {
return NextResponse.json({ error: 'Inbjudan hittades inte eller är ogiltig.' }, { status: 404 })
}
if (teamInvite.status !== 'pending') {
return NextResponse.json({ error: 'Inbjudan har redan använts.' }, { status: 410 })
}
const expired = new Date(teamInvite.expires_at) < new Date()
const { data: alreadyHasAccount } = await serviceClient.rpc('check_email_exists', {
email_to_check: teamInvite.email,
})
const teamName = teamInvite.teams?.name || 'Team'
return NextResponse.json({
data: {
type: 'team',
// companyName doubles as "what you are joining" for the invite page,
// which renders it for every invite type: kept for compatibility.
companyName: teamName,
teamName,
email: teamInvite.email,
expired,
alreadyHasAccount,
},
})
}
/**
* POST /api/team/accept
* Accepts a company invite after the user has signed up.
* Team invitations are disabled: teams are single-user.
* Accepts a company or byrå-team invite after the user has signed up.
*
* Team acceptance inserts a team_members row; the DB sync trigger
* (sync_team_member_to_companies) then grants membership in every company
* attached to the team, so no company_members writes happen here.
*/
export async function POST(request: NextRequest) {
const { user, error } = await requireAuth()
@@ -73,11 +127,120 @@ export async function POST(request: NextRequest) {
.eq('token_hash', tokenHash)
.single()
if (companyLookupError) {
if (companyLookupError && companyLookupError.code !== 'PGRST116') {
console.error('[team/accept] company lookup error:', companyLookupError.message)
}
if (!companyInvite || companyInvite.status !== 'pending') {
if (companyInvite) {
return acceptCompanyInvite(serviceClient, user, companyInvite)
}
// No company invitation for this token: try byrå-team invitations.
const { data: teamInviteRaw } = await serviceClient
.from('team_invitations')
.select('id, team_id, email, role, status, expires_at, teams:team_id(name, kind)')
.eq('token_hash', tokenHash)
.single()
const teamInvite = teamInviteRaw as unknown as TeamInviteRow | null
// Kind gate mirrors GET: byrå teams only; anything else is an invalid token.
if (!teamInvite || teamInvite.teams?.kind !== 'byra' || teamInvite.status !== 'pending') {
return NextResponse.json({ error: 'Inbjudan är ogiltig.' }, { status: 400 })
}
if (new Date(teamInvite.expires_at) < new Date()) {
await serviceClient
.from('team_invitations')
.update({ status: 'expired' })
.eq('id', teamInvite.id)
return NextResponse.json({ error: 'Inbjudan har gått ut.' }, { status: 410 })
}
if (user.email?.toLowerCase() !== teamInvite.email.toLowerCase()) {
return NextResponse.json({ error: 'E-postadressen matchar inte inbjudan.' }, { status: 403 })
}
// Invitations never mint team owners (the invite route's schema already
// forbids it; re-checked here against hand-edited rows).
const memberRole = teamInvite.role === 'admin' ? 'admin' : 'member'
const { error: memberError } = await serviceClient
.from('team_members')
.insert({
team_id: teamInvite.team_id,
user_id: user.id,
role: memberRole,
})
if (memberError) {
if (memberError.code === '23505') {
return NextResponse.json({ error: 'Du är redan medlem.' }, { status: 409 })
}
return NextResponse.json({ error: 'Kunde inte lägga till medlem.' }, { status: 500 })
}
// Point a company-less user at one of the team's companies so their first
// dashboard load resolves. A consultant with their own firma keeps their
// active company untouched: joining a byrå must never hijack the context.
const { data: prefs } = await serviceClient
.from('user_preferences')
.select('active_company_id')
.eq('user_id', user.id)
.maybeSingle()
if (!(prefs as { active_company_id: string | null } | null)?.active_company_id) {
const { data: firstCompany } = await serviceClient
.from('companies')
.select('id')
.eq('team_id', teamInvite.team_id)
.is('archived_at', null)
.order('created_at', { ascending: true })
.limit(1)
.maybeSingle()
if (firstCompany) {
const { error: prefError } = await serviceClient
.from('user_preferences')
.upsert({
user_id: user.id,
active_company_id: (firstCompany as { id: string }).id,
}, { onConflict: 'user_id' })
if (prefError) {
console.error('[team/accept] failed to set active company', prefError)
}
}
}
// Mark invite as accepted
await serviceClient
.from('team_invitations')
.update({ status: 'accepted' })
.eq('id', teamInvite.id)
return NextResponse.json({
data: {
type: 'team',
teamId: teamInvite.team_id,
teamName: teamInvite.teams?.name ?? null,
},
})
}
/** The pre-existing company-invite acceptance flow, unchanged. */
async function acceptCompanyInvite(
serviceClient: ReturnType<typeof createServiceClient>,
user: { id: string; email?: string | null },
companyInvite: {
id: string
company_id: string
email: string
role: string
status: string
expires_at: string
},
) {
if (companyInvite.status !== 'pending') {
return NextResponse.json({ error: 'Inbjudan är ogiltig.' }, { status: 400 })
}
@@ -0,0 +1,246 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { parseJsonResponse, createQueuedMockSupabase, createMockRouteParams } from '@/tests/helpers'
// DELETE /api/team/invite/[id] (WL-08 invite unfreeze): revoking a pending
// byrå-team invitation, gated on team kind and owner/admin role.
const { supabase: serviceSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceSupabase,
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/auth/invite-tokens', () => ({
generateInviteToken: () => ({ token: 'gnubok_inv_fresh-token', hash: 'hash-fresh-token' }),
getInviteExpiry: () => new Date('2099-01-08T00:00:00Z'),
}))
// The brand/sender/template wiring inside the helper is covered for real by
// the POST /api/team/invite tests; here it is mocked so the resend tests
// exercise only the route's own gates and writes.
const sendTeamInviteMailMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/email/send-team-invite', () => ({
sendTeamInviteMail: sendTeamInviteMailMock,
}))
import { DELETE, POST } from '../route'
const mockUser = { id: 'user-1', email: 'admin@byra.se' }
const req = new Request('http://localhost/api/team/invite/invite-1', {
method: 'DELETE',
}) as never
const routeParams = createMockRouteParams({ id: 'invite-1' })
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: serviceSupabase, error: null })
sendTeamInviteMailMock.mockResolvedValue({
inviteUrl: 'https://app.gnubok.se/invite/gnubok_inv_fresh-token',
emailSent: true,
})
})
describe('DELETE /api/team/invite/[id]', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: serviceSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await DELETE(req, routeParams)
expect(res.status).toBe(401)
})
it('returns 404 when the invitation does not exist', async () => {
enqueue({ data: null })
const res = await DELETE(req, routeParams)
const { status } = await parseJsonResponse(res)
expect(status).toBe(404)
})
it('returns 403 with the legacy message for a personal-team invitation', async () => {
enqueue({
data: { id: 'invite-1', team_id: 'team-p', status: 'pending', teams: { kind: 'personal' } },
})
const res = await DELETE(req, routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(403)
expect(body.error).toBe('Teaminbjudningar är inaktiverade.')
})
it('returns 403 when the caller is a plain team member', async () => {
enqueue({
data: { id: 'invite-1', team_id: 'team-b', status: 'pending', teams: { kind: 'byra' } },
})
enqueue({ data: { role: 'member' } })
const res = await DELETE(req, routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(403)
expect(body.error).toBe('Behörighet saknas.')
})
it('returns 409 when the invitation was already used', async () => {
enqueue({
data: { id: 'invite-1', team_id: 'team-b', status: 'accepted', teams: { kind: 'byra' } },
})
enqueue({ data: { role: 'owner' } })
const res = await DELETE(req, routeParams)
const { status } = await parseJsonResponse(res)
expect(status).toBe(409)
})
it('revokes a pending invitation for a byrå admin', async () => {
enqueue({
data: { id: 'invite-1', team_id: 'team-b', status: 'pending', teams: { kind: 'byra' } },
})
enqueue({ data: { role: 'admin' } })
// team_invitations update
enqueue({ data: null })
const res = await DELETE(req, routeParams)
const { status, body } = await parseJsonResponse<{ data: { id: string; status: string } }>(res)
expect(status).toBe(200)
expect(body.data).toEqual({ id: 'invite-1', status: 'revoked' })
const updates = findCalls('team_invitations', 'update')
expect(updates).toHaveLength(1)
expect(updates[0]![0]).toEqual({ status: 'revoked' })
})
})
// POST /api/team/invite/[id]: re-send a pending byrå-team invitation with a
// fresh token and expiry (the recovery path when the original mail was lost).
const postReq = new Request('http://localhost/api/team/invite/invite-1', {
method: 'POST',
}) as never
const pendingInvite = {
id: 'invite-1',
team_id: 'team-b',
email: 'konsult@byra.se',
role: 'admin',
status: 'pending',
teams: { kind: 'byra' },
}
describe('POST /api/team/invite/[id]', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: serviceSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await POST(postReq, routeParams)
expect(res.status).toBe(401)
})
it('returns 404 when the invitation does not exist', async () => {
enqueue({ data: null })
const res = await POST(postReq, routeParams)
const { status } = await parseJsonResponse(res)
expect(status).toBe(404)
})
it('returns 403 with the legacy message for a personal-team invitation', async () => {
enqueue({ data: { ...pendingInvite, teams: { kind: 'personal' } } })
const res = await POST(postReq, routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(403)
expect(body.error).toBe('Teaminbjudningar är inaktiverade.')
})
it('returns 403 when the caller is a plain team member', async () => {
enqueue({ data: pendingInvite })
enqueue({ data: { role: 'member' } })
const res = await POST(postReq, routeParams)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(403)
expect(body.error).toBe('Behörighet saknas.')
expect(sendTeamInviteMailMock).not.toHaveBeenCalled()
})
it('returns 409 when the invitation was already used', async () => {
enqueue({ data: { ...pendingInvite, status: 'accepted' } })
enqueue({ data: { role: 'owner' } })
const res = await POST(postReq, routeParams)
const { status } = await parseJsonResponse(res)
expect(status).toBe(409)
expect(sendTeamInviteMailMock).not.toHaveBeenCalled()
})
it('re-issues the token and re-sends the mail for a byrå admin', async () => {
enqueue({ data: pendingInvite })
enqueue({ data: { role: 'admin' } })
// team_invitations update (token, invited_by, expiry)
enqueue({ data: null })
const res = await POST(postReq, routeParams)
const { status, body } = await parseJsonResponse<{
data: {
id: string
teamId: string
email: string
role: string
status: string
email_sent: boolean
inviteUrl: string
}
}>(res)
expect(status).toBe(200)
expect(body.data.id).toBe('invite-1')
expect(body.data.teamId).toBe('team-b')
expect(body.data.email).toBe('konsult@byra.se')
expect(body.data.role).toBe('admin')
expect(body.data.status).toBe('pending')
expect(body.data.email_sent).toBe(true)
expect(body.data.inviteUrl).toBe('https://app.gnubok.se/invite/gnubok_inv_fresh-token')
// A fresh token hash replaces the old one (invalidating any mailed link),
// and the raw token never touches the DB write.
const updates = findCalls('team_invitations', 'update')
expect(updates).toHaveLength(1)
expect(updates[0]![0]).toMatchObject({
token_hash: 'hash-fresh-token',
invited_by: 'user-1',
expires_at: '2099-01-08T00:00:00.000Z',
})
expect(sendTeamInviteMailMock).toHaveBeenCalledWith({
teamId: 'team-b',
email: 'konsult@byra.se',
inviterEmail: 'admin@byra.se',
token: 'gnubok_inv_fresh-token',
})
})
it('keeps the invitation valid and returns the link when the mail send fails', async () => {
sendTeamInviteMailMock.mockResolvedValue({
inviteUrl: 'https://app.gnubok.se/invite/gnubok_inv_fresh-token',
emailSent: false,
})
enqueue({ data: pendingInvite })
enqueue({ data: { role: 'owner' } })
enqueue({ data: null })
const res = await POST(postReq, routeParams)
const { status, body } = await parseJsonResponse<{
data: { email_sent: boolean; inviteUrl: string }
}>(res)
expect(status).toBe(200)
expect(body.data.email_sent).toBe(false)
expect(body.data.inviteUrl).toContain('/invite/gnubok_inv_fresh-token')
})
})
+172 -7
View File
@@ -1,12 +1,177 @@
import { NextResponse } from 'next/server'
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse, type NextRequest } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireAuth } from '@/lib/auth/require-auth'
import { generateInviteToken, getInviteExpiry } from '@/lib/auth/invite-tokens'
import { sendTeamInviteMail } from '@/lib/email/send-team-invite'
// Loads the email extension so the re-send path gets the Resend
// implementation instead of the noop default (same as POST /api/team/invite).
ensureInitialized()
/**
* Kept verbatim from the pre-unfreeze hardcoded 403: personal teams remain
* uninvitable (WL-08), so their (theoretical) invitations stay untouchable.
*/
const PERSONAL_TEAM_MESSAGE = 'Teaminbjudningar är inaktiverade.'
interface InviteRow {
id: string
team_id: string
email: string
role: string
status: string
teams: { kind: string } | null
}
/**
* Shared gate chain for acting on an existing invitation: it must exist, its
* team must be kind='byra' (personal-team invitations are frozen surface),
* and the caller must be owner/admin of that team. Returns the invitation row
* or the error response to bubble.
*/
async function loadInviteForManagement(
serviceClient: ReturnType<typeof createServiceClient>,
inviteId: string,
userId: string,
): Promise<{ invite: InviteRow } | { response: NextResponse }> {
const { data: invitation } = await serviceClient
.from('team_invitations')
.select('id, team_id, email, role, status, teams:team_id(kind)')
.eq('id', inviteId)
.maybeSingle()
const invite = invitation as unknown as InviteRow | null
if (!invite) {
return { response: NextResponse.json({ error: 'Inbjudan hittades inte.' }, { status: 404 }) }
}
// Kind gate first: personal-team invitations are frozen surface, byrå only.
if (invite.teams?.kind !== 'byra') {
return { response: NextResponse.json({ error: PERSONAL_TEAM_MESSAGE }, { status: 403 }) }
}
// Role gate: the caller must be owner/admin of the invitation's team.
const { data: membership } = await serviceClient
.from('team_members')
.select('role')
.eq('team_id', invite.team_id)
.eq('user_id', userId)
.maybeSingle()
const callerRole = (membership as { role: string } | null)?.role
if (!callerRole || !['owner', 'admin'].includes(callerRole)) {
return { response: NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 }) }
}
return { invite }
}
/**
* POST /api/team/invite/[id]
* Re-send a pending byrå-team invitation (WL-08 follow-up: the Arbore case,
* where the original mail never reached the invitee and the inviter had no
* recovery path).
*
* Same gates as DELETE. Only pending invitations can be re-sent (an expired
* pending one is revived: the token and expiry are re-issued in place, which
* also invalidates any previously mailed link). The response mirrors POST
* /api/team/invite so the client gets a fresh shareable inviteUrl regardless
* of whether the mail went out.
*/
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { user, error } = await requireAuth()
if (error) return error
const { id } = await params
const serviceClient = createServiceClient()
const loaded = await loadInviteForManagement(serviceClient, id, user.id)
if ('response' in loaded) return loaded.response
const { invite } = loaded
if (invite.status !== 'pending') {
return NextResponse.json({ error: 'Inbjudan har redan använts.' }, { status: 409 })
}
const { token, hash } = generateInviteToken()
const expiresAt = getInviteExpiry()
const { error: updateError } = await serviceClient
.from('team_invitations')
.update({
token_hash: hash,
invited_by: user.id,
expires_at: expiresAt.toISOString(),
})
.eq('id', invite.id)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte skicka om inbjudan.' }, { status: 500 })
}
const { inviteUrl, emailSent } = await sendTeamInviteMail({
teamId: invite.team_id,
email: invite.email,
inviterEmail: user.email || '',
token,
})
return NextResponse.json({
data: {
id: invite.id,
teamId: invite.team_id,
email: invite.email,
role: invite.role,
status: 'pending',
expires_at: expiresAt.toISOString(),
email_sent: emailSent,
// Always returned so the inviter can share the link directly when the
// mail did not go out (or never arrives).
inviteUrl,
},
})
}
/**
* DELETE /api/team/invite/[id]
* Team-level invitations are disabled.
* Revoke a pending byrå-team invitation (WL-08 invite unfreeze).
*
* Same gates as POST /api/team/invite: the invitation's team must be
* kind='byra' and the caller must be team owner or admin. Revocation is a
* status flip (not a row delete) so the (team, email) unique pair keeps its
* history and a later re-invite reuses the row.
*/
export async function DELETE() {
return NextResponse.json(
{ error: 'Teaminbjudningar är inaktiverade.' },
{ status: 403 }
)
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { user, error } = await requireAuth()
if (error) return error
const { id } = await params
const serviceClient = createServiceClient()
const loaded = await loadInviteForManagement(serviceClient, id, user.id)
if ('response' in loaded) return loaded.response
const { invite } = loaded
if (invite.status !== 'pending') {
return NextResponse.json({ error: 'Inbjudan har redan använts.' }, { status: 409 })
}
const { error: revokeError } = await serviceClient
.from('team_invitations')
.update({ status: 'revoked' })
.eq('id', invite.id)
if (revokeError) {
return NextResponse.json({ error: 'Kunde inte återkalla inbjudan.' }, { status: 500 })
}
return NextResponse.json({ data: { id: invite.id, status: 'revoked' } })
}
+326
View File
@@ -0,0 +1,326 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
// POST /api/team/invite (WL-08 invite unfreeze): invites are allowed ONLY for
// byrå teams (kind='byra') and only by team owner/admin. Personal teams keep
// the legacy 403 message.
const { supabase: serviceSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceSupabase,
}))
vi.mock('@/lib/auth/invite-tokens', () => ({
generateInviteToken: () => ({ token: 'gnubok_inv_test-token', hash: 'hash-test-token' }),
getInviteExpiry: () => new Date('2099-01-08T00:00:00Z'),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const sendEmailMock = vi.fn()
const isConfiguredMock = vi.fn()
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({ isConfigured: isConfiguredMock, sendEmail: sendEmailMock }),
}))
// The brand resolver is mocked (no DB); brand-sender and the templates run
// for real so the test covers the actual sender/link wiring.
const resolveBrandForTeamMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/branding/resolve', () => ({
resolveBrandForTeam: resolveBrandForTeamMock,
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appName: 'Accounted', appUrl: 'https://app.gnubok.se' }),
}))
import { POST } from '../route'
const SIFFRA_BRAND = {
id: 'brand-1',
teamId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
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',
}
const mockUser = { id: 'user-1', email: 'admin@byra.se' }
const BYRA_TEAM_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const PERSONAL_TEAM_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const UNKNOWN_TEAM_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
function makeReq(body: unknown) {
return new Request('http://localhost/api/team/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}) as never
}
const byraMembership = (role: string) => ({
team_id: BYRA_TEAM_ID,
role,
teams: { id: BYRA_TEAM_ID, name: 'Siffran AB', kind: 'byra', created_at: '2026-01-01T00:00:00Z' },
})
const personalMembership = () => ({
team_id: PERSONAL_TEAM_ID,
role: 'owner',
teams: {
id: PERSONAL_TEAM_ID,
name: 'Personal',
kind: 'personal',
created_at: '2026-01-02T00:00:00Z',
},
})
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: serviceSupabase, error: null })
resolveBrandForTeamMock.mockResolvedValue(null)
isConfiguredMock.mockReturnValue(true)
sendEmailMock.mockResolvedValue({ success: true, messageId: 'msg-1' })
})
describe('POST /api/team/invite', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: serviceSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await POST(makeReq({ email: 'konsult@byra.se' }))
expect(res.status).toBe(401)
})
it('returns 400 on an invalid email', async () => {
const res = await POST(makeReq({ email: 'not-an-email' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns 403 with the legacy message for a personal-team-only user', async () => {
enqueue({ data: [personalMembership()] })
const res = await POST(makeReq({ email: 'konsult@byra.se' }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(403)
expect(body.error).toBe('Teaminbjudningar är inaktiverade. Bjud in via enskilda företag.')
})
it('returns 403 with the legacy message when explicitly targeting the personal team', async () => {
enqueue({ data: [personalMembership(), byraMembership('owner')] })
const res = await POST(makeReq({ email: 'konsult@byra.se', teamId: PERSONAL_TEAM_ID }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(403)
expect(body.error).toBe('Teaminbjudningar är inaktiverade. Bjud in via enskilda företag.')
})
it('returns 404 for a teamId outside the caller memberships', async () => {
enqueue({ data: [personalMembership(), byraMembership('owner')] })
const res = await POST(makeReq({ email: 'konsult@byra.se', teamId: UNKNOWN_TEAM_ID }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(404)
})
it('returns 403 when the caller is a plain team member', async () => {
enqueue({ data: [byraMembership('member'), personalMembership()] })
const res = await POST(makeReq({ email: 'konsult@byra.se' }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(403)
expect(body.error).toBe('Behörighet saknas.')
})
it('creates an invitation and returns the accept link for a byrå admin', async () => {
// 1. caller memberships (personal + byrå: multi-team is the normal shape)
enqueue({ data: [personalMembership(), byraMembership('admin')] })
// 2. profiles lookup (invitee has no account yet)
enqueue({ data: null })
// 3. existing invitation lookup
enqueue({ data: null })
// 4. team_invitations insert
enqueue({ data: { id: 'invite-1' } })
const res = await POST(makeReq({ email: 'Konsult@Byra.se', role: 'member' }))
const { status, body } = await parseJsonResponse<{
data: {
id: string
teamId: string
email: string
role: string
status: string
email_sent: boolean
inviteUrl: string
}
}>(res)
expect(status).toBe(200)
expect(body.data.id).toBe('invite-1')
expect(body.data.teamId).toBe(BYRA_TEAM_ID)
// Email lowercased by the schema
expect(body.data.email).toBe('konsult@byra.se')
expect(body.data.role).toBe('member')
expect(body.data.status).toBe('pending')
// Brandless team: mail goes out with platform defaults on the canonical URL.
expect(body.data.email_sent).toBe(true)
expect(body.data.inviteUrl).toBe('https://app.gnubok.se/invite/gnubok_inv_test-token')
expect(sendEmailMock).toHaveBeenCalledTimes(1)
const mail = sendEmailMock.mock.calls[0]![0] as {
to: string
subject: string
html: string
fromName?: string
fromAddress?: string
replyTo?: string
}
expect(mail.to).toBe('konsult@byra.se')
expect(mail.fromName).toBeUndefined()
expect(mail.fromAddress).toBeUndefined()
expect(mail.html).toContain('https://app.gnubok.se/invite/gnubok_inv_test-token')
// The insert went to team_invitations with the hashed token, never the raw.
const inserts = findCalls('team_invitations', 'insert')
expect(inserts).toHaveLength(1)
expect(inserts[0]![0]).toMatchObject({
team_id: BYRA_TEAM_ID,
email: 'konsult@byra.se',
role: 'member',
token_hash: 'hash-test-token',
status: 'pending',
})
})
it('sends the invite mail in the byrå brand: brand sender, brand-domain link', async () => {
resolveBrandForTeamMock.mockResolvedValue(SIFFRA_BRAND)
enqueue({ data: [byraMembership('admin')] })
enqueue({ data: null })
enqueue({ data: null })
enqueue({ data: { id: 'invite-1' } })
const res = await POST(makeReq({ email: 'konsult@byra.se' }))
const { status, body } = await parseJsonResponse<{
data: { email_sent: boolean; inviteUrl: string }
}>(res)
expect(status).toBe(200)
expect(resolveBrandForTeamMock).toHaveBeenCalledWith(BYRA_TEAM_ID)
expect(body.data.email_sent).toBe(true)
expect(body.data.inviteUrl).toBe('https://app.siffra.se/invite/gnubok_inv_test-token')
const mail = sendEmailMock.mock.calls[0]![0] as {
subject: string
html: string
fromName?: string
fromAddress?: string
replyTo?: string
}
expect(mail.fromName).toBe('Siffra')
expect(mail.fromAddress).toBe('noreply@post.siffra.se')
expect(mail.replyTo).toBe('support@siffra.se')
// Branded byrå: the subject names the byrå in its own casing, with no
// platform wording ("Du har blivit inbjuden till Siffra").
expect(mail.subject).toContain('Siffra')
expect(mail.html).toContain('https://app.siffra.se/invite/gnubok_inv_test-token')
expect(mail.html).not.toMatch(/accounted/i)
})
it('keeps the invitation valid when the mail send fails (email_sent: false)', async () => {
sendEmailMock.mockResolvedValue({ success: false, error: 'provider down' })
enqueue({ data: [byraMembership('owner')] })
enqueue({ data: null })
enqueue({ data: null })
enqueue({ data: { id: 'invite-1' } })
const res = await POST(makeReq({ email: 'konsult@byra.se' }))
const { status, body } = await parseJsonResponse<{
data: { id: string; email_sent: boolean; inviteUrl: string }
}>(res)
expect(status).toBe(200)
expect(body.data.id).toBe('invite-1')
expect(body.data.email_sent).toBe(false)
// The accept link is still returned so the inviter can share it directly.
expect(body.data.inviteUrl).toContain('/invite/gnubok_inv_test-token')
})
it('skips the mail when the email service is not configured', async () => {
isConfiguredMock.mockReturnValue(false)
enqueue({ data: [byraMembership('owner')] })
enqueue({ data: null })
enqueue({ data: null })
enqueue({ data: { id: 'invite-1' } })
const res = await POST(makeReq({ email: 'konsult@byra.se' }))
const { status, body } = await parseJsonResponse<{
data: { email_sent: boolean }
}>(res)
expect(status).toBe(200)
expect(body.data.email_sent).toBe(false)
expect(sendEmailMock).not.toHaveBeenCalled()
})
it('returns 409 when the invitee is already a team member', async () => {
enqueue({ data: [byraMembership('owner')] })
// profiles lookup -> existing account
enqueue({ data: { id: 'user-2' } })
// team_members lookup -> already a member
enqueue({ data: { id: 'tm-2' } })
const res = await POST(makeReq({ email: 'konsult@byra.se' }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(409)
expect(body.error).toBe('Denna person är redan medlem.')
})
it('returns 409 when a live pending invitation already exists', async () => {
enqueue({ data: [byraMembership('owner')] })
enqueue({ data: null })
enqueue({
data: { id: 'invite-1', status: 'pending', expires_at: '2099-01-01T00:00:00Z' },
})
const res = await POST(makeReq({ email: 'konsult@byra.se' }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(409)
})
it('re-issues a revoked invitation in place', async () => {
enqueue({ data: [byraMembership('owner')] })
enqueue({ data: null })
enqueue({
data: { id: 'invite-1', status: 'revoked', expires_at: '2099-01-01T00:00:00Z' },
})
// team_invitations update
enqueue({ data: { id: 'invite-1' } })
const res = await POST(makeReq({ email: 'konsult@byra.se', role: 'admin' }))
const { status, body } = await parseJsonResponse<{ data: { id: string; role: string } }>(res)
expect(status).toBe(200)
expect(body.data.id).toBe('invite-1')
expect(body.data.role).toBe('admin')
const updates = findCalls('team_invitations', 'update')
expect(updates).toHaveLength(1)
expect(updates[0]![0]).toMatchObject({ status: 'pending', token_hash: 'hash-test-token' })
})
})
+209 -6
View File
@@ -1,13 +1,216 @@
import { NextResponse } from 'next/server'
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse, type NextRequest } from 'next/server'
import { z } from 'zod'
import { ensureInitialized } from '@/lib/init'
import { requireAuth } from '@/lib/auth/require-auth'
import { validateBody } from '@/lib/api/validate'
import { generateInviteToken, getInviteExpiry } from '@/lib/auth/invite-tokens'
import { sendTeamInviteMail } from '@/lib/email/send-team-invite'
// Loads the email extension so getEmailService() returns the Resend
// implementation instead of the noop default (same reason as the company
// invite route: without this the invite mail silently no-ops in a fresh
// process).
ensureInitialized()
/**
* Kept verbatim from the pre-unfreeze hardcoded 403: personal teams remain
* uninvitable (WL-08), and this is the message their members still see.
*/
const PERSONAL_TEAM_MESSAGE = 'Teaminbjudningar är inaktiverade. Bjud in via enskilda företag.'
const InviteSchema = z.object({
email: z.string().trim().toLowerCase().pipe(z.string().email('Ogiltig e-postadress.')),
// Team invitations never mint owners: the schema is the first gate, and the
// accept route re-checks (defense in depth against a hand-edited row).
role: z.enum(['admin', 'member']).default('member'),
// Optional explicit team target for users in several teams; validated
// against the caller's own memberships below.
teamId: z.string().uuid().optional(),
})
interface TeamMembershipRow {
team_id: string
role: string
teams: { id: string; name: string; kind: string; created_at: string } | null
}
/**
* POST /api/team/invite
* Team-level invitations are disabled.
* Invitations should be made at the företag (company) level instead.
* Invite a consultant to a byrå team (WL-08 invite unfreeze).
*
* Gates, in order:
* - authenticated (MFA enforced by requireAuth on hosted)
* - the target team is kind='byra' (personal teams keep the legacy 403)
* - the caller is team owner or admin
*
* Team-scoped rather than company-scoped (requireAuth, not withRouteContext):
* a byrå admin's active company is irrelevant to team membership, mirroring
* GET /api/team/members.
*
* The invitation email is sent in the byrå 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. A send
* failure never fails the invite (email_sent: false + the link is returned,
* so the inviter can always share it directly).
*/
export async function POST() {
export async function POST(request: NextRequest) {
const { user, error } = await requireAuth()
if (error) return error
const validation = await validateBody(request, InviteSchema, {
operation: 'team.invite',
})
if (!validation.success) return validation.response
const { email, role, teamId: requestedTeamId } = validation.data
const serviceClient = createServiceClient()
// All of the caller's team memberships, with the team kind: the one-team-
// per-user assumption is gone (a consultant sits in their own personal team
// AND the byrå team).
const { data: memberships } = await serviceClient
.from('team_members')
.select('team_id, role, teams:team_id(id, name, kind, created_at)')
.eq('user_id', user.id)
const rows = (memberships ?? []) as unknown as TeamMembershipRow[]
let target: TeamMembershipRow | undefined
if (requestedTeamId) {
target = rows.find((m) => m.team_id === requestedTeamId)
if (!target) {
return NextResponse.json({ error: 'Team hittades inte.' }, { status: 404 })
}
} else {
// Default target: the caller's byrå team when one exists. Deterministic
// tie-break by team creation time for the (unexpected) multi-byrå case.
target = rows
.filter((m) => m.teams?.kind === 'byra')
.sort((a, b) => (a.teams?.created_at ?? '').localeCompare(b.teams?.created_at ?? ''))[0]
}
// Kind gate: invites exist for byrå teams only. A personal-team target (or
// no byrå membership at all) gets the exact legacy message.
if (!target || target.teams?.kind !== 'byra') {
return NextResponse.json({ error: PERSONAL_TEAM_MESSAGE }, { status: 403 })
}
// Role gate: team owner/admin may invite; members may not.
if (!['owner', 'admin'].includes(target.role)) {
return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
}
const teamId = target.team_id
// Already a member? (profiles.email is lowercased like the schema output.)
const { data: profile } = await serviceClient
.from('profiles')
.select('id')
.eq('email', email)
.maybeSingle()
if (profile) {
const { data: existingMember } = await serviceClient
.from('team_members')
.select('id')
.eq('team_id', teamId)
.eq('user_id', (profile as { id: string }).id)
.maybeSingle()
if (existingMember) {
return NextResponse.json({ error: 'Denna person är redan medlem.' }, { status: 409 })
}
}
// Existing invitation for (team, email): a live pending one blocks; a
// spent or expired one is re-issued in place (unique constraint on the pair).
const { data: existingInvite } = await serviceClient
.from('team_invitations')
.select('id, status, expires_at')
.eq('team_id', teamId)
.eq('email', email)
.maybeSingle()
const existing = existingInvite as
| { id: string; status: string; expires_at: string }
| null
if (
existing &&
existing.status === 'pending' &&
new Date(existing.expires_at) > new Date()
) {
return NextResponse.json(
{ error: 'Teaminbjudningar är inaktiverade. Bjud in via enskilda företag.' },
{ status: 403 }
{ error: 'En inbjudan har redan skickats till denna e-post.' },
{ status: 409 },
)
}
const { token, hash } = generateInviteToken()
const expiresAt = getInviteExpiry()
let invitationId: string
if (existing) {
const { data: updated, error: updateError } = await serviceClient
.from('team_invitations')
.update({
role,
token_hash: hash,
invited_by: user.id,
status: 'pending',
expires_at: expiresAt.toISOString(),
})
.eq('id', existing.id)
.select('id')
.single()
if (updateError || !updated) {
return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 })
}
invitationId = (updated as { id: string }).id
} else {
const { data: inserted, error: insertError } = await serviceClient
.from('team_invitations')
.insert({
team_id: teamId,
email,
role,
token_hash: hash,
invited_by: user.id,
status: 'pending',
expires_at: expiresAt.toISOString(),
})
.select('id')
.single()
if (insertError || !inserted) {
return NextResponse.json({ error: 'Kunde inte skapa inbjudan.' }, { status: 500 })
}
invitationId = (inserted as { id: string }).id
}
// Brand mail (WL-13) via the shared helper: the byrå team's brand drives
// sender identity and the accept link's base URL; a brandless team gets the
// canonical URL and the platform sender exactly as before. Non-blocking: a
// failed send leaves the invitation valid and surfaces email_sent: false so
// the inviter knows to share the link directly.
const { inviteUrl, emailSent } = await sendTeamInviteMail({
teamId,
email,
inviterEmail: user.email || '',
token,
})
return NextResponse.json({
data: {
id: invitationId,
teamId,
email,
role,
status: 'pending',
expires_at: expiresAt.toISOString(),
email_sent: emailSent,
// The accept link is always returned so the inviter can share it
// directly, e.g. when the mail bounced or the service is unconfigured.
inviteUrl,
},
})
}
@@ -0,0 +1,241 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
createMockRouteParams,
createQueuedMockSupabase,
parseJsonResponse,
} from '@/tests/helpers'
// PATCH/DELETE /api/team/members/[id] (WL-08 gap fix, the last frozen team
// surface): byrå-only role changes and removals with last-owner protection.
// Company-side effects are the DB triggers' job (AFTER UPDATE re-sync,
// BEFORE DELETE source='team' cleanup) and are pg-tested, not mocked here.
const { supabase: serviceSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceSupabase,
}))
import { PATCH, DELETE } from '../route'
const mockUser = { id: 'user-1', email: 'chef@byra.se' }
const routeParams = createMockRouteParams({ id: 'tm-target' })
const patchReq = (role: string) =>
createMockRequest('/api/team/members/tm-target', { method: 'PATCH', body: { role } })
const deleteReq = () =>
createMockRequest('/api/team/members/tm-target', { method: 'DELETE' })
const targetRow = (overrides: Partial<{ role: string; kind: string; user_id: string }> = {}) => ({
id: 'tm-target',
team_id: 'team-b',
user_id: overrides.user_id ?? 'user-2',
role: overrides.role ?? 'member',
teams: { kind: overrides.kind ?? 'byra' },
})
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: serviceSupabase, error: null })
})
describe('PATCH /api/team/members/[id]', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: serviceSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await PATCH(patchReq('admin'), routeParams)
expect(res.status).toBe(401)
})
it('returns 400 for an invalid role', async () => {
const res = await PATCH(patchReq('viewer'), routeParams)
expect(res.status).toBe(400)
})
it('returns 404 when the membership row does not exist', async () => {
enqueue({ data: null })
const { status } = await parseJsonResponse(await PATCH(patchReq('admin'), routeParams))
expect(status).toBe(404)
})
it('returns 403 with the legacy message for a personal-team membership', async () => {
enqueue({ data: targetRow({ kind: 'personal' }) })
const { status, body } = await parseJsonResponse<{ error: string }>(
await PATCH(patchReq('admin'), routeParams),
)
expect(status).toBe(403)
expect(body.error).toBe('Team har bara en ägare och kan inte ändras.')
})
it('returns 403 when the caller is a plain team member', async () => {
enqueue({ data: targetRow() })
enqueue({ data: { role: 'member' } })
const { status, body } = await parseJsonResponse<{ error: string }>(
await PATCH(patchReq('admin'), routeParams),
)
expect(status).toBe(403)
expect(body.error).toBe('Behörighet saknas.')
})
it('only an owner may grant the owner role', async () => {
enqueue({ data: targetRow() })
enqueue({ data: { role: 'admin' } })
const { status } = await parseJsonResponse(await PATCH(patchReq('owner'), routeParams))
expect(status).toBe(403)
expect(findCalls('team_members', 'update')).toHaveLength(0)
})
it("only an owner may change an owner's role", async () => {
enqueue({ data: targetRow({ role: 'owner' }) })
enqueue({ data: { role: 'admin' } })
const { status } = await parseJsonResponse(await PATCH(patchReq('member'), routeParams))
expect(status).toBe(403)
expect(findCalls('team_members', 'update')).toHaveLength(0)
})
it('returns 409 when demoting the last owner', async () => {
enqueue({ data: targetRow({ role: 'owner' }) })
enqueue({ data: { role: 'owner' } })
// owner count
enqueue({ data: null, count: 1 })
const { status, body } = await parseJsonResponse<{ error: string }>(
await PATCH(patchReq('admin'), routeParams),
)
expect(status).toBe(409)
expect(body.error).toBe('Teamet måste ha minst en ägare.')
expect(findCalls('team_members', 'update')).toHaveLength(0)
})
it('demotes a non-last owner when the caller is an owner', async () => {
enqueue({ data: targetRow({ role: 'owner' }) })
enqueue({ data: { role: 'owner' } })
enqueue({ data: null, count: 2 })
// update
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{
data: { id: string; role: string }
}>(await PATCH(patchReq('admin'), routeParams))
expect(status).toBe(200)
expect(body.data).toMatchObject({ id: 'tm-target', role: 'admin' })
const updates = findCalls('team_members', 'update')
expect(updates).toHaveLength(1)
expect(updates[0]![0]).toEqual({ role: 'admin' })
})
it('promotes a member to admin for a byrå admin caller', async () => {
enqueue({ data: targetRow({ role: 'member' }) })
enqueue({ data: { role: 'admin' } })
// update
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{
data: { id: string; user_id: string; team_id: string; role: string }
}>(await PATCH(patchReq('admin'), routeParams))
expect(status).toBe(200)
expect(body.data).toEqual({
id: 'tm-target',
user_id: 'user-2',
team_id: 'team-b',
role: 'admin',
})
const updates = findCalls('team_members', 'update')
expect(updates).toHaveLength(1)
expect(updates[0]![0]).toEqual({ role: 'admin' })
})
it('is a no-op (no write) when the role is unchanged', async () => {
enqueue({ data: targetRow({ role: 'admin' }) })
enqueue({ data: { role: 'owner' } })
const { status, body } = await parseJsonResponse<{ data: { role: string } }>(
await PATCH(patchReq('admin'), routeParams),
)
expect(status).toBe(200)
expect(body.data.role).toBe('admin')
expect(findCalls('team_members', 'update')).toHaveLength(0)
})
})
describe('DELETE /api/team/members/[id]', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: serviceSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await DELETE(deleteReq(), routeParams)
expect(res.status).toBe(401)
})
it('returns 404 when the membership row does not exist', async () => {
enqueue({ data: null })
const { status } = await parseJsonResponse(await DELETE(deleteReq(), routeParams))
expect(status).toBe(404)
})
it('returns 403 with the legacy message for a personal-team membership', async () => {
enqueue({ data: targetRow({ kind: 'personal' }) })
const { status, body } = await parseJsonResponse<{ error: string }>(
await DELETE(deleteReq(), routeParams),
)
expect(status).toBe(403)
expect(body.error).toBe('Team har bara en ägare och kan inte ändras.')
})
it('returns 403 when the caller is a plain team member', async () => {
enqueue({ data: targetRow() })
enqueue({ data: { role: 'member' } })
const { status } = await parseJsonResponse(await DELETE(deleteReq(), routeParams))
expect(status).toBe(403)
expect(findCalls('team_members', 'delete')).toHaveLength(0)
})
it('only an owner may remove an owner', async () => {
enqueue({ data: targetRow({ role: 'owner' }) })
enqueue({ data: { role: 'admin' } })
const { status } = await parseJsonResponse(await DELETE(deleteReq(), routeParams))
expect(status).toBe(403)
expect(findCalls('team_members', 'delete')).toHaveLength(0)
})
it('returns 409 when removing the last owner', async () => {
enqueue({ data: targetRow({ role: 'owner', user_id: 'user-1' }) })
enqueue({ data: { role: 'owner' } })
enqueue({ data: null, count: 1 })
const { status, body } = await parseJsonResponse<{ error: string }>(
await DELETE(deleteReq(), routeParams),
)
expect(status).toBe(409)
expect(body.error).toBe('Teamet måste ha minst en ägare.')
expect(findCalls('team_members', 'delete')).toHaveLength(0)
})
it('removes a member for a byrå admin; company cleanup is the trigger', async () => {
enqueue({ data: targetRow({ role: 'member' }) })
enqueue({ data: { role: 'admin' } })
// delete
enqueue({ data: null })
const { status, body } = await parseJsonResponse<{
data: { id: string; removed: boolean }
}>(await DELETE(deleteReq(), routeParams))
expect(status).toBe(200)
expect(body.data).toEqual({ id: 'tm-target', removed: true })
expect(findCalls('team_members', 'delete')).toHaveLength(1)
// The route never touches company_members: the BEFORE DELETE sync trigger
// owns that cleanup (migration 20260331010000).
expect(findCalls('company_members', 'delete')).toHaveLength(0)
})
})
+215 -4
View File
@@ -1,12 +1,223 @@
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
/**
* Kept verbatim from the pre-unfreeze hardcoded 403: personal teams remain
* single-user shells (WL-08), and this is the message their members still see.
*/
const PERSONAL_TEAM_MESSAGE = 'Team har bara en ägare och kan inte ändras.'
const RoleSchema = z.object({
role: z.enum(['owner', 'admin', 'member']),
})
interface TargetRow {
id: string
team_id: string
user_id: string
role: string
teams: { kind: string } | null
}
/**
* Shared gates for both verbs. Returns the target row and the caller's team
* role, or the error response to short-circuit with:
* - 404 when the membership row does not exist
* - 403 (legacy message) when the row's team is not a byrå team
* - 403 when the caller is not owner/admin of that team
*/
async function resolveTargetAndCaller(
serviceClient: ReturnType<typeof createServiceClient>,
memberId: string,
callerUserId: string,
): Promise<
| { error: NextResponse; target?: undefined; callerRole?: undefined }
| { error?: undefined; target: TargetRow; callerRole: 'owner' | 'admin' }
> {
const { data } = await serviceClient
.from('team_members')
.select('id, team_id, user_id, role, teams:team_id(kind)')
.eq('id', memberId)
.maybeSingle()
const target = data as unknown as TargetRow | null
if (!target) {
return { error: NextResponse.json({ error: 'Medlem hittades inte.' }, { status: 404 }) }
}
// Kind gate first: personal teams keep the exact legacy 403 (WL-08).
if (target.teams?.kind !== 'byra') {
return { error: NextResponse.json({ error: PERSONAL_TEAM_MESSAGE }, { status: 403 }) }
}
const { data: membership } = await serviceClient
.from('team_members')
.select('role')
.eq('team_id', target.team_id)
.eq('user_id', callerUserId)
.maybeSingle()
const callerRole = (membership as { role: string } | null)?.role
if (callerRole !== 'owner' && callerRole !== 'admin') {
return { error: NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 }) }
}
return { target, callerRole }
}
/** Count of owner rows in the team: the last-owner protections read this. */
async function countTeamOwners(
serviceClient: ReturnType<typeof createServiceClient>,
teamId: string,
): Promise<number> {
const { count } = await serviceClient
.from('team_members')
.select('id', { count: 'exact', head: true })
.eq('team_id', teamId)
.eq('role', 'owner')
return count ?? 0
}
/**
* PATCH /api/team/members/[id]
* Change a byrå team member's role (WL-08 gap fix: the last frozen surface).
*
* Gates, in order: authenticated + MFA (withRouteContext), the membership's
* team is kind='byra' (personal teams keep the legacy 403 verbatim), caller
* is team owner/admin. Owner-role protections on top:
* - the owner role is only granted by an owner
* - an owner's role is only changed by an owner
* - the last owner can never be demoted (409)
*
* The route only writes team_members.role. Propagating the new role into
* every client company's company_members rows is the job of the AFTER UPDATE
* trigger team_member_sync_role_update (migration 20260826130100): same
* owner/admin -> admin, member -> member mapping as the INSERT sync, and it
* never mints a company owner. Deliberately not duplicated here.
*
* No requireWrite: that option gates on the caller's role in the ACTIVE
* COMPANY, which is the wrong dimension for a team-scoped mutation (a byrå
* owner may have any company active). The team owner/admin gate above is the
* authorization.
*/
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
'team_members.update_role',
async (request, ctx, { params }) => {
const { id } = await params
const validation = await validateBody(request, RoleSchema, {
operation: 'team_members.update_role',
})
if (!validation.success) return validation.response
const { role: newRole } = validation.data
const serviceClient = createServiceClient()
const resolved = await resolveTargetAndCaller(serviceClient, id, ctx.user.id)
if (resolved.error) return resolved.error
const { target, callerRole } = resolved
// Owner role is only granted by an owner.
if (newRole === 'owner' && callerRole !== 'owner') {
return NextResponse.json(
{ error: 'Endast en ägare kan utse en annan ägare.' },
{ status: 403 },
)
}
// An owner's role is only changed by an owner (an admin must not be able
// to demote the byrå's owners).
if (target.role === 'owner' && callerRole !== 'owner') {
return NextResponse.json(
{ error: 'Endast en ägare kan ändra en ägares roll.' },
{ status: 403 },
)
}
// Demoting an owner: the team must keep at least one owner.
if (target.role === 'owner' && newRole !== 'owner') {
const owners = await countTeamOwners(serviceClient, target.team_id)
if (owners <= 1) {
return NextResponse.json(
{ error: 'Teamet måste ha minst en ägare.' },
{ status: 409 },
)
}
}
if (target.role === newRole) {
// No-op: skip the write (and the re-sync cascade it would fire).
return NextResponse.json({
data: { id: target.id, user_id: target.user_id, team_id: target.team_id, role: newRole },
})
}
const { error } = await serviceClient
.from('team_members')
.update({ role: newRole })
.eq('id', target.id)
if (error) {
return NextResponse.json({ error: 'Kunde inte ändra rollen.' }, { status: 500 })
}
return NextResponse.json({
data: { id: target.id, user_id: target.user_id, team_id: target.team_id, role: newRole },
})
},
)
/**
* DELETE /api/team/members/[id]
* Removing team members is disabled: teams are single-user.
* Remove a member from a byrå team (WL-08 gap fix).
*
* Same gates as PATCH. Protections: an owner is only removed by an owner, and
* the last owner can never be removed (409).
*
* The route only deletes the team_members row. Cleaning the member out of
* every client company is the job of the BEFORE DELETE trigger
* team_member_sync_delete / remove_team_member_from_companies (migration
* 20260331010000): it deletes the company_members rows with source='team' for
* the team's companies and leaves source='direct' memberships untouched.
* Deliberately not duplicated here.
*/
export async function DELETE() {
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'team_members.remove',
async (_request, ctx, { params }) => {
const { id } = await params
const serviceClient = createServiceClient()
const resolved = await resolveTargetAndCaller(serviceClient, id, ctx.user.id)
if (resolved.error) return resolved.error
const { target, callerRole } = resolved
if (target.role === 'owner') {
// Owners are only removed by owners; the last owner is never removed.
if (callerRole !== 'owner') {
return NextResponse.json(
{ error: 'Team har bara en ägare och kan inte ändras.' },
{ status: 403 }
{ error: 'Endast en ägare kan ta bort en ägare.' },
{ status: 403 },
)
}
const owners = await countTeamOwners(serviceClient, target.team_id)
if (owners <= 1) {
return NextResponse.json(
{ error: 'Teamet måste ha minst en ägare.' },
{ status: 409 },
)
}
}
const { error } = await serviceClient
.from('team_members')
.delete()
.eq('id', target.id)
if (error) {
return NextResponse.json({ error: 'Kunde inte ta bort medlemmen.' }, { status: 500 })
}
return NextResponse.json({ data: { id: target.id, removed: true } })
},
)
@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest, NextResponse } from 'next/server'
import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
// GET /api/team/members under the multi-team model (WL-08): a user may belong
// to several teams (personal shell + byrå). Default target is the byrå team;
// ?teamId= selects explicitly, validated against the caller's memberships.
const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceSupabase,
}))
import { GET } from '../route'
const mockUser = { id: 'user-1', email: 'konsult@byra.se' }
const BYRA_TEAM_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const PERSONAL_TEAM_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const byraMembership = (role: string) => ({
team_id: BYRA_TEAM_ID,
role,
teams: { id: BYRA_TEAM_ID, name: 'Siffran AB', kind: 'byra', created_at: '2026-02-01T00:00:00Z' },
})
const personalMembership = () => ({
team_id: PERSONAL_TEAM_ID,
role: 'owner',
teams: {
id: PERSONAL_TEAM_ID,
name: 'Personal',
kind: 'personal',
created_at: '2026-01-01T00:00:00Z',
},
})
function makeReq(searchParams?: Record<string, string>) {
const url = new URL('http://localhost/api/team/members')
for (const [k, v] of Object.entries(searchParams ?? {})) {
url.searchParams.set(k, v)
}
return new NextRequest(url)
}
interface MembersBody {
data: {
members: { user_id: string; email: string; role: string; is_current_user: boolean }[]
invitations: { id: string; email: string; role: string; status: string }[]
teamName: string | null
teamId: string | null
teamKind: string | null
isOwner: boolean
hasTeam: boolean
canInvite: boolean
teams: { id: string; name: string | null; kind: string; role: string }[]
ownsCompany?: boolean
}
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: serviceSupabase, error: null })
})
describe('GET /api/team/members', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: serviceSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(makeReq())
expect(res.status).toBe(401)
})
it('returns the empty shape for a teamless user', async () => {
// 1. memberships
enqueue({ data: [] })
// 2. owned-company probe
enqueue({ data: { id: 'cm-1' } })
const { status, body } = await parseJsonResponse<MembersBody>(await GET(makeReq()))
expect(status).toBe(200)
expect(body.data.hasTeam).toBe(false)
expect(body.data.members).toEqual([])
expect(body.data.teamId).toBeNull()
expect(body.data.ownsCompany).toBe(true)
})
it('defaults to the byrå team when the user is in both a personal and a byrå team', async () => {
// 1. memberships (personal listed FIRST: order must not matter)
enqueue({ data: [personalMembership(), byraMembership('admin')] })
// 2. byrå roster
enqueue({
data: [
{ id: 'tm-1', team_id: BYRA_TEAM_ID, user_id: 'user-9', role: 'owner', joined_at: '2026-02-01' },
{ id: 'tm-2', team_id: BYRA_TEAM_ID, user_id: 'user-1', role: 'admin', joined_at: '2026-02-02' },
],
})
// 3. profiles
enqueue({
data: [
{ id: 'user-9', email: 'chef@byra.se' },
{ id: 'user-1', email: 'konsult@byra.se' },
],
})
const { status, body } = await parseJsonResponse<MembersBody>(await GET(makeReq()))
expect(status).toBe(200)
expect(body.data.teamId).toBe(BYRA_TEAM_ID)
expect(body.data.teamKind).toBe('byra')
expect(body.data.teamName).toBe('Siffran AB')
expect(body.data.canInvite).toBe(true)
expect(body.data.isOwner).toBe(false)
expect(body.data.members).toHaveLength(2)
expect(body.data.members[1]).toMatchObject({
user_id: 'user-1',
email: 'konsult@byra.se',
is_current_user: true,
})
// Both memberships surface so the client can offer a switcher.
expect(body.data.teams.map((t) => t.id).sort()).toEqual(
[BYRA_TEAM_ID, PERSONAL_TEAM_ID].sort(),
)
})
it('returns pending invitations for a byrå admin', async () => {
enqueue({ data: [byraMembership('admin')] })
// roster
enqueue({
data: [
{ id: 'tm-2', team_id: BYRA_TEAM_ID, user_id: 'user-1', role: 'admin', joined_at: '2026-02-02' },
],
})
// profiles
enqueue({ data: [{ id: 'user-1', email: 'konsult@byra.se' }] })
// pending invitations
enqueue({
data: [
{
id: 'inv-1',
email: 'ny@byra.se',
role: 'member',
status: 'pending',
created_at: '2026-07-01T00:00:00Z',
expires_at: '2026-08-15T00:00:00Z',
},
],
})
const { body } = await parseJsonResponse<MembersBody>(await GET(makeReq()))
expect(body.data.canInvite).toBe(true)
expect(body.data.invitations).toHaveLength(1)
expect(body.data.invitations[0]).toMatchObject({ id: 'inv-1', email: 'ny@byra.se' })
})
it('a byrå plain member gets no invitation list', async () => {
enqueue({ data: [byraMembership('member')] })
enqueue({
data: [
{ id: 'tm-2', team_id: BYRA_TEAM_ID, user_id: 'user-1', role: 'member', joined_at: '2026-02-02' },
],
})
enqueue({ data: [{ id: 'user-1', email: 'konsult@byra.se' }] })
const { body } = await parseJsonResponse<MembersBody>(await GET(makeReq()))
expect(body.data.invitations).toEqual([])
})
it('a byrå plain member cannot invite', async () => {
enqueue({ data: [byraMembership('member')] })
enqueue({
data: [
{ id: 'tm-2', team_id: BYRA_TEAM_ID, user_id: 'user-1', role: 'member', joined_at: '2026-02-02' },
],
})
enqueue({ data: [{ id: 'user-1', email: 'konsult@byra.se' }] })
const { body } = await parseJsonResponse<MembersBody>(await GET(makeReq()))
expect(body.data.canInvite).toBe(false)
})
it('?teamId= targets the personal team explicitly', async () => {
enqueue({ data: [personalMembership(), byraMembership('admin')] })
// personal roster
enqueue({
data: [
{ id: 'tm-3', team_id: PERSONAL_TEAM_ID, user_id: 'user-1', role: 'owner', joined_at: '2026-01-01' },
],
})
enqueue({ data: [{ id: 'user-1', email: 'konsult@byra.se' }] })
const { status, body } = await parseJsonResponse<MembersBody>(
await GET(makeReq({ teamId: PERSONAL_TEAM_ID })),
)
expect(status).toBe(200)
expect(body.data.teamId).toBe(PERSONAL_TEAM_ID)
expect(body.data.teamKind).toBe('personal')
expect(body.data.isOwner).toBe(true)
// Personal teams are never invitable regardless of role.
expect(body.data.canInvite).toBe(false)
})
it('returns 404 for a teamId outside the caller memberships', async () => {
enqueue({ data: [personalMembership()] })
const { status } = await parseJsonResponse(
await GET(makeReq({ teamId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' })),
)
expect(status).toBe(404)
})
})
+91 -14
View File
@@ -1,31 +1,46 @@
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { NextResponse, type NextRequest } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
/**
* GET /api/team/members
* Returns team members (single-user teams, no invitations).
* Returns the member roster for one of the caller's teams.
*
* Multi-team model (WL-08): a user may belong to several teams (their own
* personal shell AND a byrå team), so the old `.limit(1)` single-membership
* read is gone. Selection:
* - `?teamId=` targets an explicit team, validated against the caller's own
* memberships (404 otherwise)
* - default: the caller's byrå team when one exists, else the personal team
*
* Team-scoped (not company-scoped): a brand-new user with no company must
* still get a valid empty response (ownsCompany: false), so this uses
* requireAuth() directly rather than withRouteContext, which would require an
* active company context. requireAuth still enforces MFA (AAL2) on hosted.
*
* Read-only: team role changes are re-synced to company_members by the
* DB trigger (team_member_sync_role_update); this route just reflects the
* current rows and never writes roles.
*/
export async function GET() {
export async function GET(request: NextRequest) {
const { user, error } = await requireAuth()
if (error) return error
const serviceClient = await createServiceClient()
const serviceClient = createServiceClient()
// Find the user's team membership
const { data: myMembership } = await serviceClient
// Every team membership the user holds, with each team's name and kind.
const { data: myMemberships } = await serviceClient
.from('team_members')
.select('team_id, role')
.select('team_id, role, teams:team_id(id, name, kind, created_at)')
.eq('user_id', user.id)
.limit(1)
.single()
if (!myMembership) {
const memberships = (myMemberships ?? []) as unknown as {
team_id: string
role: string
teams: { id: string; name: string; kind: string; created_at: string } | null
}[]
if (memberships.length === 0) {
// User is not in any team: check if they own a company (could start a team)
const { data: ownedCompany } = await serviceClient
.from('company_members')
@@ -38,24 +53,53 @@ export async function GET() {
return NextResponse.json({
data: {
members: [],
invitations: [],
teamName: null,
teamId: null,
teamKind: null,
isOwner: false,
hasTeam: false,
canInvite: false,
teams: [],
ownsCompany: !!ownedCompany,
},
})
}
const teamId = myMembership.team_id
const isOwner = myMembership.role === 'owner'
const requestedTeamId = request.nextUrl.searchParams.get('teamId')
// Fetch team name
let myMembership: (typeof memberships)[number] | undefined
if (requestedTeamId) {
myMembership = memberships.find((m) => m.team_id === requestedTeamId)
if (!myMembership) {
return NextResponse.json({ error: 'Team hittades inte.' }, { status: 404 })
}
} else {
// Default: byrå team first (that is the roster a consultant cares about),
// else the personal team. Deterministic tie-break on team creation time.
const byCreated = (a: (typeof memberships)[number], b: (typeof memberships)[number]) =>
(a.teams?.created_at ?? '').localeCompare(b.teams?.created_at ?? '')
myMembership =
memberships.filter((m) => m.teams?.kind === 'byra').sort(byCreated)[0] ??
[...memberships].sort(byCreated)[0]
}
const teamId = myMembership.team_id
const teamKind = myMembership.teams?.kind ?? 'personal'
const isOwner = myMembership.role === 'owner'
const canInvite = teamKind === 'byra' && ['owner', 'admin'].includes(myMembership.role)
// Team name from the joined row; fall back to a direct read when the join
// is absent (defensive: service role bypasses RLS so it should be present).
let teamName = myMembership.teams?.name ?? null
if (!teamName) {
const { data: team } = await serviceClient
.from('teams')
.select('name')
.eq('id', teamId)
.single()
teamName = (team as { name: string } | null)?.name ?? null
}
// Fetch all team members (owner is a real row now)
const { data: members, error: membersError } = await serviceClient
@@ -77,6 +121,29 @@ export async function GET() {
const emailMap = new Map((profiles || []).map((p) => [p.id, p.email]))
// Pending invitations: only meaningful (and only shown) on byrå teams for
// callers who may manage invites; everyone else gets an empty list so the
// payload shape stays constant. Revoked/accepted rows are history, not
// roster state, so only status='pending' is returned (the client derives
// "expired" from expires_at).
let invitations: {
id: string
email: string
role: string
status: string
created_at: string
expires_at: string
}[] = []
if (canInvite) {
const { data: inviteRows } = await serviceClient
.from('team_invitations')
.select('id, email, role, status, created_at, expires_at')
.eq('team_id', teamId)
.eq('status', 'pending')
.order('created_at', { ascending: true })
invitations = (inviteRows ?? []) as typeof invitations
}
return NextResponse.json({
data: {
members: (members || []).map((m) => ({
@@ -87,10 +154,20 @@ export async function GET() {
joined_at: m.joined_at,
is_current_user: m.user_id === user.id,
})),
teamName: team?.name || null,
invitations,
teamName,
teamId,
teamKind,
isOwner,
hasTeam: true,
canInvite,
// All of the caller's teams, so the client can offer a team switcher.
teams: memberships.map((m) => ({
id: m.team_id,
name: m.teams?.name ?? null,
kind: m.teams?.kind ?? 'personal',
role: m.role,
})),
},
})
}
@@ -53,6 +53,7 @@ function makeSupabase(teamId: string | null) {
return {
from: vi.fn(() => chain),
rpc: vi.fn().mockResolvedValue({ data: COMPANY_ID, error: null }),
chain,
}
}
@@ -164,6 +165,37 @@ describe('POST /api/v1/companies', () => {
})
})
it('defaults the team to the user PERSONAL team only (WL-08)', async () => {
const supabase = makeSupabase(TEAM_ID)
mockServiceClient.mockReturnValue(supabase)
const res = await createCompany(makeRequest(validBody), staticRouteContext())
expect(res.status).toBe(201)
// The default-team lookup must be restricted to kind='personal': picking
// the first membership regardless of kind attached a consultant's private
// company to their byrå team.
expect(supabase.from).toHaveBeenCalledWith('team_members')
expect(supabase.chain.select).toHaveBeenCalledWith('team_id, teams!inner(kind, created_at)')
expect(supabase.chain.eq).toHaveBeenCalledWith('teams.kind', 'personal')
})
it('passes p_team_id null when the user has no personal team', async () => {
const supabase = makeSupabase(null)
mockServiceClient.mockReturnValue(supabase)
const res = await createCompany(makeRequest(validBody), staticRouteContext())
expect(res.status).toBe(201)
const body = await res.json()
expect(body.data.team_id).toBeNull()
expect(supabase.rpc).toHaveBeenCalledWith('create_company_for_user', {
p_user_id: USER_ID,
p_name: 'Acme AB',
p_entity_type: 'aktiebolag',
p_team_id: null,
})
})
it('previews without creating for a test-mode key (dry run)', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
+11 -4
View File
@@ -170,15 +170,22 @@ export const POST = withApiV1('companies.create', async (request, ctx) => {
})
}
// Team: explicit, else the caller's first (usually personal) team, same as
// the web wizard. create_company_for_user re-checks membership.
// Team: explicit, else the caller's PERSONAL team only (WL-08: companies
// created through the normal flow always attach to the personal team;
// cockpit flows pass the byrå team id explicitly). Picking the first
// membership regardless of kind attached a consultant's private company to
// the byrå team, exposing their books to the whole byrå and suppressing the
// trial. Mirrors ensure_user_team: earliest teams row with kind='personal'.
// create_company_for_user re-checks membership (and byrå role) in the DB.
let teamId: string | null = setup.team_id ?? null
if (!teamId) {
const { data: membership } = await ctx.supabase
.from('team_members')
.select('team_id')
.select('team_id, teams!inner(kind, created_at)')
.eq('user_id', ctx.userId)
.order('created_at', { ascending: true })
.eq('teams.kind', 'personal')
.order('teams(created_at)', { ascending: true })
.order('teams(id)', { ascending: true })
.limit(1)
.maybeSingle()
teamId = (membership?.team_id as string | undefined) ?? null
+42
View File
@@ -0,0 +1,42 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import OnboardingBackdrop from '@/components/onboarding/OnboardingBackdrop'
import OnboardingJourney from '@/components/onboarding/journey/OnboardingJourney'
import { getByraMembership } from '@/lib/clients/fetch-client-overview'
export const dynamic = 'force-dynamic'
/**
* Client company creation from the byrå cockpit (WL-15): exactly today's
* creation journey (company form, fiscal year, voucher series defaults), but
* bound EXPLICITLY to the byrå team so the new company lands under the byrå
* (companies.team_id = byrå team), the team sync grants the whole byrå
* access, and the trial-suppression trigger keys on the byrå binding.
*
* Server-gated to byrå team owner/admin (creation is a commercial act: +1 on
* the byrå's invoice). The create_company_with_owner RPC enforces the same
* gate in the database, so a member cannot bypass this page via PostgREST.
*/
export default async function NewClientCompanyPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
const membership = await getByraMembership(supabase, user.id)
if (!membership) {
redirect('/')
}
if (membership.role !== 'owner' && membership.role !== 'admin') {
redirect('/clients')
}
return (
<div className="min-h-screen bg-background">
<OnboardingBackdrop />
<OnboardingJourney teamId={membership.teamId} mode="add" />
</div>
)
}
+6 -12
View File
@@ -19,18 +19,12 @@ export default async function NewCompanyPage() {
redirect('/login')
}
const { data: teamMembership } = await supabase
.from('team_members')
.select('team_id')
.eq('user_id', user.id)
.limit(1)
.maybeSingle()
let teamId = teamMembership?.team_id
if (!teamId) {
const { data: newTeamId } = await supabase.rpc('ensure_user_team')
teamId = newTeamId
}
// Deterministic personal-team attachment (WL-08): ensure_user_team returns
// the user's PERSONAL team (creating one if missing). The previous bare
// `.limit(1)` membership pick could hand a consultant's new private company
// to their byrå team; byrå client creation binds its team explicitly via
// /companies/new-client instead.
const { data: teamId } = await supabase.rpc('ensure_user_team')
if (!teamId) {
redirect('/login')
}
+116 -5
View File
@@ -1,6 +1,14 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Hedvig_Letters_Serif } from "next/font/google";
import {
Fraunces,
Lora,
Playfair_Display,
Public_Sans,
Source_Sans_3,
Work_Sans,
} from "next/font/google";
import Script from "next/script";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
@@ -15,9 +23,19 @@ import { SWRProvider } from "@/components/providers/SWRProvider";
import { ScrollbarReveal } from "@/components/ScrollbarReveal";
import { ensureInitialized } from "@/lib/init";
import { getBranding } from "@/lib/branding/service";
import { resolveRequestBrand } from "@/lib/branding/request-brand";
import { getBrandFontPair } from "@/lib/branding/fonts";
import { BrandProvider } from "@/lib/branding/brand-context";
import { toPublicBrand } from "@/lib/branding/public-brand";
import { APP_TIME_ZONE } from "@/i18n/config";
import "./globals.css";
// Brand resolution (WL-12: the lookup runs in the root layout, not
// middleware) lives in lib/branding/request-brand.ts, shared with every
// other server component so the BRAND_DEV_DOMAIN dev override applies
// uniformly. The layout used to carry a private copy of the function, which
// silently skipped that override.
// Load extensions before metadata/viewport functions read the branding service.
// Without this, an extension that calls registerBrandingService() at its module
// load time would not have run yet when the first request hits this layout.
@@ -43,21 +61,81 @@ const hedvigSerif = Hedvig_Letters_Serif({
weight: "400",
});
export function generateMetadata(): Metadata {
// Curated brand font menu (WL-03/WL-12): every menu font registers here so
// its @font-face and CSS variable exist on every host, but with
// `preload: false`: only the default pair above preloads, and a browser only
// downloads a menu font when a branded host actually resolves its var()
// (lib/branding/fonts.ts picks the pair per request). All Google Fonts, OFL.
const loraSerif = Lora({
variable: "--font-lora",
subsets: ["latin"],
display: "swap",
preload: false,
});
const sourceSans = Source_Sans_3({
variable: "--font-source-sans",
subsets: ["latin"],
display: "swap",
preload: false,
});
const frauncesSerif = Fraunces({
variable: "--font-fraunces",
subsets: ["latin"],
display: "swap",
preload: false,
});
const workSans = Work_Sans({
variable: "--font-work-sans",
subsets: ["latin"],
display: "swap",
preload: false,
});
const playfairSerif = Playfair_Display({
variable: "--font-playfair",
subsets: ["latin"],
display: "swap",
preload: false,
});
const publicSans = Public_Sans({
variable: "--font-public-sans",
subsets: ["latin"],
display: "swap",
preload: false,
});
// The brand font menu's variable classes, appended to <html> so every menu
// pair's var() is resolvable whichever brand serves the request.
const brandFontVariables = [
loraSerif.variable,
sourceSans.variable,
frauncesSerif.variable,
workSans.variable,
playfairSerif.variable,
publicSans.variable,
].join(" ");
export async function generateMetadata(): Promise<Metadata> {
const b = getBranding();
const brand = await resolveRequestBrand();
const appName = brand?.appName ?? b.appName;
return {
title: b.appName,
title: appName,
description: b.appDescription,
manifest: "/manifest.webmanifest",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: b.appName,
title: appName,
},
};
}
export function generateViewport(): Viewport {
export async function generateViewport(): Promise<Viewport> {
return {
themeColor: getBranding().themeColor,
width: "device-width",
@@ -72,12 +150,43 @@ export default async function RootLayout({
children: React.ReactNode;
}>) {
const branding = getBranding();
const brand = await resolveRequestBrand();
const locale = await getLocale();
const messages = await getMessages();
// Brand COLOR theming deliberately removed (founder call 2026-08-04):
// white-label is logo + app name + domain only; every host renders the
// standard editorial-monochrome theme. buildBrandVarsCss and the
// brands.brand_color/chrome_color columns stay dormant for a future
// opt-in, but nothing stamps data-brand anymore.
// Curated font menu (WL-03): inline style on <html> beats the :root
// declarations in globals.css. Null (default pair or unknown key) means no
// style attribute at all, keeping default hosts byte-identical.
const fontPair = brand ? getBrandFontPair(brand.fontKey) : null;
return (
<html lang={locale} suppressHydrationWarning className={`${geistSans.variable} ${geistMono.variable} ${hedvigSerif.variable}`}>
<html
lang={locale}
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} ${hedvigSerif.variable} ${brandFontVariables}`}
style={
fontPair
? ({
"--font-display": fontPair.display,
"--font-body": fontPair.body,
} as React.CSSProperties)
: undefined
}
>
<head>
<link rel="apple-touch-icon" href={branding.appleTouchIconPath} />
{/* Tab icon: the brand's dedicated square favicon when set, else the
logo (a wide lockup squeezed to 16px, but better than the default
on a branded host). */}
{brand?.faviconUrl || brand?.logoUrl ? (
<link rel="icon" href={brand.faviconUrl ?? brand.logoUrl ?? undefined} />
) : null}
</head>
<body
className="antialiased"
@@ -85,6 +194,7 @@ export default async function RootLayout({
{/* timeZone is passed explicitly: client components must format in the
same zone the server rendered with, or timestamps shift on hydration. */}
<NextIntlClientProvider locale={locale} messages={messages} timeZone={APP_TIME_ZONE}>
<BrandProvider brand={brand ? toPublicBrand(brand) : null}>
<ThemeProvider
attribute="class"
defaultTheme="light"
@@ -103,6 +213,7 @@ export default async function RootLayout({
</SWRProvider>
</PaletteProvider>
</ThemeProvider>
</BrandProvider>
</NextIntlClientProvider>
<SpeedInsights />
<Script src="/sw-register.js" strategy="afterInteractive" />
+12 -4
View File
@@ -1,12 +1,20 @@
import type { MetadataRoute } from 'next'
import { headers } from 'next/headers'
import { ensureInitialized } from '@/lib/init'
import { getBranding } from '@/lib/branding/service'
import { resolveBrandByHost } from '@/lib/branding/resolve'
// Guarantee branding extensions have registered before the manifest is built.
ensureInitialized()
export default function manifest(): MetadataRoute.Manifest {
export default async function manifest(): Promise<MetadataRoute.Manifest> {
const b = getBranding()
// Host-aware branding (WL-12 slice A3): a branded host installs a PWA with
// the brand's name and color. Reading headers() makes this route dynamic;
// unknown hosts fall through to the default values below, unchanged.
const requestHeaders = await headers()
const host = requestHeaders.get('host')
const brand = host ? await resolveBrandByHost(host) : null
const sizes = [72, 96, 128, 144, 152, 192, 384, 512]
// Next.js's Icon type doesn't accept the space-separated "any maskable" purpose
// that the original public/manifest.json used. Cast preserves the same JSON
@@ -18,13 +26,13 @@ export default function manifest(): MetadataRoute.Manifest {
purpose: 'any maskable',
})) as unknown as MetadataRoute.Manifest['icons']
return {
name: b.appName,
short_name: b.appName,
name: brand?.appName ?? b.appName,
short_name: brand?.appName ?? b.appName,
description: b.appDescription,
start_url: '/',
display: 'standalone',
background_color: b.manifestBackgroundColor,
theme_color: b.manifestThemeColor,
theme_color: brand?.brandColor ?? b.manifestThemeColor,
orientation: 'portrait-primary',
icons,
categories: ['business', 'finance', 'productivity'],
+3 -1
View File
@@ -17,6 +17,7 @@ import {
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { useBranding } from '@/lib/branding/brand-context'
import type {
AnnualReportComplianceIssue,
AnnualReportProfile,
@@ -96,6 +97,7 @@ export function AnnualReportStudio({
onBlockingCountChanged,
}: AnnualReportStudioProps) {
const t = useTranslations('annualReportStudio')
const { appName } = useBranding()
const { toast } = useToast()
const [compliance, setCompliance] = useState<ComplianceResponse | null>(null)
const [profile, setProfile] = useState<AnnualReportProfile | null>(null)
@@ -354,7 +356,7 @@ export function AnnualReportStudio({
<h3 className="font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">{t('scope_title')}</h3>
<div className="h-px flex-1 bg-border/60" />
</div>
<p className="px-1 text-sm text-muted-foreground">{t('scope_description')}</p>
<p className="px-1 text-sm text-muted-foreground">{t('scope_description', { appName })}</p>
<div className="space-y-6 px-1 pt-4">
<div className="grid gap-4 md:grid-cols-2">
<BooleanQuestion
@@ -2,6 +2,7 @@
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { guardBrowserWrite } from '@/lib/company/tab-guard'
import { useAccounts } from '@/lib/reference-data/hooks'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -31,6 +32,9 @@ export default function ChartOfAccounts() {
const [sruValue, setSruValue] = useState('')
async function updateSRUCode(accountId: string, newSruCode: string) {
// Cross-tab guard (WL-09): browser-direct Supabase write, outside the
// patched-fetch seam. The blocking dialog is the user feedback.
if (!guardBrowserWrite()) return
const supabase = createClient()
const trimmed = newSruCode.trim() || null
await supabase
@@ -3,6 +3,7 @@
import { Eye } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { useBranding } from '@/lib/branding/brand-context'
interface DocumentViewButtonProps {
documentId: string
@@ -26,6 +27,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
*/
export function DocumentViewButton({ documentId, label = 'Visa dokument', className }: DocumentViewButtonProps) {
const { toast } = useToast()
const { appName } = useBranding()
const handleClick = () => {
// documentId originates from staged preview_data (Record<string, unknown>);
@@ -50,7 +52,7 @@ export function DocumentViewButton({ documentId, label = 'Visa dokument', classN
} else {
toast({
title: 'Kunde inte öppna dokumentet',
description: 'Tillåt popupfönster för Accounted i webbläsaren och försök igen.',
description: `Tillåt popupfönster för ${appName} i webbläsaren och försök igen.`,
variant: 'destructive',
})
}
@@ -32,6 +32,7 @@ import {
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import InboxDocumentPicker from '@/components/bookkeeping/InboxDocumentPicker'
import { useBranding } from '@/lib/branding/brand-context'
interface DocumentRecord {
id: string
@@ -84,6 +85,7 @@ export default function JournalEntryAttachments({
const embedded = variant === 'section'
const t = useTranslations('journal_attachments')
const tCommon = useTranslations('common')
const { appName } = useBranding()
const { toast } = useToast()
const [documents, setDocuments] = useState<DocumentRecord[]>([])
const [loading, setLoading] = useState(true)
@@ -179,7 +181,7 @@ export default function JournalEntryAttachments({
tab.close()
toast({
title: t('download_failed'),
description: tab.blocked ? tCommon('popup_blocked_description') : undefined,
description: tab.blocked ? tCommon('popup_blocked_description', { appName }) : undefined,
variant: 'destructive',
})
}
+61
View File
@@ -0,0 +1,61 @@
'use client'
/**
* Dashboard sidebar brand mark: the top-left home link (WL-12 slice A3).
*
* On a branded host with an uploaded logo it renders that logo; on every
* other host it renders exactly the legacy `getBranding().logoPath` image
* with the same dimensions, classes and aria-label as before, keeping
* default hosts byte-identical.
*
* With `showLabel` (expanded sidebar) a branded host without an uploaded
* logo renders the brand's app name beside the mark (byra-editable in the
* Varumärke settings). With an uploaded logo the logo carries the brand
* alone (same founder call as BrandWordmark, 2026-08-05: byrå logos usually
* carry their own name, so logo + text read as a duplicate). Unbranded
* hosts never show a label.
*/
import Link from 'next/link'
import Image from 'next/image'
import { useBranding } from '@/lib/branding/brand-context'
import { useCompanyOptional } from '@/contexts/CompanyContext'
export function BrandHomeLink({ showLabel = false }: { showLabel?: boolean }) {
const { appName, logoUrl, logoPath, brand } = useBranding()
const label = brand && !logoUrl ? brand.appName : null
// Byrå team members (any role) home to the cockpit, never to "/" (which
// would open whatever client company happens to be active). Everyone
// outside a byrå keeps the legacy home link.
const byraTeam = useCompanyOptional()?.byraTeam
const homeHref = byraTeam ? '/byra' : '/'
return (
<Link href={homeHref} aria-label={appName} className="flex items-center gap-2 rounded-lg">
{logoUrl ? (
<Image
src={logoUrl}
alt=""
width={26}
height={26}
className="h-[26px] w-[26px] rounded-lg object-contain"
/>
) : (
<Image
src={logoPath}
alt=""
width={26}
height={26}
className="h-[26px] w-[26px] rounded-lg"
/>
)}
{showLabel && label && (
<span
className="truncate font-display text-base tracking-tight text-foreground"
style={{ fontWeight: 700 }}
>
{label}
</span>
)}
</Link>
)
}
+31 -7
View File
@@ -1,5 +1,8 @@
'use client'
import Image from 'next/image'
import { cn } from '@/lib/utils'
import { getBranding } from '@/lib/branding/service'
import { useBranding } from '@/lib/branding/brand-context'
interface BrandWordmarkProps {
/**
@@ -17,19 +20,40 @@ interface BrandWordmarkProps {
}
/**
* Text-only wordmark used in place of the legacy logo image on auth /
* onboarding / sandbox / invite surfaces. Renders the active brand's
* `appName` in Hedvig Letters Serif at weight 700: the display font is
* single-weight on Google Fonts so 700 ends up synthetically bolded, but
* that matches the requested aesthetic.
* Wordmark used in place of the legacy logo image on auth / onboarding /
* sandbox / invite surfaces. On a branded host with an uploaded logo it
* renders the logo image ALONE (founder call 2026-08-05: byrå logos usually
* carry their own name, so logo + text read as a duplicate); otherwise it
* renders exactly the text-only wordmark: the active brand's `appName` in
* Hedvig Letters Serif at weight 700 (the display font is single-weight on
* Google Fonts so 700 ends up synthetically bolded, but that matches the
* requested aesthetic).
*/
export function BrandWordmark({
size = 'hero',
lowercase = true,
className,
}: BrandWordmarkProps) {
const branding = getBranding()
const branding = useBranding()
const name = lowercase ? branding.appName.toLowerCase() : branding.appName
if (branding.logoUrl) {
// The logo carries the brand alone; the app name moves into alt text so
// the image keeps an accessible name.
return (
<span className={cn('inline-flex items-center', className)}>
<Image
src={branding.logoUrl}
alt={name}
width={size === 'hero' ? 214 : 88}
height={size === 'hero' ? 64 : 22}
className={cn('w-auto', size === 'hero' ? 'h-16' : 'h-[22px]')}
priority={size === 'hero'}
/>
</span>
)
}
return (
<span
className={cn(
+30
View File
@@ -0,0 +1,30 @@
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
/**
* Discreet source-code footer line (WL-06 slice A6).
*
* AGPL-3.0 section 13 compliance: the running service must offer its source
* to everyone who interacts with it over the network, so this renders on BOTH
* default and branded hosts; never gate it on a brand. It links the canonical
* repo. Dual-use component: works as a server component (privacy/dpa) and
* inside client pages (login); next-intl supports useTranslations in both.
*/
const SOURCE_REPO_URL = 'https://github.com/erp-mafia/gnubok'
export function SourceCodeFooter({ className }: { className?: string }) {
const t = useTranslations('common')
return (
<p className={cn('text-center text-xs text-muted-foreground', className)}>
<a
href={SOURCE_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
{t('source_code')}
</a>
</p>
)
}
+373
View File
@@ -0,0 +1,373 @@
'use client'
import { useMemo, useState, useTransition } from 'react'
import dynamic from 'next/dynamic'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { ArrowDown, ArrowUp, Loader2 } from 'lucide-react'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { AttnLine } from '@/components/ui/attn-line'
import { Skeleton } from '@/components/ui/skeleton'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { useToast } from '@/components/ui/use-toast'
import { ContextPicker } from '@/components/common/ContextPicker'
import { performCompanySwitch } from '@/lib/company/switch-client'
import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart'
import {
KPI_PERIOD_PRESETS,
type KpiPeriodPreset,
} from '@/lib/byra/kpi-aggregate'
import type { ByraKpiClientRow } from '@/lib/byra/kpi-overview'
// Recharts is heavy: defer the chart so the tiles and table render first
// (same pattern as components/reports/views/index.tsx).
const IncomeExpenseChart = dynamic(
() => import('@/components/reports/IncomeExpenseChart').then((m) => m.IncomeExpenseChart),
{ ssr: false, loading: () => <Skeleton className="h-64 w-full" /> },
)
interface ByraKpiViewProps {
preset: KpiPeriodPreset
allClients: Array<{ companyId: string; name: string }>
selectedIds: string[]
rows: ByraKpiClientRow[]
months: MonthlyDataPoint[]
}
type SortKey = 'name' | 'revenue' | 'result' | 'margin' | 'cash' | 'vatLiability' | 'unbookedCount'
const NUMERIC_SORT_KEYS: SortKey[] = [
'revenue',
'result',
'margin',
'cash',
'vatLiability',
'unbookedCount',
]
function formatMargin(margin: number | null): string {
if (margin === null) return '-'
return `${new Intl.NumberFormat('sv-SE', { maximumFractionDigits: 1 }).format(margin)} %`
}
/**
* The Nyckeltal surface (WL-16): period preset + company chips filter the
* URL (the server page refetches), tiles and chart summarize the selection,
* and the table ranks clients. Entering a client is the same WL-09 soft
* switch as the client list.
*/
export default function ByraKpiView({
preset,
allClients,
selectedIds,
rows,
months,
}: ByraKpiViewProps) {
const t = useTranslations('byra')
const tClients = useTranslations('clients')
const { toast } = useToast()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [isPending, startTransition] = useTransition()
const [pendingId, setPendingId] = useState<string | null>(null)
const [sort, setSort] = useState<{ key: SortKey; desc: boolean }>({
key: 'revenue',
desc: true,
})
const hasExplicitFilter = searchParams.has('companies')
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
const updateParams = (mutate: (params: URLSearchParams) => void) => {
const params = new URLSearchParams(searchParams.toString())
mutate(params)
const query = params.toString()
startTransition(() => {
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
})
}
const handlePreset = (id: string) => {
updateParams((params) => params.set('period', id))
}
/**
* Chip semantics: no `companies` param = all clients. Clicking a chip in
* the all state focuses on that client; further clicks toggle. An empty
* or complete selection collapses back to the all state.
*/
const handleToggleCompany = (companyId: string) => {
updateParams((params) => {
const next = hasExplicitFilter ? new Set(selectedIds) : new Set<string>()
if (next.has(companyId)) next.delete(companyId)
else next.add(companyId)
if (next.size === 0 || next.size === allClients.length) {
params.delete('companies')
} else {
params.set(
'companies',
allClients
.filter((c) => next.has(c.companyId))
.map((c) => c.companyId)
.join(','),
)
}
})
}
const handleAllCompanies = () => {
updateParams((params) => params.delete('companies'))
}
const handleEnter = async (companyId: string) => {
if (pendingId) return
setPendingId(companyId)
const result = await performCompanySwitch(companyId, { destination: '/' })
if (result?.error) {
setPendingId(null)
toast({ title: tClients('enter_failed'), variant: 'destructive' })
}
}
const handleSort = (key: SortKey) => {
setSort((prev) =>
prev.key === key
? { key, desc: !prev.desc }
: { key, desc: NUMERIC_SORT_KEYS.includes(key) },
)
}
const sortedRows = useMemo(() => {
const dir = sort.desc ? -1 : 1
return [...rows].sort((a, b) => {
if (sort.key === 'name') return dir * a.name.localeCompare(b.name, 'sv')
const av = a[sort.key] ?? Number.NEGATIVE_INFINITY
const bv = b[sort.key] ?? Number.NEGATIVE_INFINITY
if (av !== bv) return dir * (av - bv)
return a.name.localeCompare(b.name, 'sv')
})
}, [rows, sort])
const okRows = rows.filter((r) => !r.failed)
const failedCount = rows.length - okRows.length
const totals = {
revenue: okRows.reduce((sum, r) => sum + r.revenue, 0),
result: okRows.reduce((sum, r) => sum + r.result, 0),
cash: okRows.reduce((sum, r) => sum + r.cash, 0),
attention: okRows.filter((r) => r.unbookedCount > 0 || r.inboxCount > 0).length,
}
const tiles = [
{ label: t('kpi_revenue'), value: formatCurrency(totals.revenue), negative: false },
{ label: t('kpi_result'), value: formatCurrency(totals.result), negative: totals.result < 0 },
{ label: t('kpi_cash'), value: formatCurrency(totals.cash), negative: totals.cash < 0 },
{ label: t('stats_attention'), value: String(totals.attention), negative: false },
]
const presetItems = KPI_PERIOD_PRESETS.map((id) => ({
id,
label: t(`kpi_period_${id}`),
}))
const columns: Array<{ key: SortKey; label: string; numeric: boolean }> = [
{ key: 'name', label: tClients('col_company'), numeric: false },
{ key: 'revenue', label: t('kpi_revenue'), numeric: true },
{ key: 'result', label: t('kpi_result'), numeric: true },
{ key: 'margin', label: t('kpi_margin'), numeric: true },
{ key: 'cash', label: t('kpi_cash'), numeric: true },
{ key: 'vatLiability', label: t('kpi_vat'), numeric: true },
{ key: 'unbookedCount', label: tClients('col_unbooked'), numeric: true },
]
const SortIcon = sort.desc ? ArrowDown : ArrowUp
return (
<div className={cn('space-y-8 transition-opacity duration-150', isPending && 'opacity-60')}>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleAllCompanies}
aria-pressed={!hasExplicitFilter}
className={cn(
'rounded-full border px-3 py-[5px] text-[13px] transition-colors duration-150',
!hasExplicitFilter
? 'border-foreground/20 bg-secondary text-foreground'
: 'border-border text-muted-foreground hover:bg-secondary/60',
)}
>
{t('kpi_filter_all')}
</button>
{allClients.map((client) => {
const active = hasExplicitFilter && selectedSet.has(client.companyId)
return (
<button
key={client.companyId}
type="button"
onClick={() => handleToggleCompany(client.companyId)}
aria-pressed={active}
className={cn(
'rounded-full border px-3 py-[5px] text-[13px] transition-colors duration-150',
active
? 'border-foreground/20 bg-secondary text-foreground'
: 'border-border text-muted-foreground hover:bg-secondary/60',
)}
>
{client.name}
</button>
)
})}
<div className="ml-auto">
<ContextPicker
items={presetItems}
value={preset}
onChange={handlePreset}
triggerLabel={t(`kpi_period_${preset}`)}
ariaLabel={t('kpi_period_picker')}
/>
</div>
</div>
{failedCount > 0 && (
<AttnLine>{t('kpi_failed_notice', { count: failedCount })}</AttnLine>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{tiles.map((tile) => (
<div key={tile.label} className="rounded-lg border border-border p-4">
<div className="text-xs text-muted-foreground">{tile.label}</div>
<div
className={cn(
'mt-1 font-sans text-xl tabular-nums',
tile.negative && 'text-destructive',
)}
>
{tile.value}
</div>
</div>
))}
</div>
<IncomeExpenseChart months={months} />
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
{columns.map((col) => (
<th key={col.key} className={cn(TH_CLASS, col.numeric && 'text-right')}>
<button
type="button"
onClick={() => handleSort(col.key)}
className={cn(
'inline-flex items-center gap-1',
col.numeric && 'flex-row-reverse',
)}
>
{col.label}
{sort.key === col.key && <SortIcon className="h-3 w-3" />}
</button>
</th>
))}
<th className={TH_CLASS}>{tClients('col_next_deadline')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{sortedRows.map((row) => (
<tr
key={row.companyId}
onClick={() => void handleEnter(row.companyId)}
className={cn(
'cursor-pointer transition-colors duration-150 hover:bg-secondary/35',
pendingId && pendingId !== row.companyId && 'opacity-50',
)}
>
<td className={TD_CLASS}>
<span className="flex items-center gap-2">
<span className="font-medium text-foreground">{row.name}</span>
{row.orgNumber && (
<span className="text-xs text-muted-foreground tabular-nums">
{row.orgNumber}
</span>
)}
{pendingId === row.companyId && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
)}
</span>
</td>
{row.failed ? (
<td className={cn(TD_CLASS, 'text-muted-foreground')} colSpan={6}>
{t('kpi_row_failed')}
</td>
) : (
<>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>
{formatCurrency(row.revenue)}
</td>
<td
className={cn(
TD_CLASS,
'text-right tabular-nums',
row.result < 0 && 'text-destructive',
)}
>
{formatCurrency(row.result)}
</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums text-muted-foreground')}>
{formatMargin(row.margin)}
</td>
<td
className={cn(
TD_CLASS,
'text-right tabular-nums',
row.cash < 0 && 'text-destructive',
)}
>
{formatCurrency(row.cash)}
</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums text-muted-foreground')}>
{formatCurrency(row.vatLiability)}
</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>
{row.unbookedCount > 0 ? (
row.unbookedCount
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
</>
)}
<td className={TD_CLASS}>
{row.nextDeadline ? (
<span className="flex items-center gap-2">
{row.nextDeadline.urgency === 'overdue' && (
<Badge variant="destructive">{tClients('deadline_overdue')}</Badge>
)}
{row.nextDeadline.urgency === 'action_needed' && (
<Badge variant="warning">{tClients('deadline_action_needed')}</Badge>
)}
<span
className={cn(
'truncate',
row.nextDeadline.urgency === 'upcoming' && 'text-muted-foreground',
)}
>
{row.nextDeadline.title}
</span>
<span className="text-muted-foreground tabular-nums">
{formatDate(row.nextDeadline.dueDate)}
</span>
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+24 -2
View File
@@ -11,7 +11,7 @@ import { useToast } from '@/components/ui/use-toast'
import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react'
export default function CompanySwitcher() {
const { company, companies, isSandbox } = useCompany()
const { company, companies, isSandbox, foreignCompanies = [] } = useCompany()
const t = useTranslations('company_switcher')
const { toast } = useToast()
const [open, setOpen] = useState(false)
@@ -179,8 +179,30 @@ export default function CompanySwitcher() {
</>
)}
{/* Companies homed on another domain (home-domain rule, WL-01):
non-clickable signposts; the company is worked in over there. */}
{foreignCompanies.length > 0 && (
<div className="border-t border-border/40 mt-1 pt-1 px-1">
<p className="px-2.5 pt-1 pb-0.5 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">
{t('managed_elsewhere')}
</p>
{foreignCompanies.map((entry) => (
<div
key={entry.id}
className="px-2.5 py-1.5 text-[12px] leading-snug text-muted-foreground/60"
aria-disabled="true"
>
<span className="block truncate">{entry.name}</span>
<span className="block truncate text-[10px]">
{t('managed_via', { domain: entry.domain })}
</span>
</div>
))}
</div>
)}
{!isSandbox && (
<div className={cn(companies.length > 0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}>
<div className={cn((companies.length > 0 || foreignCompanies.length > 0) && 'border-t border-border/40 mt-1 pt-1', 'px-1')}>
<Link
href="/select-company?choose=1"
onClick={() => setOpen(false)}
+208 -36
View File
@@ -1,60 +1,169 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import { performCompanySwitch } from '@/lib/company/switch-client'
import { Button } from '@/components/ui/button'
import {
TAB_SYNC_CHANNEL,
TAB_SYNC_STORAGE_KEY,
decodeStorageValue,
guardStore,
isTabMismatch,
requestHasNextActionHeader,
shouldBlockMutation,
} from '@/lib/company/tab-guard'
/**
* CompanyTabSync: cross-tab active company enforcement.
* CompanyTabSync: the cross-tab active-company TAB GUARD (WL-09).
*
* Mounted once inside the dashboard layout (via CompanyProvider), this
* component guarantees that every open tab of the same user always shows
* the same active company. It has three layers:
* The active company is one user_preferences row per user shared across
* every tab, so a switch in one tab silently re-anchors every other tab's
* writes to the new company. Each tab remembers the company it is rendering
* (server-rendered active company at mount). Three detection layers:
*
* 1. BroadcastChannel('gnubok-company-switch')
* When the user switches company in one tab, every other live tab
* receives the message and hard-reloads if its current company differs
* from the broadcasted one.
* 1. BroadcastChannel('gnubok-company-switch'): live cross-tab signal;
* the switch action (lib/company/switch-client) broadcasts.
* 2. `storage` events on the gnubok-active-company key: fallback where
* BroadcastChannel is unavailable; the switch action writes the key.
* 3. visibilitychange + pageshow(persisted): tabs hidden/bfcache-frozen
* during the switch verify against /api/company/current on focus.
*
* 2. visibilitychange
* Catches tabs that were hidden/minimized during a switch. On focus,
* the tab checks /api/company/current and reloads on mismatch, before
* any pixel of stale data is painted to the user.
* On mismatch the tab BLOCKS with a dialog whose only exits are switching
* back to this tab's company or reloading as the new one. Deliberately no
* continue-anyway: continuing would post into the wrong company's books
* (WL-09 resolution; this is not the soft-guard pattern).
*
* 3. pageshow with event.persisted === true
* Catches tabs restored from the browser's bfcache (back/forward
* navigation). bfcache literally freezes the DOM and JS state, so
* neither BroadcastChannel nor visibilitychange fires. pageshow is the
* only reliable signal and is guaranteed to fire on bfcache restore.
* Mutations are guarded too: window.fetch is wrapped while mounted, and a
* mutating same-origin request from a tab that KNOWS the active company
* changed is refused with a synthetic 409 before it leaves the tab (covers
* autosaves and other background writes racing the dialog). Two shapes:
*
* All three layers converge on the same action: window.location.assign('/'),
* a hard navigation that wipes React state, the router cache, in-flight
* requests, blob URLs, and every other in-tab leak vector.
* - /api requests get the canonical JSON error envelope.
* - server-action POSTs (identified by the `next-action` header; verified
* to pass through the patched window.fetch on next@16.2.12) get a
* text/plain 409, which the flight client surfaces as the thrown action
* error. The sanctioned company-switch action passes through
* (guardStore.companySwitchInFlight): it is both dialog exits.
*
* No-op when the user has no active company (renders nothing, attaches no
* listeners).
* Browser-direct Supabase calls go to the Supabase origin and stay outside
* this seam; their write sites call guardBrowserWrite() (lib/company/
* tab-guard.ts), which consults the same guardStore.
*
* No-op when the user has no active company.
*/
// Guard state is the shared guardStore in lib/company/tab-guard.ts so
// browser-direct write sites can consult the same belief. Single dashboard
// shell = single writer (this component).
let originalFetch: typeof window.fetch | null = null
function installFetchGuard(): void {
if (typeof window === 'undefined' || originalFetch) return
originalFetch = window.fetch.bind(window)
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const method =
init?.method ?? (input instanceof Request ? input.method : undefined)
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url
const isServerAction = requestHasNextActionHeader(input, init)
if (
shouldBlockMutation({
method,
url,
pageOrigin: window.location.origin,
tabCompanyId: guardStore.tabCompanyId,
observedCompanyId: guardStore.observedCompanyId,
isServerAction,
companySwitchInFlight: guardStore.companySwitchInFlight,
})
) {
guardStore.notifyBlocked?.()
if (isServerAction) {
// The flight client treats a non-RSC response as a failed action and
// surfaces a text/plain >=400 body as the thrown error's message.
return Promise.resolve(
new Response('Aktivt företag har bytts i en annan flik.', {
status: 409,
headers: { 'Content-Type': 'text/plain' },
}),
)
}
return Promise.resolve(
new Response(
JSON.stringify({
error: {
code: 'ACTIVE_COMPANY_CHANGED',
message: 'Aktivt företag har bytts i en annan flik.',
message_en: 'The active company was switched in another tab.',
},
}),
{ status: 409, headers: { 'Content-Type': 'application/json' } },
),
)
}
return originalFetch!(input as RequestInfo, init)
}) as typeof window.fetch
}
function uninstallFetchGuard(): void {
if (typeof window === 'undefined' || !originalFetch) return
window.fetch = originalFetch
originalFetch = null
}
export default function CompanyTabSync() {
const { company } = useCompany()
const t = useTranslations('company_tab_guard')
const currentCompanyId = company?.id ?? null
const [mismatch, setMismatch] = useState(false)
const [resolving, setResolving] = useState(false)
useEffect(() => {
const hardReload = () => {
window.location.assign('/')
if (!currentCompanyId) return
guardStore.tabCompanyId = currentCompanyId
guardStore.observedCompanyId = null
guardStore.notifyBlocked = () => {
// A self-initiated switch is hard-navigating this tab away: blocked
// stray writes still get their 409, but the "switched in another tab"
// dialog would just flash over the tab's own page load.
if (!guardStore.selfSwitchTargetId) setMismatch(true)
}
installFetchGuard()
const observe = (observedId: string | null | undefined) => {
if (observedId === null || observedId === undefined) return
guardStore.observedCompanyId = observedId
if (observedId === guardStore.selfSwitchTargetId) return
if (isTabMismatch(currentCompanyId, observedId)) {
setMismatch(true)
}
}
// Layer 1: BroadcastChannel: live cross-tab sync
let channel: BroadcastChannel | null = null
if (typeof BroadcastChannel !== 'undefined') {
channel = new BroadcastChannel('gnubok-company-switch')
channel = new BroadcastChannel(TAB_SYNC_CHANNEL)
channel.onmessage = (event: MessageEvent<{ companyId: string | null }>) => {
const incomingId = event.data?.companyId ?? null
if (incomingId !== currentCompanyId) {
hardReload()
}
observe(event.data?.companyId ?? null)
}
}
// Layer 2: visibilitychange: on focus, verify against server
// Layer 1b: storage-event fallback (fires in every OTHER tab)
const handleStorage = (event: StorageEvent) => {
if (event.key !== TAB_SYNC_STORAGE_KEY) return
observe(decodeStorageValue(event.newValue))
}
window.addEventListener('storage', handleStorage)
// Layer 2: on focus, verify against the server
const checkServer = async () => {
try {
const res = await fetch('/api/company/current', {
@@ -63,11 +172,9 @@ export default function CompanyTabSync() {
})
if (!res.ok) return
const data = (await res.json()) as { companyId: string | null }
if (data.companyId !== currentCompanyId) {
hardReload()
}
observe(data.companyId)
} catch {
// Network error / offline: do nothing (don't accidentally reload-loop)
// Network error / offline: do nothing (don't false-positive the guard)
}
}
@@ -81,6 +188,10 @@ export default function CompanyTabSync() {
// Layer 3: pageshow (persisted === true): bfcache restore
const handlePageShow = (event: PageTransitionEvent) => {
if (event.persisted) {
// A bfcache-restored page renders the OLD company after this tab's
// own switch (back-navigation): the self-switch marker must not keep
// suppressing the dialog here.
guardStore.selfSwitchTargetId = null
void checkServer()
}
}
@@ -88,10 +199,71 @@ export default function CompanyTabSync() {
return () => {
channel?.close()
window.removeEventListener('storage', handleStorage)
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow)
guardStore.tabCompanyId = null
guardStore.observedCompanyId = null
guardStore.notifyBlocked = null
guardStore.selfSwitchTargetId = null
uninstallFetchGuard()
}
}, [currentCompanyId])
return null
if (!mismatch || !currentCompanyId) return null
const handleSwitchBack = async () => {
setResolving(true)
// Re-activate THIS tab's company and reload the page we are on (same
// company, so its data is still valid). Other tabs get the broadcast and
// face the same dialog: the hazard is symmetric by design.
const result = await performCompanySwitch(currentCompanyId, {
destination: window.location.pathname + window.location.search,
})
if (result?.error) {
// Membership lost or persist failure: this tab cannot win the company
// back, so the only safe exit left is loading the new company.
window.location.assign('/')
}
}
const handleReloadAsNew = () => {
setResolving(true)
// Land on the start page: the current path may not exist (or worse, mean
// another object) under the new company.
window.location.assign('/')
}
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4"
role="alertdialog"
aria-modal="true"
aria-labelledby="company-tab-guard-title"
>
{/* Veil: deliberately no click-to-close and no Esc: the dialog is
blocking, its two buttons are the only exits (WL-09). */}
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm" aria-hidden="true" />
<div className="relative w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<h2 id="company-tab-guard-title" className="font-display text-lg leading-6">
{t('title')}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t('body', { company: company?.name ?? '' })}
</p>
<div className="mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
variant="outline"
disabled={resolving}
onClick={handleReloadAsNew}
>
{t('reload_as_new')}
</Button>
<Button disabled={resolving} onClick={() => void handleSwitchBack()}>
{t('switch_back', { company: company?.name ?? '' })}
</Button>
</div>
</div>
</div>
)
}
+126 -27
View File
@@ -2,8 +2,7 @@
import { useEffect, useState, useRef } from 'react'
import { NavLink } from './NavLink'
import Image from 'next/image'
import { usePathname, useRouter } from 'next/navigation'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@@ -45,11 +44,15 @@ import {
PanelLeftClose,
Library,
BookCheck,
Briefcase,
ArrowLeft,
Workflow,
FolderArchive,
ShoppingCart,
Car,
} from 'lucide-react'
import { getBranding } from '@/lib/branding/service'
import { BrandHomeLink } from '@/components/branding/BrandHomeLink'
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
@@ -140,6 +143,9 @@ type NavLabelKey =
| 'income_declaration'
| 'help'
| 'settings'
| 'clients'
| 'automations'
| 'back_to_clients'
// Nav layout (July 2026, UI-migration concept, dev_docs/ui_migration_plan.md
// PR 2): same routes, concept structure.
@@ -192,6 +198,9 @@ interface NavItem {
// Statutory surfaces that only exist for one company form (INK2 vs
// NE-bilaga, årsredovisning): hidden for the other entity type.
entityOnly?: EntityType
// Byrå cockpit surfaces (WL-14): visible only to byrå team members
// (teams.kind = 'byra'). The /clients page + API enforce server-side.
byraOnly?: boolean
hidden?: boolean
comingSoon?: boolean
devBadge?: boolean
@@ -205,6 +214,8 @@ const navItems: NavItem[] = [
// Top section: flat list, always visible, no header. (Flöden joins here
// when the flow engine exists.)
{ href: '/', labelKey: 'home', icon: Home, group: 'top' },
// Byrå cockpit (WL-14): the client list, byrå team members only.
{ href: '/clients', labelKey: 'clients', icon: Briefcase, group: 'top', byraOnly: true },
{ href: '/chat', labelKey: 'assistant', icon: Sparkles, group: 'top' },
// Arbeta: everything the user produces, bookkeeping funnel first
// (Bokföring · Underlag · Transaktioner · Granskning), then the
@@ -255,6 +266,24 @@ const navItems: NavItem[] = [
{ href: '/reports/ne-declaration', labelKey: 'income_declaration', icon: FileSpreadsheet, group: 'skatt', fold: 'bokslut', entityOnly: 'enskild_firma' },
]
// Byrå cockpit sidebar (lean mode): while a byrå team member is on a
// cockpit route (client overview + the /byra pages) the sidebar shows only
// these four byrå-scope entries instead of the full company nav. Entering a
// company (performCompanySwitch from the client list) is a hard navigation
// to a company route, which swaps the full list back in.
const cockpitNavItems: NavItem[] = [
{ href: '/byra', labelKey: 'home', icon: Home, group: 'top' },
{ href: '/clients', labelKey: 'clients', icon: Briefcase, group: 'top' },
{ href: '/byra/automations', labelKey: 'automations', icon: Workflow, group: 'top' },
{ href: '/byra/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'top' },
]
// Cockpit routes are byrå-scope, not company-scope: they decide which
// sidebar variant renders and stay enabled without an active company.
const COCKPIT_PATHS = ['/byra', '/clients']
const isCockpitPath = (pathname: string) =>
COCKPIT_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`))
// Fold header presentation (label + icon). The fold rows themselves come
// from navItems entries carrying the matching `fold` key.
const foldConfig: Record<FoldKey, { labelKey: string; icon: typeof LayoutDashboard }> = {
@@ -304,7 +333,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const pathname = usePathname()
const router = useRouter()
const supabase = useRealtimeSupabase()
const { company, capabilities } = useCompany()
const { company, capabilities, byraTeam } = useCompany()
// Agent identity drives the "Assistent" nav icon: when the user has
// built their assistant we show its chosen avatar instead of the
// generic Sparkles glyph.
@@ -324,8 +353,34 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
refresh: refreshBadges,
} = useWorklistBadges(company?.id)
const hasCompany = !!company
const ALWAYS_ENABLED = new Set(['/settings'])
const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href)
// Byrå cockpit mode: lean sidebar on cockpit routes, full sidebar (with a
// back-to-clients link) once the member is inside a company. Settings opens
// as a modal OVER the current surface (intercepted route), so while the
// pathname is /settings/* the sidebar keeps the mode of the page underneath
// instead of flipping to the company nav behind the modal.
const onSettings = pathname.startsWith('/settings')
const onCockpitPath = isCockpitPath(pathname)
// Settings opened from the cockpit carry ?ctx=byra (user menu / mobile
// nav links, preserved by SettingsRail). It is the signal that SURVIVES a
// hard refresh, when the previous-surface memory below starts over at
// false and the sidebar used to flip to the full company nav.
const ctxByra = useSearchParams().get('ctx') === 'byra'
// Previous-render memory via the adjust-state-during-render pattern
// (react.dev: storing information from previous renders); a ref would be
// simpler but refs must not be read or written during render.
const [lastNonSettingsCockpit, setLastNonSettingsCockpit] = useState(false)
if (!onSettings && onCockpitPath !== lastNonSettingsCockpit) {
setLastNonSettingsCockpit(onCockpitPath)
}
const cockpitMode =
!!byraTeam && (onSettings ? lastNonSettingsCockpit || ctxByra : onCockpitPath)
// Byrå-scope surfaces, not company surfaces: they must stay reachable even
// when the active company is unresolved.
const ALWAYS_ENABLED = new Set(['/settings', '/clients', '/byra', '/byra/automations', '/byra/kpi'])
const isItemEnabled = (href: string) => {
const base = href.split('?')[0]
return hasCompany || ALWAYS_ENABLED.has(base) || base.startsWith('/settings')
}
type ExpandableGroup = Exclude<GroupKey, 'top'>
// Sidebar collapse (64px icon rail). The width is CSS-variable-driven:
@@ -378,6 +433,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
if (href === '/') {
return pathname === '/'
}
// Cockpit Hem: exact match so /byra/automations and /byra/kpi light up
// their own rows only.
if (href === '/byra') {
return pathname === '/byra'
}
if (href === '/salary') {
return pathname === '/salary' || pathname.startsWith('/salary/runs')
}
@@ -486,7 +546,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const isEmployer = entityType === 'aktiebolag' || paysSalaries
const filteredItems = navItems.filter(item => {
const filteredItems = (cockpitMode ? cockpitNavItems : navItems).filter(item => {
if (item.hidden) return false
if (hiddenNavHrefs.has(item.href)) return false
// Payroll (employerOnly) is hidden until the company is an employer, an
@@ -508,6 +568,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
// Entity-gated statutory surfaces: INK2/ÅR for aktiebolag, NE for
// enskild firma; the page for the other form doesn't exist.
if (item.entityOnly && item.entityOnly !== entityType) return false
// Byrå cockpit: the Klienter entry lives in the lean cockpit sidebar
// (cockpitNavItems); in company mode the pinned back-to-clients link
// replaces it, and non-byrå users never see it (WL-14).
if (item.byraOnly) return false
// Hide the Assistent (/chat) tab until the agent is built: mirrors the
// floating AgentTrigger and avoids a nav entry that only bounces to the
// home checklist (chat/layout redirects unverified users to /).
@@ -523,11 +587,14 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
// The TIC workspace (/e/general/tic, labelled "Företagsprofil") surfaces
// the same Bolagsuppgifter now shown under Inställningar → Företagsprofil.
// Drop it from the nav so the company profile lives in exactly one place.
const visibleExtensionNavItems = extensionNavItems.filter(
(i) => i.href !== '/e/general/tic',
)
// Extension workspaces are company surfaces: none in cockpit mode.
const visibleExtensionNavItems = cockpitMode
? []
: extensionNavItems.filter((i) => i.href !== '/e/general/tic')
const sidebarGroups: { key: ExpandableGroup; items: NavItem[] }[] = [
const sidebarGroups: { key: ExpandableGroup; items: NavItem[] }[] = cockpitMode
? []
: [
{ key: 'arbeta', items: filteredItems.filter((i) => i.group === 'arbeta') },
{ key: 'analys', items: filteredItems.filter((i) => i.group === 'analys') },
{ key: 'data', items: filteredItems.filter((i) => i.group === 'data') },
@@ -538,7 +605,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
// as plain icons (group headers and fold headers disappear).
const railItems = [...topItems, ...sidebarGroups.flatMap(({ items }) => items)]
const allMobileNavItems: { href: string; labelKey: NavLabelKey; icon: typeof LayoutDashboard }[] = [
const allMobileNavItems: { href: string; labelKey: NavLabelKey; icon: typeof LayoutDashboard }[] = cockpitMode
? cockpitNavItems.map(({ href, labelKey, icon }) => ({ href, labelKey, icon }))
: [
{ href: '/', labelKey: 'home', icon: Home },
{ href: '/chat', labelKey: 'assistant', icon: Sparkles },
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight },
@@ -712,19 +781,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
: 'justify-between pl-5 pr-3',
)}
>
<NavLink
href="/"
aria-label={getBranding().appName}
className="flex items-center rounded-lg"
>
<Image
src={getBranding().logoPath}
alt=""
width={26}
height={26}
className="h-[26px] w-[26px] rounded-lg"
/>
</NavLink>
{/* Brand mark (WL-12 slice A3): brand logo on branded hosts,
the exact legacy image everywhere else. The expanded sidebar
also shows the brand domain prefix beside the mark. */}
<BrandHomeLink showLabel={!collapsed} />
<button
type="button"
onClick={toggleCollapsed}
@@ -760,6 +820,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
)}
aria-label={tNav('main_navigation')}
>
{/* Rail counterpart of the back-to-clients link. */}
{byraTeam &&
!cockpitMode &&
renderRailItem({ href: '/clients', labelKey: 'back_to_clients', icon: ArrowLeft })}
{railItems.map((item) => renderRailItem(item))}
{visibleExtensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
@@ -799,6 +863,21 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
)}
aria-label={tNav('main_navigation')}
>
{/* Byrå members inside a company: pinned route back to the
cockpit, above everything, separated by a hairline. */}
{byraTeam && !cockpitMode && (
<div className="mb-2">
<NavLink
href="/clients"
className="group flex items-center px-3 py-[7px] text-[13px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors duration-150"
>
<ArrowLeft className="mr-2.5 h-[15px] w-[15px] flex-shrink-0 text-muted-foreground group-hover:text-foreground" />
<span className="flex-1">{tNav('back_to_clients')}</span>
</NavLink>
<div className="mx-3 mt-2 border-t border-border/60" />
</div>
)}
{/* Top section: flat, no header. Hem, Assistent. */}
<div className="mb-4 space-y-px">
{topItems.map((item) => renderSidebarItem(item))}
@@ -899,6 +978,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
userEmail={userEmail}
isSandbox={isSandbox}
collapsed={collapsed}
cockpitMode={cockpitMode}
onLogout={() => void handleLogout()}
/>
</div>
@@ -906,8 +986,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
</div>
</aside>
{/* Mobile bottom navigation */}
<nav data-ph-unmask className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label={tNav('mobile_navigation')}>
{/* Mobile bottom navigation. data-mobile-nav is the brand-style hook:
on branded hosts the brand style block re-tints the bar's tokens
(--card/--border/--primary...) to the deep chrome, mirroring the
sidebar; on default hosts the attribute matches nothing. */}
<nav data-mobile-nav="" data-ph-unmask className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-card/98 backdrop-blur-sm border-t border-border/40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} aria-label={tNav('mobile_navigation')}>
<div className="flex items-center justify-around h-16 px-2">
{mobileNavItems.map((item) => {
const active = isActive(item.href)
@@ -1013,6 +1096,20 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
CompanySwitcher above stays outside so it remains masked,
and count bubbles inside carry data-ph-mask. */}
<div data-ph-unmask className="px-2">
{/* Byrå members inside a company: route back to the cockpit. */}
{byraTeam && !cockpitMode && (
<div className="mb-1.5">
<NavLink
href="/clients"
onClick={closeMobileMenu}
className="flex items-center gap-3 px-3 min-h-[44px] rounded-lg text-foreground active:bg-muted/60 transition-colors"
>
<ArrowLeft className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
<span className="text-sm flex-1">{tNav('back_to_clients')}</span>
</NavLink>
<div className="mx-3 mt-1.5 h-px bg-border/30" />
</div>
)}
{/* Top items (Hem, Assistent) */}
<div className="space-y-0.5">
{topItems.map((item) => {
@@ -1180,7 +1277,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
link; hides itself for sandbox and paying companies). */}
<SubscriptionTouchpoint variant="mobile" onNavigate={closeMobileMenu} />
{([
{ href: '/settings', labelKey: 'settings' as NavLabelKey, icon: Settings },
// Cockpit: settings open in byrå scope (account-level
// sections only), same as the desktop user menu.
{ href: cockpitMode ? '/settings/account?ctx=byra' : '/settings', labelKey: 'settings' as NavLabelKey, icon: Settings },
{ href: '/help', labelKey: 'help' as NavLabelKey, icon: HelpCircle },
]).map((item) => {
const Icon = item.icon
+103
View File
@@ -0,0 +1,103 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { ArrowUpRight, Building2, Loader2 } from 'lucide-react'
import { performCompanySwitch } from '@/lib/company/switch-client'
import { useToast } from '@/components/ui/use-toast'
/**
* The home-domain signpost (WL-01): shown instead of the dashboard when the
* active company is homed on another domain. Never a silent redirect:
* sessions are per domain, so the user must log in again over there, and the
* signpost explains why. Companies homed HERE are offered as one-click
* switches; companies homed elsewhere link to their home domain.
*/
export default function HomeDomainSignpost({
activeCompanyName,
homedCompanies,
foreignCompanies,
}: {
activeCompanyName: string
homedCompanies: Array<{ id: string; name: string }>
foreignCompanies: Array<{ id: string; name: string; domain: string }>
}) {
const t = useTranslations('signpost')
const { toast } = useToast()
const [pendingId, setPendingId] = useState<string | null>(null)
const handleSwitch = async (companyId: string) => {
setPendingId(companyId)
const result = await performCompanySwitch(companyId, { destination: '/' })
if (result?.error) {
setPendingId(null)
toast({ title: t('switch_failed'), variant: 'destructive' })
}
}
return (
<div className="stagger-enter mx-auto max-w-lg pt-12">
<div className="flex flex-col items-center text-center">
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-xl bg-muted/60">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<h1 className="font-display text-2xl leading-8 tracking-tight">
{t('title')}
</h1>
<p className="mt-2 text-sm text-muted-foreground text-balance">
{t('body', { company: activeCompanyName })}
</p>
</div>
{foreignCompanies.length > 0 && (
<div className="mt-8">
<ul className="divide-y divide-border border-y border-border">
{foreignCompanies.map((entry) => (
<li key={entry.id}>
<a
href={`https://${entry.domain}`}
className="group flex items-center gap-3 px-1 py-3 transition-colors duration-150 hover:bg-secondary/35"
>
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
{t('managed_via', { company: entry.name, domain: entry.domain })}
</span>
<ArrowUpRight className="h-4 w-4 flex-shrink-0 text-muted-foreground group-hover:text-foreground" />
</a>
</li>
))}
</ul>
<p className="mt-2 text-xs text-muted-foreground">{t('login_hint')}</p>
</div>
)}
{homedCompanies.length > 0 && (
<div className="mt-10">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('homed_here')}
</h2>
<ul className="mt-2 divide-y divide-border border-y border-border">
{homedCompanies.map((entry) => (
<li key={entry.id}>
<button
type="button"
onClick={() => void handleSwitch(entry.id)}
disabled={pendingId !== null}
className="flex w-full items-center gap-3 px-1 py-3 text-left transition-colors duration-150 hover:bg-secondary/35 disabled:opacity-50"
>
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
{entry.name}
</span>
{pendingId === entry.id ? (
<Loader2 className="h-4 w-4 flex-shrink-0 animate-spin text-muted-foreground" />
) : (
<span className="text-xs text-muted-foreground">{t('open_company')}</span>
)}
</button>
</li>
))}
</ul>
</div>
)}
</div>
)
}
+5 -1
View File
@@ -3,6 +3,7 @@
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { AttnLine } from '@/components/ui/attn-line'
import { useBranding } from '@/lib/branding/brand-context'
import type { Notice, NoticeCategory } from '@/lib/notices/types'
interface NoticeLinesProps {
@@ -26,6 +27,9 @@ interface NoticeLinesProps {
*/
export default function NoticeLines({ notices, actionOverrides = {} }: NoticeLinesProps) {
const t = useTranslations('notices')
// Brand-aware app name for messages that mention the platform (WL-12);
// identical to the default on unbranded hosts.
const { appName } = useBranding()
const [expanded, setExpanded] = useState(false)
const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set())
@@ -81,7 +85,7 @@ export default function NoticeLines({ notices, actionOverrides = {} }: NoticeLin
</>
}
>
{t(notice.messageKey, notice.messageParams)}
{t(notice.messageKey, { appName, ...notice.messageParams })}
</AttnLine>
)
})}
+60 -13
View File
@@ -44,6 +44,10 @@ interface UserMenuProps {
userEmail: string | null
isSandbox: boolean
collapsed: boolean
// Byrå cockpit (lean sidebar): the cockpit is above the companies, so the
// widget shows no active company and no company-switcher flyout; entering
// a client happens through the Klienter list instead.
cockpitMode?: boolean
onLogout: () => void
}
@@ -82,9 +86,10 @@ export default function UserMenu({
userEmail,
isSandbox,
collapsed,
cockpitMode = false,
onLogout,
}: UserMenuProps) {
const { company, companies, isSandbox: companyCtxSandbox } = useCompany()
const { company, companies, isSandbox: companyCtxSandbox, foreignCompanies = [] } = useCompany()
const tNav = useTranslations('nav')
const tCommon = useTranslations('common')
const tSwitcher = useTranslations('company_switcher')
@@ -172,7 +177,9 @@ export default function UserMenu({
}, [open, companiesOpen, close])
const handleSwitch = async (companyId: string) => {
if (company && companyId === company.id) {
// In the cockpit nothing is "current": picking any company, including the
// technically-active one, must enter it (full navigation to its start).
if (!cockpitMode && company && companyId === company.id) {
close()
return
}
@@ -220,7 +227,7 @@ export default function UserMenu({
<span className="block truncate text-[13px] font-medium text-foreground leading-tight">
{userName?.trim() || userEmail || tNav('mitt_konto')}
</span>
{company && (
{company && !cockpitMode && (
<span className="block truncate text-[11px] text-muted-foreground leading-tight">
{company.name}
</span>
@@ -251,7 +258,9 @@ export default function UserMenu({
</div>
)}
{/* Company switcher flyout */}
{/* Company switcher flyout. In the cockpit no company reads as
active (neutral label, no check mark); picking one enters it
like the Klienter list does. */}
<div className="relative px-1 pt-1">
<button
type="button"
@@ -261,7 +270,9 @@ export default function UserMenu({
>
<CompanyMark name={company?.name || tSwitcher('default_company_name')} />
<span className="flex-1 truncate">
{company?.name || tSwitcher('default_company_name')}
{cockpitMode
? tSwitcher('choose_company')
: company?.name || tSwitcher('default_company_name')}
</span>
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 opacity-50" />
</button>
@@ -286,16 +297,18 @@ export default function UserMenu({
{tSwitcher('no_results')}
</p>
)}
{filteredCompanies.map(({ company: c, role }) => (
{filteredCompanies.map(({ company: c, role }) => {
const isCurrent = !cockpitMode && c.id === company?.id
return (
<button
key={c.id}
onClick={() => handleSwitch(c.id)}
disabled={isPending}
role="option"
aria-selected={c.id === company?.id}
aria-selected={isCurrent}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2.5 py-2 text-left text-[13px] leading-snug transition-colors',
c.id === company?.id
isCurrent
? 'bg-secondary/60 text-foreground'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
isPending && 'opacity-50',
@@ -308,15 +321,37 @@ export default function UserMenu({
{role}
</span>
)}
{c.id === company?.id && (
{isCurrent && (
<Check className="h-3.5 w-3.5 flex-shrink-0 text-primary" />
)}
{isPending && c.id !== company?.id && (
{isPending && !isCurrent && (
<Loader2 className="h-3 w-3 flex-shrink-0 animate-spin text-muted-foreground" />
)}
</button>
)
})}
</div>
{/* Companies homed on another domain (home-domain rule,
WL-01): subtle, non-clickable signpost entries. */}
{foreignCompanies.length > 0 && (
<div className="border-t border-border/60 px-1 pt-1">
<p className="px-2.5 pt-1 pb-0.5 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">
{tSwitcher('managed_elsewhere')}
</p>
{foreignCompanies.map((entry) => (
<div
key={entry.id}
className="px-2.5 py-1.5 text-[12px] leading-snug text-muted-foreground/60"
aria-disabled="true"
>
<span className="block truncate">{entry.name}</span>
<span className="block truncate text-[10px]">
{tSwitcher('managed_via', { domain: entry.domain })}
</span>
</div>
))}
</div>
)}
{!sandbox && (
<div className="border-t border-border/60 px-1 pt-1">
<Link href="/select-company?choose=1" onClick={close} className={menuRow}>
@@ -329,20 +364,32 @@ export default function UserMenu({
)}
</div>
{/* Account links */}
{/* Account links. From the cockpit, settings open in byrå scope
(?ctx=byra): account-level sections only; Abonnemang is
company-scoped and hidden there. */}
<div className="px-1 pb-1">
<Link href="/settings" onClick={close} className={menuRow}>
<Link
href={cockpitMode ? '/settings/account?ctx=byra' : '/settings'}
onClick={close}
className={menuRow}
>
<Settings className="h-4 w-4 flex-shrink-0" />
{tNav('settings')}
</Link>
<Link href="/settings/company#members" onClick={close} className={menuRow}>
<Link
href={cockpitMode ? '/settings/team?ctx=byra' : '/settings/company#members'}
onClick={close}
className={menuRow}
>
<Users className="h-4 w-4 flex-shrink-0" />
{tNav('members_roles')}
</Link>
{!cockpitMode && (
<Link href="/settings/billing" onClick={close} className={menuRow}>
<CreditCard className="h-4 w-4 flex-shrink-0" />
{tNav('subscription')}
</Link>
)}
<div className="my-1 border-t border-border/60" />
<Link href="/help" onClick={close} className={menuRow}>
<HelpCircle className="h-4 w-4 flex-shrink-0" />
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { guardBrowserWrite } from '@/lib/company/tab-guard'
import { useToast } from '@/components/ui/use-toast'
import { PaymentCalendar } from '@/extensions/general/calendar/components/PaymentCalendar'
import type { DeadlineFormValues } from '@/components/deadlines/DeadlineForm'
@@ -62,6 +63,9 @@ export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
}, [fetchData])
const handleDeadlineCreate = async (data: DeadlineFormValues) => {
// Cross-tab guard (WL-09): browser-direct Supabase write, outside the
// patched-fetch seam. The blocking dialog is the user feedback.
if (!guardBrowserWrite()) return
try {
const { error } = await supabase.from('deadlines').insert([data])
@@ -83,6 +87,7 @@ export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
}
const handleDeadlineToggle = async (deadline: Deadline) => {
if (!guardBrowserWrite()) return
try {
const { error } = await supabase
.from('deadlines')
@@ -20,6 +20,7 @@ import { getErrorMessage as getUserErrorMessage, type ErrorLocale } from '@/lib/
import { useFormat } from '@/lib/hooks/use-format'
import { useCompany } from '@/contexts/CompanyContext'
import { copyToClipboard } from '@/lib/browser/copy-to-clipboard'
import { useBranding } from '@/lib/branding/brand-context'
const BASE = '/api/extensions/ext/invoice-inbox/inbox/domain'
@@ -43,6 +44,7 @@ interface Props {
// server-side: this surface only manages the claim lifecycle.
export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
const { toast } = useToast()
const { appName } = useBranding()
const t = useTranslations('inbox_custom_domain')
const { locale, formatDateLong } = useFormat()
const errorLocale = locale as ErrorLocale
@@ -131,7 +133,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
const handleRemove = useCallback(async () => {
if (!domain) return
if (!confirm(t('remove_confirm', { domain: domain.domain }))) return
if (!confirm(t('remove_confirm', { domain: domain.domain, appName }))) return
setIsRemoving(true)
try {
const res = await fetch(BASE, { method: 'DELETE' })
@@ -148,7 +150,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
} finally {
setIsRemoving(false)
}
}, [domain, errorLocale, t, toast])
}, [domain, errorLocale, t, toast, appName])
const handleCopy = useCallback(
async (value: string) => {
@@ -208,7 +210,7 @@ export default function InboxCustomDomainDialog({ open, onOpenChange }: Props) {
<div className="space-y-1">
<p className="font-medium">{t('warning_title')}</p>
<p className="text-muted-foreground">
{t('warning_before_subdomain')}{' '}
{t('warning_before_subdomain', { appName })}{' '}
<code className="font-mono text-xs">faktura.dittbolag.se</code>{' '}
{t('warning_after_subdomain')}
</p>
@@ -60,6 +60,7 @@ import { fetchWithTimeout } from '@/lib/http/fetch-with-timeout'
import { copyInboxAddress, type AddressCopyState } from '@/components/extensions/general/inbox-address-copy'
import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { useBranding } from '@/lib/branding/brand-context'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { InboxChannelContext, InvoiceExtractionResult, InboxItemSource } from '@/types'
import { renderChannelParticipant } from '@/lib/documents/channel-context-notes'
@@ -2688,6 +2689,7 @@ function FieldsRail({
}) {
const { toast } = useToast()
const hasAi = useCapability(CAPABILITY.ai)
const { appName } = useBranding()
const data = item.extracted_data
const [proposal, setProposal] = useState<SuggestedBooking | null>(null)
const [editOpen, setEditOpen] = useState(false)
@@ -3047,7 +3049,7 @@ function FieldsRail({
AI-tolkning ingår i abonnemanget
</div>
<p className="text-xs text-muted-foreground mt-1.5 leading-relaxed">
Uppgradera för att låta Accounted läsa av leverantör, belopp och
Uppgradera för att låta {appName} läsa av leverantör, belopp och
moms automatiskt. Du kan fortfarande fylla i fälten manuellt eller
koppla dokumentet till en transaktion nedan.
</p>
@@ -5,6 +5,7 @@ import { ChevronRight, ExternalLink } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { DetailSection } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { useBranding } from '@/lib/branding/brand-context'
import type { InvoiceDelivery, InvoiceDeliveryProviderStatus } from '@/types'
export type InvoiceDeliveryView = Pick<
@@ -68,6 +69,7 @@ export function InvoiceDeliveryHistory({
}: InvoiceDeliveryHistoryProps) {
const t = useTranslations('invoice_detail')
const format = useFormatter()
const { appName } = useBranding()
if (deliveries.length === 0 && !showLegacyEmptyState) return null
@@ -133,7 +135,7 @@ export function InvoiceDeliveryHistory({
<div className="pb-4 pl-6 pt-1">
{isManual ? (
<p className="text-sm text-muted-foreground">
{t('delivery_manual_unknown_details')}
{t('delivery_manual_unknown_details', { appName })}
</p>
) : (
<dl className="grid gap-x-6 gap-y-1 text-sm sm:grid-cols-[8rem_minmax(0,1fr)]">
+3 -1
View File
@@ -50,6 +50,7 @@ import {
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useBranding } from '@/lib/branding/brand-context'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -178,6 +179,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const ts = useTranslations('self_billing')
const ta = useTranslations('accruals')
const tCommon = useTranslations('common')
const { appName } = useBranding()
// Normal customer invoice (default) or a received self-billing invoice
// (mottagen självfaktura, ML 17 kap 15§). The mode is chosen upstream in
// the Ny faktura split button (?self=1) and is fixed for the editor's
@@ -1780,7 +1782,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
window.URL.revokeObjectURL(url)
toast({
title: t('preview_pdf_failed'),
description: tCommon('popup_blocked_description'),
description: tCommon('popup_blocked_description', { appName }),
variant: 'destructive',
})
return
@@ -11,6 +11,7 @@ import { parseStartMonthDay } from '@/lib/company/first-year-defaults'
import { fetchCompanyLookup } from '@/lib/company-lookup/fetch-company-lookup'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useBranding } from '@/lib/branding/brand-context'
import posthog from 'posthog-js'
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
import {
@@ -98,6 +99,7 @@ export default function OnboardingJourney({
}: OnboardingJourneyProps) {
const router = useRouter()
const t = useTranslations('onboarding')
const { appName } = useBranding()
const locale = useLocale()
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
@@ -704,7 +706,7 @@ export default function OnboardingJourney({
<div className="jny-dawn" aria-hidden="true" />
{mode === 'add' && state.step !== 'done' ? (
<Link href="/" className="jny-btn-quiet jny-escape">
&lsaquo; {t('journey_cancel_add')}
&lsaquo; {t('journey_cancel_add', { appName })}
</Link>
) : null}
<div className="jny-center">
@@ -749,7 +751,7 @@ export default function OnboardingJourney({
{dupName && station === 0 ? (
<span className="jny-f is-on" style={{ transitionDelay: `${lookupFacts.length * 150}ms` }}>
{lookupFacts.length > 0 ? ' · ' : ''}
{t('journey_dup_note', { name: dupName })}
{t('journey_dup_note', { name: dupName, appName })}
</span>
) : null}
{!dupName && dupElsewhere && station === 0 ? (
@@ -759,7 +761,7 @@ export default function OnboardingJourney({
// more specific hint.
<span className="jny-f is-on is-warn" style={{ transitionDelay: `${lookupFacts.length * 150}ms` }}>
{lookupFacts.length > 0 ? ' · ' : ''}
{t('journey_dup_elsewhere_note')}
{t('journey_dup_elsewhere_note', { appName })}
</span>
) : null}
</div>
@@ -1030,6 +1032,7 @@ function DoneStep({
onOpen: () => void
onContinue: () => void
}) {
const { appName } = useBranding()
const s = state.settings
const shortName = (s.company_name ?? '').split(' ')[0] || ''
const rows: [string, string][] = [
@@ -1089,7 +1092,7 @@ function DoneStep({
) : (
<div className="jny-qactions">
<button type="button" className="jny-btn" onClick={onOpen}>
{t('journey_open_app')}
{t('journey_open_app', { appName })}
</button>
</div>
)}
+4 -2
View File
@@ -20,6 +20,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { SettingsGroup, SettingsInput, SettingsRow } from '@/components/settings/SettingsRows'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useBranding } from '@/lib/branding/brand-context'
interface OpeningBalancesData {
cutover_date: string
@@ -70,6 +71,7 @@ function fingerprint(v: PanelValues): string {
export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) {
const t = useTranslations('salary_employee')
const { appName } = useBranding()
const { toast } = useToast()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -234,7 +236,7 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
>
<DetailSection
kicker={t('opening_balances_title')}
help={<HelpPopover>{t('opening_balances_description')}</HelpPopover>}
help={<HelpPopover>{t('opening_balances_description', { appName })}</HelpPopover>}
aside={
<Button
type="button"
@@ -263,7 +265,7 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
<SettingsRow
label={t('opening_balances_cutover_date')}
htmlFor="ob-cutover"
help={t('opening_balances_cutover_hint')}
help={t('opening_balances_cutover_hint', { appName })}
align="baseline"
>
<SettingsInput
+7 -1
View File
@@ -5,18 +5,24 @@ import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
import { Badge } from '@/components/ui/badge'
import { useByraSettingsScope } from './useSettingsNavItems'
/**
* Quiet chip naming the active company. Settings surfaces (the routed modal
* and the full-page fallback) cover or dim the sidebar's CompanySwitcher, so
* without this the user edits company-scoped settings with no visible answer
* to "which company am I on?" (support feedback 2026-07-19).
*
* Byrå scope (?ctx=byra): no chip. The cockpit sits above the companies and
* only account-level sections show, so naming a technically-active client
* here would read as "you are inside this company" (mirrors SettingsModal).
*/
export function ActiveCompanyBadge({ className }: { className?: string }) {
const { company } = useCompany()
const byraScope = useByraSettingsScope()
const t = useTranslations('common')
if (!company) return null
if (!company || byraScope) return null
return (
<Badge
+6 -4
View File
@@ -9,6 +9,7 @@ import {
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import { useBranding } from '@/lib/branding/brand-context'
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>
@@ -38,6 +39,7 @@ function getIsStandalone() {
*/
export function InstallAppSection() {
const t = useTranslations('settings')
const { appName } = useBranding()
// Server snapshot says standalone so the row is absent from server HTML
// and only appears client-side when actually running in a browser tab.
const isStandalone = useSyncExternalStore(subscribeDisplayMode, getIsStandalone, () => true)
@@ -74,13 +76,13 @@ export function InstallAppSection() {
const isIos = /iPad|iPhone|iPod/.test(ua)
const isSafari = /Safari/.test(ua) && !/Chrome|Chromium|Edg|CriOS/.test(ua)
const hint = isIos
? t('install_app_hint_ios')
? t('install_app_hint_ios', { appName })
: isSafari
? t('install_app_hint_safari')
: t('install_app_hint_generic')
? t('install_app_hint_safari', { appName })
: t('install_app_hint_generic', { appName })
return (
<SettingsRow label={t('install_app_title')} help={t('install_app_description')}>
<SettingsRow label={t('install_app_title')} help={t('install_app_description', { appName })}>
{installPrompt ? (
<SettingsRowEnd>
<Button variant="outline" size="sm" onClick={handleInstall}>
+9 -3
View File
@@ -11,6 +11,7 @@ import {
} from '@/components/ui/dialog'
import { SETTINGS_SECTIONS } from './sections'
import { SettingsShell } from './SettingsShell'
import { useByraSettingsScope } from './useSettingsNavItems'
/**
* The settings popup. Rendered only by the intercepting route
@@ -34,11 +35,15 @@ export function SettingsModal({ sectionId }: { sectionId?: string }) {
// sectionId route param only covers the very first intercepted render.
// Bare /settings (or an unknown section) defaults to company, or to account
// when there is no active company (the no-company escape hatch).
// Byrå scope (?ctx=byra, settings opened from the cockpit): account-level
// sections only, no company kicker, and the fallback section is Konto: the
// cockpit is above the companies, so no company may read as "the" company.
const byraScope = useByraSettingsScope()
const urlSection = pathname.split('/')[2] ?? sectionId
const resolved =
urlSection && SETTINGS_SECTIONS[urlSection]
? urlSection
: company
: company && !byraScope
? 'company'
: 'account'
@@ -67,8 +72,9 @@ export function SettingsModal({ sectionId }: { sectionId?: string }) {
<div className="flex shrink-0 items-center gap-3 border-b border-border px-6 py-3">
<div className="min-w-0">
{/* The modal covers the sidebar's CompanySwitcher, so the active
company must stay visible here as the kicker over the title. */}
{company ? (
company must stay visible here as the kicker over the title.
Not in byrå scope: the cockpit has no active company. */}
{company && !byraScope ? (
<p className="truncate text-xs text-muted-foreground">{company.name}</p>
) : null}
<DialogTitle className="font-display text-lg tracking-tight">
+10 -4
View File
@@ -1,7 +1,7 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import {
@@ -32,6 +32,7 @@ interface SettingsRailProps {
export function SettingsRail({ variant, display, activeId }: SettingsRailProps) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const t = useTranslations('settings_nav')
const { items, groups } = useSettingsNavItems()
@@ -40,11 +41,16 @@ export function SettingsRail({ variant, display, activeId }: SettingsRailProps)
items.find((i) => pathname.startsWith(i.href))?.id ??
items[0]?.id
// Byrå settings scope travels as ?ctx=byra: section switches must carry it
// along or the rail would snap back to the full company section list.
const withCtx = (href: string) =>
searchParams.get('ctx') === 'byra' ? `${href}?ctx=byra` : href
function navigate(href: string) {
// Shallow update: Next syncs usePathname() from the native History API,
// so SettingsModal re-resolves the section without a route transition.
if (variant === 'modal') window.history.replaceState(null, '', href)
else router.push(href)
if (variant === 'modal') window.history.replaceState(null, '', withCtx(href))
else router.push(withCtx(href))
}
if (display === 'select') {
@@ -100,7 +106,7 @@ export function SettingsRail({ variant, display, activeId }: SettingsRailProps)
</button>
) : (
<Link
href={i.href}
href={withCtx(i.href)}
aria-current={isActive ? 'page' : undefined}
className={rowClass}
>
+172 -17
View File
@@ -1,8 +1,17 @@
'use client'
import { ChevronDown } from 'lucide-react'
import { Children, isValidElement, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { HelpPopover } from '@/components/ui/help-popover'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
/**
* The Fönster settings language (founder-approved concept 2026-07-25):
@@ -168,30 +177,176 @@ export function SettingsTextarea({ className, ...rest }: React.TextareaHTMLAttri
)
}
/** Flat native select with a quiet chevron. */
/**
* Flat select with a styled popup. Keeps the native-select prop surface so
* call sites read like a <select> (value/defaultValue, onChange with
* e.target.value, <option> children, `name` for the wrapper's FormData read)
* but renders through Radix Select: the native listbox popup cannot be
* styled and clashes with the panel (same call as TeamPanel's role
* dropdowns). The trigger keeps the quiet dashed-underline look.
*/
const SETTINGS_SELECT_TRIGGER_CLASS =
'h-auto w-auto max-w-full shrink-0 cursor-pointer gap-1.5 rounded-none border-0 border-b border-dashed border-transparent bg-transparent px-0 py-1 text-sm text-foreground ' +
'hover:border-border focus:border-solid focus:border-foreground/50 focus:ring-0 disabled:cursor-not-allowed disabled:opacity-60'
// Radix Select refuses empty-string item values; settings selects use '' for
// placeholder-shaped options ("Ingen", "Lägg till valuta"), so '' maps onto a
// sentinel at the Radix boundary and back at the native-shaped one.
const SETTINGS_SELECT_EMPTY = '__settings-select-empty__'
const toRadixValue = (value: string) => (value === '' ? SETTINGS_SELECT_EMPTY : value)
const fromRadixValue = (value: string) => (value === SETTINGS_SELECT_EMPTY ? '' : value)
interface SettingsSelectOption {
value: string
label: React.ReactNode
disabled: boolean
}
type SettingsSelectEntry =
| { kind: 'option'; option: SettingsSelectOption }
| { kind: 'group'; label: React.ReactNode; options: SettingsSelectOption[] }
function collectSelectOptions(children: React.ReactNode): SettingsSelectOption[] {
return collectSelectEntries(children).flatMap((entry) =>
entry.kind === 'group' ? entry.options : [entry.option],
)
}
/**
* Walks native-shaped select children. <option>s become option entries;
* <optgroup>s become group entries that keep their label (rendered as a
* non-interactive header row in the popup, e.g. the ROT/RUT work-type
* groups in ArticleForm); fragments/arrays are flattened transparently.
*/
function collectSelectEntries(children: React.ReactNode): SettingsSelectEntry[] {
const entries: SettingsSelectEntry[] = []
Children.forEach(children, (child) => {
if (!isValidElement(child)) return
if (child.type === 'option') {
const props = child.props as React.OptionHTMLAttributes<HTMLOptionElement>
entries.push({
kind: 'option',
option: {
value: String(props.value ?? ''),
label: props.children,
disabled: !!props.disabled,
},
})
return
}
if (child.type === 'optgroup') {
const props = child.props as React.OptgroupHTMLAttributes<HTMLOptGroupElement>
entries.push({
kind: 'group',
label: props.label,
options: collectSelectOptions(props.children),
})
return
}
// Fragments / arrays of options: flatten.
entries.push(
...collectSelectEntries((child.props as { children?: React.ReactNode }).children),
)
})
return entries
}
export function SettingsSelect({
className,
wrapperClassName,
children,
...rest
id,
name,
value,
defaultValue,
disabled,
onChange,
onInput,
'aria-label': ariaLabel,
}: React.SelectHTMLAttributes<HTMLSelectElement> & { wrapperClassName?: string }) {
const entries = collectSelectEntries(children)
const options = entries.flatMap((entry) =>
entry.kind === 'group' ? entry.options : [entry.option],
)
const isControlled = value !== undefined
// Uncontrolled fallback mirrors the native select: defaultValue if given,
// else the first option.
const [internalValue, setInternalValue] = useState<string>(() =>
defaultValue !== undefined ? String(defaultValue) : (options[0]?.value ?? ''),
)
const hiddenInputRef = useRef<HTMLInputElement>(null)
const currentValue = isControlled ? String(value) : internalValue
const handleValueChange = (encoded: string) => {
const next = fromRadixValue(encoded)
if (!isControlled) setInternalValue(next)
// SettingsFormWrapper tracks dirtiness via bubbling input events; the
// Radix trigger is a button and fires none, so raise the event from the
// hidden input. Call sites that opt out of dirty tracking pass an
// onInput stopPropagation handler, which attaches there and still
// intercepts.
hiddenInputRef.current?.dispatchEvent(new Event('input', { bubbles: true }))
onChange?.({ target: { value: next } } as unknown as React.ChangeEvent<HTMLSelectElement>)
}
return (
<span className={cn('relative inline-flex max-w-full items-center', wrapperClassName)}>
<select
{...rest}
className={cn(
'max-w-full cursor-pointer appearance-none truncate rounded-none border-0 border-b border-dashed border-transparent bg-transparent py-1 pl-0 pr-6 text-sm text-foreground',
'hover:border-border focus:outline-none focus-visible:border-solid focus-visible:border-foreground/50',
'disabled:cursor-not-allowed disabled:opacity-60',
className,
)}
>
{children}
</select>
<ChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-1 h-3.5 w-3.5 text-muted-foreground"
{/* Carries `name` into the wrapper's FormData and hosts the dirty-
tracking input event; type="hidden" keeps it out of the tab order. */}
<input
ref={hiddenInputRef}
type="hidden"
name={name}
value={currentValue}
onInput={onInput as unknown as React.FormEventHandler<HTMLInputElement>}
readOnly
/>
<Select
value={toRadixValue(currentValue)}
onValueChange={handleValueChange}
disabled={disabled}
>
<SelectTrigger
id={id}
aria-label={ariaLabel}
className={cn(SETTINGS_SELECT_TRIGGER_CLASS, className)}
>
<SelectValue />
</SelectTrigger>
<SelectContent align="start">
{entries.map((entry, index) =>
entry.kind === 'group' ? (
/* Radix Label rows are not Items: arrow-key navigation and
typeahead skip them; they render as muted eyebrow headers. */
<SelectGroup key={`group-${index}`}>
<SelectLabel
data-ph-unmask=""
className="text-[11px] uppercase tracking-wider"
>
{entry.label}
</SelectLabel>
{entry.options.map((option) => (
<SelectItem
key={option.value}
value={toRadixValue(option.value)}
disabled={option.disabled}
>
{option.label}
</SelectItem>
))}
</SelectGroup>
) : (
<SelectItem
key={entry.option.value}
value={toRadixValue(entry.option.value)}
disabled={entry.option.disabled}
>
{entry.option.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
</span>
)
}
@@ -18,6 +18,7 @@ import {
} from '@/components/settings/SettingsRows'
import { CheckCircle2, ExternalLink, Loader2, ShieldOff, FlaskConical, ShieldAlert } from 'lucide-react'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { useBranding } from '@/lib/branding/brand-context'
type Environment = 'test' | 'prod'
@@ -442,6 +443,7 @@ interface SystemConnectionState {
*/
function SkatteverketSystemConnectionCard() {
const t = useTranslations('settings_skatteverket_connect')
const { appName } = useBranding()
const { toast } = useToast()
const [state, setState] = useState<SystemConnectionState | null>(null)
const [verifying, setVerifying] = useState(false)
@@ -511,7 +513,7 @@ function SkatteverketSystemConnectionCard() {
}
return (
<SettingsGroup label={t('system_title')} help={t('system_intro')}>
<SettingsGroup label={t('system_title')} help={t('system_intro', { appName })}>
{state.ombud_org_number && (
<SettingsRow label={t('system_org_label')}>
<span className="font-mono text-sm tabular-nums">{state.ombud_org_number}</span>
@@ -527,7 +529,7 @@ function SkatteverketSystemConnectionCard() {
{state.cert?.expiresSoon && (
<WarningLine>
{t('system_cert_expires_soon', { days: state.cert.daysUntilExpiry })}
{t('system_cert_expires_soon', { days: state.cert.daysUntilExpiry, appName })}
</WarningLine>
)}
+416 -16
View File
@@ -1,12 +1,34 @@
'use client'
import { useLocale, useTranslations } from 'next-intl'
import { useState, useEffect } from 'react'
import { Loader2 } from 'lucide-react'
import { useState, useEffect, useCallback } from 'react'
import { Loader2, Mail, Plus, RefreshCw, Trash2 } from 'lucide-react'
import { AttnLine } from '@/components/ui/attn-line'
import { SettingsGroup } from '@/components/settings/SettingsRows'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import {
SettingsGroup,
SettingsInput,
} from '@/components/settings/SettingsRows'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { parseTeamMembersPayload } from '@/components/settings/members-payload'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { cn, formatDateLong } from '@/lib/utils'
// Role dropdowns use the Radix Select for its styled popup (the native
// <select> list cannot be styled and clashes with the panel), but the
// TRIGGER keeps the flat quiet SettingsSelect look: borderless, dashed
// underline on hover, no box. Shared by the member rows and the invite form.
const ROLE_TRIGGER_CLASS =
'h-auto w-auto shrink-0 gap-1.5 rounded-none border-0 border-b border-dashed border-transparent bg-transparent px-0 py-1 text-sm ' +
'hover:border-border focus:border-solid focus:border-foreground/50 focus:ring-0'
interface TeamMember {
id: string
@@ -17,19 +39,64 @@ interface TeamMember {
is_current_user: boolean
}
interface TeamInvitation {
id: string
email: string
role: string
status: string
created_at: string
expires_at: string
}
/**
* The shareable accept link from the latest invite create/re-send response.
* Raw tokens are never stored server-side (only their hash), so the link
* exists exactly once: here, until the next navigation. It is kept visible
* so a failed mail send never dead-ends the inviter (the Arbore case: the
* invitation quietly waits for a mail that never arrives).
*/
interface ShareableInvite {
email: string
url: string
sent: boolean
}
/**
* Team roster panel. Read-only for personal teams (exactly the pre-WL-08
* rendering); on a byrå team where the caller may manage members
* (canInvite = owner/admin), the same flat rows gain a role select and a
* remove action (confirm-up-front), plus the pending-invitations list with
* revoke and an inline invite form (WL-08 invite unfreeze, gap 2).
*/
export function TeamPanel() {
const t = useTranslations('settings_team_panel')
const errorLocale = useLocale() as ErrorLocale
const { toast } = useToast()
// null = the roster is not known: still loading, or the read failed
// (loadError). A failed read must never render an apparently member-less
// team; the empty look is reserved for a confirmed empty read.
const [members, setMembers] = useState<TeamMember[] | null>(null)
const [invitations, setInvitations] = useState<TeamInvitation[]>([])
const [teamName, setTeamName] = useState('')
const [teamId, setTeamId] = useState<string | null>(null)
const [isOwner, setIsOwner] = useState(false)
const [canManage, setCanManage] = useState(false)
// detail === null: transient, so the line carries a retry. A detail sentence
// means the user has to act (an expired session) and a retry cannot help.
const [loadError, setLoadError] = useState<{ detail: string | null } | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [inviteEmail, setInviteEmail] = useState('')
const [inviteRole, setInviteRole] = useState<string>('member')
const [isSending, setIsSending] = useState(false)
const [changingRoleId, setChangingRoleId] = useState<string | null>(null)
const [revokingId, setRevokingId] = useState<string | null>(null)
const [resendingId, setResendingId] = useState<string | null>(null)
const [shareInvite, setShareInvite] = useState<ShareableInvite | null>(null)
// Confirm-up-front (UI convention 10): removal opens a dialog describing
// the outcome; the DELETE only fires from its confirm button.
const [removeTarget, setRemoveTarget] = useState<TeamMember | null>(null)
const roleLabel = (role: string) => {
switch (role) {
case 'owner': return t('role_owner')
@@ -39,10 +106,13 @@ export function TeamPanel() {
}
}
useEffect(() => {
let cancelled = false
const errorTitle = (body: unknown, fallback: string): string => {
const message = (body as { error?: unknown } | null)?.error
return typeof message === 'string' && message.length > 0 ? message : fallback
}
async function load() {
const fetchMembers = useCallback(async (opts?: { cancelled?: () => boolean }) => {
const isCancelled = opts?.cancelled ?? (() => false)
setLoadError(null)
try {
const res = await fetch('/api/team/members')
@@ -50,7 +120,7 @@ export function TeamPanel() {
// Not-JSON bodies (an HTML error page, an empty 502) leave null, and
// getErrorMessage falls back to the status map.
const body = await res.json().catch(() => null)
if (cancelled) return
if (isCancelled()) return
const sessionGone = res.status === 401 || res.status === 403
setMembers(null)
setLoadError({
@@ -63,28 +133,170 @@ export function TeamPanel() {
// A 200 whose body will not parse throws into the catch below; a 200
// without the roster list is a failed read too. Neither may become a
// fabricated empty member list.
const parsed = parseTeamMembersPayload<TeamMember>(await res.json())
if (cancelled) return
const parsed = parseTeamMembersPayload<TeamMember, TeamInvitation>(await res.json())
if (isCancelled()) return
if (parsed === null) {
setMembers(null)
setLoadError({ detail: null })
return
}
setMembers(parsed.members)
setInvitations(parsed.invitations)
setTeamId(parsed.teamId)
setIsOwner(parsed.isOwner)
// Management affordances follow the API's own gate: byrå owner/admin.
setCanManage(parsed.teamKind === 'byra' && parsed.canInvite)
if (parsed.teamName) setTeamName(parsed.teamName)
} catch {
if (!cancelled) {
if (!isCancelled()) {
setMembers(null)
setLoadError({ detail: null })
}
}
}
}, [errorLocale])
void load()
useEffect(() => {
let cancelled = false
void fetchMembers({ cancelled: () => cancelled })
return () => {
cancelled = true
}
}, [reloadKey, errorLocale])
}, [fetchMembers, reloadKey])
const handleRoleChange = async (member: TeamMember, newRole: string) => {
setChangingRoleId(member.id)
try {
const res = await fetch(`/api/team/members/${member.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole }),
})
const data = await res.json().catch(() => null)
if (!res.ok) {
toast({ title: errorTitle(data, t('role_change_failed')), variant: 'destructive' })
return
}
toast({ title: t('role_changed_toast') })
} catch {
toast({ title: t('role_change_failed'), variant: 'destructive' })
} finally {
setChangingRoleId(null)
void fetchMembers()
}
}
const handleRemove = async (member: TeamMember) => {
try {
const res = await fetch(`/api/team/members/${member.id}`, { method: 'DELETE' })
const data = await res.json().catch(() => null)
if (!res.ok) {
toast({ title: errorTitle(data, t('remove_failed')), variant: 'destructive' })
return
}
toast({ title: t('removed_toast') })
} catch {
toast({ title: t('remove_failed'), variant: 'destructive' })
} finally {
void fetchMembers()
}
}
const handleRevokeInvite = async (invite: TeamInvitation) => {
setRevokingId(invite.id)
try {
const res = await fetch(`/api/team/invite/${invite.id}`, { method: 'DELETE' })
const data = await res.json().catch(() => null)
if (!res.ok) {
toast({ title: errorTitle(data, t('revoke_failed')), variant: 'destructive' })
return
}
// A revoked invitation's link is dead: never keep offering it.
setShareInvite((current) => (current?.email === invite.email ? null : current))
toast({ title: t('revoked_toast') })
} catch {
toast({ title: t('revoke_failed'), variant: 'destructive' })
} finally {
setRevokingId(null)
void fetchMembers()
}
}
const handleResendInvite = async (invite: TeamInvitation) => {
setResendingId(invite.id)
try {
const res = await fetch(`/api/team/invite/${invite.id}`, { method: 'POST' })
const data = await res.json().catch(() => null)
if (!res.ok) {
toast({ title: errorTitle(data, t('resend_failed')), variant: 'destructive' })
return
}
const payload = (data as { data?: { email_sent?: boolean; inviteUrl?: string } } | null)?.data
if (payload?.inviteUrl) {
setShareInvite({
email: invite.email,
url: payload.inviteUrl,
sent: payload.email_sent !== false,
})
}
toast({
title: t('resend_toast'),
description: payload?.email_sent === false ? t('invite_mail_not_sent') : undefined,
})
void fetchMembers()
} catch {
toast({ title: t('resend_failed'), variant: 'destructive' })
} finally {
setResendingId(null)
}
}
const handleCopyInviteLink = async (url: string) => {
try {
await navigator.clipboard.writeText(url)
toast({ title: t('invite_link_copied_toast') })
} catch {
toast({ title: t('invite_link_copy_failed'), variant: 'destructive' })
}
}
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault()
const email = inviteEmail.trim().toLowerCase()
if (!email) return
setIsSending(true)
try {
const res = await fetch('/api/team/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, role: inviteRole, ...(teamId ? { teamId } : {}) }),
})
const data = await res.json().catch(() => null)
if (!res.ok) {
toast({ title: errorTitle(data, t('invite_failed')), variant: 'destructive' })
return
}
const payload = (data as { data?: { email_sent?: boolean; inviteUrl?: string } } | null)?.data
const sent = payload?.email_sent
// Persist the shareable link next to the pending list: a failed send
// leaves the invitation valid, and the toast alone is too easy to miss
// (the Arbore case), so the recovery path stays visible on the page.
if (payload?.inviteUrl) {
setShareInvite({ email, url: payload.inviteUrl, sent: sent !== false })
}
toast({
title: t('invite_sent_toast'),
description: sent === false ? t('invite_mail_not_sent') : undefined,
})
setInviteEmail('')
setInviteRole('member')
void fetchMembers()
} catch {
toast({ title: t('invite_failed'), variant: 'destructive' })
} finally {
setIsSending(false)
}
}
if (members === null) {
return (
@@ -117,8 +329,14 @@ export function TeamPanel() {
return (
<SettingsGroup label={<span data-ph-mask="">{teamName || t('team_fallback')}</span>}>
{/* Read-only member roster: flat hairline rows, no cards. */}
{members.map((member) => (
{/* Member roster: flat hairline rows, no cards. */}
{members.map((member) => {
// Role select gates mirror the API: own row stays read-only (no
// accidental self-demotion), owner rows are owner-managed, and the
// owner option itself is owner-granted.
const roleEditable = canManage && !member.is_current_user && (member.role !== 'owner' || isOwner)
const removable = canManage && !member.is_current_user && (member.role !== 'owner' || isOwner)
return (
<div
key={member.id}
className="flex items-center gap-3 border-b border-border px-1 py-3"
@@ -134,11 +352,193 @@ export function TeamPanel() {
<span className="ml-1 text-muted-foreground">{t('you_suffix')}</span>
)}
</p>
{roleEditable ? (
<Select
value={member.role}
onValueChange={(value) => void handleRoleChange(member, value)}
disabled={changingRoleId === member.id}
>
<SelectTrigger
className={cn(ROLE_TRIGGER_CLASS, 'text-xs')}
aria-label={t('role_change_aria', { email: member.email })}
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{isOwner && <SelectItem value="owner">{t('role_owner')}</SelectItem>}
<SelectItem value="admin">{t('role_admin')}</SelectItem>
<SelectItem value="member">{t('role_member')}</SelectItem>
</SelectContent>
</Select>
) : (
<span className="shrink-0 text-xs text-muted-foreground">
{roleLabel(member.role)}
</span>
)}
{removable && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
aria-label={t('remove_aria', { email: member.email })}
onClick={() => setRemoveTarget(member)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
)
})}
{/* Pending invitations continue the same list, visually quieter. */}
{canManage &&
invitations.map((inv) => {
const expired = new Date(inv.expires_at) <= new Date()
return (
<div
key={inv.id}
className="flex items-center gap-3 border-b border-border px-1 py-3"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-dashed border-border">
<Mail className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<p className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{inv.email}
<span className="ml-1 text-xs">
·{' '}
{expired
? t('invite_expired')
: t('invite_expires', { date: formatDateLong(inv.expires_at) })}
</span>
</p>
<span className="shrink-0 text-xs text-muted-foreground">
{roleLabel(inv.role)}
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={t('resend_aria', { email: inv.email })}
onClick={() => void handleResendInvite(inv)}
disabled={resendingId === inv.id || revokingId === inv.id}
>
{resendingId === inv.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
aria-label={t('revoke_aria', { email: inv.email })}
onClick={() => void handleRevokeInvite(inv)}
disabled={revokingId === inv.id || resendingId === inv.id}
>
{revokingId === inv.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
</div>
)
})}
{/* Shareable accept link from the latest invite/re-send: one sentence,
not a banner. The failed-send case is the page's single attn line;
the sent case is a quiet muted line with the same copy action, since
the link is a legitimate share path either way. */}
{canManage && shareInvite && (
<div className="px-1 pt-3" role="status" aria-live="polite">
{shareInvite.sent ? (
<p className="text-[12.5px] leading-5 text-muted-foreground">
{t('invite_link_sent', { email: shareInvite.email })}{' '}
<button
type="button"
onClick={() => void handleCopyInviteLink(shareInvite.url)}
className="underline underline-offset-2 hover:text-foreground"
>
{t('invite_link_copy_action')}
</button>
</p>
) : (
<AttnLine
action={{
label: t('invite_link_copy_action'),
onClick: () => void handleCopyInviteLink(shareInvite.url),
}}
>
{t('invite_link_not_sent', { email: shareInvite.email })}
</AttnLine>
)}
</div>
)}
{/* Inline invite: the list's own last row instead of a separate card. */}
{canManage && (
<form
onSubmit={handleInvite}
className="flex flex-col gap-3 px-1 pt-3 sm:flex-row sm:items-center"
>
<label htmlFor="team-invite-email" className="sr-only">
{t('invite_email_label')}
</label>
<SettingsInput
id="team-invite-email"
type="email"
placeholder={t('invite_email_placeholder')}
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
disabled={isSending}
required
className="border-border sm:flex-1"
/>
<Select value={inviteRole} onValueChange={setInviteRole}>
<SelectTrigger className={ROLE_TRIGGER_CLASS} aria-label={t('invite_role_label')}>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="member">{t('role_member')}</SelectItem>
<SelectItem value="admin">{t('role_admin')}</SelectItem>
</SelectContent>
</Select>
<Button type="submit" size="sm" disabled={isSending || !inviteEmail.trim()}>
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Plus className="mr-2 h-4 w-4" />
{t('invite_button')}
</>
)}
</Button>
</form>
)}
{/* Confirm up front: the dialog describes the outcome (loses access to
every client company via the team) before anything is deleted. */}
<ConfirmDialog
open={removeTarget !== null}
onOpenChange={(open) => {
if (!open) setRemoveTarget(null)
}}
title={t('remove_confirm_title')}
description={
removeTarget
? t('remove_confirm_body', {
email: removeTarget.email,
team: teamName || t('team_fallback'),
})
: undefined
}
confirmLabel={t('remove_confirm_button')}
destructive
onConfirm={async () => {
if (removeTarget) await handleRemove(removeTarget)
}}
/>
</SettingsGroup>
)
}
@@ -57,14 +57,37 @@ describe('parseCompanyMembersPayload', () => {
describe('parseTeamMembersPayload', () => {
it('passes a well-formed payload through', () => {
expect(
parseTeamMembersPayload({ data: { members: [{ id: 'm1' }], teamName: 'Byrån AB' } }),
).toEqual({ members: [{ id: 'm1' }], teamName: 'Byrån AB' })
parseTeamMembersPayload({
data: {
members: [{ id: 'm1' }],
invitations: [{ id: 'i1' }],
teamName: 'Byrån AB',
teamId: 'team-1',
teamKind: 'byra',
isOwner: true,
canInvite: true,
},
}),
).toEqual({
members: [{ id: 'm1' }],
invitations: [{ id: 'i1' }],
teamName: 'Byrån AB',
teamId: 'team-1',
teamKind: 'byra',
isOwner: true,
canInvite: true,
})
})
it('accepts a confirmed-empty roster and a missing team name', () => {
it('accepts a confirmed-empty roster and missing management fields', () => {
expect(parseTeamMembersPayload({ data: { members: [] } })).toEqual({
members: [],
invitations: [],
teamName: null,
teamId: null,
teamKind: null,
isOwner: false,
canInvite: false,
})
})
@@ -76,9 +99,16 @@ describe('parseTeamMembersPayload', () => {
})
it('drops a non-string team name instead of rendering it', () => {
expect(parseTeamMembersPayload({ data: { members: [], teamName: 42 } })).toEqual({
members: [],
teamName: null,
})
expect(
parseTeamMembersPayload({ data: { members: [], teamName: 42 } }),
).toMatchObject({ members: [], teamName: null })
})
it('only grants management affordances on explicit boolean true', () => {
expect(
parseTeamMembersPayload({
data: { members: [], invitations: 'oops', isOwner: 'yes', canInvite: 1 },
}),
).toMatchObject({ invitations: [], isOwner: false, canInvite: false })
})
})
+24 -3
View File
@@ -33,19 +33,40 @@ export function parseCompanyMembersPayload<M, I>(body: unknown): CompanyMembersP
}
}
export interface TeamMembersPayload<M> {
export interface TeamMembersPayload<M, I> {
members: M[]
invitations: I[]
teamName: string | null
teamId: string | null
teamKind: string | null
isOwner: boolean
canInvite: boolean
}
export function parseTeamMembersPayload<M>(body: unknown): TeamMembersPayload<M> | null {
export function parseTeamMembersPayload<M, I>(body: unknown): TeamMembersPayload<M, I> | null {
if (typeof body !== 'object' || body === null) return null
const data = (body as { data?: unknown }).data
if (typeof data !== 'object' || data === null) return null
const d = data as { members?: unknown; teamName?: unknown }
const d = data as {
members?: unknown
invitations?: unknown
teamName?: unknown
teamId?: unknown
teamKind?: unknown
isOwner?: unknown
canInvite?: unknown
}
if (!Array.isArray(d.members)) return null
return {
members: d.members as M[],
// Management-only extra, not roster state: a missing list means "none",
// not a failed read (the strictness above protects the member roster).
invitations: Array.isArray(d.invitations) ? (d.invitations as I[]) : [],
teamName: typeof d.teamName === 'string' && d.teamName.length > 0 ? d.teamName : null,
teamId: typeof d.teamId === 'string' && d.teamId.length > 0 ? d.teamId : null,
teamKind: typeof d.teamKind === 'string' && d.teamKind.length > 0 ? d.teamKind : null,
// Only explicit boolean true unlocks management affordances.
isOwner: d.isOwner === true,
canInvite: d.canInvite === true,
}
}
@@ -11,6 +11,7 @@ import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
import { useBranding } from '@/lib/branding/brand-context'
const BankingPanel = getSettingsPanel('enable-banking')
@@ -18,6 +19,7 @@ export function BankingSettingsContent() {
const t = useTranslations('settings_banking')
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const { appName } = useBranding()
const searchParams = useSearchParams()
const router = useRouter()
const { toast } = useToast()
@@ -76,7 +78,7 @@ export function BankingSettingsContent() {
return (
<div>
<SettingsSectionHeader title={tNav('banking')} intro={tIntro('banking')} />
<SettingsSectionHeader title={tNav('banking')} intro={tIntro('banking', { appName })} />
{/* OAuth bounce-back failure: a live warning, so it stays visible in the
page flow, as compact warning-tone lines instead of a bordered box. */}
@@ -20,6 +20,7 @@ import { useFormat } from '@/lib/hooks/use-format'
import { BillingActions } from '@/components/settings/BillingActions'
import { PLAN_PRICES } from '@/components/settings/billing-plans'
import type { BillingPlan } from '@/lib/stripe/client'
import { useBranding } from '@/lib/branding/brand-context'
// What the paid tier unlocks: the external connections. One item per PAID
// capability in lib/entitlements/keys.ts (ai, bank_sync, skatteverket,
@@ -40,6 +41,12 @@ interface BillingView {
chargeDeferred: boolean
paidJustNow: boolean
isDemo: boolean
/**
* WL-10: present when the company is covered by its byrå team's agreement
* (active team-scoped manual grant). Replaces the upgrade pitch with a
* read-only "Ingår i <teamName>s avtal" state.
*/
teamAgreement: { teamName: string } | null
}
function UnlockList({ className }: { className?: string }) {
@@ -70,6 +77,7 @@ export function BillingSettingsContent() {
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
const t = useTranslations('settings_billing')
const { appName } = useBranding()
const errorLocale = useLocale() as ErrorLocale
const { formatDateLong } = useFormat()
// null = the billing state is not known: still loading, or the read failed
@@ -111,6 +119,7 @@ export function BillingSettingsContent() {
configured?: unknown
trialEndsAt?: unknown
isDemo?: unknown
teamAgreement?: unknown
}
if (!active) return
if (typeof d?.isPaying !== 'boolean' || typeof d?.configured !== 'boolean') {
@@ -126,6 +135,13 @@ export function BillingSettingsContent() {
// Set by the checkout success redirect. Provisioning happens via the
// Stripe webhook, so isPaying can lag the redirect by a few seconds.
const paidJustNow = new URLSearchParams(window.location.search).get('success') === '1'
// Only a well-formed teamAgreement (non-empty team name) activates the
// read-only agreement state: anything else falls back to the normal view.
const rawAgreement = d.teamAgreement as { teamName?: unknown } | null | undefined
const teamAgreement =
rawAgreement && typeof rawAgreement.teamName === 'string' && rawAgreement.teamName.length > 0
? { teamName: rawAgreement.teamName }
: null
setView({
isPaying: d.isPaying,
configured: d.configured,
@@ -134,6 +150,7 @@ export function BillingSettingsContent() {
chargeDeferred,
paidJustNow,
isDemo: d.isDemo === true,
teamAgreement,
})
} catch {
if (active) {
@@ -187,7 +204,7 @@ export function BillingSettingsContent() {
<SettingsGroup label={t('group_yours')}>
<SettingsRow label={t('row_status')} borderless>
<span>{t('status_demo')}</span>
<SettingsRowNote>{t('status_demo_note')}</SettingsRowNote>
<SettingsRowNote>{t('status_demo_note', { appName })}</SettingsRowNote>
</SettingsRow>
</SettingsGroup>
<SettingsGroup label={t('group_included')}>
@@ -240,6 +257,28 @@ export function BillingSettingsContent() {
)
}
// Covered by the byrå team's agreement (WL-10) → read-only state instead
// of the upgrade pitch and Stripe checkout. The page stays visible for
// transparency; billing is the byrå's, so there is nothing to manage here.
if (view.teamAgreement) {
return (
<div>
{header}
<SettingsGroup label={t('group_yours')}>
<SettingsRow label={t('row_status')} borderless>
<span className="font-medium">
{t('team_agreement_status', { teamName: view.teamAgreement.teamName })}
</span>
<SettingsRowNote>{t('team_agreement_note')}</SettingsRowNote>
</SettingsRow>
</SettingsGroup>
<SettingsGroup label={t('group_included')}>
<UnlockList className="pt-3" />
</SettingsGroup>
</div>
)
}
// Trialing / expired → sell view: one number, four short lines, one
// button, two quiet sentences. Everything else lives behind the "?".
const { trialEndsAt, daysLeft, chargeDeferred } = view
@@ -0,0 +1,284 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Loader2, Palette, Trash2, Upload } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast'
import { SettingsGroup, SettingsInput, SettingsRow } from '@/components/settings/SettingsRows'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
interface ByraBrand {
hasBrand: boolean
domain: string | null
appName: string | null
logoUrl: string | null
canEdit: boolean
}
/**
* Byrå settings: Varumärke (WL-17). Byråer edit the logo and the app name
* themselves; domain and colors render read-only (ops-managed, the
* white-glove decision). Upload interaction mirrors the company invoice
* logo (components/settings/LogoUpload.tsx) against the byrå brand route.
* The logo saves on upload; the app name has an inline save that appears
* when the field is dirty (SettingsFormWrapper is wired to /api/settings,
* so this single team-scoped field saves itself).
*/
export function BrandSettingsContent() {
const t = useTranslations('settings_brand')
const { toast } = useToast()
const [brand, setBrand] = useState<ByraBrand | null>(null)
const [loadFailed, setLoadFailed] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [appNameDraft, setAppNameDraft] = useState('')
const [isSavingName, setIsSavingName] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
let cancelled = false
fetch('/api/byra/brand')
.then(async (res) => {
if (!res.ok) throw new Error()
const body = (await res.json()) as { data: ByraBrand }
if (!cancelled) {
setBrand(body.data)
setAppNameDraft(body.data.appName ?? '')
}
})
.catch(() => {
if (!cancelled) setLoadFailed(true)
})
return () => {
cancelled = true
}
}, [])
const appNameDirty =
!!brand?.hasBrand &&
brand.canEdit &&
appNameDraft.trim().length > 0 &&
appNameDraft.trim() !== brand.appName
async function handleSaveAppName() {
if (!appNameDirty || isSavingName) return
setIsSavingName(true)
try {
const response = await fetch('/api/byra/brand', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appName: appNameDraft.trim() }),
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || t('app_name_save_failed'))
}
setBrand((prev) => (prev ? { ...prev, appName: result.data.app_name } : prev))
setAppNameDraft(result.data.app_name)
} catch (error) {
toast({
title: t('app_name_save_failed'),
description: error instanceof Error ? getUserErrorMessage(error) : undefined,
variant: 'destructive',
})
}
setIsSavingName(false)
}
function validateAndUpload(file: File) {
if (!ALLOWED_TYPES.includes(file.type)) {
toast({ title: t('logo_disallowed_type'), variant: 'destructive' })
return
}
if (file.size > LOGO_UPLOAD_MAX_BYTES) {
toast({ title: t('logo_too_large'), variant: 'destructive' })
return
}
void handleUpload(file)
}
async function handleUpload(file: File) {
setIsUploading(true)
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/api/byra/brand/logo', { method: 'POST', body: formData })
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || t('logo_upload_failed'))
}
setBrand((prev) => (prev ? { ...prev, logoUrl: result.data.logo_url } : prev))
} catch (error) {
toast({
title: t('logo_upload_failed'),
description: error instanceof Error ? getUserErrorMessage(error) : undefined,
variant: 'destructive',
})
}
setIsUploading(false)
}
async function handleDelete() {
setIsDeleting(true)
try {
const response = await fetch('/api/byra/brand/logo', { method: 'DELETE' })
if (!response.ok) throw new Error()
setBrand((prev) => (prev ? { ...prev, logoUrl: null } : prev))
if (inputRef.current) inputRef.current.value = ''
} catch {
toast({ title: t('logo_delete_failed'), variant: 'destructive' })
}
setIsDeleting(false)
}
function handleDrop(e: React.DragEvent<HTMLButtonElement>) {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files?.[0]
if (file) validateAndUpload(file)
}
function handleDragOver(e: React.DragEvent<HTMLButtonElement>) {
e.preventDefault()
setIsDragging(true)
}
function handleDragLeave(e: React.DragEvent<HTMLButtonElement>) {
e.preventDefault()
setIsDragging(false)
}
if (loadFailed) {
return <p className="text-sm text-muted-foreground">{t('load_failed')}</p>
}
if (!brand) {
return <SettingsLoadingSkeleton />
}
if (!brand.hasBrand) {
return (
<EmptyState
icon={Palette}
title={t('empty_title')}
description={t('empty_description')}
/>
)
}
return (
<SettingsGroup>
<SettingsRow label={t('logo_label')} help={t('logo_help')}>
{brand.logoUrl ? (
<>
<span className="inline-flex rounded-lg border border-border bg-muted/30 p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={brand.logoUrl}
alt={t('logo_alt')}
className="max-h-10 max-w-32 object-contain"
/>
</span>
{brand.canEdit && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => inputRef.current?.click()}
disabled={isUploading}
className="text-muted-foreground hover:text-foreground"
>
{isUploading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
{t('logo_change')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
disabled={isDeleting}
className="text-muted-foreground hover:text-destructive"
>
{isDeleting ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="mr-2 h-3.5 w-3.5" />
)}
{t('logo_remove')}
</Button>
</>
)}
</>
) : brand.canEdit ? (
<button
type="button"
onClick={() => inputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
disabled={isUploading}
className={`inline-flex min-h-10 items-center gap-2 rounded-lg border border-dashed px-4 py-2 text-sm text-muted-foreground transition-colors duration-150 disabled:opacity-50 ${
isDragging ? 'border-foreground bg-muted/40' : 'border-border hover:bg-muted/20'
}`}
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4 text-muted-foreground/60" />
)}
{isUploading ? t('logo_uploading') : t('logo_pick_or_drop')}
</button>
) : (
<span className="text-sm text-muted-foreground">{t('logo_none')}</span>
)}
</SettingsRow>
<SettingsRow label={t('domain_label')} help={t('domain_help')}>
<span className="text-sm tabular-nums">{brand.domain}</span>
</SettingsRow>
<SettingsRow label={t('app_name_label')} help={t('app_name_help')} htmlFor="byra-app-name">
{brand.canEdit ? (
<>
<SettingsInput
id="byra-app-name"
value={appNameDraft}
maxLength={60}
onChange={(e) => setAppNameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void handleSaveAppName()
}
}}
/>
{appNameDirty && (
<Button size="sm" onClick={handleSaveAppName} disabled={isSavingName}>
{isSavingName ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
{t('app_name_save')}
</Button>
)}
</>
) : (
<span className="text-sm">{brand.appName}</span>
)}
</SettingsRow>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) validateAndUpload(file)
}}
/>
</SettingsGroup>
)
}
+10
View File
@@ -46,6 +46,14 @@ const BillingSettingsContent = dynamic(() =>
import('./BillingSettingsContent').then((module) => ({ default: module.BillingSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const TeamSettingsContent = dynamic(() =>
import('../TeamPanel').then((module) => ({ default: module.TeamPanel })),
{ loading: SettingsLoadingSkeleton },
)
const BrandSettingsContent = dynamic(() =>
import('./BrandSettingsContent').then((module) => ({ default: module.BrandSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const WhatsAppSettingsContent = dynamic(() =>
import('./WhatsAppSettingsContent').then((module) => ({ default: module.WhatsAppSettingsContent })),
{ loading: SettingsLoadingSkeleton },
@@ -74,6 +82,8 @@ export const SETTINGS_SECTIONS: Record<string, ComponentType> = {
assistant: AssistantSettingsContent,
api: ApiSettingsContent,
billing: BillingSettingsContent,
team: TeamSettingsContent,
brand: BrandSettingsContent,
whatsapp: WhatsAppSettingsContent,
mail: MailSettingsContent,
}
+27 -2
View File
@@ -1,10 +1,25 @@
'use client'
import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
/**
* Byrå settings scope: settings opened from the cockpit carry ?ctx=byra
* (links in the user menu / mobile nav). In that scope only account-level
* and byrå-level sections show: everything company-scoped (bokföring, skatt,
* fakturering, mallar, ...) is edited from inside the client company, never
* from the cockpit. Honored only for byrå team members; cosmetic only, the
* section pages keep their own auth.
*/
export function useByraSettingsScope(): boolean {
const searchParams = useSearchParams()
const { byraTeam } = useCompany()
return searchParams.get('ctx') === 'byra' && !!byraTeam
}
export type SettingsGroupKey = 'account' | 'company' | 'accounting' | 'sales' | 'tools'
export interface SettingsNavItem {
@@ -34,8 +49,9 @@ const GROUP_ORDER: SettingsGroupKey[] = ['account', 'company', 'accounting', 'sa
* availability from the generated enabled-extensions set.
*/
export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: SettingsNavGroup[] } {
const { company, isSandbox } = useCompany()
const { company, isSandbox, byraTeam } = useCompany()
const { identity } = useAgentSheet()
const byraScope = useByraSettingsScope()
const t = useTranslations('settings_nav')
const hasCompany = !!company
@@ -49,7 +65,13 @@ export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: Setti
// Importera/Exportera. Team stays hidden (show:false) until enabled.
const defs: Array<SettingsNavItem & { show: boolean }> = [
{ id: 'account', href: '/settings/account', label: t('account'), group: 'account', show: true },
{ id: 'billing', href: '/settings/billing', label: t('billing'), group: 'account', show: true },
// Byrå scope: members & roles is the one byrå-level section; billing is
// company-scoped (team-billed byråer have no per-company subscription).
{ id: 'team', href: '/settings/team', label: t('team'), group: 'account', show: byraScope },
// Varumärke (WL-17): byrå owner/admin edits the brand logo; members see
// nothing (the section would be read-only noise for them).
{ id: 'brand', href: '/settings/brand', label: t('brand'), group: 'account', show: byraScope && !!byraTeam && (byraTeam.role === 'owner' || byraTeam.role === 'admin') },
{ id: 'billing', href: '/settings/billing', label: t('billing'), group: 'account', show: !byraScope },
{ id: 'company', href: '/settings/company', label: t('company'), group: 'company', show: hasCompany },
{ id: 'bookkeeping', href: '/settings/bookkeeping', label: t('bookkeeping'), group: 'accounting', show: hasCompany },
{ id: 'tax', href: '/settings/tax', label: t('tax'), group: 'accounting', show: hasCompany },
@@ -68,6 +90,9 @@ export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: Setti
const items: SettingsNavItem[] = defs
.filter((d) => d.show)
// Byrå scope hides every company-scoped section: those are edited from
// inside the client company where it is obvious WHICH company they hit.
.filter((d) => !byraScope || d.group === 'account')
.map(({ show: _show, ...item }) => item)
const groupLabels: Record<SettingsGroupKey, string> = {
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { openDeferredTab } from '@/lib/browser/deferred-tab'
import { useBranding } from '@/lib/branding/brand-context'
interface Props {
documentId: string | null | undefined
@@ -49,6 +50,7 @@ export function TransactionAttachmentIndicator({
}: Props) {
const t = useTranslations('tx_underlag')
const tCommon = useTranslations('common')
const { appName } = useBranding()
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
@@ -72,7 +74,7 @@ export function TransactionAttachmentIndicator({
tab.close()
toast({
title: t('open_failed'),
description: tab.blocked ? tCommon('popup_blocked_description') : undefined,
description: tab.blocked ? tCommon('popup_blocked_description', { appName }) : undefined,
variant: 'destructive',
})
}
+25
View File
@@ -5,12 +5,37 @@ import type { Company, CompanyRole, Team } from '@/types'
import type { CapabilityKey } from '@/lib/entitlements/keys'
import type { EntitlementState } from '@/lib/entitlements/has-capability'
/** The user's byrå team membership (teams.kind = 'byra'), when any (WL-08). */
export interface ByraTeamRef {
id: string
name: string
role: 'owner' | 'admin' | 'member'
}
/**
* A membership company homed on ANOTHER host (the home-domain rule, WL-01):
* shown as a non-clickable "Hanteras via <domain>" signpost entry.
*/
export interface ForeignCompanyEntry {
id: string
name: string
domain: string
}
interface CompanyContextValue {
company: Company | null
role: CompanyRole | null
/**
* Memberships homed on the CURRENT host (home-domain rule): the switcher
* offers only these. Companies homed elsewhere are in foreignCompanies.
*/
companies: { company: Company; role: CompanyRole }[]
isTeamMember: boolean
team: Team | null
/** Byrå team membership; null for everyone outside a byrå (cockpit gate). */
byraTeam?: ByraTeamRef | null
/** Companies homed on another domain, for the "Hanteras via" section. */
foreignCompanies?: ForeignCompanyEntry[]
isSandbox: boolean
/** PAID capability keys the active company currently holds (entitled + enabled). */
capabilities: CapabilityKey[]
@@ -18,6 +18,7 @@ import {
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { Box, Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react'
import { useBranding } from '@/lib/branding/brand-context'
import type {
CloudBackupStatus,
CloudLastSync,
@@ -41,6 +42,7 @@ const PROVIDER_META: Record<CloudProviderId, { label: string; icon: typeof Cloud
export default function CloudBackupCard() {
const t = useTranslations('extensions')
const { toast } = useToast()
const { appName } = useBranding()
const searchParams = useSearchParams()
const [status, setStatus] = useState<CloudBackupStatus | null>(null)
@@ -160,7 +162,7 @@ export default function CloudBackupCard() {
*/}
{destinations.length > 0 && (
<p className="mt-4 px-1 text-xs leading-5 text-muted-foreground">
{t('ext_cloud_backup_legal_note', { provider: destinations })}
{t('ext_cloud_backup_legal_note', { provider: destinations, appName })}
</p>
)}
</div>
@@ -251,6 +253,7 @@ function ProviderRow({ status, onChanged }: ProviderRowProps) {
const { toast } = useToast()
const t = useTranslations('extensions')
const { dialogProps, confirm } = useDestructiveConfirm()
const { appName } = useBranding()
const [isSyncing, setIsSyncing] = useState(false)
const [isDisconnecting, setIsDisconnecting] = useState(false)
@@ -390,7 +393,7 @@ function ProviderRow({ status, onChanged }: ProviderRowProps) {
const supportingLine = status.connected
? status.account_email
: status.configured
? t(scopeKey)
? t(scopeKey, { appName })
: null
return (
@@ -1,3 +1,12 @@
/**
* From-header composition in the Resend service.
*
* Two explicit-sender paths compose here: `from` (company's own verified
* sending domain, resolveInvoiceSender) and `fromAddress` (verified brand
* sender domain, WL-04/WL-13 via lib/email/brand-sender.ts). A fromName
* without either rides the platform address and shows the name ALONE:
* no "via <platform>" (founder call 2026-08-05).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events'
import {
@@ -30,9 +39,9 @@ beforeEach(() => {
})
describe('buildFromHeader', () => {
it('renders the platform default with the company name "via" the app', () => {
it('renders the company name alone on the platform address (no "via")', () => {
expect(buildFromHeader({ fromName: 'Hans Bolag AB' })).toBe(
'Hans Bolag AB via Accounted <noreply@platform.example>',
'Hans Bolag AB <noreply@platform.example>',
)
})
@@ -40,12 +49,24 @@ describe('buildFromHeader', () => {
expect(buildFromHeader({})).toBe('Accounted <noreply@platform.example>')
})
it('renders an explicit sender as "<name> <address>" with no "via"', () => {
it('renders an explicit sender as "<name> <address>"', () => {
expect(
buildFromHeader({ fromName: 'ignored', from: { name: 'Hans Bolag AB', address: 'faktura@hansbolag.example' } }),
).toBe('Hans Bolag AB <faktura@hansbolag.example>')
})
it('rides the brand address when fromAddress is set (verified brand domain)', () => {
expect(
buildFromHeader({ fromName: 'Siffra', fromAddress: 'noreply@post.siffra.se' }),
).toBe('Siffra <noreply@post.siffra.se>')
})
it('uses the bare address when fromAddress is set without fromName', () => {
expect(buildFromHeader({ fromAddress: 'noreply@post.siffra.se' })).toBe(
'noreply@post.siffra.se',
)
})
it('strips header-injection characters from the explicit name', () => {
expect(
buildFromHeader({ from: { name: 'Hans <Bolag>\r\nBcc: x', address: 'faktura@hansbolag.example' } }),
@@ -62,16 +83,16 @@ describe('buildFromHeader', () => {
// Platform path: a comma in the company name used to yield an ambiguous
// mailbox list; plain names are byte-identical to before.
expect(buildFromHeader({ fromName: 'Hans Bolag, AB' })).toBe(
'"Hans Bolag, AB via Accounted" <noreply@platform.example>',
'"Hans Bolag, AB" <noreply@platform.example>',
)
})
it('falls back to the platform sender when the explicit address is malformed', () => {
expect(buildFromHeader({ fromName: 'Hans Bolag AB', from: { name: 'Hans', address: 'not an address' } })).toBe(
'Hans Bolag AB via Accounted <noreply@platform.example>',
'Hans Bolag AB <noreply@platform.example>',
)
expect(buildFromHeader({ fromName: 'Hans Bolag AB', from: { name: ' ', address: 'faktura@hansbolag.example' } })).toBe(
'Hans Bolag AB via Accounted <noreply@platform.example>',
'Hans Bolag AB <noreply@platform.example>',
)
})
})
@@ -91,7 +112,7 @@ describe('ResendEmailService.sendEmail', () => {
const result = await service.sendEmail(base)
expect(result).toEqual({ success: true, provider: 'resend', messageId: 'msg_1' })
expect(sendMock).toHaveBeenCalledTimes(1)
expect(sendMock.mock.calls[0][0].from).toBe('Hans Bolag AB via Accounted <noreply@platform.example>')
expect(sendMock.mock.calls[0][0].from).toBe('Hans Bolag AB <noreply@platform.example>')
})
it('sends as the company sender when Resend accepts it', async () => {
@@ -105,6 +126,32 @@ describe('ResendEmailService.sendEmail', () => {
expect(sendMock.mock.calls[0][0].from).toBe('Hans Bolag AB <faktura@hansbolag.example>')
})
it('sends as the brand sender when fromAddress is set (verified brand domain)', async () => {
sendMock.mockResolvedValue({ data: { id: 'msg_b' }, error: null })
const result = await service.sendEmail({
...base,
fromName: 'Siffra',
fromAddress: 'noreply@post.siffra.se',
})
expect(result.success).toBe(true)
expect(sendMock).toHaveBeenCalledTimes(1)
expect(sendMock.mock.calls[0][0].from).toBe('Siffra <noreply@post.siffra.se>')
})
it('sanitizes header injection attempts in name and address parts', async () => {
sendMock.mockResolvedValue({ data: { id: 'msg_s' }, error: null })
await service.sendEmail({
...base,
fromName: 'Evil\r\nName',
fromAddress: 'noreply@post.siffra.se>\r\n<evil@x.se',
})
const from = sendMock.mock.calls[0][0].from as string
expect(from).not.toMatch(/[\r\n]/)
// The injected angle brackets are stripped; only the wrapper pair remains.
expect(from.match(/</g)).toHaveLength(1)
expect(from.match(/>/g)).toHaveLength(1)
})
it('retries once as the platform sender when Resend rejects the company sender', async () => {
sendMock
.mockResolvedValueOnce({ data: null, error: { message: 'The hansbolag.example domain is not verified' } })
@@ -116,12 +163,27 @@ describe('ResendEmailService.sendEmail', () => {
expect(result).toEqual({ success: true, provider: 'resend', messageId: 'msg_3' })
expect(sendMock).toHaveBeenCalledTimes(2)
expect(sendMock.mock.calls[0][0].from).toBe('Hans Bolag AB <faktura@hansbolag.example>')
expect(sendMock.mock.calls[1][0].from).toBe('Hans Bolag AB via Accounted <noreply@platform.example>')
expect(sendMock.mock.calls[1][0].from).toBe('Hans Bolag AB <noreply@platform.example>')
// Same recipients and content on the retry.
expect(sendMock.mock.calls[1][0].to).toEqual(['kund@example.com'])
expect(sendMock.mock.calls[1][0].subject).toBe('Faktura 1')
})
it('retries once as the platform sender when Resend rejects the brand sender', async () => {
sendMock
.mockResolvedValueOnce({ data: null, error: { message: 'The post.siffra.se domain is not verified' } })
.mockResolvedValueOnce({ data: { id: 'msg_4' }, error: null })
const result = await service.sendEmail({
...base,
fromName: 'Siffra',
fromAddress: 'noreply@post.siffra.se',
})
expect(result).toEqual({ success: true, provider: 'resend', messageId: 'msg_4' })
expect(sendMock).toHaveBeenCalledTimes(2)
expect(sendMock.mock.calls[0][0].from).toBe('Siffra <noreply@post.siffra.se>')
expect(sendMock.mock.calls[1][0].from).toBe('Siffra <noreply@platform.example>')
})
it('does not retry a platform-sender failure (nothing to fall back to)', async () => {
sendMock.mockResolvedValue({ data: null, error: { message: 'invalid recipient' } })
const result = await service.sendEmail(base)
+18 -5
View File
@@ -36,8 +36,14 @@ const FROM_ADDRESS_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}@[a-z0-9.-]{4,253}$/
/**
* Builds the From header. With an explicit `from` (company's own verified
* sending domain) the mail leaves as "<name> <address>" and the platform
* sender is not involved at all. Otherwise the platform default:
* "<fromName> via <App> <RESEND_FROM_EMAIL>" or "<App> <RESEND_FROM_EMAIL>".
* sender is not involved at all. `fromAddress` is only ever set by
* lib/email/brand-sender.ts for VERIFIED brand sender domains (WL-13).
* Otherwise the platform default: "<fromName> <RESEND_FROM_EMAIL>" or
* "<App> <RESEND_FROM_EMAIL>". A fromName WITHOUT an explicit address (a
* brand or company riding the platform address) shows the name ALONE
* (founder call 2026-08-05: no "via <platform>" in the display name; the
* platform stays visible in the actual From address until a sender domain
* is verified).
*
* Strip CRLF and angle brackets from name parts to prevent header injection.
* Resend's API does its own validation, but defense in depth: fromName and
@@ -48,6 +54,7 @@ const FROM_ADDRESS_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}@[a-z0-9.-]{4,253}$/
export function buildFromHeader(input: {
fromName?: string
from?: { name: string; address: string }
fromAddress?: string
}): string {
const safeAppName = sanitizeHeaderPart(getBranding().appName)
@@ -62,8 +69,14 @@ export function buildFromHeader(input: {
}
const safeFromName = input.fromName ? sanitizeHeaderPart(input.fromName) : null
const safeFromAddress = input.fromAddress ? sanitizeHeaderPart(input.fromAddress) : null
if (safeFromAddress) {
return safeFromName
? `${encodeDisplayName(`${safeFromName} via ${safeAppName}`)} <${DEFAULT_FROM_EMAIL}>`
? `${encodeDisplayName(safeFromName)} <${safeFromAddress}>`
: safeFromAddress
}
return safeFromName
? `${encodeDisplayName(safeFromName)} <${DEFAULT_FROM_EMAIL}>`
: `${encodeDisplayName(safeAppName)} <${DEFAULT_FROM_EMAIL}>`
}
@@ -91,13 +104,13 @@ function isResendConfigured(): boolean {
export class ResendEmailService implements EmailService {
async sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
const { to, cc, bcc, subject, html, text, replyTo, fromName, attachments } = options
const { to, cc, bcc, subject, html, text, replyTo, fromName, fromAddress, attachments } = options
if (!this.isConfigured()) {
return { success: false, error: 'Email service is not configured' }
}
const from = buildFromHeader({ fromName, from: options.from })
const from = buildFromHeader({ fromName, from: options.from, fromAddress })
const platformFrom = buildFromHeader({ fromName })
try {
@@ -28,6 +28,7 @@ function supabaseWithTeam(teamId: string | null) {
return {
from: vi.fn(() => chain),
rpc: vi.fn().mockResolvedValue({ data: COMPANY_ID, error: null }),
chain,
}
}
@@ -156,6 +157,25 @@ describe('gnubok_create_company', () => {
expect(mocks.createCompanyCore).not.toHaveBeenCalled()
})
it('defaults the team to the user PERSONAL team only (WL-08)', async () => {
const supabase = supabaseWithTeam(TEAM_ID)
const result = (await tool.execute(setup, '', 'user-1', supabase as never)) as Record<string, unknown>
expect((result.preview as Record<string, unknown>).team_id).toBe(TEAM_ID)
// The default-team lookup must be restricted to kind='personal': picking
// the first membership regardless of kind attached a consultant's private
// company to their byrå team.
expect(supabase.from).toHaveBeenCalledWith('team_members')
expect(supabase.chain.select).toHaveBeenCalledWith('team_id, teams!inner(kind, created_at)')
expect(supabase.chain.eq).toHaveBeenCalledWith('teams.kind', 'personal')
})
it('leaves team_id null when the user has no personal team', async () => {
const supabase = supabaseWithTeam(null)
const result = (await tool.execute(setup, '', 'user-1', supabase as never)) as Record<string, unknown>
expect((result.preview as Record<string, unknown>).team_id).toBeNull()
})
it('uses an explicit team_id over the default team', async () => {
const supabase = supabaseWithTeam(TEAM_ID)
const other = '66666666-6666-4666-8666-666666666666'
+12 -5
View File
@@ -1563,16 +1563,23 @@ function connectLinkBaseUrl(): string {
/**
* The team a company created through the API/MCP path attaches to when the
* caller does not name one: the user's first (usually the silent personal)
* team, mirroring what the web wizard passes. null when the user has no
* team at all; create_company_for_user then leaves team_id NULL.
* caller does not name one: the user's PERSONAL team only (WL-08: companies
* created through the normal flow always attach to the personal team; cockpit
* flows pass the byrå team id explicitly). Picking the first membership
* regardless of kind attached a consultant's private company to their byrå
* team, exposing their books to the whole byrå and suppressing the trial.
* Mirrors ensure_user_team: earliest teams row with kind='personal'. null
* when the user has no personal team; create_company_for_user then leaves
* team_id NULL. Explicit team_id args are authorized by the DB gate instead.
*/
async function defaultTeamForUser(supabase: SupabaseClient, userId: string): Promise<string | null> {
const { data, error } = await supabase
.from('team_members')
.select('team_id')
.select('team_id, teams!inner(kind, created_at)')
.eq('user_id', userId)
.order('created_at', { ascending: true })
.eq('teams.kind', 'personal')
.order('teams(created_at)', { ascending: true })
.order('teams(id)', { ascending: true })
.limit(1)
.maybeSingle()
if (error) {
+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)
})
})

Some files were not shown because too many files have changed in this diff Show More