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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
dc07ca8872
commit
6ac9679fb5
+6
-6
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')}{' '}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user