feat(white-label): invite-only signup for brand domains (#1995)
* feat(white-label): invite-only signup for brand domains
A brand domain belongs to the partner's people (founder decision
2026-08-27): only allowlisted or invited users may create an account on
an invite-only brand domain; everyone else is shown an interstitial that
sends them to the canonical Accounted signup.
- brands.signup_mode ('open' default / 'invite_only') +
brand_signup_allowlist (lowercase emails, team-scoped RLS, owner/admin
writes) + create_company_for_brand_signup RPC, with pg-real coverage
- server-side gate (lib/auth/brand-signup-gate.ts) enforced on every
signup path: email signup moved to POST /api/auth/signup (the browser
used to call GoTrue directly, so a client-side check would be
bypassable), BankID gated in /bankid/complete, Google covered by the
dashboard layout's brand-domain bounce
- company invites bypass the allowlist: the invite is the authorization
- register page interstitial on gated brands (no email in the outbound
URL), sv+en strings
- dashboard layout bounces non-belonging sessions off gated brand hosts
to the canonical domain (navigation rule like WL-01, not a security
boundary)
- allowlisted signups' onboarding-created companies attach to the
brand's byra team via the new RPC, so WL-01 homes them on the brand
domain; the allowlist entry recorded by an owner/admin stands in for
the WL-15 admin gate
- byra cockpit page /clients/access + /api/clients/signup-access to
manage the mode and the allowlist
All existing brands default to 'open': behavior is byte-identical until
a brand is flipped to invite_only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4
* fix(white-label): rollback brand-signup company with the service client
Skeptic (correctness) found that a brand-signup company created under the
service role rolled back with the cookie-session client: `companies` has
RLS and no FOR DELETE policy, so the delete was a silent 0-row no-op,
stranding a member-less ghost company on the partner's byra team. Pass an
optional rollbackClient to createCompanyCore and hand it the service
client on that path; user_preferences.active_company_id then clears itself
via its ON DELETE SET NULL FK once the company row is actually deleted.
Also map a validateBody 400 (flat envelope, no code) on the register page
to the specific email-invalid field message instead of the generic one,
since the client already pre-gates password strength.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4
* fix(white-label): fail-safe brand lookup, pg-test seed, anonymize fixtures
Second resolve-pr cycle: skeptic + CodeRabbit findings and a green-up.
- Fail safe on a brands-table error (CodeRabbit CWE-285): the gate treated a
failed resolveBrandByHost as an unbranded host, opening invite-only signup
during a transient DB blip. resolveBrandResultByHost now distinguishes
"no brand" from "lookup failed"; the gate returns lookupFailed and the
email + BankID routes answer 503 (retry), never creating an account.
- pg-real: the RLS delete test seeded its row inside withUserContext, which
always rolls back, so the owner DELETE saw zero rows. Seed on the superuser
pool instead.
- Anonymize every test/fixture brand to the repo's existing synthetic
placeholder (Siffra / app.siffra.se): no real partner names in code.
- SignupAccessManager: functional setData updates so a concurrent mode
toggle and an add/remove do not clobber each other's snapshot (CodeRabbit).
- Route a transient-error message through i18n instead of the raw envelope
(raw-user-error guard); new register.error_temporary sv+en.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4
* test(white-label): anonymize new signup-gate fixtures; log oracle residual
Rename the placeholder brand in the four new brand-signup test files to a
clearly-fake, partner-unrelated name (Testbrand / app.testbrand.example);
the previous placeholder echoed a real partner. Scoped to files this PR
creates; the repo-wide legacy placeholder is left for a separate cleanup.
Also record in DECISIONS.md that the feature ships accepting the
low-severity allowlist-enumeration residual (captcha-free 403 vs 200 on
the signup endpoint), with rate-limiting as the follow-up option.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByL5dQXG8gGLtNBPj8g2C4
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
325c827322
commit
4f6ecad549
@@ -1298,5 +1298,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-27] New `unlinked_documents` category on the Accounted://attention resource, backed by lib/documents/unlinked-documents.ts. The whole design is the mime ALLOW-LIST, and the naive predicate is a trap: "current version, no journal_entry_id, referenced by none of the eight linking tables" returns 15 806 rows on prod, of which 11 309 are application/json and every single one is named psd2-response_<ts>_pN.json, the archived PSD2 bank-API responses the integration stores as evidence of each fetch. Those are unlinked BY DESIGN; surfacing them would hand an agent 11 309 items of work it must not action, which is worse than showing nothing. Measured 2026-08-27: application/json was 11 309 of 11 309 psd2, and pdf/png/jpeg/heic were 0 of 4 495, so the split is clean. Chose an allow-list of underlag-shaped mime types over excluding known-bad filenames, so a future machine-payload format (XML, CSV, an audit bundle) stays out by default instead of leaking until someone notices. Real remaining surface: 4 497 documents across 210 companies, median 3 per company, 481 in the preceding week, and NOT agent-specific (2 374 upload_source=api vs 1 623 file_upload from the web UI). Two-pass fetch mirroring fetchPurchasesWithoutUnderlag: indexed column filter, then eight reference lookups that run only when candidates exist, so the common case costs one query. Scan cap is 300 and is set by URL LENGTH, not table size: each candidate id is echoed through eight .in(column, ids) lookups at ~38 bytes per UUID, and a cap in the thousands would exceed the gateway limit, fail the lookups, and the "claims nothing" fallback would turn every candidate into a false positive. A failing lookup is deliberately treated as "claims nothing" (can only ADD a row) rather than dropping the category, so one misbehaving table cannot hide real work. UnlinkedDocument is a type alias not an interface: the resource assigns it into samples: Record<string, unknown>[] and an interface has no implicit index signature; vitest does not typecheck so this only fails in npm run build.
|
||||
[2026-08-27] NOT fixed, and recorded so the next person does not act on an inflated number: the agent-facing readers (resources/attention.ts, resources/recent-activity.ts) still test booked-ness with a raw journal_entry_id null check instead of the canonical isTransactionBooked, which misses the bulk-book (transaction_voucher_links) and multi-allocation (invoice_payments / supplier_invoice_payments) cases. Real scale measured on prod 2026-08-27: 4 transactions, in 1 company, out of 567 column-filtered unbooked, all 4 via transaction_voucher_links and 0 via either payments table. Worth fixing as hygiene, but it is a 4-row problem and doing it properly in attention.ts needs the same two-pass treatment plus a decision about count semantics for a tenant with thousands of unbooked rows, so it does not belong bolted onto this change.
|
||||
[2026-08-27] Klarmarkera (markPeriodClosedExternally) gets an undo, reopenExternallyClosedPeriod, allowed only while the closed state still comes from klarmarkera (closed_externally set, no closing entry): that close was a person's control decision without a bokslutsverifikat, so reversing it strands nothing, whereas a closePeriod close keeps its closing entry and stays irreversible here. The reopen clears the lock too, because the reason to reopen is to change the period's contents (Forsslund Systems 2026-08-27: five imported years klarmarkerade, then the prior-year SIE turned out wrong; replace refused the closed year, unlock refused the closed state, no way back). Audit_log row plus period.unlocked event; the MCP staged-op surface (lock/unlock) does not get a reopen op yet, follow-up.
|
||||
[2026-08-27] Brand domains gate signup server-side (brands.signup_mode + brand_signup_allowlist), not in the register page: the browser used to call supabase.auth.signUp directly, so any client-side host check would be cosmetic. Email signup moved to POST /api/auth/signup on ALL hosts (byte-identical GoTrue call for open hosts); BankID gates in /bankid/complete; Google gates via the dashboard layout's brand-domain bounce (the account exists after OAuth, but gets no branded experience). Company invites bypass the allowlist because the invite is the authorization. Allowlisted signups' companies attach to the brand's byrå team via create_company_for_brand_signup (allowlist entry = the byrå's standing WL-15 authorization, recorded by an owner/admin); without the attach, WL-01 would home the company on the canonical domain, invisible on the very domain the user signed up on. Rejected a Supabase before-user-created hook: it does not reliably see the originating host and adds dashboard config coupling.
|
||||
[2026-08-27] New `tool-pg` vitest project: MCP tools driven through a REAL supabase-js client against a REAL PostgREST (tests/tool-pg/, scripts/tool-pg/reset.sh, `npm run tools:pg:reset` + `npm run test:tools`, plus a tool-pg CI job). NOT a duplicate of pg-real: that project holds a `pg` Pool and writes SQL, which structurally cannot see the half of a tool that PostgREST resolves at request time (the `.select()` column strings, the resource embeds, the `or=(...)` grammar, `.contains()` operand types). Before this, all 100 files in extensions/general/mcp-server/__tests__ faked supabase and query-journal.test.ts deferred its query chain to "the live MCP smoke test", which does not exist in CI: the PostgREST grammar of 157 tools was gated by nothing. Three findings worth keeping. (1) supabase-js hard-codes a `/rest/v1` prefix that a bare PostgREST does not serve, so the first version of the harness 404'd all 55 sweep queries, the tools reported the empty response as "Database error: undefined", and the suite passed GREEN while exercising nothing; fixed with a URL-rewriting `global.fetch` in createToolPgClient, and a permanent self-test now injects a bad column and asserts the harness detects 42703, so a green sweep means something. (2) Errors are captured at the TRANSPORT, not from the thrown Error: the tools wrap failures in their own prose and lose the payload, so a real 42703 arrives as an unclassifiable string. (3) The reset recreates the CONTAINER rather than dropping schemas: `storage` is owned by supabase_storage_admin so `DROP SCHEMA storage` fails as postgres, and dropping only `public` leaves the storage RLS policies migration 20240101000024 creates unconditionally, aborting the next replay partway and leaving a half-migrated database that looks like a migration bug. CI runs PostgREST via `docker run --network host` rather than a service container, because service containers on a non-containerized job are reachable from the runner but not from each other by name. Current coverage is honest and partial: 74 read tools, 87 real requests, 0 malformed queries, 4 failures all 22P02 from the empty argument set. Per-tool argument fixtures are what deepen it, and the harness is the thing that makes writing them worthwhile.
|
||||
[2026-08-27] Added `npm run check:types`, a typecheck ratchet (scripts/checks/no-new-type-errors.mjs + typecheck-baseline.json), wired into the core-build `checks` job next to check:lint. Reason: `npm test` does NOT typecheck. Vitest transpiles and discards types, so a type error passes all 18 000 tests and only surfaces in `npm run build` minutes later; that happened TWICE on 2026-08-27 (a widened errorKind union in the MCP server that lib/events/types.ts still contradicted, and an `interface` that would not assign into `Record<string, unknown>[]` because interfaces have no implicit index signature). It is not merely a faster copy of the build job: `tsc --noEmit` also covers `__tests__` files, which the Next.js build never compiles, and that is where all 539 baseline errors live. Baseline is keyed per FILE, deliberately unlike the per-RULE lint ratchet: the legacy errors are concentrated in a handful of old test files and TS2322 is common enough that a code-keyed budget would silently absorb a real regression somewhere else, whereas per-file trips the moment a previously-clean file gains an error. Verified the gate actually fires by introducing a deliberate `const x: number = 'str'` and watching it fail with the exact location, then restoring. Cost measured: 36 s cold (what CI pays, since tsconfig.tsbuildinfo is gitignored) and 4.4 s warm locally via the existing `incremental: true`. The script sets NODE_OPTIONS=--max-old-space-size=8192 because a bare tsc dies with "Ineffective mark-compacts near heap limit" on this graph after about two minutes, which reads like a hang rather than a misconfiguration; it also detects that OOM string and exits 2 with a "raise HEAP_MB" message rather than silently reporting zero errors. NOT changed: Definition of Done item 1 still says only lint + test. CI enforcement is the stronger mechanism and does not need the policy edit; adding it to DoD is a founder call.
|
||||
[2026-08-27] Invite-only brand signup ships accepting a low-severity allowlist enumeration residual: POST /api/auth/signup returns 403 for a non-allowlisted email vs 200/400 for an allowlisted one, and the 403 short-circuits before GoTrue, so it is captcha-free and unthrottled: someone with candidate emails can test which are on a brand's allowlist. Not closed because (a) the app deliberately never holds the Turnstile secret (it lives in Supabase/GoTrue; a repo test forbids TURNSTILE_SECRET_KEY in app env), and (b) the clear "you're not invited, go to Accounted" redirect UX inherently reveals the verdict. It leaks membership of guessed emails, not the list, and no ledger/credential data. Follow-up option if it matters later: add signup-endpoint rate limiting. The related fail-OPEN (a brands-table error was read as unbranded, opening invite-only signup during a DB blip) WAS fixed: the gate now returns lookupFailed and both signup routes answer 503.
|
||||
|
||||
+107
-17
@@ -77,6 +77,9 @@ function RegisterPageContent() {
|
||||
const [isCancelling, setIsCancelling] = useState(false)
|
||||
const [isRegistered, setIsRegistered] = useState(false)
|
||||
const [duplicateEmail, setDuplicateEmail] = useState<string | null>(null)
|
||||
// Invite-only brand domain (signup gate said no): the form is replaced by
|
||||
// an interstitial pointing at the canonical Accounted signup.
|
||||
const [inviteOnlyBlocked, setInviteOnlyBlocked] = useState(false)
|
||||
const [inviteEmail, setInviteEmail] = useState<string | null>(null)
|
||||
const [bankIdUser, setBankIdUser] = useState<{ givenName?: string; surname?: string } | null>(null)
|
||||
const [bankIdFlowId, setBankIdFlowId] = useState<string | null>(null)
|
||||
@@ -220,6 +223,11 @@ function RegisterPageContent() {
|
||||
const json = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
if (json.error === 'signup_not_allowed') {
|
||||
// Invite-only brand domain: same interstitial as the email path.
|
||||
setInviteOnlyBlocked(true)
|
||||
return
|
||||
}
|
||||
if (json.error === 'already_linked') {
|
||||
// email_exists kind: the alert renders a sign-in link, which is the
|
||||
// recovery path for both "BankID taken" and "email taken".
|
||||
@@ -335,20 +343,51 @@ function RegisterPageContent() {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// The confirmation link lands on /auth/callback; carry the consent
|
||||
// destination along so the confirmed session resumes it.
|
||||
const confirmationCallback = new URL('/auth/callback', window.location.origin)
|
||||
if (nextPath !== '/') confirmationCallback.searchParams.set('next', nextPath)
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: emailValue,
|
||||
password: passwordValue,
|
||||
options: {
|
||||
emailRedirectTo: confirmationCallback.toString(),
|
||||
...captchaTokenOptions(captchaToken),
|
||||
},
|
||||
// Server-side signup (POST /api/auth/signup): the route performs the
|
||||
// GoTrue signUp and enforces the invite-only brand-domain gate, which
|
||||
// a direct browser call to Supabase would bypass. It builds the
|
||||
// /auth/callback confirmation URL from the request host and carries
|
||||
// `next` along, so the mail flow is unchanged.
|
||||
const res = await fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: emailValue,
|
||||
password: passwordValue,
|
||||
captchaToken: captchaTokenOptions(captchaToken).captchaToken ?? null,
|
||||
next: nextPath !== '/' ? nextPath : null,
|
||||
}),
|
||||
})
|
||||
const json = await res.json().catch(() => ({}))
|
||||
|
||||
if (error) {
|
||||
if (!res.ok) {
|
||||
const error = {
|
||||
code: json?.error?.code,
|
||||
message: json?.error?.message ?? t('register_failed_default'),
|
||||
status: res.status,
|
||||
}
|
||||
if (error.code === 'signup_not_allowed') {
|
||||
// Invite-only brand domain: swap the form for the interstitial
|
||||
// that sends the visitor to the canonical Accounted signup.
|
||||
setInviteOnlyBlocked(true)
|
||||
return
|
||||
}
|
||||
if (error.code === 'brand_lookup_failed') {
|
||||
// Transient brand-lookup error (the gate failed safe rather than
|
||||
// guess). Ask the user to retry instead of implying they were
|
||||
// turned away. Localized here, not from the raw response envelope.
|
||||
setFormError({ kind: 'unknown', message: t('error_temporary') })
|
||||
return
|
||||
}
|
||||
// The route's validateBody rejection is a flat envelope with no
|
||||
// `code`; the client already gates password strength and presence
|
||||
// before this fetch, so a 400 without a code is an email Zod
|
||||
// rejected (e.g. user@localhost, which passes the browser's
|
||||
// type=email). Surface the specific field message, not the generic.
|
||||
if (!error.code && res.status === 400) {
|
||||
setFormError({ kind: 'email_invalid', message: t('error_email_invalid') })
|
||||
return
|
||||
}
|
||||
console.error('[register] signUp error', error.message)
|
||||
const kind = classifyAuthError(error)
|
||||
if (kind === 'weak_password') {
|
||||
@@ -375,7 +414,7 @@ function RegisterPageContent() {
|
||||
persistLoginMethodHint('email')
|
||||
|
||||
// If auto-confirmed (local dev), process invite immediately and redirect
|
||||
if (data.session) {
|
||||
if (json?.data?.status === 'session') {
|
||||
const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
|
||||
const inviteToken = cookieMatch?.[1]
|
||||
|
||||
@@ -405,10 +444,10 @@ function RegisterPageContent() {
|
||||
}
|
||||
|
||||
// Supabase obfuscates duplicate signups (to prevent user enumeration):
|
||||
// when the email already belongs to a confirmed account, it returns
|
||||
// data.user with identities: [] and no error, and sends no email.
|
||||
// Detect that case so we don't show a misleading "check your email" screen.
|
||||
if (data.user && (data.user.identities?.length ?? 0) === 0) {
|
||||
// when the email already belongs to a confirmed account, no mail is
|
||||
// sent. The route surfaces that as 'duplicate' so we don't show a
|
||||
// misleading "check your email" screen.
|
||||
if (json?.data?.status === 'duplicate') {
|
||||
setDuplicateEmail(emailValue)
|
||||
return
|
||||
}
|
||||
@@ -427,6 +466,57 @@ function RegisterPageContent() {
|
||||
}
|
||||
}
|
||||
|
||||
if (inviteOnlyBlocked) {
|
||||
// Deliberately no email in the outbound URL: the canonical register page
|
||||
// never reads one, and an address in a URL lands in browser history,
|
||||
// Referer headers and proxy logs. The visitor retypes it.
|
||||
const canonicalRegisterHref = `${branding.appUrl.replace(/\/+$/, '')}/register`
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
|
||||
<div className="w-full max-w-sm animate-slide-up space-y-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="h-14 w-14 rounded-xl bg-primary/8 flex items-center justify-center">
|
||||
<Mail className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl tracking-tight">
|
||||
{t('invite_only_title', { appName: branding.appName })}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t('invite_only_body', { appName: branding.appName })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-background p-4">
|
||||
<p className="text-sm text-muted-foreground text-center leading-relaxed">
|
||||
{t('invite_only_hint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button className="w-full" asChild>
|
||||
<a href={canonicalRegisterHref}>
|
||||
{t('invite_only_cta')}
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full text-muted-foreground"
|
||||
onClick={() => setInviteOnlyBlocked(false)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t('back')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (duplicateEmail) {
|
||||
return (
|
||||
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Loader2, Plus, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TD_CLASS, TH_CLASS } from '@/components/ui/dry-table'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
|
||||
interface AllowlistEntry {
|
||||
id: string
|
||||
email: string
|
||||
note: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface AccessData {
|
||||
brand: { domain: string; appName: string; signupMode: 'open' | 'invite_only' }
|
||||
role: 'owner' | 'admin' | 'member'
|
||||
entries: AllowlistEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite-only signup management (2026-08-27): the mode switch and the email
|
||||
* allowlist for the byrå's brand domain. Company invites bypass the list, so
|
||||
* this list only governs who can create an account cold on the domain.
|
||||
*/
|
||||
export default function SignupAccessManager({ canEdit }: { canEdit: boolean }) {
|
||||
const t = useTranslations('clients')
|
||||
const { toast } = useToast()
|
||||
const [data, setData] = useState<AccessData | null>(null)
|
||||
const [loadError, setLoadError] = useState<'no_brand' | 'failed' | null>(null)
|
||||
const [savingMode, setSavingMode] = useState(false)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [removingId, setRemovingId] = useState<string | null>(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [note, setNote] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/clients/signup-access', { cache: 'no-store' })
|
||||
if (res.status === 404) {
|
||||
setLoadError('no_brand')
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
setLoadError('failed')
|
||||
return
|
||||
}
|
||||
const json = (await res.json()) as { data: AccessData }
|
||||
setData(json.data)
|
||||
setLoadError(null)
|
||||
} catch {
|
||||
setLoadError('failed')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const toggleMode = async (inviteOnly: boolean) => {
|
||||
if (!data) return
|
||||
setSavingMode(true)
|
||||
const previous = data.brand.signupMode
|
||||
setData({
|
||||
...data,
|
||||
brand: { ...data.brand, signupMode: inviteOnly ? 'invite_only' : 'open' },
|
||||
})
|
||||
try {
|
||||
const res = await fetch('/api/clients/signup-access', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ signup_mode: inviteOnly ? 'invite_only' : 'open' }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`PATCH failed: ${res.status}`)
|
||||
} catch {
|
||||
setData((current) =>
|
||||
current
|
||||
? { ...current, brand: { ...current.brand, signupMode: previous } }
|
||||
: current,
|
||||
)
|
||||
toast({ title: t('access_save_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setSavingMode(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addEntry = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!email.trim() || !data) return
|
||||
setAdding(true)
|
||||
try {
|
||||
const res = await fetch('/api/clients/signup-access', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: email.trim(),
|
||||
note: note.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
const json = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title:
|
||||
res.status === 409
|
||||
? t('access_duplicate')
|
||||
: t('access_save_failed'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
// Functional update: a concurrent mode toggle or remove must not be
|
||||
// clobbered by the `data` snapshot captured when this request started.
|
||||
setData((current) =>
|
||||
current
|
||||
? { ...current, entries: [json.data as AllowlistEntry, ...current.entries] }
|
||||
: current,
|
||||
)
|
||||
setEmail('')
|
||||
setNote('')
|
||||
} catch {
|
||||
toast({ title: t('access_save_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const removeEntry = async (id: string) => {
|
||||
if (!data) return
|
||||
setRemovingId(id)
|
||||
try {
|
||||
const res = await fetch('/api/clients/signup-access', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`DELETE failed: ${res.status}`)
|
||||
// Functional update: see addEntry. Avoids clobbering a concurrent
|
||||
// mode toggle or add with a stale snapshot.
|
||||
setData((current) =>
|
||||
current
|
||||
? { ...current, entries: current.entries.filter((entry) => entry.id !== id) }
|
||||
: current,
|
||||
)
|
||||
} catch {
|
||||
toast({ title: t('access_save_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
setRemovingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError === 'no_brand') {
|
||||
return <p className="text-sm text-muted-foreground">{t('access_no_brand')}</p>
|
||||
}
|
||||
if (loadError === 'failed') {
|
||||
return <p className="text-sm text-muted-foreground">{t('access_load_failed')}</p>
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-2/3" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const inviteOnly = data.brand.signupMode === 'invite_only'
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="rounded-lg border border-border p-6 space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t('access_mode_label', { domain: data.brand.domain })}
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground leading-relaxed mt-1">
|
||||
{t('access_mode_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={inviteOnly}
|
||||
disabled={!canEdit || savingMode}
|
||||
onCheckedChange={(checked) => void toggleMode(checked)}
|
||||
aria-label={t('access_mode_label', { domain: data.brand.domain })}
|
||||
/>
|
||||
</div>
|
||||
{!canEdit && (
|
||||
<p className="text-xs text-muted-foreground">{t('access_readonly_hint')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('access_list_heading')}
|
||||
</h2>
|
||||
|
||||
{canEdit && (
|
||||
<form onSubmit={addEntry} className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="allowlist-email">{t('access_col_email')}</Label>
|
||||
<Input
|
||||
id="allowlist-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('access_add_placeholder')}
|
||||
required
|
||||
disabled={adding}
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="allowlist-note">{t('access_col_note')}</Label>
|
||||
<Input
|
||||
id="allowlist-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t('access_add_note_placeholder')}
|
||||
disabled={adding}
|
||||
className="w-56"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={adding || !email.trim()}>
|
||||
{adding ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('access_add')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{data.entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('access_empty')}</p>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={TH_CLASS}>{t('access_col_email')}</th>
|
||||
<th className={TH_CLASS}>{t('access_col_note')}</th>
|
||||
<th className={TH_CLASS}>{t('access_col_added')}</th>
|
||||
{canEdit && <th className={TH_CLASS} aria-hidden />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.entries.map((entry) => (
|
||||
<tr key={entry.id} className="hover:bg-secondary/35">
|
||||
<td className={TD_CLASS}>{entry.email}</td>
|
||||
<td className={`${TD_CLASS} text-muted-foreground`}>
|
||||
{entry.note || ''}
|
||||
</td>
|
||||
<td className={`${TD_CLASS} tabular-nums text-muted-foreground`}>
|
||||
{formatDate(entry.created_at)}
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td className={`${TD_CLASS} text-right`}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('access_remove')}
|
||||
disabled={removingId === entry.id}
|
||||
onClick={() => void removeEntry(entry.id)}
|
||||
>
|
||||
{removingId === entry.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<X className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { getByraMembership } from '@/lib/clients/fetch-client-overview'
|
||||
import { getDashboardAuthContext } from '../../request-context'
|
||||
import SignupAccessManager from './SignupAccessManager'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Byrå cockpit: invite-only signup management for the team's brand domain
|
||||
* (2026-08-27). Any byrå team member may look; owner/admin may change the
|
||||
* mode and the allowlist (the API and RLS both enforce that). Non-byrå
|
||||
* users are redirected like the rest of the cockpit.
|
||||
*/
|
||||
export default async function SignupAccessPage() {
|
||||
const { supabase, user } = await getDashboardAuthContext()
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const membership = await getByraMembership(supabase, user.id)
|
||||
if (!membership) {
|
||||
redirect('/')
|
||||
}
|
||||
|
||||
const t = await getTranslations('clients')
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader title={t('access_title')} />
|
||||
<SignupAccessManager
|
||||
canEdit={membership.role === 'owner' || membership.role === 'admin'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { fetchClientOverview } from '@/lib/clients/fetch-client-overview'
|
||||
import { getDashboardAuthContext } from '../request-context'
|
||||
@@ -34,7 +36,14 @@ export default async function ClientsPage() {
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
action={canCreate ? <NewClientCompanyButton /> : undefined}
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" className="text-muted-foreground" asChild>
|
||||
<Link href="/clients/access">{t('access_link')}</Link>
|
||||
</Button>
|
||||
{canCreate && <NewClientCompanyButton />}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ClientsTable clients={overview.clients} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { headers } from 'next/headers'
|
||||
import { cookies, headers } from 'next/headers'
|
||||
import DashboardNav from '@/components/dashboard/DashboardNav'
|
||||
import { MainContainer } from '@/components/dashboard/MainContainer'
|
||||
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
|
||||
@@ -19,6 +19,8 @@ import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
|
||||
import { getDashboardNavFlags } from '@/lib/dashboard/nav-flags'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { resolveBrandByHost } from '@/lib/branding/resolve'
|
||||
import { resolveBrandDomainBounce } from '@/lib/auth/brand-signup-gate'
|
||||
import { INVITE_COOKIE_NAME } from '@/lib/auth/consume-invite-cookie'
|
||||
import { resolveBrandsForTeams } from '@/lib/branding/team-brands'
|
||||
import {
|
||||
partitionCompaniesByHomeDomain,
|
||||
@@ -133,6 +135,31 @@ export default async function DashboardLayout({
|
||||
null
|
||||
const isTeamMember = membershipRows.length > 0
|
||||
|
||||
// Invite-only brand-domain gate (2026-08-27): on a gated brand host, a
|
||||
// session with no tie to the brand (team, company, allowlist entry, or
|
||||
// pending invite) is sent to the canonical domain instead of getting a
|
||||
// branded shell. Runs before the zero-company branch below, because that
|
||||
// branch would otherwise walk a stranger into /onboarding under the
|
||||
// partner's brand. Navigation-level like WL-01; RLS is the data boundary.
|
||||
const hostHeader =
|
||||
headerStore.get('x-forwarded-host') ?? headerStore.get('host') ?? ''
|
||||
const bounceUrl = await resolveBrandDomainBounce({
|
||||
host: hostHeader,
|
||||
userEmail: user.email,
|
||||
teamIds: membershipRows
|
||||
.map((m) => m.teams?.id)
|
||||
.filter((id): id is string => typeof id === 'string'),
|
||||
companyTeamIds: (allMemberships || []).map(
|
||||
(m) => (m.companies as { team_id?: string | null } | null)?.team_id,
|
||||
),
|
||||
hasPendingInviteCookie:
|
||||
(await cookies()).get(INVITE_COOKIE_NAME)?.value != null,
|
||||
canonicalAppUrl: getBranding().appUrl,
|
||||
})
|
||||
if (bounceUrl) {
|
||||
redirect(bounceUrl)
|
||||
}
|
||||
|
||||
// No companies: redirect to onboarding, except for allowed escape-hatch
|
||||
// routes (so the user can still reach /settings/account to delete their
|
||||
// account after archiving their last company) and byrå team members (any
|
||||
@@ -206,11 +233,9 @@ export default async function DashboardLayout({
|
||||
|
||||
// Home-domain rule (WL-01): which brand serves this host, and which brand
|
||||
// (if any) each membership company's team owns. Both resolvers are ~60s
|
||||
// cached; unknown hosts and brandless teams resolve to null/absent, so the
|
||||
// canonical no-brands hot path stays byte-identical. Both only depend on
|
||||
// wave-1 data, so they ride in the wave-2 batch below.
|
||||
const hostHeader =
|
||||
headerStore.get('x-forwarded-host') ?? headerStore.get('host') ?? ''
|
||||
// cached (the domain-gate lookup above already warmed the host entry);
|
||||
// unknown hosts and brandless teams resolve to null/absent, so the
|
||||
// canonical no-brands hot path stays byte-identical.
|
||||
|
||||
// Wave 2: everything keyed on the company. Nav badge counts are NOT fetched
|
||||
// here: DashboardNav loads them client-side after mount
|
||||
|
||||
@@ -46,6 +46,7 @@ function makeBrand(overrides: Partial<Brand> = {}): Brand {
|
||||
senderDomain: 'post.siffra.se',
|
||||
senderDomainStatus: 'verified',
|
||||
resendDomainId: 'rd-1',
|
||||
signupMode: 'open',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const signUpMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(async () => ({ auth: { signUp: signUpMock } })),
|
||||
}))
|
||||
|
||||
const gateMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/auth/brand-signup-gate', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@/lib/auth/brand-signup-gate')>()
|
||||
return {
|
||||
...actual,
|
||||
evaluateBrandSignupGate: (...args: unknown[]) => gateMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
function makeRequest(
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
): Request {
|
||||
return new Request('https://internal/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
const validBody = { email: 'kund@example.com', password: 'Str0ng!Pass' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
gateMock.mockResolvedValue({ allowed: true, brand: null, via: 'no_brand' })
|
||||
signUpMock.mockResolvedValue({
|
||||
data: { user: { identities: [{ id: 'i1' }] }, session: null },
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/signup', () => {
|
||||
it('400s on invalid body', async () => {
|
||||
const res = await POST(makeRequest({ email: 'not-an-email', password: 'x' }))
|
||||
expect(res.status).toBe(400)
|
||||
expect(signUpMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('403s with signup_not_allowed when the gate blocks', async () => {
|
||||
gateMock.mockResolvedValue({ allowed: false, brand: { id: 'brand-1' } })
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(validBody, { host: 'app.testbrand.example' }),
|
||||
)
|
||||
const { body: json } = await parseJsonResponse<{ error: { code: string } }>(res)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
expect(json.error.code).toBe('signup_not_allowed')
|
||||
expect(signUpMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('503s (fail safe) when the brand lookup errors, without creating an account', async () => {
|
||||
gateMock.mockResolvedValue({ allowed: false, brand: null, lookupFailed: true })
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(validBody, { host: 'app.testbrand.example' }),
|
||||
)
|
||||
const { body: json } = await parseJsonResponse<{ error: { code: string } }>(res)
|
||||
|
||||
expect(res.status).toBe(503)
|
||||
expect(json.error.code).toBe('brand_lookup_failed')
|
||||
expect(signUpMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('feeds the gate the forwarded host, normalized email and invite cookie', async () => {
|
||||
await POST(
|
||||
makeRequest(
|
||||
{ ...validBody, email: ' Kund@Example.COM ' },
|
||||
{
|
||||
host: 'internal',
|
||||
'x-forwarded-host': 'app.testbrand.example',
|
||||
cookie: 'gnubok-invite-token=gnubok_inv_x',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
expect(gateMock).toHaveBeenCalledWith({
|
||||
host: 'app.testbrand.example',
|
||||
email: 'kund@example.com',
|
||||
inviteToken: 'gnubok_inv_x',
|
||||
})
|
||||
})
|
||||
|
||||
it('signs up with a confirmation callback on the originating host', async () => {
|
||||
const res = await POST(
|
||||
makeRequest(validBody, {
|
||||
'x-forwarded-host': 'app.testbrand.example',
|
||||
'x-forwarded-proto': 'https',
|
||||
}),
|
||||
)
|
||||
const { body: json } = await parseJsonResponse<{ data: { status: string } }>(res)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(json.data.status).toBe('confirmation_sent')
|
||||
expect(signUpMock).toHaveBeenCalledWith({
|
||||
email: 'kund@example.com',
|
||||
password: 'Str0ng!Pass',
|
||||
options: {
|
||||
emailRedirectTo: 'https://app.testbrand.example/auth/callback',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards the captcha token and a safe next path', async () => {
|
||||
await POST(
|
||||
makeRequest(
|
||||
{ ...validBody, captchaToken: 'tok', next: '/api/mcp-oauth/authorize?x=1' },
|
||||
{ host: 'app.accounted.se' },
|
||||
),
|
||||
)
|
||||
|
||||
const call = signUpMock.mock.calls[0][0]
|
||||
expect(call.options.captchaToken).toBe('tok')
|
||||
expect(call.options.emailRedirectTo).toBe(
|
||||
'https://app.accounted.se/auth/callback?next=%2Fapi%2Fmcp-oauth%2Fauthorize%3Fx%3D1',
|
||||
)
|
||||
})
|
||||
|
||||
it('drops an unsafe next path instead of forwarding it', async () => {
|
||||
await POST(
|
||||
makeRequest(
|
||||
{ ...validBody, next: 'https://evil.example.com/phish' },
|
||||
{ host: 'app.accounted.se' },
|
||||
),
|
||||
)
|
||||
const call = signUpMock.mock.calls[0][0]
|
||||
expect(call.options.emailRedirectTo).toBe(
|
||||
'https://app.accounted.se/auth/callback',
|
||||
)
|
||||
})
|
||||
|
||||
it('maps a GoTrue error to the canonical envelope', async () => {
|
||||
signUpMock.mockResolvedValue({
|
||||
data: { user: null, session: null },
|
||||
error: { code: 'weak_password', message: 'Password is too weak', status: 422 },
|
||||
})
|
||||
|
||||
const res = await POST(makeRequest(validBody, { host: 'app.accounted.se' }))
|
||||
const { body: json } = await parseJsonResponse<{ error: { code: string; message: string } }>(res)
|
||||
|
||||
expect(res.status).toBe(422)
|
||||
expect(json.error.code).toBe('weak_password')
|
||||
})
|
||||
|
||||
it('reports duplicate for the obfuscated existing-account response', async () => {
|
||||
signUpMock.mockResolvedValue({
|
||||
data: { user: { identities: [] }, session: null },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const res = await POST(makeRequest(validBody, { host: 'app.accounted.se' }))
|
||||
const { body: json } = await parseJsonResponse<{ data: { status: string } }>(res)
|
||||
|
||||
expect(json.data.status).toBe('duplicate')
|
||||
})
|
||||
|
||||
it('reports session for auto-confirmed signups', async () => {
|
||||
signUpMock.mockResolvedValue({
|
||||
data: { user: { identities: [{ id: 'i1' }] }, session: { access_token: 'x' } },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const res = await POST(makeRequest(validBody, { host: 'app.accounted.se' }))
|
||||
const { body: json } = await parseJsonResponse<{ data: { status: string } }>(res)
|
||||
|
||||
expect(json.data.status).toBe('session')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import {
|
||||
evaluateBrandSignupGate,
|
||||
readInviteTokenFromCookieHeader,
|
||||
} from '@/lib/auth/brand-signup-gate'
|
||||
import { safeReturnTo } from '@/lib/auth/safe-return-to'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('auth-signup')
|
||||
|
||||
/**
|
||||
* POST /api/auth/signup: email+password signup, moved server-side so the
|
||||
* invite-only brand-domain gate (lib/auth/brand-signup-gate.ts) cannot be
|
||||
* bypassed. The register page used to call supabase.auth.signUp straight
|
||||
* from the browser; that call never touched Next.js, so any host-based
|
||||
* gating there would have been cosmetic. This route is now the only
|
||||
* email-signup path on every host: on canonical and open-brand hosts the
|
||||
* behavior is byte-identical to the old direct call (same GoTrue request,
|
||||
* same captcha, same emailRedirectTo shape), on invite-only brand hosts it
|
||||
* refuses with signup_not_allowed unless the email is allowlisted or a
|
||||
* valid invite cookie rides along.
|
||||
*
|
||||
* Anonymous by design: there is no session to authenticate at signup time,
|
||||
* so no withRouteContext / requireAuth. Abuse is bounded the same way the
|
||||
* direct GoTrue call was: the forwarded Turnstile token (verified by
|
||||
* GoTrue) plus GoTrue's own signup rate limits.
|
||||
*/
|
||||
|
||||
const SignupSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().max(320).pipe(z.string().email()),
|
||||
password: z.string().min(8).max(256),
|
||||
captchaToken: z.string().max(4096).nullish(),
|
||||
/** Post-signup resume path (MCP OAuth consent); same-origin enforced. */
|
||||
next: z.string().max(2048).nullish(),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const validation = await validateBody(request, SignupSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { email, password, captchaToken } = validation.data
|
||||
|
||||
const host =
|
||||
request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? ''
|
||||
|
||||
// The invite cookie is set by /invite/[token] before it redirects to
|
||||
// /register, so an invitee's signup carries it automatically.
|
||||
const inviteToken = readInviteTokenFromCookieHeader(request.headers.get('cookie'))
|
||||
|
||||
const gate = await evaluateBrandSignupGate({ host, email, inviteToken })
|
||||
if (!gate.allowed && 'lookupFailed' in gate) {
|
||||
// Transient brands-table error: fail safe, do not create the account.
|
||||
// 503 tells the client to retry rather than the misleading "not allowed".
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'brand_lookup_failed',
|
||||
message: 'Tillfälligt fel. Försök igen om en stund.',
|
||||
message_en: 'Temporary error. Please try again shortly.',
|
||||
},
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
if (!gate.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'signup_not_allowed',
|
||||
// Brand-neutral copy: the interstitial on the register page owns
|
||||
// the user-facing story; this message is the API-level fallback.
|
||||
message: 'Registrering på den här domänen kräver inbjudan.',
|
||||
message_en: 'Signing up on this domain requires an invitation.',
|
||||
},
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
// Confirmation links must land back on the ORIGINATING host (WL-05 brand
|
||||
// mail resolves its brand from this URL), so build the callback from the
|
||||
// forwarded host rather than request.url, which can be an internal origin
|
||||
// behind the proxy.
|
||||
const proto = request.headers.get('x-forwarded-proto') ?? 'https'
|
||||
const confirmationCallback = new URL(`${proto}://${host}/auth/callback`)
|
||||
const nextPath = safeReturnTo(validation.data.next ?? null, '/')
|
||||
if (nextPath !== '/') confirmationCallback.searchParams.set('next', nextPath)
|
||||
|
||||
const supabase = await createClient()
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: confirmationCallback.toString(),
|
||||
...(captchaToken ? { captchaToken } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (error) {
|
||||
log.warn('signUp rejected', { status: error.status, code: error.code })
|
||||
// The register page feeds this envelope to classifyAuthError, which
|
||||
// keys on the GoTrue code (and the HTTP status); the display message is
|
||||
// localized through getErrorMessage like every other auth surface.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: error.code ?? 'auth_error',
|
||||
message: getErrorMessage(error, { context: 'auth', locale: 'sv' }),
|
||||
message_en: getErrorMessage(error, { context: 'auth', locale: 'en' }),
|
||||
},
|
||||
},
|
||||
{ status: error.status && error.status >= 400 ? error.status : 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Supabase obfuscates duplicate signups (anti-enumeration): a confirmed
|
||||
// existing email returns a user with identities: [] and sends no mail.
|
||||
// Surface that as a distinct status so the page can skip the misleading
|
||||
// "check your email" screen; the information is the same the browser call
|
||||
// exposed, so nothing new leaks.
|
||||
const status = data.session
|
||||
? 'session'
|
||||
: data.user && (data.user.identities?.length ?? 0) === 0
|
||||
? 'duplicate'
|
||||
: 'confirmation_sent'
|
||||
|
||||
return NextResponse.json({ data: { status } })
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const service = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
const byraMembershipMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/clients/fetch-client-overview', () => ({
|
||||
getByraMembership: (...args: unknown[]) => byraMembershipMock(...args),
|
||||
}))
|
||||
|
||||
const brandForTeamMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/branding/resolve', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/branding/resolve')>()
|
||||
return {
|
||||
...actual,
|
||||
resolveBrandForTeam: (...args: unknown[]) => brandForTeamMock(...args),
|
||||
clearBrandCache: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', () => ({
|
||||
createServiceClientNoCookies: vi.fn(() => service.supabase),
|
||||
}))
|
||||
|
||||
import { GET, PATCH, POST, DELETE } from '../route'
|
||||
|
||||
const BRAND = {
|
||||
id: 'brand-1',
|
||||
teamId: 'team-1',
|
||||
domain: 'app.testbrand.example',
|
||||
appName: 'Testbrand',
|
||||
signupMode: 'invite_only',
|
||||
}
|
||||
|
||||
function makeRequest(method: string, body?: unknown): Request {
|
||||
return new Request('https://app.test/api/clients/signup-access', {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function authed() {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1', email: 'byra@example.com' },
|
||||
supabase,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
service.reset()
|
||||
authed()
|
||||
byraMembershipMock.mockResolvedValue({
|
||||
teamId: 'team-1',
|
||||
teamName: 'Byrån',
|
||||
role: 'owner',
|
||||
})
|
||||
brandForTeamMock.mockResolvedValue(BRAND)
|
||||
})
|
||||
|
||||
describe('/api/clients/signup-access', () => {
|
||||
it('401s when unauthenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
const res = await GET()
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('403s for non-byrå users', async () => {
|
||||
byraMembershipMock.mockResolvedValue(null)
|
||||
const res = await GET()
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('404s when the team has no brand', async () => {
|
||||
brandForTeamMock.mockResolvedValue(null)
|
||||
const res = await GET()
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns mode, role and entries', async () => {
|
||||
enqueue({
|
||||
data: [{ id: 'e1', email: 'kund@example.com', note: null, created_at: '2026-08-27' }],
|
||||
})
|
||||
|
||||
const res = await GET()
|
||||
const { body } = await parseJsonResponse<{
|
||||
data: {
|
||||
brand: { domain: string; signupMode: string }
|
||||
role: string
|
||||
entries: unknown[]
|
||||
}
|
||||
}>(res)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.data.brand.signupMode).toBe('invite_only')
|
||||
expect(body.data.role).toBe('owner')
|
||||
expect(body.data.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('PATCH 403s for plain members', async () => {
|
||||
byraMembershipMock.mockResolvedValue({
|
||||
teamId: 'team-1',
|
||||
teamName: 'Byrån',
|
||||
role: 'member',
|
||||
})
|
||||
const res = await PATCH(makeRequest('PATCH', { signup_mode: 'invite_only' }))
|
||||
expect(res.status).toBe(403)
|
||||
expect(service.supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('PATCH 400s on an unknown mode', async () => {
|
||||
const res = await PATCH(makeRequest('PATCH', { signup_mode: 'wide_open' }))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('PATCH flips the mode through the service client', async () => {
|
||||
service.enqueue({ data: null, error: null })
|
||||
const res = await PATCH(makeRequest('PATCH', { signup_mode: 'invite_only' }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(service.findCall('brands', 'update')).toEqual([
|
||||
{ signup_mode: 'invite_only' },
|
||||
])
|
||||
expect(service.findCall('brands', 'eq')).toEqual(['id', 'brand-1'])
|
||||
})
|
||||
|
||||
it('POST adds a lowercased entry', async () => {
|
||||
enqueue({
|
||||
data: { id: 'e1', email: 'ny@example.com', note: 'VD', created_at: '2026-08-27' },
|
||||
})
|
||||
|
||||
const res = await POST(
|
||||
makeRequest('POST', { email: ' NY@Example.com ', note: 'VD' }),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const insert = supabase.from as unknown as ReturnType<typeof vi.fn>
|
||||
expect(insert).toHaveBeenCalledWith('brand_signup_allowlist')
|
||||
})
|
||||
|
||||
it('POST 409s on a duplicate email', async () => {
|
||||
enqueue({ data: null, error: { code: '23505', message: 'duplicate' } })
|
||||
const res = await POST(makeRequest('POST', { email: 'kund@example.com' }))
|
||||
expect(res.status).toBe(409)
|
||||
})
|
||||
|
||||
it('POST 400s on an invalid email', async () => {
|
||||
const res = await POST(makeRequest('POST', { email: 'not-an-email' }))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('DELETE removes scoped to the brand', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
const res = await DELETE(
|
||||
makeRequest('DELETE', { id: '11111111-1111-4111-8111-111111111111' }),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { getByraMembership } from '@/lib/clients/fetch-client-overview'
|
||||
import { clearBrandCache, resolveBrandForTeam, type Brand } from '@/lib/branding/resolve'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import {
|
||||
BrandAllowlistAddSchema,
|
||||
BrandAllowlistRemoveSchema,
|
||||
BrandSignupModeSchema,
|
||||
} from '@/lib/api/schemas'
|
||||
|
||||
/**
|
||||
* /api/clients/signup-access: the byrå cockpit's management surface for
|
||||
* invite-only signup on the team's brand domain (2026-08-27).
|
||||
*
|
||||
* GET signup mode + allowlist entries (any byrå team member)
|
||||
* PATCH { signup_mode } flip open/invite_only (owner/admin)
|
||||
* POST { email, note? } add an allowlist entry (owner/admin)
|
||||
* DELETE { id } remove an allowlist entry (owner/admin)
|
||||
*
|
||||
* Uses requireAuth() directly (the sanctioned withRouteContext opt-out, MFA
|
||||
* still enforced): byrå staff without a company of their own are the
|
||||
* cockpit's primary persona, and this surface needs no active company.
|
||||
*
|
||||
* Allowlist reads/writes go through the caller's client so RLS enforces the
|
||||
* same team/role rules a second time. The signup_mode flip uses the service
|
||||
* client because brands rows are ops-managed (no user write policies); the
|
||||
* owner/admin check here is the authorization for that single column.
|
||||
*/
|
||||
|
||||
type Access =
|
||||
| {
|
||||
ok: true
|
||||
supabase: SupabaseClient
|
||||
userId: string
|
||||
role: 'owner' | 'admin' | 'member'
|
||||
brand: Brand
|
||||
}
|
||||
| { ok: false; response: NextResponse }
|
||||
|
||||
async function resolveAccess(opts: { write: boolean }): Promise<Access> {
|
||||
const auth = await requireAuth()
|
||||
if (auth.error) return { ok: false, response: auth.error }
|
||||
const { user, supabase } = auth
|
||||
|
||||
const membership = await getByraMembership(supabase, user.id)
|
||||
if (!membership) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Endast byråteam har åtkomst till registreringsinställningarna.',
|
||||
message_en: 'Signup access settings are only available to byrå teams.',
|
||||
},
|
||||
},
|
||||
{ status: 403 },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.write && membership.role !== 'owner' && membership.role !== 'admin') {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Endast byråns ägare och administratörer kan ändra registreringsåtkomst.',
|
||||
message_en: 'Only byrå owners and admins can change signup access.',
|
||||
},
|
||||
},
|
||||
{ status: 403 },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const brand = await resolveBrandForTeam(membership.teamId)
|
||||
if (!brand) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Byrån har ingen egen domän ännu.',
|
||||
message_en: 'The byrå has no white-label domain yet.',
|
||||
},
|
||||
},
|
||||
{ status: 404 },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, supabase, userId: user.id, role: membership.role, brand }
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const access = await resolveAccess({ write: false })
|
||||
if (!access.ok) return access.response
|
||||
|
||||
const { data: entries, error } = await access.supabase
|
||||
.from('brand_signup_allowlist')
|
||||
.select('id, email, note, created_at')
|
||||
.eq('brand_id', access.brand.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'INTERNAL', message: 'Kunde inte hämta listan.', message_en: 'Could not load the list.' } },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
brand: {
|
||||
domain: access.brand.domain,
|
||||
appName: access.brand.appName,
|
||||
signupMode: access.brand.signupMode,
|
||||
},
|
||||
role: access.role,
|
||||
entries: entries ?? [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const access = await resolveAccess({ write: true })
|
||||
if (!access.ok) return access.response
|
||||
|
||||
const validation = await validateBody(request, BrandSignupModeSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const service = createServiceClientNoCookies()
|
||||
const { error } = await service
|
||||
.from('brands')
|
||||
.update({ signup_mode: validation.data.signup_mode })
|
||||
.eq('id', access.brand.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'INTERNAL', message: 'Kunde inte spara.', message_en: 'Could not save.' } },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
// The host-resolution cache holds the old mode for up to ~60s; drop it so
|
||||
// the gate on this server instance flips immediately. Other instances
|
||||
// converge within the TTL, same as every other brand edit.
|
||||
clearBrandCache()
|
||||
|
||||
return NextResponse.json({ data: { signupMode: validation.data.signup_mode } })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const access = await resolveAccess({ write: true })
|
||||
if (!access.ok) return access.response
|
||||
|
||||
const validation = await validateBody(request, BrandAllowlistAddSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const { data: entry, error } = await access.supabase
|
||||
.from('brand_signup_allowlist')
|
||||
.insert({
|
||||
brand_id: access.brand.id,
|
||||
email: validation.data.email,
|
||||
note: validation.data.note ?? null,
|
||||
created_by: access.userId,
|
||||
})
|
||||
.select('id, email, note, created_at')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === '23505') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'CONFLICT',
|
||||
message: 'E-postadressen finns redan i listan.',
|
||||
message_en: 'That email is already on the list.',
|
||||
},
|
||||
},
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'INTERNAL', message: 'Kunde inte lägga till.', message_en: 'Could not add the email.' } },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: entry })
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const access = await resolveAccess({ write: true })
|
||||
if (!access.ok) return access.response
|
||||
|
||||
const validation = await validateBody(request, BrandAllowlistRemoveSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const { error } = await access.supabase
|
||||
.from('brand_signup_allowlist')
|
||||
.delete()
|
||||
.eq('id', validation.data.id)
|
||||
.eq('brand_id', access.brand.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'INTERNAL', message: 'Kunde inte ta bort.', message_en: 'Could not remove the email.' } },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { removed: validation.data.id } })
|
||||
}
|
||||
@@ -57,6 +57,7 @@ const SIFFRA_BRAND = {
|
||||
senderDomain: 'post.siffra.se',
|
||||
senderDomainStatus: 'verified',
|
||||
resendDomainId: 'rd-1',
|
||||
signupMode: 'open',
|
||||
}
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'admin@byra.se' }
|
||||
|
||||
@@ -36,6 +36,10 @@ import {
|
||||
} from './lib/bankid-flow-cookie'
|
||||
import { lookupCompanyByOrgNumber, registrationDateToMs } from './lib/lookup'
|
||||
import { hashPersonalNumber, encryptPersonalNumberForStorage } from '@/lib/auth/bankid'
|
||||
import {
|
||||
evaluateBrandSignupGate,
|
||||
readInviteTokenFromCookieHeader,
|
||||
} from '@/lib/auth/brand-signup-gate'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
@@ -1110,6 +1114,41 @@ export const ticExtension: Extension = {
|
||||
))
|
||||
}
|
||||
|
||||
// Invite-only brand domain gate (founder decision 2026-08-27):
|
||||
// same rule POST /api/auth/signup enforces on the email path. Runs
|
||||
// AFTER the existing-identity check so a returning user's login is
|
||||
// never blocked, and before anything is created or consumed:
|
||||
// deliberately NOT settled, so the visitor keeps the completed
|
||||
// BankID identification if the byrå allowlists them mid-flow.
|
||||
const gateResult = await evaluateBrandSignupGate({
|
||||
host:
|
||||
request.headers.get('x-forwarded-host') ??
|
||||
request.headers.get('host') ??
|
||||
'',
|
||||
email: trimmedEmail!,
|
||||
inviteToken: readInviteTokenFromCookieHeader(request.headers.get('cookie')),
|
||||
})
|
||||
if (!gateResult.allowed && 'lookupFailed' in gateResult) {
|
||||
// Transient brands-table error: fail safe, do not create the
|
||||
// account. Not settled, so the completed BankID flow can retry.
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'brand_lookup_failed',
|
||||
message: 'Tillfälligt fel. Försök igen om en stund.',
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
if (!gateResult.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'signup_not_allowed',
|
||||
message: 'Registrering på den här domänen kräver inbjudan.',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create new Supabase user. Email uniqueness is checked by createUser
|
||||
// itself against auth.users: do NOT pre-check profiles.email instead.
|
||||
// The profile mirror can lack the address while the auth row still
|
||||
|
||||
@@ -3839,3 +3839,21 @@ export const FiscalYearResetSchema = z.object({
|
||||
export const NoticeDismissSchema = z.object({
|
||||
notice_id: z.string().min(1).max(200),
|
||||
})
|
||||
|
||||
/**
|
||||
* Brand signup access (invite-only white-label domains, 2026-08-27).
|
||||
* Emails are lowercased here so they match the CHECK-enforced lowercase
|
||||
* storage in brand_signup_allowlist.
|
||||
*/
|
||||
export const BrandSignupModeSchema = z.object({
|
||||
signup_mode: z.enum(['open', 'invite_only']),
|
||||
})
|
||||
|
||||
export const BrandAllowlistAddSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().max(320).pipe(z.string().email()),
|
||||
note: z.string().trim().max(200).optional(),
|
||||
})
|
||||
|
||||
export const BrandAllowlistRemoveSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { hashInviteToken } from '@/lib/auth/invite-tokens'
|
||||
import type { Brand } from '@/lib/branding/resolve'
|
||||
|
||||
const serviceClient = vi.hoisted(() => ({ current: null as unknown }))
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', () => ({
|
||||
createServiceClientNoCookies: vi.fn(() => serviceClient.current),
|
||||
}))
|
||||
|
||||
// The gate resolves via resolveBrandResultByHost ({ brand, lookupFailed });
|
||||
// resolveBrandDomainBounce still uses resolveBrandByHost. Mock both off one
|
||||
// brand value, and let tests override lookupFailed when they need it.
|
||||
const resolveBrandByHostMock = vi.hoisted(() => vi.fn())
|
||||
const resolveBrandResultMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/branding/resolve', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/branding/resolve')>()
|
||||
return {
|
||||
...actual,
|
||||
resolveBrandByHost: (...args: unknown[]) => resolveBrandByHostMock(...args),
|
||||
resolveBrandResultByHost: (...args: unknown[]) => resolveBrandResultMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
evaluateBrandSignupGate,
|
||||
isEmailOnBrandAllowlist,
|
||||
readInviteTokenFromCookieHeader,
|
||||
resolveBrandDomainBounce,
|
||||
} from '@/lib/auth/brand-signup-gate'
|
||||
|
||||
function makeBrand(overrides: Partial<Brand> = {}): Brand {
|
||||
return {
|
||||
id: 'brand-1',
|
||||
teamId: 'team-1',
|
||||
domain: 'app.testbrand.example',
|
||||
appName: 'Testbrand',
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
brandColor: '#123456',
|
||||
chromeColor: null,
|
||||
fontKey: 'default',
|
||||
supportEmail: 'support@testbrand.example',
|
||||
authEmailFrom: null,
|
||||
senderDomain: null,
|
||||
senderDomainStatus: 'unverified',
|
||||
resendDomainId: null,
|
||||
signupMode: 'invite_only',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
let mock: ReturnType<typeof createQueuedMockSupabase>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mock = createQueuedMockSupabase()
|
||||
serviceClient.current = mock.supabase
|
||||
// By default the strict resolver mirrors resolveBrandByHostMock's value
|
||||
// with lookupFailed:false, so the existing tests keep configuring one mock.
|
||||
// The fail-safe test overrides this to return lookupFailed:true.
|
||||
resolveBrandResultMock.mockImplementation(async (host: string) => ({
|
||||
brand: await resolveBrandByHostMock(host),
|
||||
lookupFailed: false,
|
||||
}))
|
||||
})
|
||||
|
||||
describe('evaluateBrandSignupGate', () => {
|
||||
it('allows when the host has no brand', async () => {
|
||||
resolveBrandByHostMock.mockResolvedValue(null)
|
||||
const result = await evaluateBrandSignupGate({
|
||||
host: 'app.accounted.se',
|
||||
email: 'anyone@example.com',
|
||||
})
|
||||
expect(result).toEqual({ allowed: true, brand: null, via: 'no_brand' })
|
||||
})
|
||||
|
||||
it('allows with no host at all', async () => {
|
||||
const result = await evaluateBrandSignupGate({ host: '', email: 'a@b.se' })
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(resolveBrandByHostMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows on an open brand without touching the allowlist', async () => {
|
||||
resolveBrandByHostMock.mockResolvedValue(makeBrand({ signupMode: 'open' }))
|
||||
const result = await evaluateBrandSignupGate({
|
||||
host: 'app.testbrand.example',
|
||||
email: 'anyone@example.com',
|
||||
})
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.allowed && result.via).toBe('open')
|
||||
expect(mock.supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows an allowlisted email on an invite-only brand, case-insensitively', async () => {
|
||||
resolveBrandByHostMock.mockResolvedValue(makeBrand())
|
||||
mock.enqueue({ data: { id: 'entry-1' } })
|
||||
|
||||
const result = await evaluateBrandSignupGate({
|
||||
host: 'app.testbrand.example',
|
||||
email: ' Kund@Example.COM ',
|
||||
})
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.allowed && result.via).toBe('allowlist')
|
||||
// The lookup used the normalized address.
|
||||
expect(mock.findCalls('brand_signup_allowlist', 'eq')).toContainEqual([
|
||||
'email',
|
||||
'kund@example.com',
|
||||
])
|
||||
})
|
||||
|
||||
it('blocks a non-allowlisted email on an invite-only brand', async () => {
|
||||
const brand = makeBrand()
|
||||
resolveBrandByHostMock.mockResolvedValue(brand)
|
||||
mock.enqueue({ data: null })
|
||||
|
||||
const result = await evaluateBrandSignupGate({
|
||||
host: 'app.testbrand.example',
|
||||
email: 'stranger@example.com',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ allowed: false, brand })
|
||||
})
|
||||
|
||||
it('fails safe (lookupFailed) when the brand lookup itself errors', async () => {
|
||||
resolveBrandResultMock.mockResolvedValue({ brand: null, lookupFailed: true })
|
||||
|
||||
const result = await evaluateBrandSignupGate({
|
||||
host: 'app.testbrand.example',
|
||||
email: 'anyone@example.com',
|
||||
})
|
||||
|
||||
// Must NOT degrade to allowed no_brand: a transient DB error cannot open
|
||||
// an invite-only domain.
|
||||
expect(result).toEqual({ allowed: false, brand: null, lookupFailed: true })
|
||||
})
|
||||
|
||||
it('fails closed when the allowlist lookup errors', async () => {
|
||||
resolveBrandByHostMock.mockResolvedValue(makeBrand())
|
||||
mock.enqueue({ data: null, error: { message: 'boom' } })
|
||||
|
||||
const result = await evaluateBrandSignupGate({
|
||||
host: 'app.testbrand.example',
|
||||
email: 'kund@example.com',
|
||||
})
|
||||
|
||||
expect(result.allowed).toBe(false)
|
||||
})
|
||||
|
||||
it('allows a pending unexpired invite for the same email', async () => {
|
||||
resolveBrandByHostMock.mockResolvedValue(makeBrand())
|
||||
// Allowlist miss, then the invite row.
|
||||
mock.enqueueMany([
|
||||
{ data: null },
|
||||
{
|
||||
data: {
|
||||
email: 'Invitee@Example.com',
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const result = await evaluateBrandSignupGate({
|
||||
host: 'app.testbrand.example',
|
||||
email: 'invitee@example.com',
|
||||
inviteToken: 'gnubok_inv_abc',
|
||||
})
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.allowed && result.via).toBe('invite')
|
||||
// Lookup is by token hash, never the raw token.
|
||||
expect(mock.findCall('company_invitations', 'eq')).toEqual([
|
||||
'token_hash',
|
||||
hashInviteToken('gnubok_inv_abc'),
|
||||
])
|
||||
})
|
||||
|
||||
it('blocks when the invite is for a different email', async () => {
|
||||
resolveBrandByHostMock.mockResolvedValue(makeBrand())
|
||||
mock.enqueueMany([
|
||||
{ data: null },
|
||||
{
|
||||
data: {
|
||||
email: 'someone-else@example.com',
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const result = await evaluateBrandSignupGate({
|
||||
host: 'app.testbrand.example',
|
||||
email: 'stranger@example.com',
|
||||
inviteToken: 'gnubok_inv_abc',
|
||||
})
|
||||
|
||||
expect(result.allowed).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks when the invite is expired', async () => {
|
||||
resolveBrandByHostMock.mockResolvedValue(makeBrand())
|
||||
mock.enqueueMany([
|
||||
{ data: null },
|
||||
{
|
||||
data: {
|
||||
email: 'invitee@example.com',
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() - 60_000).toISOString(),
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const result = await evaluateBrandSignupGate({
|
||||
host: 'app.testbrand.example',
|
||||
email: 'invitee@example.com',
|
||||
inviteToken: 'gnubok_inv_abc',
|
||||
})
|
||||
|
||||
expect(result.allowed).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEmailOnBrandAllowlist', () => {
|
||||
it('returns false for an empty email without querying', async () => {
|
||||
expect(await isEmailOnBrandAllowlist('brand-1', ' ')).toBe(false)
|
||||
expect(mock.supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveBrandDomainBounce', () => {
|
||||
const base = {
|
||||
host: 'app.testbrand.example',
|
||||
userEmail: 'user@example.com',
|
||||
teamIds: [] as string[],
|
||||
companyTeamIds: [] as Array<string | null>,
|
||||
hasPendingInviteCookie: false,
|
||||
canonicalAppUrl: 'https://app.accounted.se',
|
||||
}
|
||||
|
||||
it('stays on open brands and brandless hosts', async () => {
|
||||
resolveBrandByHostMock.mockResolvedValue(null)
|
||||
expect(await resolveBrandDomainBounce(base)).toBeNull()
|
||||
|
||||
resolveBrandByHostMock.mockResolvedValue(makeBrand({ signupMode: 'open' }))
|
||||
expect(await resolveBrandDomainBounce(base)).toBeNull()
|
||||
})
|
||||
|
||||
it('stays for brand team members and brand-homed company members', async () => {
|
||||
resolveBrandByHostMock.mockResolvedValue(makeBrand())
|
||||
expect(
|
||||
await resolveBrandDomainBounce({ ...base, teamIds: ['team-1'] }),
|
||||
).toBeNull()
|
||||
expect(
|
||||
await resolveBrandDomainBounce({ ...base, companyTeamIds: [null, 'team-1'] }),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('stays when a pending invite cookie rides along', async () => {
|
||||
resolveBrandByHostMock.mockResolvedValue(makeBrand())
|
||||
expect(
|
||||
await resolveBrandDomainBounce({ ...base, hasPendingInviteCookie: true }),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('stays for an allowlisted email', async () => {
|
||||
resolveBrandByHostMock.mockResolvedValue(makeBrand())
|
||||
mock.enqueue({ data: { id: 'entry-1' } })
|
||||
expect(await resolveBrandDomainBounce(base)).toBeNull()
|
||||
})
|
||||
|
||||
it('bounces a non-belonging session to the canonical URL', async () => {
|
||||
resolveBrandByHostMock.mockResolvedValue(makeBrand())
|
||||
mock.enqueue({ data: null })
|
||||
expect(await resolveBrandDomainBounce(base)).toBe('https://app.accounted.se')
|
||||
})
|
||||
|
||||
it('never bounces onto the same host', async () => {
|
||||
resolveBrandByHostMock.mockResolvedValue(makeBrand())
|
||||
mock.enqueue({ data: null })
|
||||
expect(
|
||||
await resolveBrandDomainBounce({
|
||||
...base,
|
||||
canonicalAppUrl: 'https://app.testbrand.example',
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('never bounces on a malformed canonical URL', async () => {
|
||||
resolveBrandByHostMock.mockResolvedValue(makeBrand())
|
||||
mock.enqueue({ data: null })
|
||||
expect(
|
||||
await resolveBrandDomainBounce({ ...base, canonicalAppUrl: '' }),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readInviteTokenFromCookieHeader', () => {
|
||||
it('reads the invite token among other cookies', () => {
|
||||
expect(
|
||||
readInviteTokenFromCookieHeader(
|
||||
'a=1; gnubok-invite-token=gnubok_inv_x; b=2',
|
||||
),
|
||||
).toBe('gnubok_inv_x')
|
||||
})
|
||||
|
||||
it('decodes URI-encoded values and tolerates missing cookies', () => {
|
||||
expect(
|
||||
readInviteTokenFromCookieHeader('gnubok-invite-token=abc%3D%3D'),
|
||||
).toBe('abc==')
|
||||
expect(readInviteTokenFromCookieHeader(null)).toBeNull()
|
||||
expect(readInviteTokenFromCookieHeader('a=1; b=2')).toBeNull()
|
||||
expect(readInviteTokenFromCookieHeader('gnubok-invite-token=')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -79,10 +79,16 @@ describe('Turnstile integration contract', () => {
|
||||
expect(login).toContain('action="accounted_login"')
|
||||
expect(login).toContain('action="accounted_password_reset"')
|
||||
|
||||
// The register page's email flow moved server-side (invite-only brand
|
||||
// domain gate, 2026-08-27): the captcha token must travel to
|
||||
// POST /api/auth/signup, and that route must forward it into the GoTrue
|
||||
// signUp call, so the CAPTCHA still guards the flow end to end.
|
||||
expect(register).toMatch(
|
||||
/signUp\([\s\S]*?captchaTokenOptions\(captchaToken\)/,
|
||||
/fetch\('\/api\/auth\/signup'[\s\S]*?captchaTokenOptions\(captchaToken\)/,
|
||||
)
|
||||
expect(register).toContain('action="accounted_signup"')
|
||||
const signupRoute = readRepoFile('app/api/auth/signup/route.ts')
|
||||
expect(signupRoute).toMatch(/signUp\(\{[\s\S]*?captchaToken/)
|
||||
|
||||
expect(sandbox).toMatch(
|
||||
/signInAnonymously\([\s\S]*?captchaTokenOptions\(captchaToken\)/,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import 'server-only'
|
||||
|
||||
/**
|
||||
* Invite-only signup gate for white-label brand domains (founder decision
|
||||
* 2026-08-27): a brand domain belongs to the partner's people, so when a
|
||||
* brand has signup_mode = 'invite_only', creating an account on that domain
|
||||
* requires either a brand_signup_allowlist entry for the email or a valid
|
||||
* pending company invite for it. Everyone else is sent to the canonical
|
||||
* signup by the register page's interstitial.
|
||||
*
|
||||
* Enforced SERVER-SIDE at the moment an account would be created, on every
|
||||
* signup path:
|
||||
* - email+password: POST /api/auth/signup (the register page no longer
|
||||
* calls supabase.auth.signUp from the browser on any host, because a
|
||||
* client-side check would be bypassable)
|
||||
* - BankID: extensions/general/tic /bankid/complete (signup mode)
|
||||
* - Google OAuth: the account exists after the OAuth round-trip, so the
|
||||
* dashboard layout's domain gate bounces non-belonging sessions to the
|
||||
* canonical domain instead
|
||||
*
|
||||
* Uses the cookieless service client: the visitor is anonymous at signup
|
||||
* time, so RLS cannot scope these reads. Fail-CLOSED on the allowlist and
|
||||
* invite lookups: on a transient query error a gated brand refuses the
|
||||
* signup rather than silently opening the door.
|
||||
*/
|
||||
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { INVITE_COOKIE_NAME } from '@/lib/auth/consume-invite-cookie'
|
||||
import { hashInviteToken } from '@/lib/auth/invite-tokens'
|
||||
import {
|
||||
normalizeHost,
|
||||
resolveBrandByHost,
|
||||
resolveBrandResultByHost,
|
||||
type Brand,
|
||||
} from '@/lib/branding/resolve'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('brand-signup-gate')
|
||||
|
||||
export type BrandSignupGateResult =
|
||||
| {
|
||||
allowed: true
|
||||
brand: Brand | null
|
||||
/** What let the signup through, for logging and tests. */
|
||||
via: 'no_brand' | 'open' | 'allowlist' | 'invite'
|
||||
}
|
||||
| { allowed: false; brand: Brand }
|
||||
// The brands lookup itself failed (transient DB error). The caller must
|
||||
// fail SAFE (retry / 503), never treat this as an unbranded host, or a
|
||||
// blip would open invite-only signup.
|
||||
| { allowed: false; brand: null; lookupFailed: true }
|
||||
|
||||
/**
|
||||
* Whether `email` is on the brand's signup allowlist. Case-insensitive: the
|
||||
* table stores lowercase (CHECK-enforced) and the lookup lowercases too.
|
||||
* Fail-closed: a query error reads as "not allowlisted".
|
||||
*/
|
||||
export async function isEmailOnBrandAllowlist(
|
||||
brandId: string,
|
||||
email: string,
|
||||
): Promise<boolean> {
|
||||
const normalized = email.trim().toLowerCase()
|
||||
if (!normalized) return false
|
||||
|
||||
const supabase = createServiceClientNoCookies()
|
||||
const { data, error } = await supabase
|
||||
.from('brand_signup_allowlist')
|
||||
.select('id')
|
||||
.eq('brand_id', brandId)
|
||||
.eq('email', normalized)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
log.error('allowlist lookup failed; treating as not allowlisted', {
|
||||
brandId,
|
||||
message: error.message,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return data !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `inviteToken` is a live company invite for `email`: pending,
|
||||
* unexpired, and addressed to the same email (case-insensitive). The invite
|
||||
* itself is the authorization, so it bypasses the allowlist. Mirrors the
|
||||
* acceptance checks in /api/team/accept and /auth/callback; acceptance
|
||||
* re-validates everything, so this gate can stay a read.
|
||||
*/
|
||||
async function isValidInviteForEmail(
|
||||
inviteToken: string,
|
||||
email: string,
|
||||
): Promise<boolean> {
|
||||
const supabase = createServiceClientNoCookies()
|
||||
const { data, error } = await supabase
|
||||
.from('company_invitations')
|
||||
.select('email, status, expires_at')
|
||||
.eq('token_hash', hashInviteToken(inviteToken))
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
log.error('invite lookup failed; treating as invalid', { message: error.message })
|
||||
return false
|
||||
}
|
||||
if (!data) return false
|
||||
return (
|
||||
data.status === 'pending' &&
|
||||
new Date(data.expires_at) > new Date() &&
|
||||
data.email.toLowerCase() === email.trim().toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a signup with `email` may proceed on `host`.
|
||||
*
|
||||
* Allowed when the host has no brand (canonical and unknown hosts, the
|
||||
* additive guarantee), the brand is open, the email is allowlisted, or a
|
||||
* valid invite token rides along (the gnubok-invite-token cookie set by
|
||||
* /invite/[token]).
|
||||
*/
|
||||
export async function evaluateBrandSignupGate(opts: {
|
||||
host: string | null | undefined
|
||||
email: string
|
||||
inviteToken?: string | null
|
||||
}): Promise<BrandSignupGateResult> {
|
||||
const { brand, lookupFailed } = opts.host
|
||||
? await resolveBrandResultByHost(opts.host)
|
||||
: { brand: null, lookupFailed: false }
|
||||
if (lookupFailed) {
|
||||
// Do not fall through to no_brand: a transient brands-table error must
|
||||
// not open an invite-only domain. The caller turns this into a 503.
|
||||
log.error('brand lookup failed; refusing to decide signup gate')
|
||||
return { allowed: false, brand: null, lookupFailed: true }
|
||||
}
|
||||
if (!brand) return { allowed: true, brand: null, via: 'no_brand' }
|
||||
if (brand.signupMode !== 'invite_only') return { allowed: true, brand, via: 'open' }
|
||||
|
||||
if (await isEmailOnBrandAllowlist(brand.id, opts.email)) {
|
||||
return { allowed: true, brand, via: 'allowlist' }
|
||||
}
|
||||
|
||||
if (opts.inviteToken && (await isValidInviteForEmail(opts.inviteToken, opts.email))) {
|
||||
return { allowed: true, brand, via: 'invite' }
|
||||
}
|
||||
|
||||
// Observability, not enumeration: log the brand and outcome, never the
|
||||
// attempted address.
|
||||
log.info('signup blocked on invite-only brand domain', { brandId: brand.id, domain: brand.domain })
|
||||
return { allowed: false, brand }
|
||||
}
|
||||
|
||||
/**
|
||||
* Logged-in counterpart of the signup gate, for the dashboard layout: on an
|
||||
* invite-only brand host, a session that does not belong to the brand (no
|
||||
* team membership on the brand's team, no company on it, not allowlisted,
|
||||
* no pending invite) is bounced to the canonical domain instead of getting
|
||||
* a branded shell. Returns the absolute URL to redirect to, or null to stay.
|
||||
*
|
||||
* A NAVIGATION rule like the home-domain rule (WL-01), not a security
|
||||
* boundary: data access is governed by membership and RLS regardless of
|
||||
* host. That is why the pending-invite check is presence-only here; the
|
||||
* actual invite acceptance re-validates the token server-side.
|
||||
*/
|
||||
export async function resolveBrandDomainBounce(opts: {
|
||||
host: string
|
||||
userEmail: string | null | undefined
|
||||
/** teams.id of every team the user belongs to (any role). */
|
||||
teamIds: string[]
|
||||
/** companies.team_id of every company the user belongs to. */
|
||||
companyTeamIds: Array<string | null | undefined>
|
||||
hasPendingInviteCookie: boolean
|
||||
/** getBranding().appUrl: where non-belonging sessions are sent. */
|
||||
canonicalAppUrl: string
|
||||
}): Promise<string | null> {
|
||||
const brand = opts.host ? await resolveBrandByHost(opts.host) : null
|
||||
if (!brand || brand.signupMode !== 'invite_only') return null
|
||||
|
||||
if (opts.teamIds.includes(brand.teamId)) return null
|
||||
if (opts.companyTeamIds.some((teamId) => teamId === brand.teamId)) return null
|
||||
if (opts.hasPendingInviteCookie) return null
|
||||
|
||||
if (opts.userEmail && (await isEmailOnBrandAllowlist(brand.id, opts.userEmail))) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Never bounce onto the same host (misconfigured canonical URL would
|
||||
// otherwise loop), and never bounce when no canonical URL is known.
|
||||
let canonicalHost: string
|
||||
try {
|
||||
canonicalHost = new URL(opts.canonicalAppUrl).hostname
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!canonicalHost || canonicalHost === normalizeHost(opts.host)) return null
|
||||
|
||||
log.info('bouncing non-member session off invite-only brand domain', {
|
||||
brandId: brand.id,
|
||||
domain: brand.domain,
|
||||
})
|
||||
return opts.canonicalAppUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* The pending-invite token from a raw Cookie header, for server routes that
|
||||
* hold a Request rather than a Next.js cookie store. The cookie is set by
|
||||
* /invite/[token] (not httpOnly, so the client auth surfaces read it too).
|
||||
*/
|
||||
export function readInviteTokenFromCookieHeader(header: string | null): string | null {
|
||||
if (!header) return null
|
||||
for (const part of header.split(';')) {
|
||||
const eq = part.indexOf('=')
|
||||
if (eq === -1) continue
|
||||
if (part.slice(0, eq).trim() !== INVITE_COOKIE_NAME) continue
|
||||
const value = part.slice(eq + 1).trim()
|
||||
if (!value) return null
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const fullBrand: Brand = {
|
||||
senderDomain: 'mail.siffra.se',
|
||||
senderDomainStatus: 'verified',
|
||||
resendDomainId: 'rd_123',
|
||||
signupMode: 'open',
|
||||
}
|
||||
|
||||
describe('toPublicBrand', () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ const brandRow = {
|
||||
sender_domain: null,
|
||||
sender_domain_status: 'unverified',
|
||||
resend_domain_id: null,
|
||||
signup_mode: 'open',
|
||||
}
|
||||
|
||||
let mock: ReturnType<typeof createQueuedMockSupabase>
|
||||
@@ -78,6 +79,7 @@ describe('resolveBrandByHost', () => {
|
||||
senderDomain: null,
|
||||
senderDomainStatus: 'unverified',
|
||||
resendDomainId: null,
|
||||
signupMode: 'open',
|
||||
})
|
||||
expect(mock.findCall('brands', 'eq')).toEqual(['domain', 'app.siffra.se'])
|
||||
})
|
||||
|
||||
+28
-5
@@ -39,6 +39,8 @@ export interface Brand {
|
||||
senderDomain: string | null
|
||||
senderDomainStatus: 'unverified' | 'pending' | 'verified' | 'failed'
|
||||
resendDomainId: string | null
|
||||
/** 'invite_only' arms the signup gate (lib/auth/brand-signup-gate.ts). */
|
||||
signupMode: 'open' | 'invite_only'
|
||||
}
|
||||
|
||||
interface BrandRow {
|
||||
@@ -56,6 +58,7 @@ interface BrandRow {
|
||||
sender_domain: string | null
|
||||
sender_domain_status: string
|
||||
resend_domain_id: string | null
|
||||
signup_mode?: string | null
|
||||
}
|
||||
|
||||
function mapRow(row: BrandRow): Brand {
|
||||
@@ -74,6 +77,9 @@ function mapRow(row: BrandRow): Brand {
|
||||
senderDomain: row.sender_domain,
|
||||
senderDomainStatus: row.sender_domain_status as Brand['senderDomainStatus'],
|
||||
resendDomainId: row.resend_domain_id,
|
||||
// Anything but the explicit invite_only value reads as 'open' so the
|
||||
// additive guarantee holds even against a stale schema cache.
|
||||
signupMode: row.signup_mode === 'invite_only' ? 'invite_only' : 'open',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,12 +136,28 @@ export function normalizeHost(host: string): string {
|
||||
* the additive guarantee (unknown host = default Accounted, bit for bit).
|
||||
*/
|
||||
export async function resolveBrandByHost(host: string): Promise<Brand | null> {
|
||||
return (await resolveBrandResultByHost(host)).brand
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand resolution that distinguishes "this host has no brand" from "the
|
||||
* lookup itself failed". Callers that only pick chrome/branding want the
|
||||
* null-means-default behavior of resolveBrandByHost. A caller enforcing a
|
||||
* SECURITY decision on the result (the invite-only signup gate,
|
||||
* lib/auth/brand-signup-gate.ts) must NOT read a transient DB error as an
|
||||
* unbranded host, which would fail open: a blip on the brands table would
|
||||
* let anyone sign up on an invite-only domain. `lookupFailed` lets that
|
||||
* caller fail safe (503 / retry) instead.
|
||||
*/
|
||||
export async function resolveBrandResultByHost(
|
||||
host: string,
|
||||
): Promise<{ brand: Brand | null; lookupFailed: boolean }> {
|
||||
const normalized = normalizeHost(host)
|
||||
if (!normalized) return null
|
||||
if (!normalized) return { brand: null, lookupFailed: false }
|
||||
|
||||
const key = `host:${normalized}`
|
||||
const hit = readCache(key)
|
||||
if (hit) return hit.value
|
||||
if (hit) return { brand: hit.value, lookupFailed: false }
|
||||
|
||||
const supabase = createServiceClientNoCookies()
|
||||
const { data, error } = await supabase
|
||||
@@ -146,13 +168,14 @@ export async function resolveBrandByHost(host: string): Promise<Brand | null> {
|
||||
|
||||
if (error) {
|
||||
// Transient failure: fall back to defaults without caching, so a live
|
||||
// brand is not masked for a whole TTL window by one failed query.
|
||||
return null
|
||||
// brand is not masked for a whole TTL window by one failed query. The
|
||||
// flag lets a security caller tell this apart from a real unbranded host.
|
||||
return { brand: null, lookupFailed: true }
|
||||
}
|
||||
|
||||
const brand = data ? mapRow(data as BrandRow) : null
|
||||
writeCache(key, brand)
|
||||
return brand
|
||||
return { brand, lookupFailed: false }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+61
-7
@@ -1,9 +1,11 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { headers } from 'next/headers'
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { setActiveCompany, CompanyContextError } from '@/lib/company/context'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createCompanyCore } from '@/lib/company/create-company'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
@@ -120,6 +122,62 @@ async function createCompanyFromOnboardingImpl(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// Brand-host signup homing (2026-08-27): when this wizard runs on an
|
||||
// invite-only brand host and the creating user is on the brand's signup
|
||||
// allowlist, the company attaches to the brand's byrå team via the
|
||||
// create_company_for_brand_signup RPC (which re-checks the allowlist).
|
||||
// Without this the company would get the personal team and the home-domain
|
||||
// rule (WL-01) would home it on the canonical domain, invisible on the
|
||||
// very brand domain the user signed up on. Only the personal-team path is
|
||||
// rerouted: an explicit byrå-team creation (the cockpit's new-client flow)
|
||||
// already passed the byrå team and stays under the WL-15 admin gate above.
|
||||
let createCompanyRow: () => PromiseLike<{ data: unknown; error: unknown }> =
|
||||
() =>
|
||||
supabase.rpc('create_company_with_owner', {
|
||||
p_name: companyName,
|
||||
p_entity_type: entityType,
|
||||
p_team_id: params.teamId,
|
||||
})
|
||||
// When the row is created under the service role (brand-signup path below),
|
||||
// rollback must also run under the service role: `companies` has RLS and no
|
||||
// FOR DELETE policy, so a cookie-session rollback of a service-created
|
||||
// company deletes nothing and strands a member-less orphan on the brand's
|
||||
// team. Stays null on the normal path, where the session client is correct.
|
||||
// Once the rollback delete lands, user_preferences.active_company_id (which
|
||||
// the RPC set) auto-clears via its ON DELETE SET NULL FK, so no dangling
|
||||
// active company survives.
|
||||
let rollbackClient: SupabaseClient | undefined
|
||||
|
||||
if ((teamRow as { kind?: string } | null)?.kind !== 'byra' && user.email) {
|
||||
// Dynamic imports: this file is imported by client components (through
|
||||
// switch-client.ts) for its other actions, and these two modules reach
|
||||
// node:crypto; a static import would drag Node builtins into the client
|
||||
// graph (client-node-builtin guard). Server actions always execute
|
||||
// server-side, so the dynamic import is free here.
|
||||
const [{ resolveBrandByHost }, { isEmailOnBrandAllowlist }] = await Promise.all([
|
||||
import('@/lib/branding/resolve'),
|
||||
import('@/lib/auth/brand-signup-gate'),
|
||||
])
|
||||
const requestHeaders = await headers()
|
||||
const host =
|
||||
requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host') ?? ''
|
||||
const hostBrand = host ? await resolveBrandByHost(host) : null
|
||||
if (
|
||||
hostBrand?.signupMode === 'invite_only' &&
|
||||
(await isEmailOnBrandAllowlist(hostBrand.id, user.email))
|
||||
) {
|
||||
const serviceClient = createServiceClient()
|
||||
rollbackClient = serviceClient
|
||||
createCompanyRow = () =>
|
||||
serviceClient.rpc('create_company_for_brand_signup', {
|
||||
p_user_id: user.id,
|
||||
p_name: companyName,
|
||||
p_entity_type: entityType,
|
||||
p_brand_id: hostBrand.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Steps 1-5 (company + owner via RPC, org number, TIC snapshot, chart,
|
||||
// settings, fiscal period, tax deadlines, with rollback) are shared with
|
||||
// the MCP and v1 creation paths: lib/company/create-company.ts.
|
||||
@@ -133,12 +191,8 @@ async function createCompanyFromOnboardingImpl(params: {
|
||||
fiscalPeriod: params.fiscalPeriod,
|
||||
ticLookup: params.ticLookup,
|
||||
},
|
||||
() =>
|
||||
supabase.rpc('create_company_with_owner', {
|
||||
p_name: companyName,
|
||||
p_entity_type: entityType,
|
||||
p_team_id: params.teamId,
|
||||
}),
|
||||
createCompanyRow,
|
||||
rollbackClient,
|
||||
)
|
||||
if (created.error !== undefined) {
|
||||
return { error: created.error }
|
||||
|
||||
@@ -53,6 +53,15 @@ export async function createCompanyCore(
|
||||
supabase: SupabaseClient,
|
||||
input: CreateCompanyInput,
|
||||
createCompanyRow: () => PromiseLike<{ data: unknown; error: unknown }>,
|
||||
// Client used for rollback deletes. Defaults to `supabase`, but a caller
|
||||
// whose createCompanyRow ran under the SERVICE role (the brand-signup path,
|
||||
// lib/company/actions.ts) MUST pass the service client here: `companies`
|
||||
// has RLS enabled and no FOR DELETE policy, so a cookie-session delete of
|
||||
// the companies row is a silent 0-row no-op. Rolling back a service-created
|
||||
// company with the session client would strip its members but leave the
|
||||
// orphaned companies row behind, attached to whichever team it was created
|
||||
// on. The service client bypasses RLS, so its delete actually removes it.
|
||||
rollbackClient: SupabaseClient = supabase,
|
||||
): Promise<CreateCompanyResult> {
|
||||
// Org-number format validation. We intentionally do NOT enforce
|
||||
// uniqueness: the same org number may legitimately appear on multiple
|
||||
@@ -84,11 +93,11 @@ export async function createCompanyCore(
|
||||
const rollback = async (reason: string, err: unknown) => {
|
||||
console.error(`[createCompany] rolling back ${newCompanyId}: ${reason}`, err)
|
||||
const deletions: Array<[table: string, run: () => PromiseLike<{ error: unknown }>]> = [
|
||||
['company_settings', () => supabase.from('company_settings').delete().eq('company_id', newCompanyId)],
|
||||
['fiscal_periods', () => supabase.from('fiscal_periods').delete().eq('company_id', newCompanyId)],
|
||||
['chart_of_accounts', () => supabase.from('chart_of_accounts').delete().eq('company_id', newCompanyId)],
|
||||
['company_members', () => supabase.from('company_members').delete().eq('company_id', newCompanyId)],
|
||||
['companies', () => supabase.from('companies').delete().eq('id', newCompanyId)],
|
||||
['company_settings', () => rollbackClient.from('company_settings').delete().eq('company_id', newCompanyId)],
|
||||
['fiscal_periods', () => rollbackClient.from('fiscal_periods').delete().eq('company_id', newCompanyId)],
|
||||
['chart_of_accounts', () => rollbackClient.from('chart_of_accounts').delete().eq('company_id', newCompanyId)],
|
||||
['company_members', () => rollbackClient.from('company_members').delete().eq('company_id', newCompanyId)],
|
||||
['companies', () => rollbackClient.from('companies').delete().eq('id', newCompanyId)],
|
||||
]
|
||||
for (const [table, run] of deletions) {
|
||||
const { error: deleteError } = await run()
|
||||
|
||||
@@ -51,6 +51,7 @@ function makeBrand(overrides: Partial<Brand> = {}): Brand {
|
||||
senderDomain: 'post.siffra.se',
|
||||
senderDomainStatus: 'verified',
|
||||
resendDomainId: 'rd-1',
|
||||
signupMode: 'open',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ function makeBrand(overrides: Partial<Brand> = {}): Brand {
|
||||
senderDomain: 'post.siffra.se',
|
||||
senderDomainStatus: 'verified',
|
||||
resendDomainId: 'rd-1',
|
||||
signupMode: 'open',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
+24
-1
@@ -1191,6 +1191,7 @@
|
||||
"password_error_requirements": "The password doesn't meet all the requirements yet.",
|
||||
"error_email_invalid": "That email address doesn't look valid. Check the spelling.",
|
||||
"error_rate_limited": "Too many attempts. Wait a moment and try again.",
|
||||
"error_temporary": "Temporary error. Please try again shortly.",
|
||||
"error_signup_disabled": "Account registration is turned off on this installation. Contact the person who invited you or your administrator to get an account.",
|
||||
"create_account": "Create account",
|
||||
"creating": "Creating account...",
|
||||
@@ -1217,6 +1218,10 @@
|
||||
"password_mismatch_description": "Check that you typed the same password in both fields.",
|
||||
"duplicate_title": "Account already exists",
|
||||
"duplicate_body_prefix": "There is already an account linked to",
|
||||
"invite_only_title": "{appName} is invite-only",
|
||||
"invite_only_body": "You need an invitation or pre-approval to create an account with {appName}.",
|
||||
"invite_only_hint": "Received an invitation? Open the link in the invitation email and everything is pre-filled for you. Otherwise you can create a regular Accounted account instead.",
|
||||
"invite_only_cta": "Create an account on Accounted",
|
||||
"duplicate_hint": "Sign in with your email and password. If you have forgotten your password you can reset it via \"Forgot password?\" on the sign-in page.",
|
||||
"confirm_email_title": "Confirm your email",
|
||||
"confirm_email_body": "We sent a confirmation link to <strong>{email}</strong>",
|
||||
@@ -5885,7 +5890,25 @@
|
||||
"never_booked": "Never",
|
||||
"empty": "No client companies yet. Create the first one via New client company.",
|
||||
"no_matches": "No clients match the filter.",
|
||||
"enter_failed": "Could not open the client"
|
||||
"enter_failed": "Could not open the client",
|
||||
"access_link": "Signup access",
|
||||
"access_title": "Signup access",
|
||||
"access_mode_label": "Only invited people can create an account on {domain}",
|
||||
"access_mode_hint": "When this is on, only email addresses in the list below, and people invited to a company, can sign up on your domain. Everyone else is referred to Accounted.",
|
||||
"access_readonly_hint": "Only the byrå's owners and admins can make changes.",
|
||||
"access_list_heading": "Approved email addresses",
|
||||
"access_add_placeholder": "name@company.com",
|
||||
"access_add_note_placeholder": "Note (optional)",
|
||||
"access_add": "Add",
|
||||
"access_col_email": "Email",
|
||||
"access_col_note": "Note",
|
||||
"access_col_added": "Added",
|
||||
"access_remove": "Remove",
|
||||
"access_empty": "No email addresses in the list yet.",
|
||||
"access_duplicate": "That email is already on the list.",
|
||||
"access_save_failed": "Could not save the change.",
|
||||
"access_load_failed": "Could not load the settings.",
|
||||
"access_no_brand": "The byrå has no white-label domain yet."
|
||||
},
|
||||
"byra": {
|
||||
"home_title": "Home",
|
||||
|
||||
+24
-1
@@ -1191,6 +1191,7 @@
|
||||
"password_error_requirements": "Lösenordet uppfyller inte alla krav än.",
|
||||
"error_email_invalid": "E-postadressen verkar inte vara giltig. Kontrollera stavningen.",
|
||||
"error_rate_limited": "För många försök. Vänta en stund och försök igen.",
|
||||
"error_temporary": "Tillfälligt fel. Försök igen om en stund.",
|
||||
"error_signup_disabled": "Kontoregistrering är avstängd på den här installationen. Kontakta den som bjöd in dig eller din administratör för att få ett konto.",
|
||||
"create_account": "Skapa konto",
|
||||
"creating": "Skapar konto...",
|
||||
@@ -1217,6 +1218,10 @@
|
||||
"password_mismatch_description": "Kontrollera att du skrev samma lösenord i båda fälten.",
|
||||
"duplicate_title": "Kontot finns redan",
|
||||
"duplicate_body_prefix": "Det finns redan ett konto kopplat till",
|
||||
"invite_only_title": "{appName} är endast för inbjudna",
|
||||
"invite_only_body": "Du behöver en inbjudan eller ett förhandsgodkännande för att skapa ett konto hos {appName}.",
|
||||
"invite_only_hint": "Har du fått en inbjudan? Öppna länken i inbjudningsmejlet så förifylls allt åt dig. Annars kan du skapa ett vanligt Accounted-konto istället.",
|
||||
"invite_only_cta": "Skapa konto på Accounted",
|
||||
"duplicate_hint": "Logga in med din e-post och lösenord. Om du har glömt lösenordet kan du återställa det via \"Glömt lösenord?\" på inloggningssidan.",
|
||||
"confirm_email_title": "Bekräfta din e-post",
|
||||
"confirm_email_body": "Vi har skickat en bekräftelselänk till <strong>{email}</strong>",
|
||||
@@ -5885,7 +5890,25 @@
|
||||
"never_booked": "Aldrig",
|
||||
"empty": "Inga klientbolag ännu. Skapa det första via Nytt klientbolag.",
|
||||
"no_matches": "Inga klienter matchar filtret.",
|
||||
"enter_failed": "Det gick inte att öppna klienten"
|
||||
"enter_failed": "Det gick inte att öppna klienten",
|
||||
"access_link": "Registrering",
|
||||
"access_title": "Registrering",
|
||||
"access_mode_label": "Endast inbjudna kan skapa konto på {domain}",
|
||||
"access_mode_hint": "När detta är på kan bara e-postadresser i listan nedan, och personer som bjudits in till ett bolag, registrera sig på er domän. Alla andra hänvisas till Accounted.",
|
||||
"access_readonly_hint": "Endast byråns ägare och administratörer kan göra ändringar.",
|
||||
"access_list_heading": "Godkända e-postadresser",
|
||||
"access_add_placeholder": "namn@foretag.se",
|
||||
"access_add_note_placeholder": "Anteckning (valfritt)",
|
||||
"access_add": "Lägg till",
|
||||
"access_col_email": "E-post",
|
||||
"access_col_note": "Anteckning",
|
||||
"access_col_added": "Tillagd",
|
||||
"access_remove": "Ta bort",
|
||||
"access_empty": "Inga e-postadresser i listan ännu.",
|
||||
"access_duplicate": "E-postadressen finns redan i listan.",
|
||||
"access_save_failed": "Kunde inte spara ändringen.",
|
||||
"access_load_failed": "Kunde inte hämta inställningarna.",
|
||||
"access_no_brand": "Byrån har ingen egen domän ännu."
|
||||
},
|
||||
"byra": {
|
||||
"home_title": "Hem",
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
-- Migration: invite-only signup for white-label brand domains
|
||||
--
|
||||
-- A brand domain belongs to the partner's people: only whitelisted or invited
|
||||
-- users may create an account on it; everyone else is sent to the canonical
|
||||
-- signup (founder decision 2026-08-27). Three pieces:
|
||||
--
|
||||
-- 1. brands.signup_mode: 'open' (default, byte-identical behavior) or
|
||||
-- 'invite_only'. Flipping a brand to invite_only arms the server-side
|
||||
-- signup gate (lib/auth/brand-signup-gate.ts) on that brand's domain.
|
||||
-- 2. brand_signup_allowlist: the emails allowed to self-signup on an
|
||||
-- invite_only brand domain. Company invites bypass the list (the invite
|
||||
-- itself is the authorization); the list only governs cold signups.
|
||||
-- 3. create_company_for_brand_signup: service-role RPC that lets an
|
||||
-- allowlisted user's onboarding-created company attach to the brand's
|
||||
-- byrå team. Without this the company would have team_id NULL, and the
|
||||
-- home-domain rule (WL-01) would home it on the canonical domain,
|
||||
-- invisible on the very brand domain the user signed up on. The WL-15
|
||||
-- owner/admin gate does not apply here by design: the allowlist entry is
|
||||
-- the byrå's standing authorization for this signup, recorded by a byrå
|
||||
-- owner/admin (or ops) when the email was added.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. brands.signup_mode
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.brands
|
||||
ADD COLUMN signup_mode text NOT NULL DEFAULT 'open';
|
||||
|
||||
ALTER TABLE public.brands
|
||||
ADD CONSTRAINT brands_signup_mode_check
|
||||
CHECK (signup_mode IN ('open', 'invite_only'));
|
||||
|
||||
COMMENT ON COLUMN public.brands.signup_mode IS
|
||||
'open: anyone may self-signup on this brand domain (default, canonical '
|
||||
'behavior). invite_only: cold signups require a brand_signup_allowlist '
|
||||
'entry; company invites bypass the list. Enforced server-side in '
|
||||
'lib/auth/brand-signup-gate.ts on every signup path (email, BankID, '
|
||||
'Google).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. brand_signup_allowlist
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE public.brand_signup_allowlist (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id uuid NOT NULL REFERENCES public.brands(id) ON DELETE CASCADE,
|
||||
|
||||
-- Stored lowercase so matching is one indexed equality; the CHECK makes a
|
||||
-- mixed-case insert an error instead of a silent never-matching row.
|
||||
email text NOT NULL,
|
||||
|
||||
-- Free-text label for the byrå ("VD Nya Kunden AB"), never load-bearing.
|
||||
note text,
|
||||
|
||||
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT brand_signup_allowlist_email_lowercase
|
||||
CHECK (email = lower(email)),
|
||||
CONSTRAINT brand_signup_allowlist_email_format
|
||||
CHECK (email ~ '^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$'),
|
||||
CONSTRAINT brand_signup_allowlist_unique_email
|
||||
UNIQUE (brand_id, email)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.brand_signup_allowlist IS
|
||||
'Emails allowed to self-signup on an invite_only brand domain. The list '
|
||||
'governs only cold signups: a pending company invite bypasses it because '
|
||||
'the invite itself is the authorization. Managed by the brand''s byrå team '
|
||||
'(owner/admin) via the cockpit, and by ops via the service role.';
|
||||
|
||||
CREATE INDEX idx_brand_signup_allowlist_brand_email
|
||||
ON public.brand_signup_allowlist (brand_id, email);
|
||||
|
||||
-- =============================================================================
|
||||
-- RLS: the brand's byrå team reads its own list; owner/admin write it.
|
||||
-- The signup gate itself runs with the service role (the visitor is
|
||||
-- anonymous at signup time) and bypasses RLS by design.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.brand_signup_allowlist ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "brand_signup_allowlist_select" ON public.brand_signup_allowlist
|
||||
FOR SELECT USING (
|
||||
brand_id IN (
|
||||
SELECT b.id FROM public.brands b
|
||||
WHERE b.team_id IN (SELECT public.user_team_ids())
|
||||
)
|
||||
);
|
||||
|
||||
-- Writes are owner/admin only: adding an email is a commercial act (the
|
||||
-- signup it authorizes creates a client company on the byrå's invoice via
|
||||
-- create_company_for_brand_signup), same bar as WL-15 client creation.
|
||||
CREATE POLICY "brand_signup_allowlist_insert" ON public.brand_signup_allowlist
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM public.brands b
|
||||
JOIN public.team_members tm ON tm.team_id = b.team_id
|
||||
WHERE b.id = brand_id
|
||||
AND tm.user_id = auth.uid()
|
||||
AND tm.role IN ('owner', 'admin')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "brand_signup_allowlist_delete" ON public.brand_signup_allowlist
|
||||
FOR DELETE USING (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM public.brands b
|
||||
JOIN public.team_members tm ON tm.team_id = b.team_id
|
||||
WHERE b.id = brand_id
|
||||
AND tm.user_id = auth.uid()
|
||||
AND tm.role IN ('owner', 'admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. create_company_for_brand_signup
|
||||
--
|
||||
-- Body mirrors create_company_for_user (20260826130600) step for step, with
|
||||
-- the team-membership gate replaced by an allowlist gate: the owner must be
|
||||
-- on the brand's signup allowlist, and the company attaches to the brand's
|
||||
-- team. Callable by service_role ONLY; the calling server action
|
||||
-- (lib/company/actions.ts) has already verified the request host is the
|
||||
-- brand's domain.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_company_for_brand_signup(
|
||||
p_user_id uuid,
|
||||
p_name text,
|
||||
p_entity_type text,
|
||||
p_brand_id uuid
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_company_id uuid;
|
||||
v_team_id uuid;
|
||||
v_email text;
|
||||
BEGIN
|
||||
IF p_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'p_user_id is required';
|
||||
END IF;
|
||||
|
||||
IF p_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'p_brand_id is required';
|
||||
END IF;
|
||||
|
||||
SELECT lower(u.email) INTO v_email FROM auth.users u WHERE u.id = p_user_id;
|
||||
IF v_email IS NULL THEN
|
||||
RAISE EXCEPTION 'Unknown user %', p_user_id
|
||||
USING ERRCODE = '23503'; -- foreign_key_violation
|
||||
END IF;
|
||||
|
||||
IF p_entity_type NOT IN ('enskild_firma', 'aktiebolag') THEN
|
||||
RAISE EXCEPTION 'Invalid entity_type: %', p_entity_type;
|
||||
END IF;
|
||||
|
||||
IF p_name IS NULL OR length(btrim(p_name)) = 0 THEN
|
||||
RAISE EXCEPTION 'p_name is required';
|
||||
END IF;
|
||||
|
||||
SELECT b.team_id INTO v_team_id FROM public.brands b WHERE b.id = p_brand_id;
|
||||
IF v_team_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Unknown brand %', p_brand_id
|
||||
USING ERRCODE = '23503'; -- foreign_key_violation
|
||||
END IF;
|
||||
|
||||
-- The allowlist entry IS the authorization: it was recorded by a byrå
|
||||
-- owner/admin (RLS above) or ops, standing in for the WL-15 admin gate.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.brand_signup_allowlist a
|
||||
WHERE a.brand_id = p_brand_id
|
||||
AND a.email = v_email
|
||||
) THEN
|
||||
RAISE EXCEPTION 'User % is not on the signup allowlist for brand %', p_user_id, p_brand_id
|
||||
USING ERRCODE = '42501'; -- insufficient_privilege
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.companies (name, entity_type, created_by, team_id)
|
||||
VALUES (btrim(p_name), p_entity_type, p_user_id, v_team_id)
|
||||
RETURNING id INTO v_company_id;
|
||||
|
||||
INSERT INTO public.company_members (company_id, user_id, role)
|
||||
VALUES (v_company_id, p_user_id, 'owner');
|
||||
|
||||
INSERT INTO public.cash_accounts (
|
||||
company_id, ledger_account, currency, name, enabled, is_primary, source
|
||||
)
|
||||
VALUES (
|
||||
v_company_id, '1930', 'SEK', 'Företagskonto (SEK)', true, true, 'manual'
|
||||
)
|
||||
ON CONFLICT (company_id, ledger_account) DO NOTHING;
|
||||
|
||||
INSERT INTO public.user_preferences (user_id, active_company_id)
|
||||
VALUES (p_user_id, v_company_id)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET active_company_id = EXCLUDED.active_company_id;
|
||||
|
||||
PERFORM public.sync_team_to_company(v_company_id, v_team_id);
|
||||
|
||||
RETURN v_company_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Service role only. PostgREST exposes functions to every role by default
|
||||
-- (PUBLIC grant), so revoke first, then grant the one role that may call it.
|
||||
REVOKE ALL ON FUNCTION public.create_company_for_brand_signup(uuid, text, text, uuid) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION public.create_company_for_brand_signup(uuid, text, text, uuid) FROM anon;
|
||||
REVOKE ALL ON FUNCTION public.create_company_for_brand_signup(uuid, text, text, uuid) FROM authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.create_company_for_brand_signup(uuid, text, text, uuid) TO service_role;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,336 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool, withUserContext } from '@/tests/pg/setup'
|
||||
import { insertAuthUser } from '@/tests/pg/fixtures'
|
||||
|
||||
// Tests for 20260827120000_brand_invite_only_signup.sql: brands.signup_mode,
|
||||
// the brand_signup_allowlist table (lowercase/format/unique CHECKs, cascade,
|
||||
// team-scoped RLS with owner/admin-only writes) and the
|
||||
// create_company_for_brand_signup RPC (allowlist-gated byrå team attachment).
|
||||
|
||||
async function insertTeam(params: {
|
||||
createdBy: string
|
||||
kind?: 'personal' | 'byra'
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.teams (id, name, created_by, kind)
|
||||
VALUES ($1, 'Byra Team', $2, $3)`,
|
||||
[id, params.createdBy, params.kind ?? 'byra'],
|
||||
)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.team_members (team_id, user_id, role)
|
||||
VALUES ($1, $2, 'owner')`,
|
||||
[id, params.createdBy],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function addTeamMember(teamId: string, userId: string, role: string): Promise<void> {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.team_members (team_id, user_id, role) VALUES ($1, $2, $3)`,
|
||||
[teamId, userId, role],
|
||||
)
|
||||
}
|
||||
|
||||
async function insertBrand(teamId: string, signupMode?: string): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.brands (id, team_id, domain, app_name, brand_color, support_email, signup_mode)
|
||||
VALUES ($1, $2, $3, 'Testbrand', '#2563eb', 'support@testbrand.example', COALESCE($4, 'open'))`,
|
||||
[id, teamId, `${randomUUID().slice(0, 8)}.accounted.se`, signupMode ?? null],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertAllowlistEntry(brandId: string, email: string): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.brand_signup_allowlist (id, brand_id, email) VALUES ($1, $2, $3)`,
|
||||
[id, brandId, email],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
/** The deterministic email insertAuthUser gives an auth user. */
|
||||
function authEmail(userId: string): string {
|
||||
return `pg-real-${userId}@test.invalid`
|
||||
}
|
||||
|
||||
async function expectSqlstate(fn: () => Promise<unknown>, expected: string): Promise<void> {
|
||||
let sqlstate: string | undefined
|
||||
try {
|
||||
await fn()
|
||||
} catch (err) {
|
||||
sqlstate = (err as { code?: string }).code
|
||||
}
|
||||
expect(sqlstate).toBe(expected)
|
||||
}
|
||||
|
||||
describe('brands.signup_mode', () => {
|
||||
it('defaults to open and accepts invite_only', async () => {
|
||||
const owner = await insertAuthUser()
|
||||
const teamId = await insertTeam({ createdBy: owner })
|
||||
const brandId = await insertBrand(teamId)
|
||||
|
||||
const { rows } = await getPool().query<{ signup_mode: string }>(
|
||||
`SELECT signup_mode FROM public.brands WHERE id = $1`,
|
||||
[brandId],
|
||||
)
|
||||
expect(rows[0].signup_mode).toBe('open')
|
||||
|
||||
await getPool().query(
|
||||
`UPDATE public.brands SET signup_mode = 'invite_only' WHERE id = $1`,
|
||||
[brandId],
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unknown modes (23514)', async () => {
|
||||
const owner = await insertAuthUser()
|
||||
const teamId = await insertTeam({ createdBy: owner })
|
||||
const brandId = await insertBrand(teamId)
|
||||
|
||||
await expectSqlstate(
|
||||
() =>
|
||||
getPool().query(`UPDATE public.brands SET signup_mode = 'closed' WHERE id = $1`, [
|
||||
brandId,
|
||||
]),
|
||||
'23514',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('brand_signup_allowlist: shape', () => {
|
||||
it('rejects mixed-case and malformed emails (23514)', async () => {
|
||||
const owner = await insertAuthUser()
|
||||
const teamId = await insertTeam({ createdBy: owner })
|
||||
const brandId = await insertBrand(teamId)
|
||||
|
||||
await expectSqlstate(() => insertAllowlistEntry(brandId, 'Kund@Example.com'), '23514')
|
||||
await expectSqlstate(() => insertAllowlistEntry(brandId, 'not-an-email'), '23514')
|
||||
await expectSqlstate(() => insertAllowlistEntry(brandId, 'a b@example.com'), '23514')
|
||||
})
|
||||
|
||||
it('enforces one entry per brand and email (23505), same email ok on another brand', async () => {
|
||||
const ownerA = await insertAuthUser()
|
||||
const ownerB = await insertAuthUser()
|
||||
const brandA = await insertBrand(await insertTeam({ createdBy: ownerA }))
|
||||
const brandB = await insertBrand(await insertTeam({ createdBy: ownerB }))
|
||||
|
||||
await insertAllowlistEntry(brandA, 'kund@example.com')
|
||||
await expectSqlstate(() => insertAllowlistEntry(brandA, 'kund@example.com'), '23505')
|
||||
await insertAllowlistEntry(brandB, 'kund@example.com')
|
||||
})
|
||||
|
||||
it('cascades on brand delete', async () => {
|
||||
const owner = await insertAuthUser()
|
||||
const brandId = await insertBrand(await insertTeam({ createdBy: owner }))
|
||||
const entryId = await insertAllowlistEntry(brandId, 'kund@example.com')
|
||||
|
||||
await getPool().query(`DELETE FROM public.brands WHERE id = $1`, [brandId])
|
||||
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT 1 FROM public.brand_signup_allowlist WHERE id = $1`,
|
||||
[entryId],
|
||||
)
|
||||
expect(rows).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('brand_signup_allowlist: RLS', () => {
|
||||
it('team members read their own list; outsiders see nothing', async () => {
|
||||
const owner = await insertAuthUser()
|
||||
const member = await insertAuthUser()
|
||||
const stranger = await insertAuthUser()
|
||||
const teamId = await insertTeam({ createdBy: owner })
|
||||
await addTeamMember(teamId, member, 'member')
|
||||
const brandId = await insertBrand(teamId)
|
||||
await insertAllowlistEntry(brandId, 'kund@example.com')
|
||||
|
||||
for (const insider of [owner, member]) {
|
||||
await withUserContext(insider, async (client) => {
|
||||
const { rows } = await client.query(
|
||||
`SELECT id FROM public.brand_signup_allowlist WHERE brand_id = $1`,
|
||||
[brandId],
|
||||
)
|
||||
expect(rows).toHaveLength(1)
|
||||
})
|
||||
}
|
||||
|
||||
await withUserContext(stranger, async (client) => {
|
||||
const { rows } = await client.query(
|
||||
`SELECT id FROM public.brand_signup_allowlist WHERE brand_id = $1`,
|
||||
[brandId],
|
||||
)
|
||||
expect(rows).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('owner/admin can insert and delete; plain members cannot', async () => {
|
||||
const owner = await insertAuthUser()
|
||||
const admin = await insertAuthUser()
|
||||
const member = await insertAuthUser()
|
||||
const teamId = await insertTeam({ createdBy: owner })
|
||||
await addTeamMember(teamId, admin, 'admin')
|
||||
await addTeamMember(teamId, member, 'member')
|
||||
const brandId = await insertBrand(teamId)
|
||||
|
||||
// Admin INSERT passes the WITH CHECK (no 42501). withUserContext always
|
||||
// rolls back, so this asserts the policy allows the write; it does not
|
||||
// persist. The persistent row for the DELETE assertions is seeded on the
|
||||
// superuser pool below.
|
||||
await withUserContext(admin, async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO public.brand_signup_allowlist (brand_id, email, created_by)
|
||||
VALUES ($1, 'ny@example.com', $2)`,
|
||||
[brandId, admin],
|
||||
)
|
||||
})
|
||||
|
||||
// Plain member INSERT violates the with-check (42501).
|
||||
let sqlstate: string | undefined
|
||||
try {
|
||||
await withUserContext(member, async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO public.brand_signup_allowlist (brand_id, email) VALUES ($1, 'rogue@example.com')`,
|
||||
[brandId],
|
||||
)
|
||||
})
|
||||
} catch (err) {
|
||||
sqlstate = (err as { code?: string }).code
|
||||
}
|
||||
expect(sqlstate).toBe('42501')
|
||||
|
||||
// Seed a row that persists (superuser pool, no rollback) so the DELETE
|
||||
// assertions below act on a real row: member DELETE must be RLS-filtered
|
||||
// to zero, owner DELETE must remove the one row.
|
||||
await insertAllowlistEntry(brandId, 'target@example.com')
|
||||
|
||||
// Plain member DELETE is silently filtered to zero rows.
|
||||
await withUserContext(member, async (client) => {
|
||||
const deleted = await client.query(
|
||||
`DELETE FROM public.brand_signup_allowlist WHERE brand_id = $1`,
|
||||
[brandId],
|
||||
)
|
||||
expect(deleted.rowCount).toBe(0)
|
||||
})
|
||||
|
||||
// The member's rolled-back DELETE left the row intact; owner removes it.
|
||||
await withUserContext(owner, async (client) => {
|
||||
const deleted = await client.query(
|
||||
`DELETE FROM public.brand_signup_allowlist WHERE brand_id = $1`,
|
||||
[brandId],
|
||||
)
|
||||
expect(deleted.rowCount).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('create_company_for_brand_signup', () => {
|
||||
it('creates a company on the brand team for an allowlisted user', async () => {
|
||||
const byraOwner = await insertAuthUser()
|
||||
const client = await insertAuthUser()
|
||||
const teamId = await insertTeam({ createdBy: byraOwner })
|
||||
const brandId = await insertBrand(teamId, 'invite_only')
|
||||
await insertAllowlistEntry(brandId, authEmail(client))
|
||||
|
||||
const { rows } = await getPool().query<{ id: string }>(
|
||||
`SELECT public.create_company_for_brand_signup($1, 'Kundbolaget AB', 'aktiebolag', $2) AS id`,
|
||||
[client, brandId],
|
||||
)
|
||||
const companyId = rows[0].id
|
||||
|
||||
const { rows: companyRows } = await getPool().query<{
|
||||
team_id: string
|
||||
created_by: string
|
||||
}>(`SELECT team_id, created_by FROM public.companies WHERE id = $1`, [companyId])
|
||||
expect(companyRows[0]).toEqual({ team_id: teamId, created_by: client })
|
||||
|
||||
// The signup user owns the company; team sync gave the byrå access too.
|
||||
const { rows: memberRows } = await getPool().query<{ user_id: string; role: string }>(
|
||||
`SELECT user_id, role FROM public.company_members WHERE company_id = $1 ORDER BY role`,
|
||||
[companyId],
|
||||
)
|
||||
expect(memberRows).toContainEqual({ user_id: client, role: 'owner' })
|
||||
expect(memberRows.some((m) => m.user_id === byraOwner)).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the allowlist case-insensitively against the auth email', async () => {
|
||||
const byraOwner = await insertAuthUser()
|
||||
const client = await insertAuthUser()
|
||||
const teamId = await insertTeam({ createdBy: byraOwner })
|
||||
const brandId = await insertBrand(teamId, 'invite_only')
|
||||
// Auth emails from the fixture are already lowercase; the allowlist
|
||||
// stores lowercase by CHECK, so this is the canonical match.
|
||||
await insertAllowlistEntry(brandId, authEmail(client))
|
||||
|
||||
const { rows } = await getPool().query<{ id: string }>(
|
||||
`SELECT public.create_company_for_brand_signup($1, 'EF Kund', 'enskild_firma', $2) AS id`,
|
||||
[client, brandId],
|
||||
)
|
||||
expect(rows[0].id).toBeTruthy()
|
||||
})
|
||||
|
||||
it('refuses a user who is not on the allowlist (42501)', async () => {
|
||||
const byraOwner = await insertAuthUser()
|
||||
const stranger = await insertAuthUser()
|
||||
const teamId = await insertTeam({ createdBy: byraOwner })
|
||||
const brandId = await insertBrand(teamId, 'invite_only')
|
||||
|
||||
await expectSqlstate(
|
||||
() =>
|
||||
getPool().query(
|
||||
`SELECT public.create_company_for_brand_signup($1, 'Rogue AB', 'aktiebolag', $2)`,
|
||||
[stranger, brandId],
|
||||
),
|
||||
'42501',
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses unknown brands (23503) and unknown users (23503)', async () => {
|
||||
const byraOwner = await insertAuthUser()
|
||||
const client = await insertAuthUser()
|
||||
const teamId = await insertTeam({ createdBy: byraOwner })
|
||||
const brandId = await insertBrand(teamId, 'invite_only')
|
||||
await insertAllowlistEntry(brandId, authEmail(client))
|
||||
|
||||
await expectSqlstate(
|
||||
() =>
|
||||
getPool().query(
|
||||
`SELECT public.create_company_for_brand_signup($1, 'X AB', 'aktiebolag', $2)`,
|
||||
[client, randomUUID()],
|
||||
),
|
||||
'23503',
|
||||
)
|
||||
|
||||
await expectSqlstate(
|
||||
() =>
|
||||
getPool().query(
|
||||
`SELECT public.create_company_for_brand_signup($1, 'X AB', 'aktiebolag', $2)`,
|
||||
[randomUUID(), brandId],
|
||||
),
|
||||
'23503',
|
||||
)
|
||||
})
|
||||
|
||||
it('is not executable by authenticated sessions (42501)', async () => {
|
||||
const byraOwner = await insertAuthUser()
|
||||
const client = await insertAuthUser()
|
||||
const teamId = await insertTeam({ createdBy: byraOwner })
|
||||
const brandId = await insertBrand(teamId, 'invite_only')
|
||||
await insertAllowlistEntry(brandId, authEmail(client))
|
||||
|
||||
let sqlstate: string | undefined
|
||||
try {
|
||||
await withUserContext(client, async (session) => {
|
||||
await session.query(
|
||||
`SELECT public.create_company_for_brand_signup($1, 'Self AB', 'aktiebolag', $2)`,
|
||||
[client, brandId],
|
||||
)
|
||||
})
|
||||
} catch (err) {
|
||||
sqlstate = (err as { code?: string }).code
|
||||
}
|
||||
expect(sqlstate).toBe('42501')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user