Files
accounted/lib/company/actions.ts
T
MattssonandClaude Fable 5 4f6ecad549 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>
2026-08-27 18:32:52 +02:00

214 lines
8.8 KiB
TypeScript

'use 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'
/**
* Switch the active company. Returns an error *code* (translated by the
* caller, same pattern as `org_number_invalid` below): 'not_member' when the
* user lacks membership, 'persist_failed' when the user_preferences write
* failed or could not be verified (#701).
*/
export async function switchCompany(companyId: string): Promise<{ error?: string }> {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { error: 'Unauthorized' }
}
try {
await setActiveCompany(supabase, user.id, companyId)
// No revalidatePath: the client performs a hard navigation
// (window.location.assign) after this action returns, which wipes
// every React/router/fetch cache wholesale. revalidatePath would be a
// no-op and would just race with the hard reload.
return {}
} catch (err) {
console.error('[switchCompany] failed', err)
if (err instanceof CompanyContextError && err.code === 'not_member') {
return { error: 'not_member' }
}
// persist_failed and anything unexpected: a retryable failure, not a
// permissions problem: don't tell the user they lack access.
return { error: 'persist_failed' }
}
}
/**
* Create a company from onboarding wizard data.
*
* This runs on the server so that if the Next.js server is unavailable when
* the user clicks the final "Fortsätt" button, the action never reaches
* Supabase and no ghost company is created. All operations (company,
* membership, chart of accounts, settings, fiscal period, active company)
* happen sequentially; if any step after company creation fails the company
* is rolled back to avoid partial state.
*/
export async function createCompanyFromOnboarding(params: {
teamId: string
settings: Record<string, unknown>
fiscalPeriod: {
startDate: string
endDate: string
name: string
}
// Optional TIC lookup result captured during the onboarding form. When
// supplied, persisted to companies.tic_snapshot so downstream features
// (specialized accountant agent composer, MCP briefing) can read the same
// Bolagsverket-sourced data the form used. Empty for manual entry paths.
ticLookup?: CompanyLookupResult | null
}): Promise<{ companyId?: string; error?: string }> {
try {
return await createCompanyFromOnboardingImpl(params)
} catch (err) {
// Defensive top-level catch: a thrown error escapes to the client as
// an opaque Next.js server-action exception with no message in dev
// and a redacted message in prod. Logging the full error here gives
// us a server-side trace and returns a localized fallback to the UI.
console.error('[createCompanyFromOnboarding] unexpected error', err)
return { error: getErrorMessage(err, { context: 'settings' }) }
}
}
async function createCompanyFromOnboardingImpl(params: {
teamId: string
settings: Record<string, unknown>
fiscalPeriod: { startDate: string; endDate: string; name: string }
ticLookup?: CompanyLookupResult | null
}): Promise<{ companyId?: string; error?: string }> {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { error: 'Unauthorized' }
}
const entityType = params.settings.entity_type as string | undefined
if (entityType !== 'enskild_firma' && entityType !== 'aktiebolag') {
return { error: 'Ogiltig företagsform.' }
}
const companyName = (params.settings.company_name as string | undefined) || 'Mitt företag'
// Creating a company under a BYRÅ team is admin-gated (WL-15): every
// created client company is +1 on the byrå's monthly invoice, so only team
// owner/admin may do it. Personal-team creation is untouched. The
// create_company_with_owner RPC enforces the same rule in the database
// (migration 20260826130400); this check exists to return a readable error
// instead of a raw 42501. A team the caller cannot read via RLS resolves
// to null kind here and falls through to the RPC's own membership check.
const { data: teamRow } = await supabase
.from('teams')
.select('kind')
.eq('id', params.teamId)
.maybeSingle()
if ((teamRow as { kind?: string } | null)?.kind === 'byra') {
const { data: teamMemberRow } = await supabase
.from('team_members')
.select('role')
.eq('team_id', params.teamId)
.eq('user_id', user.id)
.maybeSingle()
const teamRole = (teamMemberRow as { role?: string } | null)?.role
if (teamRole !== 'owner' && teamRole !== 'admin') {
return { error: 'Endast byråns ägare och administratörer kan skapa klientbolag.' }
}
}
// 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.
const created = await createCompanyCore(
supabase,
{
entityType,
companyName,
orgNumber: params.settings.org_number as string | undefined,
settings: params.settings,
fiscalPeriod: params.fiscalPeriod,
ticLookup: params.ticLookup,
},
createCompanyRow,
rollbackClient,
)
if (created.error !== undefined) {
return { error: created.error }
}
const newCompanyId = created.companyId
// 6. Set as active company
try {
await setActiveCompany(supabase, user.id, newCompanyId)
} catch (err) {
// Non-fatal: the company was created successfully; the user can switch manually
console.error('[createCompanyFromOnboarding] setActiveCompany failed', err)
}
revalidatePath('/')
return { companyId: newCompanyId }
}