fix(auth): show confirmation-specific copy when a signup link fails (#1027)

A failed email-verification link redirected to /login?error=auth_error with no
flow context, so the login page framed every callback failure as a broken
password-reset link and pushed new users into a reset form for an account that
was never confirmed. The callback now forwards a coarse flow hint (recovery vs
signup); the login page renders confirmation copy without the reset CTA for the
signup case. The new copy names the likely cause (link opened in a different
browser than signup, or a one-time token consumed by a mail scanner) instead of
only "expired or already used".

Silent-team creation is also wrapped in try/catch so a transient insert failure
cannot turn an otherwise-successful first-time confirmation into a 500.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-16 11:20:18 +02:00
committed by GitHub
parent 072aedeaf9
commit d704714eef
5 changed files with 86 additions and 35 deletions
@@ -57,7 +57,7 @@ describe('GET /auth/callback: recovery flow', () => {
expect(exchangeCodeForSession).toHaveBeenCalledWith('xyz')
})
it('redirects to /login?error=auth_error when the recovery OTP is expired or already consumed', async () => {
it('tags a failed recovery link with flow=recovery so the login page shows reset copy', async () => {
verifyOtp.mockResolvedValue({ error: { message: 'Token has expired or is invalid' } })
const request = new NextRequest(
@@ -66,6 +66,22 @@ describe('GET /auth/callback: recovery flow', () => {
const response = await GET(request)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe('http://localhost:3000/login?error=auth_error')
expect(response.headers.get('location')).toBe(
'http://localhost:3000/login?error=auth_error&flow=recovery'
)
})
it('tags a failed signup confirmation (PKCE code, no type/next) with flow=signup', async () => {
exchangeCodeForSession.mockResolvedValue({
error: { message: 'code verifier missing' },
})
const request = new NextRequest('http://localhost:3000/auth/callback?code=xyz')
const response = await GET(request)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'http://localhost:3000/login?error=auth_error&flow=signup'
)
})
})
+36 -19
View File
@@ -164,24 +164,31 @@ export async function GET(request: NextRequest) {
.maybeSingle()
if (!teamMembership) {
// Create team via service client (RPC requires auth.uid() which isn't available here)
const serviceClient = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ cookies: { getAll: () => [], setAll: () => {} } }
)
// Create team via service client (RPC requires auth.uid() which isn't available here).
// Non-fatal: a failure here must not turn a successfully confirmed session into a
// 500 that reads as "signup verification failed". The dashboard / onboarding path
// recreates the silent team when it is missing, so log and continue.
try {
const serviceClient = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ cookies: { getAll: () => [], setAll: () => {} } }
)
const teamId = crypto.randomUUID()
await serviceClient.from('teams').insert({
id: teamId,
name: 'Personal',
created_by: user.id,
})
await serviceClient.from('team_members').insert({
team_id: teamId,
user_id: user.id,
role: 'owner',
})
const teamId = crypto.randomUUID()
await serviceClient.from('teams').insert({
id: teamId,
name: 'Personal',
created_by: user.id,
})
await serviceClient.from('team_members').insert({
team_id: teamId,
user_id: user.id,
role: 'owner',
})
} catch (err) {
console.error('[auth/callback] silent team creation failed:', err)
}
}
// Always redirect to dashboard: it handles zero-company and incomplete states
@@ -198,6 +205,16 @@ export async function GET(request: NextRequest) {
return response
}
// Authentication failed: redirect to login with error
return NextResponse.redirect(new URL('/login?error=auth_error', origin))
// Authentication failed: redirect to login with error. Forward a coarse
// flow hint so the login page can show the right copy: a failed signup
// 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; everything
// else defaults to the signup/confirmation framing.
const failedFlow =
type === 'recovery' || next === '/reset-password' ? 'recovery' : 'signup'
const loginUrl = new URL('/login', origin)
loginUrl.searchParams.set('error', 'auth_error')
loginUrl.searchParams.set('flow', failedFlow)
return NextResponse.redirect(loginUrl)
}
+28 -14
View File
@@ -43,6 +43,7 @@ function LoginPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const callbackError = searchParams.get('error')
const callbackFlow = searchParams.get('flow')
const supabase = createClient()
const bankIdEnabled = isBankIdEnabled()
const tAuth = useTranslations('auth')
@@ -385,20 +386,33 @@ function LoginPageContent() {
<div className="rounded-lg border bg-card p-6">
{callbackError === 'auth_error' && (
<div className="mb-5 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<p className="text-sm font-medium text-destructive">
{tAuth('callback_error_title')}
</p>
<p className="mt-1 text-sm text-destructive/90">
{tAuth('callback_error_body')}{' '}
<button
type="button"
onClick={() => setShowResetPassword(true)}
className="font-medium underline underline-offset-2"
>
{tAuth('request_new_reset_link')}
</button>
.
</p>
{callbackFlow === 'recovery' ? (
<>
<p className="text-sm font-medium text-destructive">
{tAuth('callback_error_title')}
</p>
<p className="mt-1 text-sm text-destructive/90">
{tAuth('callback_error_body')}{' '}
<button
type="button"
onClick={() => setShowResetPassword(true)}
className="font-medium underline underline-offset-2"
>
{tAuth('request_new_reset_link')}
</button>
.
</p>
</>
) : (
<>
<p className="text-sm font-medium text-destructive">
{tAuth('callback_error_title_signup')}
</p>
<p className="mt-1 text-sm text-destructive/90">
{tAuth('callback_error_body_signup')}
</p>
</>
)}
</div>
)}
{bankIdEnabled && (
+2
View File
@@ -276,6 +276,8 @@
"callback_error_title": "The reset link did not work",
"callback_error_body": "The link has expired or has already been used.",
"request_new_reset_link": "Request a new reset link",
"callback_error_title_signup": "The confirmation link did not work",
"callback_error_body_signup": "The link may have expired, already been used, or been opened in a different browser than the one you signed up in. If you have already confirmed your email, sign in below; otherwise you can register again.",
"bankid_no_account_greeting": "Hi {name}!",
"bankid_no_account_body": "We did not find an account linked to your BankID. Sign in with email below and then link BankID in settings.",
"bankid_no_account_create": "Or create a new account",
+2
View File
@@ -276,6 +276,8 @@
"callback_error_title": "Återställningslänken fungerade inte",
"callback_error_body": "Länken har gått ut eller använts redan.",
"request_new_reset_link": "Begär en ny återställningslänk",
"callback_error_title_signup": "Bekräftelselänken fungerade inte",
"callback_error_body_signup": "Länken kan ha upphört, redan använts, eller öppnats i en annan webbläsare än den du registrerade dig i. Har du redan bekräftat din e-post loggar du in nedan, annars kan du registrera dig igen.",
"bankid_no_account_greeting": "Hej {name}!",
"bankid_no_account_body": "Vi hittade inget konto kopplat till ditt BankID. Logga in med e-post nedan och koppla sedan BankID i inställningar.",
"bankid_no_account_create": "Eller skapa ett nytt konto",