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:
@@ -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)
|
||||
}
|
||||
|
||||
+28
-14
@@ -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 && (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user