Delete features (#218)
* 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.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockSupabase } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn(),
|
||||
}))
|
||||
|
||||
import { requireWritePermission } from '../require-write'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
|
||||
describe('requireWritePermission', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns ok for owner', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
vi.mocked(getActiveCompanyId).mockResolvedValue('company-1')
|
||||
mockResult({ data: { role: 'owner' } })
|
||||
|
||||
const result = await requireWritePermission(supabase, 'user-1')
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('returns ok for admin', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
vi.mocked(getActiveCompanyId).mockResolvedValue('company-1')
|
||||
mockResult({ data: { role: 'admin' } })
|
||||
|
||||
const result = await requireWritePermission(supabase, 'user-1')
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('returns ok for member', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
vi.mocked(getActiveCompanyId).mockResolvedValue('company-1')
|
||||
mockResult({ data: { role: 'member' } })
|
||||
|
||||
const result = await requireWritePermission(supabase, 'user-1')
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 403 for viewer', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
vi.mocked(getActiveCompanyId).mockResolvedValue('company-1')
|
||||
mockResult({ data: { role: 'viewer' } })
|
||||
|
||||
const result = await requireWritePermission(supabase, 'user-1')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.response.status).toBe(403)
|
||||
const body = await result.response.json()
|
||||
expect(body.error).toContain('läsbehörighet')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns 403 when user has no membership', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
vi.mocked(getActiveCompanyId).mockResolvedValue('company-1')
|
||||
mockResult({ data: null })
|
||||
|
||||
const result = await requireWritePermission(supabase, 'user-1')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.response.status).toBe(403)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns 403 when there is no active company', async () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
vi.mocked(getActiveCompanyId).mockResolvedValue(null)
|
||||
|
||||
const result = await requireWritePermission(supabase, 'user-1')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.response.status).toBe(403)
|
||||
const body = await result.response.json()
|
||||
expect(body.error).toContain('aktivt företag')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
|
||||
/**
|
||||
* Write-permission guard for API routes.
|
||||
*
|
||||
* Looks up the caller's role in the currently active company. Returns a
|
||||
* 403 JSON response if the role is 'viewer' (or if the user has no role
|
||||
* in any resolvable company). Any other role (owner / admin / member)
|
||||
* passes.
|
||||
*
|
||||
* Meant to be called AFTER `requireAuth()` in every API route that
|
||||
* mutates tenant data (POST / PATCH / PUT / DELETE). Read-only POSTs
|
||||
* that only generate PDFs or run utility lookups (e.g. VAT validation)
|
||||
* should skip this check.
|
||||
*
|
||||
* This is the application-layer half of the defense-in-depth story; the
|
||||
* RLS helper `public.current_user_can_write()` is the database half.
|
||||
* Having both means a viewer who bypasses the JS UI and calls the API
|
||||
* directly still gets a clean 403, and even if someone forgets to add
|
||||
* this guard to a new route, the RLS policy blocks the write at the
|
||||
* database layer.
|
||||
*/
|
||||
type WritePermissionResult =
|
||||
| { ok: true }
|
||||
| { ok: false; response: NextResponse }
|
||||
|
||||
export async function requireWritePermission(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
): Promise<WritePermissionResult> {
|
||||
const companyId = await getActiveCompanyId(supabase, userId)
|
||||
|
||||
if (!companyId) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ error: 'Inget aktivt företag.' },
|
||||
{ status: 403 },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!membership || membership.role === 'viewer') {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ error: 'Du har endast läsbehörighet i detta företag.' },
|
||||
{ status: 403 },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -14,7 +14,10 @@ export async function switchCompany(companyId: string): Promise<{ error?: string
|
||||
|
||||
try {
|
||||
await setActiveCompany(supabase, user.id, companyId)
|
||||
revalidatePath('/')
|
||||
// No revalidatePath — the client performs a hard navigation
|
||||
// (window.location.assign) after this action returns, which wipes
|
||||
// every React/router/fetch cache wholesale. revalidatePath would be a
|
||||
// no-op and would just race with the hard reload.
|
||||
return {}
|
||||
} catch {
|
||||
return { error: 'Du har inte tillgång till detta företag.' }
|
||||
|
||||
+29
-29
@@ -5,56 +5,52 @@ const COMPANY_COOKIE = 'gnubok-company-id'
|
||||
|
||||
/**
|
||||
* Get the active company ID for the authenticated user.
|
||||
* Resolution order: cookie → user_preferences → first owned company.
|
||||
* Returns null if user has no companies.
|
||||
*
|
||||
* Resolution order: user_preferences → first non-archived membership.
|
||||
*
|
||||
* `user_preferences.active_company_id` is the authoritative source. The
|
||||
* cookie `gnubok-company-id` is written as a hint for backwards-compat but
|
||||
* is no longer READ as a source of truth, because Postgres RLS (via
|
||||
* `current_active_company_id()`) can only read the database, not cookies.
|
||||
* Having Next.js and RLS both read from `user_preferences` keeps them
|
||||
* perfectly in sync.
|
||||
*
|
||||
* Returns null if the user has no non-archived companies.
|
||||
*/
|
||||
export async function getActiveCompanyId(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
// 1. Try cookie
|
||||
const cookieStore = await cookies()
|
||||
const cookieValue = cookieStore.get(COMPANY_COOKIE)?.value
|
||||
|
||||
if (cookieValue) {
|
||||
// Validate the user is a member of this company
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id')
|
||||
.eq('company_id', cookieValue)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (membership) return membership.company_id
|
||||
}
|
||||
|
||||
// 2. Try user_preferences
|
||||
// 1. user_preferences — authoritative
|
||||
const { data: prefs } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('active_company_id')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
.maybeSingle()
|
||||
|
||||
if (prefs?.active_company_id) {
|
||||
// Validate membership
|
||||
// Validate the preference still points to a non-archived company the
|
||||
// user is a member of.
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id')
|
||||
.select('company_id, companies!inner(archived_at)')
|
||||
.eq('company_id', prefs.active_company_id)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
.is('companies.archived_at', null)
|
||||
.maybeSingle()
|
||||
|
||||
if (membership) return membership.company_id
|
||||
}
|
||||
|
||||
// 3. Fallback: first company where user is owner (or any member)
|
||||
// 2. Fallback: first non-archived membership by created_at
|
||||
const { data: firstCompany } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id')
|
||||
.select('company_id, companies!inner(archived_at)')
|
||||
.eq('user_id', userId)
|
||||
.is('companies.archived_at', null)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.single()
|
||||
.maybeSingle()
|
||||
|
||||
return firstCompany?.company_id ?? null
|
||||
}
|
||||
@@ -89,7 +85,11 @@ export async function getUserCompanies(
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active company for the user (updates cookie + user_preferences).
|
||||
* Set the active company for the user.
|
||||
*
|
||||
* Writes to `user_preferences` (authoritative, consulted by RLS via
|
||||
* `current_active_company_id()`) and refreshes the `gnubok-company-id`
|
||||
* cookie for backwards-compat with any code still reading it.
|
||||
*/
|
||||
export async function setActiveCompany(
|
||||
supabase: SupabaseClient,
|
||||
@@ -108,7 +108,7 @@ export async function setActiveCompany(
|
||||
throw new Error('User is not a member of this company')
|
||||
}
|
||||
|
||||
// Update user_preferences
|
||||
// Update user_preferences — this is the authoritative value RLS reads
|
||||
await supabase
|
||||
.from('user_preferences')
|
||||
.upsert(
|
||||
@@ -116,7 +116,7 @@ export async function setActiveCompany(
|
||||
{ onConflict: 'user_id' }
|
||||
)
|
||||
|
||||
// Set cookie
|
||||
// Refresh the cookie as a compat hint
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set(COMPANY_COOKIE, companyId, {
|
||||
path: '/',
|
||||
|
||||
@@ -72,6 +72,9 @@ export type CoreEvent =
|
||||
| { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string; companyId: string } }
|
||||
| { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string; companyId: string } }
|
||||
| { type: 'supplier_invoice.confirmed'; payload: { inboxItem: InvoiceInboxItem; supplierInvoice: SupplierInvoice; userId: string; companyId: string } }
|
||||
// Company & account lifecycle
|
||||
| { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: string } }
|
||||
| { type: 'account.deleted'; payload: { userId: string; deletedAt: string } }
|
||||
|
||||
// ============================================================
|
||||
// Helper Types
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
|
||||
/**
|
||||
* Returns whether the current user can perform write actions in the
|
||||
* active company. Viewers (role === 'viewer') get `canWrite = false`;
|
||||
* owner / admin / member all get `canWrite = true`. Users with no active
|
||||
* company (null role) also get `canWrite = false`.
|
||||
*
|
||||
* Used by every write-action button (create / edit / delete / send /
|
||||
* approve / etc.) across the dashboard to render the button in a
|
||||
* disabled state with a lock icon and tooltip.
|
||||
*
|
||||
* This is the UI layer of the viewer role enforcement. The API layer
|
||||
* (`requireWritePermission()`) and RLS layer (`current_user_can_write()`)
|
||||
* remain the security-critical backstops — this hook only controls what
|
||||
* the user sees and can click.
|
||||
*/
|
||||
export function useCanWrite(): { canWrite: boolean } {
|
||||
const { role } = useCompany()
|
||||
return { canWrite: role !== null && role !== 'viewer' }
|
||||
}
|
||||
+69
-27
@@ -49,14 +49,22 @@ export async function updateSession(request: NextRequest) {
|
||||
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') ||
|
||||
pathname.startsWith('/invite')
|
||||
pathname.startsWith('/sandbox')
|
||||
) {
|
||||
// If user is logged in and trying to access auth pages, redirect to dashboard
|
||||
if (user) {
|
||||
@@ -99,12 +107,32 @@ export async function updateSession(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// No companies — only allow /onboarding, redirect everything else there
|
||||
// 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 (pathname.startsWith('/onboarding')) {
|
||||
if (isNoCompanyAllowed) {
|
||||
return supabaseResponse
|
||||
}
|
||||
return NextResponse.redirect(new URL('/onboarding', request.url))
|
||||
@@ -129,53 +157,67 @@ export async function updateSession(request: NextRequest) {
|
||||
|
||||
/**
|
||||
* Resolve the active company for the authenticated user.
|
||||
* Uses cookie → user_preferences → first membership as fallback.
|
||||
*
|
||||
* 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
|
||||
_request: NextRequest
|
||||
): Promise<string | null> {
|
||||
// 1. Try cookie
|
||||
const cookieCompanyId = request.cookies.get('gnubok-company-id')?.value
|
||||
if (cookieCompanyId) {
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id')
|
||||
.eq('company_id', cookieCompanyId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (membership) return membership.company_id
|
||||
}
|
||||
|
||||
// 2. Try user_preferences
|
||||
// 1. user_preferences (authoritative)
|
||||
const { data: prefs } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('active_company_id')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
.maybeSingle()
|
||||
|
||||
if (prefs?.active_company_id) {
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id')
|
||||
.select('company_id, companies!inner(archived_at)')
|
||||
.eq('company_id', prefs.active_company_id)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
.is('companies.archived_at', null)
|
||||
.maybeSingle()
|
||||
|
||||
if (membership) return membership.company_id
|
||||
}
|
||||
|
||||
// 3. Fallback: first company membership
|
||||
// 2. Fallback: first non-archived membership by created_at
|
||||
const { data: firstCompany } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id')
|
||||
.select('company_id, companies!inner(archived_at)')
|
||||
.eq('user_id', userId)
|
||||
.is('companies.archived_at', null)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.single()
|
||||
.maybeSingle()
|
||||
|
||||
return firstCompany?.company_id ?? null
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user