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:
co-authored by
Claude Opus 4.8
parent
072aedeaf9
commit
d704714eef
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user