Files
accounted/app/(auth)/auth/callback/route.ts
T
Mattsson bf5a8d9195 Fix/multiple company (#203)
* Refactor onboarding and dashboard logic; add silent team creation for users

- Removed unnecessary useCompany context in DashboardContent and SettingsSidebar components.
- Simplified onboarding setup logic to allow direct access to the dashboard for users without companies.
- Introduced WelcomeOnboarding component to handle user onboarding steps.
- Added migration to create silent teams for all users at signup, backfilling existing users without teams, and cleaning up incomplete companies.

* fix: update greeting logic and improve email handling in TIC extension

* Redirect to onboarding for users without companies and update onboarding flow

* Build issue fix

* Enhance onboarding experience by adding existing companies check
2026-04-09 11:54:12 +02:00

185 lines
6.7 KiB
TypeScript

import { createServerClient } from '@supabase/ssr'
import { type NextRequest, NextResponse } from 'next/server'
import { hashInviteToken } from '@/lib/auth/invite-tokens'
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const token_hash = searchParams.get('token_hash')
const type = searchParams.get('type')
const next = searchParams.get('next') ?? '/'
// Collect cookies that Supabase sets during auth so we can
// explicitly forward them on the redirect response.
const pendingCookies: { name: string; value: string; options: Record<string, unknown> }[] = []
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
pendingCookies.length = 0
cookiesToSet.forEach((cookie) => {
// Mirror the cookie into request.cookies so subsequent getAll()
// calls within this request lifecycle return the updated values
// (matches the pattern used in middleware.ts).
request.cookies.set(cookie.name, cookie.value)
pendingCookies.push(cookie)
})
},
},
}
)
let authenticated = false
// Handle PKCE flow (code exchange)
if (code) {
const { error } = await supabase.auth.exchangeCodeForSession(code)
authenticated = !error
}
// Handle token hash flow (email verification / magic link)
else if (token_hash && type) {
const { error } = await supabase.auth.verifyOtp({
token_hash,
type: type as 'signup' | 'invite' | 'magiclink' | 'recovery' | 'email_change' | 'email',
})
authenticated = !error
}
if (authenticated) {
let redirectPath = next
const { data: { user } } = await supabase.auth.getUser()
if (user) {
// Check MFA status — redirect to verify if factor is enrolled but session is AAL1
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
const response = NextResponse.redirect(new URL('/mfa/verify', origin))
for (const { name, value, options } of pendingCookies) {
response.cookies.set({ name, value, ...options })
}
return response
}
// Check for pending invite token (set by invite page before redirecting to register)
const inviteToken = request.cookies.get('gnubok-invite-token')?.value
if (inviteToken) {
try {
const tokenHash = hashInviteToken(inviteToken)
// Use the service role client to bypass RLS for invite acceptance
const serviceClient = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ cookies: { getAll: () => [], setAll: () => {} } }
)
// Look up company invitation
const { data: invite } = await serviceClient
.from('company_invitations')
.select('id, company_id, email, role, status, expires_at')
.eq('token_hash', tokenHash)
.single()
if (
invite &&
invite.status === 'pending' &&
new Date(invite.expires_at) > new Date() &&
user.email?.toLowerCase() === invite.email.toLowerCase()
) {
// Add user to company
await serviceClient.from('company_members').insert({
company_id: invite.company_id,
user_id: user.id,
role: invite.role,
source: 'direct',
})
// Set active company
await serviceClient.from('user_preferences').upsert({
user_id: user.id,
active_company_id: invite.company_id,
}, { onConflict: 'user_id' })
// Mark invite as accepted
await serviceClient
.from('company_invitations')
.update({ status: 'accepted' })
.eq('id', invite.id)
// Invited user goes straight to dashboard — no onboarding needed
redirectPath = '/'
// Clear invite cookie and set company cookie on response
const response = NextResponse.redirect(new URL(redirectPath, origin))
for (const { name, value, options } of pendingCookies) {
response.cookies.set({ name, value, ...options })
}
response.cookies.set('gnubok-company-id', invite.company_id, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365,
})
response.cookies.delete('gnubok-invite-token')
return response
}
} catch (err) {
console.error('[auth/callback] invite acceptance failed:', err)
// Fall through to normal onboarding check
}
}
// Ensure user has a silent team (for new signups and existing users without one)
const { data: teamMembership } = await supabase
.from('team_members')
.select('team_id')
.eq('user_id', user.id)
.limit(1)
.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: () => {} } }
)
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',
})
}
// Always redirect to dashboard — it handles zero-company and incomplete states
redirectPath = '/'
}
// Create redirect and explicitly set auth cookies on the response
const response = NextResponse.redirect(new URL(redirectPath, origin))
for (const { name, value, options } of pendingCookies) {
response.cookies.set({ name, value, ...options })
}
// Keep the invite cookie alive so the onboarding page fallback can
// retry acceptance (only clear it when successfully processed above).
return response
}
// Authentication failed — redirect to login with error
return NextResponse.redirect(new URL('/login?error=auth_error', origin))
}