diff --git a/.env.example b/.env.example index adf0f205..feaf106c 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/DECISIONS.md b/DECISIONS.md index 06c07572..a1f7024d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1378,3 +1378,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(auth)/auth/callback/route.ts b/app/(auth)/auth/callback/route.ts index e2348fa1..c127b252 100644 --- a/app/(auth)/auth/callback/route.ts +++ b/app/(auth)/auth/callback/route.ts @@ -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' diff --git a/app/(auth)/login/login-client.tsx b/app/(auth)/login/login-client.tsx index 57b86013..d139c086 100644 --- a/app/(auth)/login/login-client.tsx +++ b/app/(auth)/login/login-client.tsx @@ -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 (
@@ -604,14 +650,16 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu {tAuth('bankid_no_account_greeting', { name: bankIdNoAccount.givenName ?? '' })}

{tAuth('bankid_no_account_body')}

-

- - {tAuth('bankid_no_account_create')} - -

+ {passwordLoginEnabled && registrationEnabled && ( +

+ + {tAuth('bankid_no_account_create')} + +

+ )}
)} {bankIdUnavailable && ( @@ -622,9 +670,9 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu )}
- {method === 'bankid' ? ( + {method === 'bankid' && bankIdEnabled ? ( - ) : ( + ) : method === 'email' && passwordLoginEnabled ? (
@@ -727,10 +775,58 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu )} + ) : ( + providers.length > 0 ? ( +
+ {providers.map((provider) => ( + setFormError({ kind: 'oauth', message })} + /> + ))} + {samlAvailable && !hasPrimaryMethod && ( + + )} +
+ ) : samlAvailable ? ( + + ) : ( +

+ {tAuth('no_login_methods')} +

+ ) )}
- {chipCount > 0 && ( + {hasPrimaryMethod && hasSecondaryMethods && ( <>
@@ -760,13 +856,15 @@ export function LoginClient({ initialMethod }: { initialMethod: LoginMethod | nu BankID )} - {googleAuthEnabled && ( - ( + setFormError({ kind: 'oauth', message })} /> - )} + ))} {showEmailChip && ( + )}
)}
-

- {tAuth('login_new_here')}{' '} - - {tAuth('no_account')} - -

+ {passwordLoginEnabled && registrationEnabled && ( +

+ {tAuth('login_new_here')}{' '} + + {tAuth('no_account')} + +

+ )}

{tAuth('terms_prefix')}{' '} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 81acc17b..6dce1f3e 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -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 ( }> - + ) } diff --git a/app/(auth)/register/__tests__/invite-email-prefill.test.ts b/app/(auth)/register/__tests__/invite-email-prefill.test.ts index b6de12d1..ff6cf6c4 100644 --- a/app/(auth)/register/__tests__/invite-email-prefill.test.ts +++ b/app/(auth)/register/__tests__/invite-email-prefill.test.ts @@ -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') diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 775f4291..153d53f2 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -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 ( }> - + ) } - -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(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(null) - const [bankIdUser, setBankIdUser] = useState<{ givenName?: string; surname?: string } | null>(null) - const [bankIdFlowId, setBankIdFlowId] = useState(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(null) - const [confirmError, setConfirmError] = useState(null) - const [showPassword, setShowPassword] = useState(false) - const [captchaToken, setCaptchaToken] = useState(null) - const passwordInputRef = useRef(null) - const confirmInputRef = useRef(null) - const emailInputRef = useRef(null) - const turnstileRef = useRef(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(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 => { - 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) => { - 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) => { - 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> = { - 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 ( -

-
-
-
- -
-
- -
-

- {t('invite_only_title', { appName: branding.appName })} -

-

- {t('invite_only_body', { appName: branding.appName })} -

-
- -
-

- {t('invite_only_hint')} -

-
- -
- - -
-
-
- ) - } - - if (duplicateEmail) { - return ( -
-
-
-
- -
-
- -
-

{t('duplicate_title')}

-

- {t('duplicate_body_prefix')}{' '} - {duplicateEmail}. -

-
- -
-

- {t('duplicate_hint')} -

-
- -
- {/* - 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. - */} - - -
-
-
- ) - } - - if (isRegistered) { - const webmailHint = detectWebmailHint(email, branding.authEmailFrom) - - return ( -
-
-
-
- -
-
- -
-

{t('confirm_email_title')}

-

- {t.rich('confirm_email_body', { - email, - strong: (chunks) => {chunks}, - })} -

-
- -
-

- {t('confirm_email_hint')} -

-
- - -
-
- ) - } - - return ( -
-
-
-

{t('create_account')}

- -
- -
- {bankIdUnavailable && !bankIdUser && ( -

- {t('bankid_unavailable_body')} -

- )} - - {formError && ( -
- - {t('sign_in')} - - ) : undefined - } - /> -
- )} - - {bankIdUser ? ( -
-
-

- {bankIdUser.givenName} {bankIdUser.surname} -

-

- {t('bankid_verified')} -

-
-
- - setBankIdEmail(e.target.value)} - required - disabled={isLoading || !!inviteEmail} - readOnly={!!inviteEmail} - className="h-11" - /> -

- {inviteEmail ? t('invite_email_hint') : t('bankid_email_hint')} -

-
- {/* Also disabled while Back's /cancel is in flight: submitting - then would race the cookie clear (recoverable, but pointless). */} - - -
- ) : ( -
- {method === 'bankid' && bankIdEnabled ? ( - - ) : ( -
-
- - setEmail(e.target.value)} - required - disabled={isLoading || !!inviteEmail} - readOnly={!!inviteEmail} - className="h-11" - /> - {inviteEmail && ( -

- {t('invite_email_hint')} -

- )} -
-
- -
- { - 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" - /> - -
-
    - {passwordChecks.map((check) => ( -
  • - - {check.met ? ( - - ) : ( - - )} - - {t(check.key)} -
  • - ))} -
- {passwordError && ( -

- {passwordError} -

- )} -
-
- - { - 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 && ( - - )} -
- - - - )} -
- )} - - {!bankIdUser && chipCount > 0 && ( - <> -
-
-
-
-
- - {tAuth('or_divider')} - -
-
-
- {showBankIdChip && ( - - )} - {googleAuthEnabled && ( - setFormError({ kind: 'oauth', message })} - /> - )} - {showEmailChip && ( - - )} -
- - )} -
- -

- {t('already_have_account')}{' '} - - {t('sign_in')} - -

- -

- {t('terms_prefix')}{' '} - {/* Same targets as the login page: platform terms on the marketing - site, in-app /privacy (host-relative for branded domains). */} - - {t('terms_link')} - {' '} - {t('terms_and')}{' '} - - {t('privacy_link')} - - . -

-
-
- ) -} diff --git a/app/(auth)/register/register-client.tsx b/app/(auth)/register/register-client.tsx new file mode 100644 index 00000000..3dcbe25c --- /dev/null +++ b/app/(auth)/register/register-client.tsx @@ -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(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(null) + const [bankIdUser, setBankIdUser] = useState<{ givenName?: string; surname?: string } | null>(null) + const [bankIdFlowId, setBankIdFlowId] = useState(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(null) + const [confirmError, setConfirmError] = useState(null) + const [showPassword, setShowPassword] = useState(false) + const [captchaToken, setCaptchaToken] = useState(null) + const passwordInputRef = useRef(null) + const confirmInputRef = useRef(null) + const emailInputRef = useRef(null) + const turnstileRef = useRef(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(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 => { + 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) => { + 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) => { + 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> = { + 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 ( +
+
+
+
+ +
+
+ +
+

+ {t('invite_only_title', { appName: branding.appName })} +

+

+ {t('invite_only_body', { appName: branding.appName })} +

+
+ +
+

+ {t('invite_only_hint')} +

+
+ +
+ + +
+
+
+ ) + } + + if (duplicateEmail) { + return ( +
+
+
+
+ +
+
+ +
+

{t('duplicate_title')}

+

+ {t('duplicate_body_prefix')}{' '} + {duplicateEmail}. +

+
+ +
+

+ {t('duplicate_hint')} +

+
+ +
+ {/* + 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. + */} + + +
+
+
+ ) + } + + if (isRegistered) { + const webmailHint = detectWebmailHint(email, branding.authEmailFrom) + + return ( +
+
+
+
+ +
+
+ +
+

{t('confirm_email_title')}

+

+ {t.rich('confirm_email_body', { + email, + strong: (chunks) => {chunks}, + })} +

+
+ +
+

+ {t('confirm_email_hint')} +

+
+ + +
+
+ ) + } + + return ( +
+
+
+

{t('create_account')}

+ +
+ +
+ {bankIdUnavailable && !bankIdUser && ( +

+ {t('bankid_unavailable_body')} +

+ )} + + {formError && ( +
+ + {t('sign_in')} + + ) : undefined + } + /> +
+ )} + + {bankIdUser ? ( +
+
+

+ {bankIdUser.givenName} {bankIdUser.surname} +

+

+ {t('bankid_verified')} +

+
+
+ + setBankIdEmail(e.target.value)} + required + disabled={isLoading || !!inviteEmail} + readOnly={!!inviteEmail} + className="h-11" + /> +

+ {inviteEmail ? t('invite_email_hint') : t('bankid_email_hint')} +

+
+ {/* Also disabled while Back's /cancel is in flight: submitting + then would race the cookie clear (recoverable, but pointless). */} + + +
+ ) : ( +
+ {method === 'bankid' && bankIdEnabled ? ( + + ) : !passwordLoginEnabled ? ( +

{t('password_signup_unavailable')}

+ ) : ( +
+
+ + setEmail(e.target.value)} + required + disabled={isLoading || !!inviteEmail} + readOnly={!!inviteEmail} + className="h-11" + /> + {inviteEmail && ( +

+ {t('invite_email_hint')} +

+ )} +
+
+ +
+ { + 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" + /> + +
+
    + {passwordChecks.map((check) => ( +
  • + + {check.met ? ( + + ) : ( + + )} + + {t(check.key)} +
  • + ))} +
+ {passwordError && ( +

+ {passwordError} +

+ )} +
+
+ + { + 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 && ( + + )} +
+ + + + )} +
+ )} + + {!bankIdUser && chipCount > 0 && ( + <> +
+
+
+
+
+ + {tAuth('or_divider')} + +
+
+
+ {showBankIdChip && ( + + )} + {providers.map((provider) => ( + setFormError({ kind: 'oauth', message })} + next={nextPath} + /> + ))} + {showEmailChip && ( + + )} +
+ + )} +
+ +

+ {t('already_have_account')}{' '} + + {t('sign_in')} + +

+ +

+ {t('terms_prefix')}{' '} + {/* Same targets as the login page: platform terms on the marketing + site, in-app /privacy (host-relative for branded domains). */} + + {t('terms_link')} + {' '} + {t('terms_and')}{' '} + + {t('privacy_link')} + + . +

+
+
+ ) +} diff --git a/app/api/agent/onboarding/stream/route.ts b/app/api/agent/onboarding/stream/route.ts index b74d04b3..e3739cfd 100644 --- a/app/api/agent/onboarding/stream/route.ts +++ b/app/api/agent/onboarding/stream/route.ts @@ -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(promise: Promise, ms: number): Promise { - return new Promise((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) - }, - ) - }) -} - diff --git a/components/auth/GoogleAuthButton.tsx b/components/auth/OAuthButton.tsx similarity index 62% rename from components/auth/GoogleAuthButton.tsx rename to components/auth/OAuthButton.tsx index 089e9f95..e13b765e 100644 --- a/components/auth/GoogleAuthButton.tsx +++ b/components/auth/OAuthButton.tsx @@ -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 + else if (provider.id === 'azure') return + else if (provider.id === 'github') return + else return +} + +/** + * 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[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 ( ) } diff --git a/components/ui/provider-marks.tsx b/components/ui/provider-marks.tsx index 01b6afe7..e9986f37 100644 --- a/components/ui/provider-marks.tsx +++ b/components/ui/provider-marks.tsx @@ -46,3 +46,15 @@ export function MicrosoftMark({ className = 'h-4 w-4' }: { className?: string }) ) } + +/** + * 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 ( + + + + ) +} diff --git a/lib/__tests__/utils.test.ts b/lib/__tests__/utils.test.ts index 452b2baa..2ef88e6a 100644 --- a/lib/__tests__/utils.test.ts +++ b/lib/__tests__/utils.test.ts @@ -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((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') + }) +}) diff --git a/lib/auth/__tests__/gotrue-providers.test.ts b/lib/auth/__tests__/gotrue-providers.test.ts new file mode 100644 index 00000000..2b2b3e9a --- /dev/null +++ b/lib/auth/__tests__/gotrue-providers.test.ts @@ -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 { + 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) + }) +}) diff --git a/lib/auth/__tests__/turnstile.test.ts b/lib/auth/__tests__/turnstile.test.ts index 4edf60d6..d5493772 100644 --- a/lib/auth/__tests__/turnstile.test.ts +++ b/lib/auth/__tests__/turnstile.test.ts @@ -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( diff --git a/lib/auth/google-oauth.ts b/lib/auth/google-oauth.ts deleted file mode 100644 index 15e6a7be..00000000 --- a/lib/auth/google-oauth.ts +++ /dev/null @@ -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) -} diff --git a/lib/auth/gotrue-providers.ts b/lib/auth/gotrue-providers.ts new file mode 100644 index 00000000..0b5ec3fb --- /dev/null +++ b/lib/auth/gotrue-providers.ts @@ -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 + 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([ + '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 { + 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 } + } +} diff --git a/lib/utils.ts b/lib/utils.ts index a55cd7aa..68410157 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -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(promise: Promise, ms: number): Promise { + return new Promise((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) + }, + ) + }) +} diff --git a/messages/en.json b/messages/en.json index 5c7f7ec1..a09bb1e6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index ba4623b4..0b803a94 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",