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:
co-authored by
Claude Fable 5
parent
6f0f4d1eea
commit
fdb5f6f891
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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({})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 } })
|
||||
})
|
||||
@@ -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: [] })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+175
-12
@@ -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,25 +36,64 @@ export async function GET(request: NextRequest) {
|
||||
.eq('token_hash', tokenHash)
|
||||
.single()
|
||||
|
||||
if (!companyInvite) {
|
||||
if (companyInvite) {
|
||||
if (companyInvite.status !== 'pending') {
|
||||
return NextResponse.json({ error: 'Inbjudan har redan använts.' }, { status: 410 })
|
||||
}
|
||||
|
||||
const expired = new Date(companyInvite.expires_at) < new Date()
|
||||
|
||||
const { data: alreadyHasAccount } = await serviceClient.rpc('check_email_exists', {
|
||||
email_to_check: companyInvite.email,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
type: 'company',
|
||||
companyName: (companyInvite.companies as unknown as { name: string })?.name || 'Företag',
|
||||
email: companyInvite.email,
|
||||
expired,
|
||||
alreadyHasAccount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 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 (companyInvite.status !== 'pending') {
|
||||
if (teamInvite.status !== 'pending') {
|
||||
return NextResponse.json({ error: 'Inbjudan har redan använts.' }, { status: 410 })
|
||||
}
|
||||
|
||||
const expired = new Date(companyInvite.expires_at) < new Date()
|
||||
const expired = new Date(teamInvite.expires_at) < new Date()
|
||||
|
||||
const { data: alreadyHasAccount } = await serviceClient.rpc('check_email_exists', {
|
||||
email_to_check: companyInvite.email,
|
||||
email_to_check: teamInvite.email,
|
||||
})
|
||||
|
||||
const teamName = teamInvite.teams?.name || 'Team'
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
type: 'company',
|
||||
companyName: (companyInvite.companies as unknown as { name: string })?.name || 'Företag',
|
||||
email: companyInvite.email,
|
||||
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,
|
||||
},
|
||||
@@ -51,8 +102,11 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
@@ -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' } })
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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() {
|
||||
return NextResponse.json(
|
||||
{ error: 'Teaminbjudningar är inaktiverade. Bjud in via enskilda företag.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
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: '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)
|
||||
})
|
||||
})
|
||||
@@ -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() {
|
||||
return NextResponse.json(
|
||||
{ error: 'Team har bara en ägare och kan inte ändras.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
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: '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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
const { data: team } = await serviceClient
|
||||
.from('teams')
|
||||
.select('name')
|
||||
.eq('id', teamId)
|
||||
.single()
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user