feat(auth): base available login methods off GoTrue providers (#1869)

* feat(auth): base login fields on GoTrue providers

Signed-off-by: Goostaf <gasplund2@gmail.com>

# Conflicts:
#	app/(auth)/login/login-client.tsx
#	app/(auth)/register/page.tsx

* fix: address feedback

Signed-off-by: Goostaf <gasplund2@gmail.com>

* chore: remove hardcoded Google enabled checks

Signed-off-by: Goostaf <gasplund2@gmail.com>

# Conflicts:
#	.env.example

* feat: show label when password login is disabled

Signed-off-by: Goostaf <gasplund2@gmail.com>

* feat: use MicrosoftMark, correct comment

Signed-off-by: Goostaf <gasplund2@gmail.com>

* feat: display custom providers

Signed-off-by: Goostaf <gasplund2@gmail.com>

* feat: show when no methods are available

Signed-off-by: Goostaf <gasplund2@gmail.com>

* feat: display custom provider labels

Signed-off-by: Goostaf <gasplund2@gmail.com>

* feat: add SAML login path

Signed-off-by: Goostaf <gasplund2@gmail.com>

* feat: show when no methods are available

Signed-off-by: Goostaf <gasplund2@gmail.com>

* fix: display SAML button when enabled

Signed-off-by: Goostaf <gasplund2@gmail.com>

* fix: preserve nextPath and broken key

Signed-off-by: Goostaf <gasplund2@gmail.com>

* fix: redirect test to client

Signed-off-by: Goostaf <gasplund2@gmail.com>

* fix: expose registerEnabled

Signed-off-by: Goostaf <gasplund2@gmail.com>

* feat: provider allowlist and request timeout

Signed-off-by: Goostaf <gasplund2@gmail.com>

* fix: restore compact labels

Signed-off-by: Goostaf <gasplund2@gmail.com>

* refactor: move withTimeout implementation to utils

Signed-off-by: Goostaf <gasplund2@gmail.com>

* fix: include nextPath

Signed-off-by: Goostaf <gasplund2@gmail.com>

* fix: export function and test case

Signed-off-by: Goostaf <gasplund2@gmail.com>

* feat: only show SAML button if vars configured

Signed-off-by: Goostaf <gasplund2@gmail.com>

* fix(auth): map SAML sign-in error through getErrorMessage

The antipattern ratchet (check:guards, raw-user-error) rejects a raw
error.message reaching a user-visible sink. Route the signInWithSSO
error through getErrorMessage like the other auth error paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BAzJjXQBa9F5L1U42wUMj
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

* feat(auth): GitHub brand mark on the provider button; decision log

GitHub allows its invertocat in solid black/white, so currentColor is
correct; custom OIDC providers keep the generic key icon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BAzJjXQBa9F5L1U42wUMj
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

---------

Signed-off-by: Goostaf <gasplund2@gmail.com>
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Goostaf
2026-08-31 09:13:39 +01:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent dc07ca8872
commit 6ac9679fb5
19 changed files with 1728 additions and 1078 deletions
+6 -6
View File
@@ -61,12 +61,6 @@ RECEIPT_HUNT_COMPANY_IDS=
# Hosted keeps this unset: public signup stays open there.
# AUTH_SIGNUPS_DISABLED=false
# Sign in with Google. Requires the Google provider to be configured in
# Supabase/GoTrue first (Google Cloud OAuth client + redirect URI):
# https://supabase.com/docs/guides/auth/social-login/auth-google
# The button stays hidden until this is true.
# NEXT_PUBLIC_GOOGLE_AUTH_ENABLED=true
# Cloudflare Turnstile site key for Supabase Auth bot protection. This value is
# public and is embedded in the browser bundle. Leave it unset until a widget
# has been created for the deployment's exact hostnames. Deploy the site key
@@ -74,6 +68,12 @@ RECEIPT_HUNT_COMPANY_IDS=
# existing login flow remains available throughout rollout.
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# SAML SSO login (Enterprise). When enabled in Supabase GoTrue (saml_enabled),
# the login page shows a SAML button. At least one of these is required to
# tell Supabase which identity provider to redirect to:
# NEXT_PUBLIC_SSO_DOMAIN=your-domain.okta.com # discovers the provider by IdP domain
# NEXT_PUBLIC_SSO_PROVIDER_ID= # explicit provider uuid (overrides domain)
# ── Optional: extension features (core runs without these) ─
# AI features (document extraction + AI assistant). Three ways to provide a
# backend; set one of them. AI_PROVIDER (bedrock|anthropic|openai-compatible)
+1
View File
@@ -1378,3 +1378,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-30] Correction to the PR #2047 residual-gap note: the DuplicateBookingDialog backstop only covers a single skipped bank movement within 7 days of the omforing's fiscal-year-end date; mid-year and aggregated-skip variants are unmitigated and accepted on rarity (conjunction of skipped voucher + tail date + synced account + fetched history). Exact closure = persist skipped-voucher max date as coverage_end on sie_imports at import time (skippedDetails already has the dates); filed as follow-up.
[2026-08-30] Receipt-hunt excludes prominent-amounts fallback candidates (amountSource tag on UnderlagCandidate): the nightly hunt scans outflows only and its 0.8 skip-adjudication threshold was calibrated for invoice totals, so a fallback pair (0.85 on date+printed-figure, no merchant) would auto-classify certain on a wrong-by-construction pairing. Fallback docs stay reachable via picker + agent candidates.
[2026-08-31] Re-versioned the ignore_transaction CHECK pair to 20260831070000/070001 and rebuilt its value list from main's 20260830160000: three op-type CHECK pairs (book_skattekonto 130000, delete_draft_invoice 150000, update_salary_run 160000) landed on main after this branch's pair was written, and a wholesale re-created CHECK from a stale list applying last would silently revoke those op types on prod (the standing migration hazard from the #1411 rebuild).
[2026-08-31] Login/register methods come from GoTrue (/auth/v1/settings + admin customProviders) instead of app-side flags; NEXT_PUBLIC_GOOGLE_AUTH_ENABLED removed (PR #1869): the Supabase dashboard becomes the single switch, an allowlist of auth-js provider ids filters non-login entries like anonymous_users, and hosted rendering is unchanged because Google is enabled in prod GoTrue. The Vercel env var stays set for old-build rollback safety; delete it after a few deploys.
+3 -3
View File
@@ -330,9 +330,9 @@ export async function GET(request: NextRequest) {
// confirmation must not be framed as a failed password reset. On the PKCE
// (?code=) path there is no `type`, so recovery is identified by the
// next=/reset-password marker that resetPasswordForEmail sets, and OAuth
// by the flow=oauth marker that GoogleAuthButton puts in redirectTo
// (provider denials arrive here with ?error and no code); everything
// else defaults to the signup/confirmation framing.
// by the flow=oauth marker that OAuthButton puts in redirectTo
// (provider denials arrive here with ?error and no code); everything else
// defaults to the signup/confirmation framing.
const failedFlow =
searchParams.get('flow') === 'oauth'
? 'oauth'
+145 -29
View File
@@ -36,12 +36,11 @@ import {
} from '@/lib/auth/consume-invite-cookie'
import { buildPasswordResetRedirectTo } from '@/lib/domains/trusted-app-origin'
import { AuthFormError } from '@/components/auth/AuthFormError'
import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton'
import { OAuthButton } from '@/components/auth/OAuthButton'
import {
TurnstileChallenge,
type TurnstileChallengeHandle,
} from '@/components/auth/TurnstileChallenge'
import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth'
import {
captchaTokenOptions,
isTurnstileSubmissionBlocked,
@@ -54,6 +53,7 @@ import {
setSessionAuthMethodHint,
type SessionTimeoutReason,
} from '@/lib/auth/session-timeout-shared'
import type { GoTrueAuthSettings } from '@/lib/auth/gotrue-providers'
import type { BankIdResult } from '@/components/auth/BankIdAuth'
@@ -69,7 +69,16 @@ const BankIdAuth = dynamic(
* `initialMethod` comes from the server page reading the method-hint cookie,
* so a returning password user lands straight on the form with no flash.
*/
export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | null }) {
export function LoginClient({
initialMethod,
authSettings,
canUseSaml
}: {
initialMethod: LoginMethod | null
authSettings: GoTrueAuthSettings
canUseSaml?: boolean
}) {
const { providers, passwordLoginEnabled, registrationEnabled, samlEnabled } = authSettings
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
@@ -115,7 +124,6 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
// Per-request brand merged over getBranding() defaults (WL-12): identical
// values on default hosts, brand values on branded hosts.
const branding = useBranding()
const googleAuthEnabled = isGoogleAuthEnabled()
const tAuth = useTranslations('auth')
const tCommon = useTranslations('common')
const tInvite = useTranslations('invite')
@@ -171,6 +179,38 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
setShowResetPassword(false)
}
const handleSamlLogin = async () => {
setFormError(null)
setIsLoading(true)
try {
const ssoDomain = process.env.NEXT_PUBLIC_SSO_DOMAIN
const ssoProviderId = process.env.NEXT_PUBLIC_SSO_PROVIDER_ID
const params = ssoProviderId
? { providerId: ssoProviderId }
: ssoDomain
? { domain: ssoDomain }
: null
if (!params) {
setFormError({ kind: 'oauth', message: tAuth('saml_no_domain') })
return
}
const { error } = await supabase.auth.signInWithSSO({
...params,
options: { redirectTo: `${window.location.origin}/auth/callback?flow=oauth&next=${encodeURIComponent(nextPath)}` },
})
if (error) {
setFormError({ kind: 'oauth', message: getErrorMessage(error, { context: 'auth', locale: errorLocale }) })
}
} catch (error) {
setFormError({
kind: 'oauth',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
setIsLoading(false)
}
}
// Accept a pending invite, if any, and report a non-definitive failure.
// Returns true when the caller should land the user in the app directly.
// The invite cookie survives anything that is not a settled outcome, so
@@ -548,8 +588,14 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
}
const showBankIdChip = method === 'email' && bankIdEnabled
const showEmailChip = method === 'bankid'
const chipCount = (showBankIdChip ? 1 : 0) + (showEmailChip ? 1 : 0) + (googleAuthEnabled ? 1 : 0)
const showEmailChip = method === 'bankid' && passwordLoginEnabled
const samlAvailable = samlEnabled && canUseSaml
const chipCount = (showBankIdChip ? 1 : 0) + (showEmailChip ? 1 : 0) + providers.length + (samlAvailable ? 1 : 0)
const hasPrimaryMethod =
(method === 'bankid' && bankIdEnabled) ||
(method === 'email' && passwordLoginEnabled)
const hasSecondaryMethods = chipCount > 0
return (
<div className="min-h-dvh flex flex-col items-center justify-center bg-frame p-4">
@@ -604,14 +650,16 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
{tAuth('bankid_no_account_greeting', { name: bankIdNoAccount.givenName ?? '' })}
</p>
<p className="mt-1 text-muted-foreground">{tAuth('bankid_no_account_body')}</p>
<p className="mt-1">
<Link
href={registerHref}
className="text-muted-foreground underline underline-offset-2 hover:text-foreground transition-colors"
>
{tAuth('bankid_no_account_create')}
</Link>
</p>
{passwordLoginEnabled && registrationEnabled && (
<p className="mt-1">
<Link
href={registerHref}
className="text-muted-foreground underline underline-offset-2 hover:text-foreground transition-colors"
>
{tAuth('bankid_no_account_create')}
</Link>
</p>
)}
</div>
)}
{bankIdUnavailable && (
@@ -622,9 +670,9 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
)}
<div key={method} className="animate-fade-in">
{method === 'bankid' ? (
{method === 'bankid' && bankIdEnabled ? (
<BankIdAuth mode="login" hero onComplete={handleBankIdComplete} />
) : (
) : method === 'email' && passwordLoginEnabled ? (
<form onSubmit={handlePasswordLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">{tAuth('email_label')}</Label>
@@ -727,10 +775,58 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
)}
</Button>
</form>
) : (
providers.length > 0 ? (
<div className="space-y-3">
{providers.map((provider) => (
<OAuthButton
key={provider.id}
provider={provider}
next={nextPath}
onError={(message) => setFormError({ kind: 'oauth', message })}
/>
))}
{samlAvailable && !hasPrimaryMethod && (
<Button
type="button"
variant="outline"
className="w-full h-11 gap-2"
onClick={handleSamlLogin}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<KeyRound className="mr-2 h-4 w-4 text-muted-foreground" aria-hidden="true" />
)}
{tAuth('continue_with_provider', { provider: 'SAML' })}
</Button>
)}
</div>
) : samlAvailable ? (
<Button
type="button"
variant="outline"
className="w-full h-11 gap-2"
onClick={handleSamlLogin}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<KeyRound className="mr-2 h-4 w-4 text-muted-foreground" aria-hidden="true" />
)}
{tAuth('continue_with_provider', { provider: 'SAML' })}
</Button>
) : (
<p className="text-center text-sm text-muted-foreground">
{tAuth('no_login_methods')}
</p>
)
)}
</div>
{chipCount > 0 && (
{hasPrimaryMethod && hasSecondaryMethods && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
@@ -760,13 +856,15 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
BankID
</Button>
)}
{googleAuthEnabled && (
<GoogleAuthButton
{providers.map((provider) => (
<OAuthButton
compact
key={provider.id}
provider={provider}
next={nextPath}
onError={(message) => setFormError({ kind: 'oauth', message })}
/>
)}
))}
{showEmailChip && (
<Button
type="button"
@@ -778,20 +876,38 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu
{tAuth('method_email_chip')}
</Button>
)}
{samlAvailable && (
<Button
type="button"
variant="outline"
className="h-10 w-full gap-2"
onClick={handleSamlLogin}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<KeyRound className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
)}
{tAuth('continue_with_provider', { provider: 'SAML' })}
</Button>
)}
</div>
</>
)}
</div>
<p className="mt-6 text-center text-[13px] text-muted-foreground">
{tAuth('login_new_here')}{' '}
<Link
href={registerHref}
className="font-medium text-foreground underline underline-offset-2 hover:opacity-80 transition-opacity"
>
{tAuth('no_account')}
</Link>
</p>
{passwordLoginEnabled && registrationEnabled && (
<p className="mt-6 text-center text-[13px] text-muted-foreground">
{tAuth('login_new_here')}{' '}
<Link
href={registerHref}
className="font-medium text-foreground underline underline-offset-2 hover:opacity-80 transition-opacity"
>
{tAuth('no_account')}
</Link>
</p>
)}
<p className="mt-3 text-center text-xs text-muted-foreground/80 leading-relaxed">
{tAuth('terms_prefix')}{' '}
+10 -1
View File
@@ -2,6 +2,7 @@ import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
import { LOGIN_METHOD_COOKIE, isLoginMethod } from '@/lib/auth/login-method'
import { fetchAuthSettings, type GoTrueAuthSettings } from '@/lib/auth/gotrue-providers'
import { LoginClient } from './login-client'
// Server component: reads the method-hint cookie so the panel opens in the
@@ -12,9 +13,17 @@ export default async function LoginPage() {
const cookieStore = await cookies()
const stored = cookieStore.get(LOGIN_METHOD_COOKIE)?.value
// Fetch auth settings from GoTrue: enabled providers, password login,
// and registration status. Lightweight GET, cached for 60 seconds.
const authSettings: GoTrueAuthSettings = await fetchAuthSettings()
return (
<Suspense fallback={<AuthPageSkeleton />}>
<LoginClient initialMethod={isLoginMethod(stored) ? stored : null} />
<LoginClient
initialMethod={isLoginMethod(stored) ? stored : null}
authSettings={authSettings}
canUseSaml={!!(process.env.NEXT_PUBLIC_SSO_DOMAIN || process.env.NEXT_PUBLIC_SSO_PROVIDER_ID)}
/>
</Suspense>
)
}
@@ -25,7 +25,10 @@ import path from 'node:path'
* app/(auth)/reset-password/__tests__/invite-handoff.test.ts and
* app/invite/[token]/__tests__/invite-cookie.test.ts.
*/
const SRC = fs.readFileSync(path.resolve(__dirname, '../page.tsx'), 'utf8')
const SRC = fs.readFileSync(
path.resolve(__dirname, '../register-client.tsx'),
'utf8',
)
/** The page source with comment lines dropped, so prose about a pattern is never mistaken for the pattern. */
const CODE = SRC.split('\n')
+6 -971
View File
@@ -1,979 +1,14 @@
'use client'
import { useState, useEffect, useRef, Suspense } from 'react'
import dynamic from 'next/dynamic'
import Image from 'next/image'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Check, Loader2, Mail, ArrowLeft, ExternalLink, Eye, EyeOff } from 'lucide-react'
import { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid-flags'
import type { BankIdResult } from '@/components/auth/BankIdAuth'
import { useBranding } from '@/lib/branding/brand-context'
import { detectWebmailHint } from '@/lib/auth/webmail-search'
import {
consumeInviteCookie,
INVITE_PROBLEM_MESSAGE_KEYS,
} from '@/lib/auth/consume-invite-cookie'
import { Suspense } from 'react'
import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton'
import { AuthFormError } from '@/components/auth/AuthFormError'
import { GoogleAuthButton } from '@/components/auth/GoogleAuthButton'
import {
TurnstileChallenge,
type TurnstileChallengeHandle,
} from '@/components/auth/TurnstileChallenge'
import { isGoogleAuthEnabled } from '@/lib/auth/google-oauth'
import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error'
import {
captchaTokenOptions,
isTurnstileSubmissionBlocked,
} from '@/lib/auth/turnstile'
import { persistLoginMethodHint, type LoginMethod } from '@/lib/auth/login-method'
import { safeReturnTo } from '@/lib/auth/safe-return-to'
import { cn } from '@/lib/utils'
import { fetchAuthSettings, type GoTrueAuthSettings } from '@/lib/auth/gotrue-providers'
import { RegisterClient } from './register-client'
const BankIdAuth = dynamic(
() => import('@/components/auth/BankIdAuth').then((module) => module.BankIdAuth),
{ ssr: false },
)
export default async function RegisterPage() {
const authSettings: GoTrueAuthSettings = await fetchAuthSettings()
export default function RegisterPage() {
return (
<Suspense fallback={<AuthPageSkeleton />}>
<RegisterPageContent />
<RegisterClient authSettings={authSettings} />
</Suspense>
)
}
function RegisterPageContent() {
// `invite` and `next` are the only query parameters this page reads.
//
// `next` is the post-signup destination /login forwards when a visitor with
// no account arrives from the MCP OAuth consent page
// (/login?next=/api/mcp-oauth/authorize?…, issue #1814). It goes through
// safeReturnTo (lib/auth/safe-return-to.ts); a hand-rolled check on this
// value is an open redirect. Without one ('/'), nothing changes: the
// password path leaves through the confirmation mail and /auth/callback,
// and the BankID path lands a brand-new account on /select-company. With
// one, every path resumes it: BankID hard-navigates (the consent page is a
// route handler returning raw HTML), while the confirmation link and Google
// OAuth carry it to /auth/callback, which honours only the consent
// destination. A new account has no membership to spend a deep link on, so
// nothing else may ever be forwarded here.
const searchParams = useSearchParams()
const nextPath = safeReturnTo(searchParams.get('next'), '/')
const loginHref = nextPath === '/' ? '/login' : `/login?next=${encodeURIComponent(nextPath)}`
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
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)
const [bankIdEmail, setBankIdEmail] = useState('')
// Signup failures render inline next to the form (see AuthFormError), never
// as a toast. Field-level problems attach to their field; everything else
// goes to the form-level alert above the form.
const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid' | 'oauth'; message: string } | null>(null)
const [passwordError, setPasswordError] = useState<string | null>(null)
const [confirmError, setConfirmError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
const confirmInputRef = useRef<HTMLInputElement>(null)
const emailInputRef = useRef<HTMLInputElement>(null)
const turnstileRef = useRef<TurnstileChallengeHandle>(null)
const { toast } = useToast()
const router = useRouter()
const supabase = createClient()
const bankIdEnabled = isBankIdEnabled()
// Per-request brand merged over getBranding() defaults (WL-12): identical
// values on default hosts, brand values on branded hosts.
const branding = useBranding()
const googleAuthEnabled = isGoogleAuthEnabled()
const t = useTranslations('register')
const tAuth = useTranslations('auth')
const tInvite = useTranslations('invite')
const errorLocale = useLocale() as ErrorLocale
// Which method owns the panel (mirrors the login page): BankID is the
// Swedish default for a fresh signup; the email form is one chip away.
const [method, setMethod] = useState<LoginMethod>(bankIdEnabled ? 'bankid' : 'email')
const prevMethodRef = useRef(method)
// Switching to the email form should land the caret in the first field,
// except when an invite pre-filled and locked it.
useEffect(() => {
if (prevMethodRef.current !== method) {
prevMethodRef.current = method
if (method === 'email' && !inviteEmail) emailInputRef.current?.focus()
}
}, [method, inviteEmail])
const switchMethod = (next: LoginMethod) => {
setFormError(null)
setMethod(next)
}
const showBankIdChip = method === 'email' && bankIdEnabled
const showEmailChip = method === 'bankid'
const chipCount = (showBankIdChip ? 1 : 0) + (showEmailChip ? 1 : 0) + (googleAuthEnabled ? 1 : 0)
// Accept a pending invite, if any, and report a non-definitive failure.
// Returns true when the caller should land the user in the app directly.
// The invite cookie survives anything that is not a settled outcome, so
// /onboarding and /select-company can retry acceptance server-side.
const acceptPendingInvite = async (): Promise<boolean> => {
const invite = await consumeInviteCookie()
if (invite.accepted) return true
if (invite.problem) {
const keys = INVITE_PROBLEM_MESSAGE_KEYS[invite.problem]
toast({
title: tInvite(keys.title),
description: tInvite(keys.body),
variant: 'destructive',
})
}
return false
}
// When arriving from an invite link, fetch the invite info to pre-fill
// and lock the email field so the user registers with the correct address.
// BOTH signup forms are pre-filled: the BankID form used to be left blank
// and editable, so an invitee who signed up with BankID typed their private
// address, POST /api/team/accept answered 403 on the email equality check,
// and they landed on /select-company with no membership. The token now
// survives that 403 (lib/auth/consume-invite-cookie.ts) so it is recoverable
// rather than terminal, but the signup should not walk into it at all.
useEffect(() => {
const inviteToken = searchParams.get('invite')
if (!inviteToken) return
fetch(`/api/team/accept?token=${encodeURIComponent(inviteToken)}`)
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (data?.data?.email) {
setInviteEmail(data.data.email)
setEmail(data.data.email)
setBankIdEmail(data.data.email)
}
})
.catch(() => {})
}, [searchParams])
const [bankIdUnavailable, setBankIdUnavailable] = useState(false)
const handleBankIdComplete = (result: BankIdResult) => {
if (result.error === 'service_unavailable') {
setBankIdUnavailable(true)
setMethod('email')
return
}
if (result.error) {
setFormError({ kind: 'bankid', message: t('bankid_failed_description') })
return
}
// BankID verified: show the email form. The session itself stays in the
// server's HttpOnly flow cookie, so there is nothing to hold on to here.
setFormError(null)
setBankIdUser({ givenName: result.givenName, surname: result.surname })
setBankIdFlowId(result.flowId ?? null)
}
const handleBankIdSignup = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setFormError(null)
const formData = new FormData(e.currentTarget)
const emailValue = (formData.get('bankid_email') as string) || bankIdEmail
if (!bankIdFlowId) {
setFormError({ kind: 'bankid', message: t('bankid_failed_description') })
return
}
setIsLoading(true)
try {
// Only the e-mail travels: the session and the fact that this is a
// signup are both pinned in the server's flow cookie.
const res = await fetch('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-bankid-flow-id': bankIdFlowId,
},
body: JSON.stringify({ email: emailValue }),
})
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".
setFormError({ kind: 'email_exists', message: t('bankid_already_linked_description') })
} else if (json.error === 'account_exists') {
// Inline with a sign-in link instead of yanking the user to /login
// mid-read: they keep the context and choose when to leave.
setFormError({ kind: 'email_exists', message: t('account_exists_description') })
} else {
setFormError({
kind: 'unknown',
message: json.message || json.error || t('register_failed_default'),
})
}
return
}
// Exchange token hash for Supabase session
const { error } = await supabase.auth.verifyOtp({
token_hash: json.data.tokenHash,
type: json.data.type as 'magiclink',
})
if (error) {
console.error('[register] BankID verifyOtp failed', error.message)
setFormError({
kind: 'unknown',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
return
}
// Invited signup: accept the pending invite before routing to the
// picker, same as the login page's BankID path. Without this, an
// invitee who registers with BankID lands on /select-company with no
// membership and gets funneled into creating a company instead of
// joining the one they were invited to.
persistLoginMethodHint('bankid')
if (await acceptPendingInvite()) {
window.location.href = '/'
return
}
if (nextPath !== '/') {
// Resume the MCP consent flow: the account exists and the consent
// page accepts a user with no company yet.
window.location.assign(nextPath)
return
}
router.push('/select-company')
router.refresh()
} catch (error) {
console.error('[register] BankID signup error', error instanceof Error ? error.message : String(error))
setFormError({
kind: 'unknown',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
setIsLoading(false)
}
}
// The live checklist under the password field mirrors these rules; the
// aggregate check gates submission.
const passwordChecks = [
{ key: 'password_req_length', met: password.length >= 8 },
{ key: 'password_req_case', met: /[a-z]/.test(password) && /[A-Z]/.test(password) },
{ key: 'password_req_number', met: /[0-9]/.test(password) },
{ key: 'password_req_special', met: /[^a-zA-Z0-9]/.test(password) },
] as const
function isStrongPassword(pw: string): boolean {
return pw.length >= 8
&& /[a-z]/.test(pw)
&& /[A-Z]/.test(pw)
&& /[0-9]/.test(pw)
&& /[^a-zA-Z0-9]/.test(pw)
}
const handleRegister = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setFormError(null)
setPasswordError(null)
setConfirmError(null)
const formData = new FormData(e.currentTarget)
const emailValue = (formData.get('email') as string) || email
const passwordValue = (formData.get('password') as string) || password
const confirmValue = (formData.get('confirm_password') as string) || confirmPassword
// Client-side checks run before isLoading so the inputs are still enabled
// when focus moves to the offending field.
if (!isStrongPassword(passwordValue)) {
setPasswordError(t('password_error_requirements'))
passwordInputRef.current?.focus()
return
}
if (passwordValue !== confirmValue) {
setConfirmError(t('password_mismatch_description'))
confirmInputRef.current?.focus()
confirmInputRef.current?.select()
return
}
if (isTurnstileSubmissionBlocked(captchaToken)) {
setFormError({ kind: 'unknown', message: tAuth('turnstile_required') })
return
}
setIsLoading(true)
try {
// 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 (!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') {
// Server-side password policy rejection: same field, same message
// as the client-side check.
setPasswordError(t('password_error_requirements'))
} else {
const messageByKind: Partial<Record<AuthErrorKind, string>> = {
email_exists: t('account_exists_description'),
email_invalid: t('error_email_invalid'),
rate_limited: t('error_rate_limited'),
signup_disabled: t('error_signup_disabled'),
}
setFormError({
kind,
message:
messageByKind[kind] ??
getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
}
return
}
persistLoginMethodHint('email')
// If auto-confirmed (local dev), process invite immediately and redirect
if (json?.data?.status === 'session') {
const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
const inviteToken = cookieMatch?.[1]
if (inviteToken) {
try {
const res = await fetch('/api/team/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: inviteToken }),
})
if (res.ok) {
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
window.location.href = '/'
return
}
} catch (err) {
console.error('[register] invite acceptance failed:', err instanceof Error ? err.message : String(err))
}
}
// Auto-confirmed but no invite or invite failed: go to onboarding
// (invite cookie is preserved so the onboarding fallback can retry),
// or resume the MCP consent flow when that is where we came from.
window.location.href = nextPath
return
}
// Supabase obfuscates duplicate signups (to prevent user enumeration):
// 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
}
setEmail(emailValue)
setIsRegistered(true)
} catch (error) {
console.error('[register] unexpected exception', error instanceof Error ? error.message : String(error))
setFormError({
kind: 'unknown',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
turnstileRef.current?.reset()
setIsLoading(false)
}
}
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">
<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('duplicate_title')}</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
{t('duplicate_body_prefix')}{' '}
<span className="font-medium text-foreground">{duplicateEmail}</span>.
</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('duplicate_hint')}
</p>
</div>
<div className="space-y-2">
{/*
Plain /login, no `email` parameter: app/(auth)/login/page.tsx
reads only `error`, `flow` and `next`, so the address was
travelling in the URL (browser history, Referer, every proxy
access log) and arriving nowhere. The address is already on
screen above, so nothing is lost by dropping it.
*/}
<Button className="w-full" asChild>
<Link href={loginHref}>
{t('sign_in')}
</Link>
</Button>
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={() => setDuplicateEmail(null)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('back')}
</Button>
</div>
</div>
</div>
)
}
if (isRegistered) {
const webmailHint = detectWebmailHint(email, branding.authEmailFrom)
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('confirm_email_title')}</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
{t.rich('confirm_email_body', {
email,
strong: (chunks) => <span className="font-medium text-foreground">{chunks}</span>,
})}
</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('confirm_email_hint')}
</p>
</div>
<div className="space-y-2">
{webmailHint && (
<Button className="w-full" asChild>
<a href={webmailHint.url} target="_blank" rel="noopener noreferrer">
{t(webmailHint.hasSearch ? 'open_webmail_search' : 'open_webmail_inbox', {
provider: webmailHint.name,
})}
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
)}
<Button variant="ghost" className="w-full text-muted-foreground" asChild>
<Link href="/login">
<ArrowLeft className="mr-2 h-4 w-4" />
{t('back_to_login')}
</Link>
</Button>
</div>
</div>
</div>
)
}
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">
<header className="text-center mb-8">
<h1 className="sr-only">{t('create_account')}</h1>
<BrandWordmark size="hero" />
</header>
<div className="rounded-xl border border-border bg-background p-6">
{bankIdUnavailable && !bankIdUser && (
<p className="mb-4 text-[13px] leading-5 text-muted-foreground">
{t('bankid_unavailable_body')}
</p>
)}
{formError && (
<div className="mb-4">
<AuthFormError
message={formError.message}
action={
formError.kind === 'email_exists' ? (
<Link
href={loginHref}
className="font-medium underline underline-offset-2"
>
{t('sign_in')}
</Link>
) : undefined
}
/>
</div>
)}
{bankIdUser ? (
<form onSubmit={handleBankIdSignup} className="space-y-4">
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-sm font-medium">
{bankIdUser.givenName} {bankIdUser.surname}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{t('bankid_verified')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="bankid_email">{t('email_label')}</Label>
<Input
id="bankid_email"
name="bankid_email"
type="email"
autoComplete="email"
placeholder={t('email_placeholder')}
value={bankIdEmail}
onChange={(e) => setBankIdEmail(e.target.value)}
required
disabled={isLoading || !!inviteEmail}
readOnly={!!inviteEmail}
className="h-11"
/>
<p className="text-xs text-muted-foreground">
{inviteEmail ? t('invite_email_hint') : t('bankid_email_hint')}
</p>
</div>
{/* Also disabled while Back's /cancel is in flight: submitting
then would race the cookie clear (recoverable, but pointless). */}
<Button type="submit" className="w-full h-11" disabled={isLoading || isCancelling}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('creating')}
</>
) : (
t('create_account')
)}
</Button>
<Button
type="button"
variant="ghost"
className="w-full text-muted-foreground"
disabled={isLoading || isCancelling}
onClick={async () => {
// AWAIT the cancel before remounting BankIdAuth. The flow
// cookie outlives this component and BankIdAuth probes for a
// live flow on mount, so clearing the form first would let it
// find the still-completed session. Its own isCancelling
// state, not isLoading: isLoading drives the submit button's
// "Skapar konto...", and pressing Back should not claim an
// account is being created.
setIsCancelling(true)
try {
const res = await fetch('/api/extensions/ext/tic/bankid/cancel', {
method: 'POST',
headers: bankIdFlowId
? { 'x-bankid-flow-id': bankIdFlowId }
: undefined,
})
if (!res.ok) throw new Error(`cancel failed: ${res.status}`)
// Flow cleared server-side; the remounted BankIdAuth will
// probe, find nothing, and show the start button.
setBankIdUser(null)
setBankIdFlowId(null)
} catch {
// The flow is still live, so resetting the form would just
// bounce the user back here. Say so instead of looping.
setFormError({ kind: 'bankid', message: t('bankid_cancel_failed') })
} finally {
setIsCancelling(false)
}
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('back')}
</Button>
</form>
) : (
<div key={method} className="animate-fade-in">
{method === 'bankid' && bankIdEnabled ? (
<BankIdAuth mode="signup" hero onComplete={handleBankIdComplete} />
) : (
<form onSubmit={handleRegister} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">{t('email_label')}</Label>
<Input
ref={emailInputRef}
id="email"
name="email"
type="email"
autoComplete="email"
placeholder={t('email_placeholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading || !!inviteEmail}
readOnly={!!inviteEmail}
className="h-11"
/>
{inviteEmail && (
<p className="text-xs text-muted-foreground">
{t('invite_email_hint')}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">{t('password_label')}</Label>
<div className="relative">
<Input
ref={passwordInputRef}
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
placeholder={t('password_placeholder')}
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (passwordError && isStrongPassword(e.target.value)) {
setPasswordError(null)
}
}}
required
minLength={8}
disabled={isLoading}
aria-invalid={passwordError ? true : undefined}
aria-describedby="password-requirements"
className="h-11 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((visible) => !visible)}
className="absolute inset-y-0 right-0 flex items-center px-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={showPassword ? tAuth('hide_password') : tAuth('show_password')}
aria-pressed={showPassword}
>
{showPassword ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</button>
</div>
<ul
id="password-requirements"
className="grid grid-cols-2 gap-x-3 gap-y-1 pt-1"
>
{passwordChecks.map((check) => (
<li
key={check.key}
className={cn(
'flex items-center gap-2 text-xs transition-colors duration-150',
check.met ? 'text-foreground' : 'text-muted-foreground',
)}
>
<span
aria-hidden
className="flex h-3 w-3 items-center justify-center"
>
{check.met ? (
<Check className="h-3 w-3" />
) : (
<span className="h-1 w-1 rounded-full bg-current opacity-60" />
)}
</span>
{t(check.key)}
</li>
))}
</ul>
{passwordError && (
<p role="alert" className="text-xs text-destructive">
{passwordError}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm_password">{t('confirm_password_label')}</Label>
<Input
ref={confirmInputRef}
id="confirm_password"
name="confirm_password"
type="password"
autoComplete="new-password"
placeholder={t('confirm_password_placeholder')}
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value)
if (confirmError && e.target.value === password) {
setConfirmError(null)
}
}}
required
minLength={8}
disabled={isLoading}
aria-invalid={confirmError ? true : undefined}
aria-describedby={confirmError ? 'confirm-password-error' : undefined}
className="h-11"
/>
{confirmError && (
<p
id="confirm-password-error"
role="alert"
className="text-xs text-destructive"
>
{confirmError}
</p>
)}
</div>
<TurnstileChallenge
ref={turnstileRef}
action="accounted_signup"
onTokenChange={setCaptchaToken}
/>
<Button
type="submit"
className="w-full h-11"
disabled={isLoading || isTurnstileSubmissionBlocked(captchaToken)}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('creating')}
</>
) : (
t('create_account')
)}
</Button>
</form>
)}
</div>
)}
{!bankIdUser && chipCount > 0 && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center">
<span className="bg-background px-3 text-xs text-muted-foreground">
{tAuth('or_divider')}
</span>
</div>
</div>
<div className={chipCount === 2 ? 'grid grid-cols-2 gap-3' : 'grid grid-cols-1 gap-3'}>
{showBankIdChip && (
<Button
type="button"
variant="outline"
className="h-10 w-full gap-2"
onClick={() => switchMethod('bankid')}
>
<Image
src="/logos/bankid-seeklogo.svg"
alt=""
width={18}
height={18}
className="dark:invert"
/>
BankID
</Button>
)}
{googleAuthEnabled && (
<GoogleAuthButton
compact
next={nextPath}
onError={(message) => setFormError({ kind: 'oauth', message })}
/>
)}
{showEmailChip && (
<Button
type="button"
variant="outline"
className="h-10 w-full gap-2"
onClick={() => switchMethod('email')}
>
<Mail className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{tAuth('method_email_chip')}
</Button>
)}
</div>
</>
)}
</div>
<p className="mt-6 text-center text-[13px] text-muted-foreground">
{t('already_have_account')}{' '}
<Link
href={loginHref}
className="font-medium text-foreground underline underline-offset-2 hover:opacity-80 transition-opacity"
>
{t('sign_in')}
</Link>
</p>
<p className="mt-3 text-center text-xs text-muted-foreground/80 leading-relaxed">
{t('terms_prefix')}{' '}
{/* Same targets as the login page: platform terms on the marketing
site, in-app /privacy (host-relative for branded domains). */}
<a
href="https://accounted.se/terms"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
{t('terms_link')}
</a>{' '}
{t('terms_and')}{' '}
<a
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
{t('privacy_link')}
</a>
.
</p>
</div>
</div>
)
}
+984
View File
@@ -0,0 +1,984 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import dynamic from 'next/dynamic'
import Image from 'next/image'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Check, Loader2, Mail, ArrowLeft, ExternalLink, Eye, EyeOff } from 'lucide-react'
import { BrandWordmark } from '@/components/branding/BrandWordmark'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { isBankIdEnabled } from '@/lib/auth/bankid-flags'
import type { BankIdResult } from '@/components/auth/BankIdAuth'
import { useBranding } from '@/lib/branding/brand-context'
import { detectWebmailHint } from '@/lib/auth/webmail-search'
import {
consumeInviteCookie,
INVITE_PROBLEM_MESSAGE_KEYS,
} from '@/lib/auth/consume-invite-cookie'
import { AuthFormError } from '@/components/auth/AuthFormError'
import { OAuthButton } from '@/components/auth/OAuthButton'
import {
TurnstileChallenge,
type TurnstileChallengeHandle,
} from '@/components/auth/TurnstileChallenge'
import { classifyAuthError, type AuthErrorKind } from '@/lib/auth/classify-auth-error'
import {
captchaTokenOptions,
isTurnstileSubmissionBlocked,
} from '@/lib/auth/turnstile'
import { persistLoginMethodHint, type LoginMethod } from '@/lib/auth/login-method'
import { safeReturnTo } from '@/lib/auth/safe-return-to'
import { cn } from '@/lib/utils'
import type { GoTrueAuthSettings } from '@/lib/auth/gotrue-providers'
const BankIdAuth = dynamic(
() => import('@/components/auth/BankIdAuth').then((module) => module.BankIdAuth),
{ ssr: false },
)
export function RegisterClient({ authSettings }: { authSettings: GoTrueAuthSettings }) {
const { providers, passwordLoginEnabled, registrationEnabled } = authSettings
// `invite` and `next` are the only query parameters this page reads.
//
// `next` is the post-signup destination /login forwards when a visitor with
// no account arrives from the MCP OAuth consent page
// (/login?next=/api/mcp-oauth/authorize?…, issue #1814). It goes through
// safeReturnTo (lib/auth/safe-return-to.ts); a hand-rolled check on this
// value is an open redirect. Without one ('/'), nothing changes: the
// password path leaves through the confirmation mail and /auth/callback,
// and the BankID path lands a brand-new account on /select-company. With
// one, every path resumes it: BankID hard-navigates (the consent page is a
// route handler returning raw HTML), while the confirmation link and Google
// OAuth carry it to /auth/callback, which honours only the consent
// destination. A new account has no membership to spend a deep link on, so
// nothing else may ever be forwarded here.
const searchParams = useSearchParams()
const nextPath = safeReturnTo(searchParams.get('next'), '/')
const loginHref = nextPath === '/' ? '/login' : `/login?next=${encodeURIComponent(nextPath)}`
const inviteToken = searchParams.get('invite')
// When password login is not available, self-service registration is not
// possible. Redirect to login unless this is an invite-based signup.
const router = useRouter()
useEffect(() => {
if ((!passwordLoginEnabled || !registrationEnabled) && !inviteToken) {
router.replace('/login')
}
}, [inviteToken, router, passwordLoginEnabled, registrationEnabled])
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
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)
const [bankIdEmail, setBankIdEmail] = useState('')
// Signup failures render inline next to the form (see AuthFormError), never
// as a toast. Field-level problems attach to their field; everything else
// goes to the form-level alert above the form.
const [formError, setFormError] = useState<{ kind: AuthErrorKind | 'bankid' | 'oauth'; message: string } | null>(null)
const [passwordError, setPasswordError] = useState<string | null>(null)
const [confirmError, setConfirmError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
const confirmInputRef = useRef<HTMLInputElement>(null)
const emailInputRef = useRef<HTMLInputElement>(null)
const turnstileRef = useRef<TurnstileChallengeHandle>(null)
const { toast } = useToast()
const supabase = createClient()
const bankIdEnabled = isBankIdEnabled()
// Per-request brand merged over getBranding() defaults (WL-12): identical
// values on default hosts, brand values on branded hosts.
const branding = useBranding()
const t = useTranslations('register')
const tAuth = useTranslations('auth')
const tInvite = useTranslations('invite')
const errorLocale = useLocale() as ErrorLocale
// Which method owns the panel (mirrors the login page): BankID is the
// Swedish default for a fresh signup; the email form is one chip away.
const [method, setMethod] = useState<LoginMethod>(bankIdEnabled ? 'bankid' : 'email')
const prevMethodRef = useRef(method)
// Switching to the email form should land the caret in the first field,
// except when an invite pre-filled and locked it.
useEffect(() => {
if (prevMethodRef.current !== method) {
prevMethodRef.current = method
if (method === 'email' && !inviteEmail) emailInputRef.current?.focus()
}
}, [method, inviteEmail])
const switchMethod = (next: LoginMethod) => {
setFormError(null)
setMethod(next)
}
const showBankIdChip = method === 'email' && bankIdEnabled
const showEmailChip = method === 'bankid' && passwordLoginEnabled
const chipCount = (showBankIdChip ? 1 : 0) + (showEmailChip ? 1 : 0) + providers.length
// Accept a pending invite, if any, and report a non-definitive failure.
// Returns true when the caller should land the user in the app directly.
// The invite cookie survives anything that is not a settled outcome, so
// /onboarding and /select-company can retry acceptance server-side.
const acceptPendingInvite = async (): Promise<boolean> => {
const invite = await consumeInviteCookie()
if (invite.accepted) return true
if (invite.problem) {
const keys = INVITE_PROBLEM_MESSAGE_KEYS[invite.problem]
toast({
title: tInvite(keys.title),
description: tInvite(keys.body),
variant: 'destructive',
})
}
return false
}
// When arriving from an invite link, fetch the invite info to pre-fill
// and lock the email field so the user registers with the correct address.
// BOTH signup forms are pre-filled: the BankID form used to be left blank
// and editable, so an invitee who signed up with BankID typed their private
// address, POST /api/team/accept answered 403 on the email equality check,
// and they landed on /select-company with no membership. The token now
// survives that 403 (lib/auth/consume-invite-cookie.ts) so it is recoverable
// rather than terminal, but the signup should not walk into it at all.
useEffect(() => {
const inviteToken = searchParams.get('invite')
if (!inviteToken) return
fetch(`/api/team/accept?token=${encodeURIComponent(inviteToken)}`)
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (data?.data?.email) {
setInviteEmail(data.data.email)
setEmail(data.data.email)
setBankIdEmail(data.data.email)
}
})
.catch(() => {})
}, [searchParams])
const [bankIdUnavailable, setBankIdUnavailable] = useState(false)
const handleBankIdComplete = (result: BankIdResult) => {
if (result.error === 'service_unavailable') {
setBankIdUnavailable(true)
setMethod('email')
return
}
if (result.error) {
setFormError({ kind: 'bankid', message: t('bankid_failed_description') })
return
}
// BankID verified: show the email form. The session itself stays in the
// server's HttpOnly flow cookie, so there is nothing to hold on to here.
setFormError(null)
setBankIdUser({ givenName: result.givenName, surname: result.surname })
setBankIdFlowId(result.flowId ?? null)
}
const handleBankIdSignup = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setFormError(null)
const formData = new FormData(e.currentTarget)
const emailValue = (formData.get('bankid_email') as string) || bankIdEmail
if (!bankIdFlowId) {
setFormError({ kind: 'bankid', message: t('bankid_failed_description') })
return
}
setIsLoading(true)
try {
// Only the e-mail travels: the session and the fact that this is a
// signup are both pinned in the server's flow cookie.
const res = await fetch('/api/extensions/ext/tic/bankid/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-bankid-flow-id': bankIdFlowId,
},
body: JSON.stringify({ email: emailValue }),
})
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".
setFormError({ kind: 'email_exists', message: t('bankid_already_linked_description') })
} else if (json.error === 'account_exists') {
// Inline with a sign-in link instead of yanking the user to /login
// mid-read: they keep the context and choose when to leave.
setFormError({ kind: 'email_exists', message: t('account_exists_description') })
} else {
setFormError({
kind: 'unknown',
message: json.message || json.error || t('register_failed_default'),
})
}
return
}
// Exchange token hash for Supabase session
const { error } = await supabase.auth.verifyOtp({
token_hash: json.data.tokenHash,
type: json.data.type as 'magiclink',
})
if (error) {
console.error('[register] BankID verifyOtp failed', error.message)
setFormError({
kind: 'unknown',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
return
}
// Invited signup: accept the pending invite before routing to the
// picker, same as the login page's BankID path. Without this, an
// invitee who registers with BankID lands on /select-company with no
// membership and gets funneled into creating a company instead of
// joining the one they were invited to.
persistLoginMethodHint('bankid')
if (await acceptPendingInvite()) {
window.location.href = '/'
return
}
if (nextPath !== '/') {
// Resume the MCP consent flow: the account exists and the consent
// page accepts a user with no company yet.
window.location.assign(nextPath)
return
}
router.push('/select-company')
router.refresh()
} catch (error) {
console.error('[register] BankID signup error', error instanceof Error ? error.message : String(error))
setFormError({
kind: 'unknown',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
setIsLoading(false)
}
}
// The live checklist under the password field mirrors these rules; the
// aggregate check gates submission.
const passwordChecks = [
{ key: 'password_req_length', met: password.length >= 8 },
{ key: 'password_req_case', met: /[a-z]/.test(password) && /[A-Z]/.test(password) },
{ key: 'password_req_number', met: /[0-9]/.test(password) },
{ key: 'password_req_special', met: /[^a-zA-Z0-9]/.test(password) },
] as const
function isStrongPassword(pw: string): boolean {
return pw.length >= 8
&& /[a-z]/.test(pw)
&& /[A-Z]/.test(pw)
&& /[0-9]/.test(pw)
&& /[^a-zA-Z0-9]/.test(pw)
}
const handleRegister = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setFormError(null)
setPasswordError(null)
setConfirmError(null)
const formData = new FormData(e.currentTarget)
const emailValue = (formData.get('email') as string) || email
const passwordValue = (formData.get('password') as string) || password
const confirmValue = (formData.get('confirm_password') as string) || confirmPassword
// Client-side checks run before isLoading so the inputs are still enabled
// when focus moves to the offending field.
if (!isStrongPassword(passwordValue)) {
setPasswordError(t('password_error_requirements'))
passwordInputRef.current?.focus()
return
}
if (passwordValue !== confirmValue) {
setConfirmError(t('password_mismatch_description'))
confirmInputRef.current?.focus()
confirmInputRef.current?.select()
return
}
if (isTurnstileSubmissionBlocked(captchaToken)) {
setFormError({ kind: 'unknown', message: tAuth('turnstile_required') })
return
}
setIsLoading(true)
try {
// 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 (!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') {
// Server-side password policy rejection: same field, same message
// as the client-side check.
setPasswordError(t('password_error_requirements'))
} else {
const messageByKind: Partial<Record<AuthErrorKind, string>> = {
email_exists: t('account_exists_description'),
email_invalid: t('error_email_invalid'),
rate_limited: t('error_rate_limited'),
signup_disabled: t('error_signup_disabled'),
}
setFormError({
kind,
message:
messageByKind[kind] ??
getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
}
return
}
persistLoginMethodHint('email')
// If auto-confirmed (local dev), process invite immediately and redirect
if (json?.data?.status === 'session') {
const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/)
const inviteToken = cookieMatch?.[1]
if (inviteToken) {
try {
const res = await fetch('/api/team/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: inviteToken }),
})
if (res.ok) {
document.cookie = 'gnubok-invite-token=; path=/; max-age=0'
window.location.href = '/'
return
}
} catch (err) {
console.error('[register] invite acceptance failed:', err instanceof Error ? err.message : String(err))
}
}
// Auto-confirmed but no invite or invite failed: go to onboarding
// (invite cookie is preserved so the onboarding fallback can retry),
// or resume the MCP consent flow when that is where we came from.
window.location.href = nextPath
return
}
// Supabase obfuscates duplicate signups (to prevent user enumeration):
// 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
}
setEmail(emailValue)
setIsRegistered(true)
} catch (error) {
console.error('[register] unexpected exception', error instanceof Error ? error.message : String(error))
setFormError({
kind: 'unknown',
message: getErrorMessage(error, { context: 'auth', locale: errorLocale }),
})
} finally {
turnstileRef.current?.reset()
setIsLoading(false)
}
}
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">
<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('duplicate_title')}</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
{t('duplicate_body_prefix')}{' '}
<span className="font-medium text-foreground">{duplicateEmail}</span>.
</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('duplicate_hint')}
</p>
</div>
<div className="space-y-2">
{/*
Plain /login, no `email` parameter: app/(auth)/login/page.tsx
reads only `error`, `flow` and `next`, so the address was
travelling in the URL (browser history, Referer, every proxy
access log) and arriving nowhere. The address is already on
screen above, so nothing is lost by dropping it.
*/}
<Button className="w-full" asChild>
<Link href={loginHref}>
{t('sign_in')}
</Link>
</Button>
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={() => setDuplicateEmail(null)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('back')}
</Button>
</div>
</div>
</div>
)
}
if (isRegistered) {
const webmailHint = detectWebmailHint(email, branding.authEmailFrom)
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('confirm_email_title')}</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
{t.rich('confirm_email_body', {
email,
strong: (chunks) => <span className="font-medium text-foreground">{chunks}</span>,
})}
</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('confirm_email_hint')}
</p>
</div>
<div className="space-y-2">
{webmailHint && (
<Button className="w-full" asChild>
<a href={webmailHint.url} target="_blank" rel="noopener noreferrer">
{t(webmailHint.hasSearch ? 'open_webmail_search' : 'open_webmail_inbox', {
provider: webmailHint.name,
})}
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
)}
<Button variant="ghost" className="w-full text-muted-foreground" asChild>
<Link href="/login">
<ArrowLeft className="mr-2 h-4 w-4" />
{t('back_to_login')}
</Link>
</Button>
</div>
</div>
</div>
)
}
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">
<header className="text-center mb-8">
<h1 className="sr-only">{t('create_account')}</h1>
<BrandWordmark size="hero" />
</header>
<div className="rounded-xl border border-border bg-background p-6">
{bankIdUnavailable && !bankIdUser && (
<p className="mb-4 text-[13px] leading-5 text-muted-foreground">
{t('bankid_unavailable_body')}
</p>
)}
{formError && (
<div className="mb-4">
<AuthFormError
message={formError.message}
action={
formError.kind === 'email_exists' ? (
<Link
href={loginHref}
className="font-medium underline underline-offset-2"
>
{t('sign_in')}
</Link>
) : undefined
}
/>
</div>
)}
{bankIdUser ? (
<form onSubmit={handleBankIdSignup} className="space-y-4">
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-sm font-medium">
{bankIdUser.givenName} {bankIdUser.surname}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{t('bankid_verified')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="bankid_email">{t('email_label')}</Label>
<Input
id="bankid_email"
name="bankid_email"
type="email"
autoComplete="email"
placeholder={t('email_placeholder')}
value={bankIdEmail}
onChange={(e) => setBankIdEmail(e.target.value)}
required
disabled={isLoading || !!inviteEmail}
readOnly={!!inviteEmail}
className="h-11"
/>
<p className="text-xs text-muted-foreground">
{inviteEmail ? t('invite_email_hint') : t('bankid_email_hint')}
</p>
</div>
{/* Also disabled while Back's /cancel is in flight: submitting
then would race the cookie clear (recoverable, but pointless). */}
<Button type="submit" className="w-full h-11" disabled={isLoading || isCancelling}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('creating')}
</>
) : (
t('create_account')
)}
</Button>
<Button
type="button"
variant="ghost"
className="w-full text-muted-foreground"
disabled={isLoading || isCancelling}
onClick={async () => {
// AWAIT the cancel before remounting BankIdAuth. The flow
// cookie outlives this component and BankIdAuth probes for a
// live flow on mount, so clearing the form first would let it
// find the still-completed session. Its own isCancelling
// state, not isLoading: isLoading drives the submit button's
// "Skapar konto...", and pressing Back should not claim an
// account is being created.
setIsCancelling(true)
try {
const res = await fetch('/api/extensions/ext/tic/bankid/cancel', {
method: 'POST',
headers: bankIdFlowId
? { 'x-bankid-flow-id': bankIdFlowId }
: undefined,
})
if (!res.ok) throw new Error(`cancel failed: ${res.status}`)
// Flow cleared server-side; the remounted BankIdAuth will
// probe, find nothing, and show the start button.
setBankIdUser(null)
setBankIdFlowId(null)
} catch {
// The flow is still live, so resetting the form would just
// bounce the user back here. Say so instead of looping.
setFormError({ kind: 'bankid', message: t('bankid_cancel_failed') })
} finally {
setIsCancelling(false)
}
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('back')}
</Button>
</form>
) : (
<div key={method} className="animate-fade-in">
{method === 'bankid' && bankIdEnabled ? (
<BankIdAuth mode="signup" hero onComplete={handleBankIdComplete} />
) : !passwordLoginEnabled ? (
<p className="text-[13px] leading-5 text-muted-foreground">{t('password_signup_unavailable')}</p>
) : (
<form onSubmit={handleRegister} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">{t('email_label')}</Label>
<Input
ref={emailInputRef}
id="email"
name="email"
type="email"
autoComplete="email"
placeholder={t('email_placeholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading || !!inviteEmail}
readOnly={!!inviteEmail}
className="h-11"
/>
{inviteEmail && (
<p className="text-xs text-muted-foreground">
{t('invite_email_hint')}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">{t('password_label')}</Label>
<div className="relative">
<Input
ref={passwordInputRef}
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
placeholder={t('password_placeholder')}
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (passwordError && isStrongPassword(e.target.value)) {
setPasswordError(null)
}
}}
required
minLength={8}
disabled={isLoading}
aria-invalid={passwordError ? true : undefined}
aria-describedby="password-requirements"
className="h-11 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((visible) => !visible)}
className="absolute inset-y-0 right-0 flex items-center px-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={showPassword ? tAuth('hide_password') : tAuth('show_password')}
aria-pressed={showPassword}
>
{showPassword ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</button>
</div>
<ul
id="password-requirements"
className="grid grid-cols-2 gap-x-3 gap-y-1 pt-1"
>
{passwordChecks.map((check) => (
<li
key={check.key}
className={cn(
'flex items-center gap-2 text-xs transition-colors duration-150',
check.met ? 'text-foreground' : 'text-muted-foreground',
)}
>
<span
aria-hidden
className="flex h-3 w-3 items-center justify-center"
>
{check.met ? (
<Check className="h-3 w-3" />
) : (
<span className="h-1 w-1 rounded-full bg-current opacity-60" />
)}
</span>
{t(check.key)}
</li>
))}
</ul>
{passwordError && (
<p role="alert" className="text-xs text-destructive">
{passwordError}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm_password">{t('confirm_password_label')}</Label>
<Input
ref={confirmInputRef}
id="confirm_password"
name="confirm_password"
type="password"
autoComplete="new-password"
placeholder={t('confirm_password_placeholder')}
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value)
if (confirmError && e.target.value === password) {
setConfirmError(null)
}
}}
required
minLength={8}
disabled={isLoading}
aria-invalid={confirmError ? true : undefined}
aria-describedby={confirmError ? 'confirm-password-error' : undefined}
className="h-11"
/>
{confirmError && (
<p
id="confirm-password-error"
role="alert"
className="text-xs text-destructive"
>
{confirmError}
</p>
)}
</div>
<TurnstileChallenge
ref={turnstileRef}
action="accounted_signup"
onTokenChange={setCaptchaToken}
/>
<Button
type="submit"
className="w-full h-11"
disabled={isLoading || isTurnstileSubmissionBlocked(captchaToken)}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('creating')}
</>
) : (
t('create_account')
)}
</Button>
</form>
)}
</div>
)}
{!bankIdUser && chipCount > 0 && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center">
<span className="bg-background px-3 text-xs text-muted-foreground">
{tAuth('or_divider')}
</span>
</div>
</div>
<div className={chipCount === 2 ? 'grid grid-cols-2 gap-3' : 'grid grid-cols-1 gap-3'}>
{showBankIdChip && (
<Button
type="button"
variant="outline"
className="h-10 w-full gap-2"
onClick={() => switchMethod('bankid')}
>
<Image
src="/logos/bankid-seeklogo.svg"
alt=""
width={18}
height={18}
className="dark:invert"
/>
BankID
</Button>
)}
{providers.map((provider) => (
<OAuthButton
key={provider.id}
provider={provider}
compact
onError={(message) => setFormError({ kind: 'oauth', message })}
next={nextPath}
/>
))}
{showEmailChip && (
<Button
type="button"
variant="outline"
className="h-10 w-full gap-2"
onClick={() => switchMethod('email')}
>
<Mail className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{tAuth('method_email_chip')}
</Button>
)}
</div>
</>
)}
</div>
<p className="mt-6 text-center text-[13px] text-muted-foreground">
{t('already_have_account')}{' '}
<Link
href={loginHref}
className="font-medium text-foreground underline underline-offset-2 hover:opacity-80 transition-opacity"
>
{t('sign_in')}
</Link>
</p>
<p className="mt-3 text-center text-xs text-muted-foreground/80 leading-relaxed">
{t('terms_prefix')}{' '}
{/* Same targets as the login page: platform terms on the marketing
site, in-app /privacy (host-relative for branded domains). */}
<a
href="https://accounted.se/terms"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
{t('terms_link')}
</a>{' '}
{t('terms_and')}{' '}
<a
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground transition-colors"
>
{t('privacy_link')}
</a>
.
</p>
</div>
</div>
)
}
+1 -21
View File
@@ -16,6 +16,7 @@ import { OPUS_MODEL } from '@/lib/agent/composer/client'
import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch'
import type { AtomSelection } from '@/lib/agent/composer/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { withTimeout } from '@/lib/utils'
const BodySchema = z.object({
company_id: z.string().uuid().optional(),
@@ -294,24 +295,3 @@ export async function POST(request: Request) {
},
})
}
// Run a promise against a wall-clock budget. The underlying work continues to
// completion on the server when the budget elapses: we just stop waiting for
// it. For Anthropic calls that's fine: a slow Opus turn finishing later still
// warms its own cache.
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
promise.then(
(v) => {
clearTimeout(timer)
resolve(v)
},
(e) => {
clearTimeout(timer)
reject(e)
},
)
})
}
@@ -4,31 +4,39 @@ import { useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { Loader2, KeyRound } from 'lucide-react'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { GoogleMark } from '@/components/ui/provider-marks'
import { GitHubMark, GoogleMark, MicrosoftMark } from '@/components/ui/provider-marks'
import type { ResolvedProvider } from '@/lib/auth/gotrue-providers'
/**
* "Continue with Google" for the login and register pages.
*
* Kicks off the Supabase OAuth redirect; the round-trip lands in
* /auth/callback (PKCE code exchange), which owns MFA routing, invite
* acceptance and silent-team creation for OAuth sign-ins and sign-ups alike.
* The flow=oauth marker lets the callback tag failures so the login page
* shows Google-specific copy instead of the email-confirmation framing.
* Render the brand mark for a known provider, or a generic key icon for
* custom OIDC providers.
*/
export function GoogleAuthButton({
function ProviderMark({ provider }: { provider: ResolvedProvider }) {
if (provider.id === 'google') return <GoogleMark />
else if (provider.id === 'azure') return <MicrosoftMark />
else if (provider.id === 'github') return <GitHubMark />
else return <KeyRound className="h-4 w-4 text-muted-foreground" />
}
/**
* Generic OAuth login button. Works with any Supabase GoTrue provider.
*
* For known providers (Google, GitHub, etc.) the button shows the brand
* name; for custom OIDC providers it shows "Sign in with SSO" style text.
*
* Kicks off the Supabase OAuth redirect, with flow=oauth so
* /auth/callback can tag failures.
*/
export function OAuthButton({
provider,
onError,
compact = false,
next,
}: {
provider: ResolvedProvider
onError: (message: string) => void
/**
* Half-width alternative-method chip on the login panel: shows just the
* mark and "Google" (a brand name, never translated), with the full label
* kept as the accessible name.
*/
compact?: boolean
/**
* Post-auth destination, already passed through safeReturnTo by the caller.
@@ -51,7 +59,7 @@ export function GoogleAuthButton({
callback.searchParams.set('flow', 'oauth')
if (next && next !== '/') callback.searchParams.set('next', next)
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
provider: provider.id as Parameters<typeof supabase.auth.signInWithOAuth>[0]['provider'],
options: {
redirectTo: callback.toString(),
},
@@ -60,13 +68,14 @@ export function GoogleAuthButton({
onError(getErrorMessage(error, { context: 'auth', locale: errorLocale }))
setIsRedirecting(false)
}
// On success the browser navigates away; keep the spinner until then.
} catch (error) {
onError(getErrorMessage(error, { context: 'auth', locale: errorLocale }))
setIsRedirecting(false)
}
}
const label = tAuth('continue_with_provider', { provider: provider.label })
return (
<Button
type="button"
@@ -74,16 +83,16 @@ export function GoogleAuthButton({
className={compact ? 'h-10 w-full gap-2' : 'w-full h-11'}
onClick={handleClick}
disabled={isRedirecting}
aria-label={tAuth('continue_with_google')}
aria-label={label}
>
{isRedirecting ? (
<Loader2 className={compact ? 'h-4 w-4 animate-spin' : 'mr-2 h-4 w-4 animate-spin'} />
) : (
<span className={compact ? 'flex items-center' : 'mr-2 flex items-center'}>
<GoogleMark />
<ProviderMark provider={provider} />
</span>
)}
{compact ? 'Google' : tAuth('continue_with_google')}
{compact ? provider.label : label}
</Button>
)
}
+12
View File
@@ -46,3 +46,15 @@ export function MicrosoftMark({ className = 'h-4 w-4' }: { className?: string })
</svg>
)
}
/**
* GitHub's invertocat. Monochrome by GitHub's own brand rules (solid black on
* light, white on dark), so currentColor is the correct fill, not a tint.
*/
export function GitHubMark({ className = 'h-4 w-4' }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" aria-hidden className={className} fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z" />
</svg>
)
}
+18 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { swedishToday, formatCurrency } from '../utils'
import { swedishToday, formatCurrency, withTimeout } from '../utils'
describe('swedishToday', () => {
it('formats the date as ISO yyyy-MM-dd with a Swedish weekday', () => {
@@ -43,3 +43,20 @@ describe('formatCurrency', () => {
expect(formatCurrency(10, 'EUR')).toContain('€')
})
})
describe('withTimeout', () => {
it('resolves with the promise value when it settles in time', async () => {
const result = await withTimeout(Promise.resolve('ok'), 1000)
expect(result).toBe('ok')
})
it('rejects when the promise exceeds the deadline', async () => {
const slow = new Promise<string>((resolve) => setTimeout(() => resolve('late'), 200))
await expect(withTimeout(slow, 50)).rejects.toThrow('Timeout after 50ms')
})
it('rejects when the promise itself rejects', async () => {
const failing = Promise.reject(new Error('boom'))
await expect(withTimeout(failing, 1000)).rejects.toThrow('boom')
})
})
+267
View File
@@ -0,0 +1,267 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchAuthSettings, type GoTrueSettingsResponse } from '@/lib/auth/gotrue-providers'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn(),
}))
const mockListProviders = vi.fn()
vi.mocked(createServiceClientNoCookies).mockReturnValue({
auth: {
admin: {
customProviders: {
listProviders: mockListProviders,
},
},
},
} as never)
function fakeSettings(overrides: Partial<GoTrueSettingsResponse> = {}): GoTrueSettingsResponse {
return {
external: {},
disable_signup: false,
mailer_autoconfirm: true,
phone_autoconfirm: true,
sms_provider: 'twilio',
saml_enabled: false,
passkeys_enabled: false,
...overrides,
}
}
function mockFetch(body: GoTrueSettingsResponse, status = 200) {
return vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify(body), { status }),
)
}
describe('fetchAuthSettings', () => {
const savedUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const savedKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
const savedServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
afterEach(() => {
vi.restoreAllMocks()
if (savedUrl !== undefined) process.env.NEXT_PUBLIC_SUPABASE_URL = savedUrl
else delete process.env.NEXT_PUBLIC_SUPABASE_URL
if (savedKey !== undefined) process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = savedKey
else delete process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
if (savedServiceKey !== undefined) process.env.SUPABASE_SERVICE_ROLE_KEY = savedServiceKey
else delete process.env.SUPABASE_SERVICE_ROLE_KEY
mockListProviders.mockReset()
})
it('returns empty providers and defaults when env vars are missing', async () => {
delete process.env.NEXT_PUBLIC_SUPABASE_URL
delete process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
const spy = vi.spyOn(global, 'fetch')
const result = await fetchAuthSettings()
expect(result).toEqual({
providers: [],
passwordLoginEnabled: true,
registrationEnabled: true,
samlEnabled: false,
})
expect(spy).not.toHaveBeenCalled()
})
it('returns safe defaults on non-200 response', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
mockFetch(fakeSettings(), 500)
const result = await fetchAuthSettings()
expect(result).toEqual({
providers: [],
passwordLoginEnabled: true,
registrationEnabled: true,
samlEnabled: false,
})
})
it('returns safe defaults on fetch error', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('network'))
const result = await fetchAuthSettings()
expect(result).toEqual({
providers: [],
passwordLoginEnabled: true,
registrationEnabled: true,
samlEnabled: false,
})
})
it('calls GoTrue settings endpoint with apikey header', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
const spy = mockFetch(fakeSettings())
await fetchAuthSettings()
expect(spy).toHaveBeenCalledWith(
'https://project.supabase.co/auth/v1/settings',
expect.objectContaining({
headers: { apikey: 'anon-key-123' },
}),
)
})
it('returns passwordLoginEnabled=true when email is enabled', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
mockFetch(fakeSettings({ external: { email: true } }))
const result = await fetchAuthSettings()
expect(result.passwordLoginEnabled).toBe(true)
})
it('returns passwordLoginEnabled=false when email is disabled', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
mockFetch(fakeSettings({ external: { email: false } }))
const result = await fetchAuthSettings()
expect(result.passwordLoginEnabled).toBe(false)
})
it('returns registrationEnabled=true when disable_signup is false', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
mockFetch(fakeSettings({ disable_signup: false }))
const result = await fetchAuthSettings()
expect(result.registrationEnabled).toBe(true)
})
it('returns registrationEnabled=false when disable_signup is true', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
mockFetch(fakeSettings({ disable_signup: true }))
const result = await fetchAuthSettings()
expect(result.registrationEnabled).toBe(false)
})
it('resolves known providers with brand labels', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
mockFetch(fakeSettings({ external: { google: true, github: true, email: true } }))
const result = await fetchAuthSettings()
expect(result.providers).toEqual([
{ id: 'google', label: 'Google', isCustom: false },
{ id: 'github', label: 'GitHub', isCustom: false },
])
})
it('excludes unknown external providers not in the allowlist', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
mockFetch(fakeSettings({ external: { 'my-oidc': true } }))
const result = await fetchAuthSettings()
expect(result.providers).toEqual([])
})
it('excludes disabled providers', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
mockFetch(fakeSettings({ external: { google: false, github: true } }))
const result = await fetchAuthSettings()
expect(result.providers).toEqual([
{ id: 'github', label: 'GitHub', isCustom: false },
])
})
it('excludes the email provider from the provider list', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
mockFetch(fakeSettings({ external: { email: true, google: true } }))
const result = await fetchAuthSettings()
expect(result.providers).toHaveLength(1)
expect(result.providers[0].id).toBe('google')
})
it('excludes the phone provider from the provider list', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
mockFetch(fakeSettings({ external: { phone: true, google: true } }))
const result = await fetchAuthSettings()
expect(result.providers).toHaveLength(1)
expect(result.providers[0].id).toBe('google')
})
it('excludes non-provider entries like anonymous_users', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
mockFetch(fakeSettings({ external: { anonymous_users: true, google: true, email: true } }))
const result = await fetchAuthSettings()
expect(result.providers).toHaveLength(1)
expect(result.providers[0].id).toBe('google')
})
it('returns empty providers when no external providers are enabled', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
mockFetch(fakeSettings({ external: { email: true } }))
const result = await fetchAuthSettings()
expect(result.providers).toEqual([])
})
it('merges custom providers from the admin endpoint', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role-key'
mockFetch(fakeSettings({ external: { google: true } }))
mockListProviders.mockResolvedValue({
data: {
providers: [
{ identifier: 'custom:mycompany', name: 'My Company SSO', enabled: true },
{ identifier: 'custom:other', name: 'Other', enabled: false },
],
},
error: null,
})
const result = await fetchAuthSettings()
expect(result.providers).toEqual([
{ id: 'google', label: 'Google', isCustom: false },
{ id: 'custom:mycompany', label: 'My Company SSO', isCustom: true },
])
})
it('falls back to built-in providers when custom endpoint throws', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role-key'
mockFetch(fakeSettings({ external: { github: true } }))
mockListProviders.mockRejectedValue(new Error('network'))
const result = await fetchAuthSettings()
expect(result.providers).toEqual([{ id: 'github', label: 'GitHub', isCustom: false }])
})
it('skips custom providers when service_role key is missing', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
delete process.env.SUPABASE_SERVICE_ROLE_KEY
const spy = mockFetch(fakeSettings({ external: { google: true } }))
const result = await fetchAuthSettings()
expect(result.providers).toEqual([{ id: 'google', label: 'Google', isCustom: false }])
// Only one fetch call (settings), no admin call
expect(spy).toHaveBeenCalledTimes(1)
})
it('returns samlEnabled=true when SAML is enabled', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
mockFetch(fakeSettings({ saml_enabled: true }))
const result = await fetchAuthSettings()
expect(result.samlEnabled).toBe(true)
})
it('returns samlEnabled=false when SAML is disabled', async () => {
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://project.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-key-123'
mockFetch(fakeSettings({ saml_enabled: false }))
const result = await fetchAuthSettings()
expect(result.samlEnabled).toBe(false)
})
})
+1 -1
View File
@@ -67,7 +67,7 @@ describe('Turnstile rollout state', () => {
describe('Turnstile integration contract', () => {
it('protects every public Supabase Auth flow in scope', () => {
const login = readRepoFile('app/(auth)/login/login-client.tsx')
const register = readRepoFile('app/(auth)/register/page.tsx')
const register = readRepoFile('app/(auth)/register/register-client.tsx')
const sandbox = readRepoFile('app/sandbox/page.tsx')
expect(login).toMatch(
-15
View File
@@ -1,15 +0,0 @@
/**
* Google OAuth feature flag.
*
* Signing in with Google requires the Google provider to be configured in
* Supabase (GoTrue) with a Google Cloud OAuth client; the flag ships the UI
* dark until that is done:
* https://supabase.com/docs/guides/auth/social-login/auth-google
* Unlike BankID this is not hosted-only: self-hosted installations can
* configure their own Google OAuth client.
*/
import { flagEnabled } from '@/lib/env/public-flags'
export function isGoogleAuthEnabled(): boolean {
return flagEnabled(process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED)
}
+206
View File
@@ -0,0 +1,206 @@
/**
* Fetch available auth providers and capabilities from Supabase GoTrue.
*
* Calls two endpoints:
* 1. /auth/v1/settings (anon key) - returns built-in providers and signup config
* 2. auth.admin.customProviders.listProviders() (service_role key) - custom OIDC/OAuth providers
*
* Both are merged into a single provider list. If the service_role key is
* unavailable, only built-in providers are returned (custom providers are skipped).
*/
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { withTimeout } from '@/lib/utils'
export type ExternalProvider =
| 'apple'
| 'azure'
| 'bitbucket'
| 'discord'
| 'facebook'
| 'figma'
| 'fly'
| 'github'
| 'gitlab'
| 'google'
| 'kakao'
| 'keycloak'
| 'linkedin'
| 'linkedin_oidc'
| 'notion'
| 'slack'
| 'slack_oidc'
| 'snapchat'
| 'spotify'
| 'twitch'
| 'twitter'
| 'workos'
| 'zoom'
| (string & {})
export interface GoTrueSettingsResponse {
external: Record<string, boolean>
disable_signup: boolean
mailer_autoconfirm: boolean
phone_autoconfirm: boolean
sms_provider: string
saml_enabled: boolean
passkeys_enabled: boolean
}
/**
* Display metadata for known OAuth providers.
* Unknown providers (custom OIDC) get a generic SSO label.
*/
const PROVIDER_META: Record<
string,
{ label: string }
> = {
apple: { label: 'Apple' },
azure: { label: 'Microsoft' },
bitbucket: { label: 'Bitbucket' },
discord: { label: 'Discord' },
facebook: { label: 'Facebook' },
figma: { label: 'Figma' },
fly: { label: 'Fly' },
github: { label: 'GitHub' },
gitlab: { label: 'GitLab' },
google: { label: 'Google' },
kakao: { label: 'Kakao' },
keycloak: { label: 'Keycloak' },
linkedin: { label: 'LinkedIn' },
linkedin_oidc: { label: 'LinkedIn' },
notion: { label: 'Notion' },
slack: { label: 'Slack' },
slack_oidc: { label: 'Slack' },
snapchat: { label: 'Snapchat' },
spotify: { label: 'Spotify' },
twitch: { label: 'Twitch' },
twitter: { label: 'X / Twitter' },
workos: { label: 'WorkOS' },
zoom: { label: 'Zoom' },
}
export interface ResolvedProvider {
/** Provider id passed to supabase.auth.signInWithOAuth({ provider }) */
id: string
/** Human-readable display name */
label: string
/** True for custom OIDC providers not in the built-in list */
isCustom: boolean
}
export interface GoTrueAuthSettings {
/** Enabled OAuth/OIDC providers for button rendering */
providers: ResolvedProvider[]
/** Whether email+password login is available (email provider enabled) */
passwordLoginEnabled: boolean
/** Whether self-service registration is allowed (disable_signup = false) */
registrationEnabled: boolean
/** Whether SAML SSO is enabled */
samlEnabled: boolean
}
/**
* Allowlist of auth-js Provider identifiers that may appear as OAuth/OIDC
* buttons. GoTrue's /auth/v1/settings `external` map can include entries
* that are not login providers (e.g. `anonymous_users` in the sandbox
* project). Only entries in this set are forwarded to the UI.
*
* Custom OIDC providers (prefixed `custom:`) are merged separately via
* the admin endpoint and do not go through this filter.
*/
const ALLOWED_EXTERNAL_PROVIDERS = new Set<ExternalProvider>([
'apple',
'azure',
'bitbucket',
'discord',
'facebook',
'figma',
'fly',
'github',
'gitlab',
'google',
'kakao',
'keycloak',
'linkedin',
'linkedin_oidc',
'notion',
'slack',
'slack_oidc',
'snapchat',
'spotify',
'twitch',
'twitter',
'workos',
'zoom',
])
/**
* Fetch auth settings from GoTrue.
*
* Returns the list of enabled OAuth/OIDC providers for button rendering,
* plus whether email+password login and registration are available.
* Falls back to a safe default (no providers, password login enabled)
* on network errors so the login page still renders.
*/
export async function fetchAuthSettings(): Promise<GoTrueAuthSettings> {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
if (!supabaseUrl || !anonKey) {
return { providers: [], passwordLoginEnabled: true, registrationEnabled: true, samlEnabled: false }
}
try {
const res = await fetch(`${supabaseUrl}/auth/v1/settings`, {
headers: { apikey: anonKey },
next: { revalidate: 60 }, // cache for 1 minute
signal: AbortSignal.timeout(3000),
})
if (!res.ok) {
return { providers: [], passwordLoginEnabled: true, registrationEnabled: true, samlEnabled: false }
}
const data: GoTrueSettingsResponse = await res.json()
const providers = Object.entries(data.external)
.filter(([name, enabled]) => enabled && ALLOWED_EXTERNAL_PROVIDERS.has(name as ExternalProvider))
.map(([name]) => ({
id: name,
label: PROVIDER_META[name]?.label ?? name,
isCustom: false,
}))
if (process.env.SUPABASE_SERVICE_ROLE_KEY) {
try {
const serviceClient = createServiceClientNoCookies()
const { data: customData } = await withTimeout(
serviceClient.auth.admin.customProviders.listProviders(),
3000,
)
for (const cp of customData?.providers ?? []) {
if (cp.enabled && cp.identifier) {
providers.push({
id: cp.identifier,
label: PROVIDER_META[cp.identifier]?.label ?? cp.name ?? cp.identifier,
isCustom: !(cp.identifier in PROVIDER_META),
})
}
}
} catch {
// Custom providers are best-effort; don't break login if the admin endpoint is unreachable or slow.
}
}
return {
providers,
passwordLoginEnabled: data.external.email === true,
registrationEnabled: !data.disable_signup,
samlEnabled: data.saml_enabled,
}
} catch {
return { providers: [], passwordLoginEnabled: true, registrationEnabled: true, samlEnabled: false }
}
}
+20
View File
@@ -198,3 +198,23 @@ export function generateInvoiceNumber(): string {
export function isValidExchangeRate(rate: number | null | undefined): rate is number {
return rate != null && rate > 0 && rate < 100000
}
// Run a promise against a wall-clock budget. The underlying work continues to
// completion on the server when the budget elapses: we just stop waiting for
// it. For Anthropic calls that's fine: a slow Opus turn finishing later still
// warms its own cache.
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
promise.then(
(v) => {
clearTimeout(timer)
resolve(v)
},
(e) => {
clearTimeout(timer)
reject(e)
},
)
})
}
+7 -4
View File
@@ -302,9 +302,10 @@
"login_error_rate_limited": "Too many sign-in attempts. Wait a moment and try again.",
"login_error_user_banned": "This account has been suspended. Contact support if you think this is a mistake.",
"login_error_reset_link": "Reset your password",
"continue_with_google": "Continue with Google",
"callback_error_title_oauth": "Google sign-in didn't work",
"callback_error_body_oauth": "The Google sign-in could not be completed. Try again, or sign in with email and password.",
"continue_with_provider": "Continue with {provider}",
"saml_no_domain": "SAML is not configured. Contact your administrator.",
"callback_error_title_oauth": "External sign-in didn't work",
"callback_error_body_oauth": "The external sign-in could not be completed. Try again, or sign in with another method.",
"session_idle": "You were inactive. Sign in again.",
"session_absolute": "Your session expired for security reasons.",
"use_password_instead": "Sign in with email instead",
@@ -342,6 +343,7 @@
"bankid_resume_continue": "Continue",
"bankid_resume_hint": "Complete the BankID identification...",
"bankid_resume_restart": "Start over",
"no_login_methods": "No sign-in methods are available on this installation. Contact your administrator.",
"terms_prefix": "By signing in you agree to our",
"terms_link": "terms",
"terms_and": "and",
@@ -1242,7 +1244,8 @@
"confirm_email_hint": "Click the link in the email to activate your account. The link is valid for 24 hours.",
"open_webmail_search": "Find the email in {provider}",
"open_webmail_inbox": "Open {provider}",
"back_to_login": "Back to sign in"
"back_to_login": "Back to sign in",
"password_signup_unavailable": "Password sign-up is not available on this installation."
},
"mfa": {
"enroll_title": "Set up two-step verification",
+7 -4
View File
@@ -302,9 +302,10 @@
"login_error_rate_limited": "För många inloggningsförsök. Vänta en stund och försök igen.",
"login_error_user_banned": "Kontot är avstängt. Kontakta supporten om du tror att det är fel.",
"login_error_reset_link": "Återställ lösenordet",
"continue_with_google": "Fortsätt med Google",
"callback_error_title_oauth": "Google-inloggningen fungerade inte",
"callback_error_body_oauth": "Inloggningen med Google kunde inte slutföras. Försök igen, eller logga in med e-post och lösenord.",
"continue_with_provider": "Fortsätt med {provider}",
"saml_no_domain": "SAML är inte konfigurerat. Kontakta din administratör.",
"callback_error_title_oauth": "Extern inloggning misslyckades",
"callback_error_body_oauth": "Inloggningen med extern tjänst kunde inte slutföras. Försök igen, eller logga in med en annan metod.",
"session_idle": "Du har varit inaktiv. Logga in igen.",
"session_absolute": "Sessionen har upphört av säkerhetsskäl.",
"use_password_instead": "Logga in med e-post i stället",
@@ -342,6 +343,7 @@
"bankid_resume_continue": "Fortsätt",
"bankid_resume_hint": "Slutför BankID-identifieringen...",
"bankid_resume_restart": "Starta om",
"no_login_methods": "Inga inloggningsmetoder är tillgängliga på den här installationen. Kontakta din administratör.",
"terms_prefix": "Genom att logga in godkänner du våra",
"terms_link": "villkor",
"terms_and": "och",
@@ -1242,7 +1244,8 @@
"confirm_email_hint": "Klicka på länken i e-posten för att aktivera ditt konto. Länken är giltig i 24 timmar.",
"open_webmail_search": "Hitta e-posten i {provider}",
"open_webmail_inbox": "Öppna {provider}",
"back_to_login": "Tillbaka till inloggning"
"back_to_login": "Tillbaka till inloggning",
"password_signup_unavailable": "Lösenordsregistrering är inte tillgänglig på den här installationen."
},
"mfa": {
"enroll_title": "Aktivera tvåstegsverifiering",