a1a816b4a5
* Implement company and account deletion features - Add event types for company and account deletion to CoreEvent. - Enhance Supabase middleware to handle company context resolution and cookie management for archived companies. - Create API routes for deleting accounts and companies, including necessary validations and event emissions. - Implement tests for account and company deletion endpoints to ensure proper functionality and error handling. - Add retention notice component to inform users about bookkeeping data retention during destructive actions. - Create database migrations to support soft deletion of companies and anonymization of user accounts, ensuring compliance with retention laws. * feat: enhance account deletion process and update user notifications * Add service client for onboarding completion check and update escape hatch visibility * Enhance invite flow and email handling for company members * Refactor company context and RLS policies for active company isolation - Update `switchCompany` to remove unnecessary revalidation as client handles navigation. - Revise `getActiveCompanyId` to prioritize `user_preferences` and validate against non-archived memberships. - Modify `setActiveCompany` to ensure `user_preferences` is the authoritative source while maintaining cookie compatibility. - Enhance middleware to resolve active company using `user_preferences` and fallback to first non-archived membership. - Introduce new API route `/api/company/current` to fetch the active company ID for cross-tab synchronization. - Implement `CompanyTabSync` component for real-time active company enforcement across tabs. - Create migration for RLS policies to enforce single-active-company isolation using `current_active_company_id()`. * feat: implement viewer role enforcement for write permissions - Added `useCanWrite` hook to determine if the current user has write permissions based on their role in the active company. - Updated various components (JournalEntryForm, CustomerForm, DeadlineForm, etc.) to disable write actions and show a lock icon with a tooltip for users without write permissions. - Introduced `requireWritePermission` function to enforce write permissions at the API level, returning a 403 response for viewers. - Created tests to verify the behavior of the viewer role and write permissions. - Added database migration to enforce read-only access for viewers at the database level.
224 lines
7.6 KiB
TypeScript
224 lines
7.6 KiB
TypeScript
import { createServerClient } from '@supabase/ssr'
|
|
import { NextResponse, type NextRequest } from 'next/server'
|
|
import { shouldEnforceMfa } from '@/lib/auth/mfa'
|
|
|
|
export async function updateSession(request: NextRequest) {
|
|
let supabaseResponse = NextResponse.next({
|
|
request,
|
|
})
|
|
|
|
const supabase = createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
{
|
|
cookies: {
|
|
getAll() {
|
|
return request.cookies.getAll()
|
|
},
|
|
setAll(cookiesToSet) {
|
|
cookiesToSet.forEach(({ name, value }) =>
|
|
request.cookies.set(name, value)
|
|
)
|
|
supabaseResponse = NextResponse.next({
|
|
request,
|
|
})
|
|
cookiesToSet.forEach(({ name, value, options }) =>
|
|
supabaseResponse.cookies.set(name, value, options)
|
|
)
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
// IMPORTANT: Avoid writing any logic between createServerClient and
|
|
// supabase.auth.getUser(). A simple mistake could make it very hard to debug
|
|
// issues with users being randomly logged out.
|
|
|
|
const {
|
|
data: { user },
|
|
error: authError,
|
|
} = await supabase.auth.getUser()
|
|
|
|
// Get the pathname
|
|
const pathname = request.nextUrl.pathname
|
|
|
|
// If the refresh token is stale/invalid, clear the session cookies
|
|
// so the browser stops sending them on every request.
|
|
// Skip on auth routes — the callback needs PKCE cookies intact.
|
|
if (authError && !user && !pathname.startsWith('/auth')) {
|
|
await supabase.auth.signOut()
|
|
}
|
|
|
|
// Invite pages — accessible to everyone, signed in or not. A user who
|
|
// already has an account and is signed in should still be able to land on
|
|
// /invite/[token] to accept the invite with one click (see
|
|
// app/invite/[token]/page.tsx). If we bounce them to '/', they never see
|
|
// the invite at all.
|
|
if (pathname.startsWith('/invite')) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Public auth routes — allow access
|
|
if (
|
|
pathname.startsWith('/login') ||
|
|
pathname.startsWith('/register') ||
|
|
pathname.startsWith('/auth') ||
|
|
pathname.startsWith('/reset-password') ||
|
|
pathname.startsWith('/sandbox')
|
|
) {
|
|
// If user is logged in and trying to access auth pages, redirect to dashboard
|
|
if (user) {
|
|
return NextResponse.redirect(new URL('/', request.url))
|
|
}
|
|
return supabaseResponse
|
|
}
|
|
|
|
// Protected routes - require authentication
|
|
if (!user) {
|
|
const url = request.nextUrl.clone()
|
|
url.pathname = '/login'
|
|
return NextResponse.redirect(url)
|
|
}
|
|
|
|
// MFA pages — accessible to authenticated users (AAL1+), skip MFA enforcement
|
|
if (pathname.startsWith('/mfa/')) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
// MFA enforcement (application-side only, not RLS)
|
|
if (shouldEnforceMfa(user)) {
|
|
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
|
|
|
// User has MFA enrolled but hasn't verified this session → redirect to verify
|
|
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
|
|
return NextResponse.redirect(new URL('/mfa/verify', request.url))
|
|
}
|
|
|
|
// MFA required but user has no factor enrolled yet → force enrollment
|
|
// Skip for users with no companies (still setting up)
|
|
const companyIdForMfa = await resolveCompanyForMiddleware(supabase, user.id, request)
|
|
if (companyIdForMfa) {
|
|
const { data: factors } = await supabase.auth.mfa.listFactors()
|
|
const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified')
|
|
|
|
if (!hasVerifiedFactor) {
|
|
return NextResponse.redirect(new URL('/mfa/enroll', request.url))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Forward the pathname so server layouts can branch on it (e.g. render a
|
|
// no-company shell for /settings/account).
|
|
supabaseResponse.headers.set('x-pathname', pathname)
|
|
|
|
// Company context resolution
|
|
const cookieCompanyId = request.cookies.get('gnubok-company-id')?.value
|
|
const companyId = await resolveCompanyForMiddleware(supabase, user.id, request)
|
|
|
|
// If the cookie pointed at a company we can no longer resolve (e.g.
|
|
// archived), clear it so the browser stops sending it.
|
|
if (cookieCompanyId && cookieCompanyId !== companyId) {
|
|
supabaseResponse.cookies.set('gnubok-company-id', '', { path: '/', maxAge: 0 })
|
|
}
|
|
|
|
// Routes that stay accessible when the user has no active company.
|
|
// Needed so a user who archived their last company can still delete
|
|
// their account without being trapped on /onboarding forever.
|
|
const isNoCompanyAllowed =
|
|
pathname.startsWith('/onboarding') ||
|
|
pathname.startsWith('/settings/account') ||
|
|
pathname.startsWith('/api/account/') ||
|
|
pathname.startsWith('/api/company')
|
|
|
|
// No companies — redirect to onboarding, but allow the escape-hatch routes
|
|
if (!companyId) {
|
|
if (isNoCompanyAllowed) {
|
|
return supabaseResponse
|
|
}
|
|
return NextResponse.redirect(new URL('/onboarding', request.url))
|
|
}
|
|
|
|
// Set company cookie on the response so downstream requests have it
|
|
supabaseResponse.cookies.set('gnubok-company-id', companyId, {
|
|
path: '/',
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
maxAge: 60 * 60 * 24 * 365,
|
|
})
|
|
|
|
// Allow access to onboarding (for adding new companies), select-company, and companies/new
|
|
if (pathname.startsWith('/select-company') || pathname.startsWith('/companies/new') || pathname.startsWith('/onboarding')) {
|
|
return supabaseResponse
|
|
}
|
|
|
|
return supabaseResponse
|
|
}
|
|
|
|
/**
|
|
* Resolve the active company for the authenticated user.
|
|
*
|
|
* Resolution: user_preferences → first non-archived membership.
|
|
*
|
|
* `user_preferences.active_company_id` is the authoritative source for
|
|
* the active company on both the Next.js and Postgres RLS side. The
|
|
* `gnubok-company-id` cookie is still refreshed for legacy read paths
|
|
* but it is no longer READ here, because RLS (via
|
|
* `current_active_company_id()`) cannot see cookies — so letting the
|
|
* cookie override the database would re-introduce the divergence this
|
|
* entire migration exists to fix.
|
|
*
|
|
* When we fall back to "first membership" (no user_preferences row yet),
|
|
* we also upsert user_preferences so subsequent RLS lookups agree with
|
|
* us without needing the fallback scan.
|
|
*
|
|
* Cannot use lib/company/context.ts because middleware runs on Edge.
|
|
*/
|
|
async function resolveCompanyForMiddleware(
|
|
supabase: ReturnType<typeof createServerClient>,
|
|
userId: string,
|
|
_request: NextRequest
|
|
): Promise<string | null> {
|
|
// 1. user_preferences (authoritative)
|
|
const { data: prefs } = await supabase
|
|
.from('user_preferences')
|
|
.select('active_company_id')
|
|
.eq('user_id', userId)
|
|
.maybeSingle()
|
|
|
|
if (prefs?.active_company_id) {
|
|
const { data: membership } = await supabase
|
|
.from('company_members')
|
|
.select('company_id, companies!inner(archived_at)')
|
|
.eq('company_id', prefs.active_company_id)
|
|
.eq('user_id', userId)
|
|
.is('companies.archived_at', null)
|
|
.maybeSingle()
|
|
|
|
if (membership) return membership.company_id
|
|
}
|
|
|
|
// 2. Fallback: first non-archived membership by created_at
|
|
const { data: firstCompany } = await supabase
|
|
.from('company_members')
|
|
.select('company_id, companies!inner(archived_at)')
|
|
.eq('user_id', userId)
|
|
.is('companies.archived_at', null)
|
|
.order('created_at', { ascending: true })
|
|
.limit(1)
|
|
.maybeSingle()
|
|
|
|
if (!firstCompany) return null
|
|
|
|
// Write the fallback back to user_preferences so future RLS lookups
|
|
// see the same active company without needing this fallback scan.
|
|
await supabase
|
|
.from('user_preferences')
|
|
.upsert(
|
|
{ user_id: userId, active_company_id: firstCompany.company_id },
|
|
{ onConflict: 'user_id' }
|
|
)
|
|
|
|
return firstCompany.company_id
|
|
}
|