Files
accounted/app/api/billing/portal/route.ts
T
MattssonandClaude Fable 5.1 d29a5bda14 fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS (#2376)
* fix(auth): resolve auth-link hosts from the brands table, drop NEXT_PUBLIC_WHITELABEL_DOMAINS

Password reset, invite, email change and signup links now resolve the
request host against brands.domain server-side. The env var was a second
copy of that registry compiled into the browser; every new brand needed
the row, the env var, the GoTrue allowlist and a redeploy, and two
partners shipped with the env var stale, so their reset mails went out
canonical-branded to the canonical host.

- New POST /api/auth/password-reset: the login page no longer calls
  GoTrue directly, so the browser carries no domain list.
- lib/domains/trusted-app-origin.ts is async and registry-backed; it
  also trusts this deployment's own VERCEL_URL / VERCEL_BRANCH_URL so
  previews keep sending links to themselves.
- Signup shares the same resolver instead of following the raw host.
- Docs and .env.example describe the single registry; GoTrue keeps the
  redirect allowlist as backstop (hosted: *.accounted.se wildcard).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

* fix(auth): await the async origin resolver in the billing routes merged from main

PR #2370 added resolveRequestAppOrigin callers in billing/checkout and
billing/portal after this branch made the resolver async. Await them and
move their tests from the removed env var to the brands mock; update the
login source-assert test to the server-routed reset.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

* fix(auth): refuse auth links on a failed brand lookup, keep local dev hosts, correct GoTrue allowlist docs

Skeptic and CI findings on #2376, one pass:

- A failed brands lookup now throws BrandLookupFailedError (TRANSIENT_ERROR,
  503, retryable) instead of falling back to the canonical origin: a
  canonical link is the wrong-brand mail this PR removes. Password reset
  and email change answer 503 themselves; withRouteContext routes map the
  code.
- A local canonical (dev) trusts other local hosts and ports on the same
  scheme, so lane servers on 3001-3003 confirm signups on themselves.
- GoTrue matches the full redirect_to including the query and `*` stops
  at `.` and `/`: docs and decision line now prescribe
  https://*.accounted.se/auth/callback** and https://*.accounted.se/invite/**.
- The Turnstile contract test asserts the server-routed reset forwards
  the captcha token (it still asserted the removed browser call).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUNB7qjua8EUaJmZgfFscx

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:12:22 +02:00

59 lines
2.2 KiB
TypeScript

import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { createServiceClient } from '@/lib/supabase/server'
import { getStripe } from '@/lib/stripe/client'
import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard'
import { resolveRequestAppOrigin } from '@/lib/domains/trusted-app-origin'
/**
* Create a Stripe Billing Customer Portal session so the user can manage,
* upgrade/downgrade, or cancel their subscription. Stripe handles all the
* compliance/PCI surface: we never build those flows ourselves.
*
* company_subscriptions is read via the service client on purpose: the row
* is webhook-owned and not member-readable under RLS; the query still filters
* by the membership-validated companyId.
*/
export const POST = withRouteContext('billing.portal', async (request, ctx) => {
const { user, supabase, companyId } = ctx
// Demo accounts must never reach Stripe (see billing/checkout for the full
// rationale). Defense in depth: a demo tenant should never own a portal
// session even if a stray customer row exists.
if (user.is_anonymous) return sandboxBlockedResponse()
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
const service = createServiceClient()
const { data: sub } = await service
.from('company_subscriptions')
.select('stripe_customer_id')
.eq('company_id', companyId)
.maybeSingle()
const customerId = (sub as { stripe_customer_id: string | null } | null)?.stripe_customer_id
if (!customerId) {
return NextResponse.json(
{
error: {
code: 'NO_SUBSCRIPTION',
message: 'Det finns inget abonnemang att hantera.',
message_en: 'No subscription to manage.',
},
},
{ status: 400 },
)
}
// Same host the user started on (see billing/checkout): a registered
// white-label host stays on its brand, anything else returns to the
// canonical app. The path is fixed.
const appOrigin = await resolveRequestAppOrigin(request)
const portal = await getStripe().billingPortal.sessions.create({
customer: customerId,
return_url: `${appOrigin}/settings/billing`,
})
return NextResponse.json({ url: portal.url })
})