Files
accounted/app/(dashboard)/clients/access/SignupAccessManager.tsx
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

291 lines
9.6 KiB
TypeScript

'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>
)
}