* 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.
365 lines
17 KiB
PL/PgSQL
365 lines
17 KiB
PL/PgSQL
-- =============================================================================
|
|
-- Viewer role read-only enforcement
|
|
-- =============================================================================
|
|
--
|
|
-- Activates the `viewer` role in company_members. Until now the role existed
|
|
-- in the type system (CompanyRole) but was dormant — a viewer had the same
|
|
-- write capabilities as a member. This migration makes viewers truly
|
|
-- read-only at the database level.
|
|
--
|
|
-- Mechanism:
|
|
-- 1. New helper `public.current_user_can_write()` returns true only if
|
|
-- the authenticated user's role in the active company is NOT 'viewer'.
|
|
-- 2. Every INSERT / UPDATE / DELETE policy on company-scoped tables is
|
|
-- recreated with an added `AND public.current_user_can_write()` clause.
|
|
-- 3. SELECT policies are left untouched — viewers read everything.
|
|
--
|
|
-- After this migration, any write by a viewer is rejected with:
|
|
-- "new row violates row-level security policy" (server-side)
|
|
-- regardless of whether it originates from the UI, a browser console, or
|
|
-- an API key bound to a viewer's session.
|
|
-- =============================================================================
|
|
|
|
-- =============================================================================
|
|
-- 1. current_user_can_write() helper
|
|
-- =============================================================================
|
|
CREATE OR REPLACE FUNCTION public.current_user_can_write()
|
|
RETURNS boolean
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM public.company_members cm
|
|
WHERE cm.user_id = auth.uid()
|
|
AND cm.company_id = public.current_active_company_id()
|
|
AND cm.role <> 'viewer'
|
|
);
|
|
$$;
|
|
|
|
GRANT EXECUTE ON FUNCTION public.current_user_can_write() TO authenticated;
|
|
|
|
COMMENT ON FUNCTION public.current_user_can_write() IS
|
|
'Returns true if auth.uid() has a non-viewer role (owner / admin / member) '
|
|
'in the current active company. Used by RLS INSERT/UPDATE/DELETE policies '
|
|
'to make the ''viewer'' role truly read-only. Viewers, non-members, and '
|
|
'unauthenticated callers all get false.';
|
|
|
|
-- =============================================================================
|
|
-- 2. Standard tables — recreate INSERT / UPDATE / DELETE policies
|
|
-- =============================================================================
|
|
-- Loop over all company-scoped tables that currently have the full set of
|
|
-- insert/update/delete policies. SELECT policies are left alone (viewers
|
|
-- must be able to read everything).
|
|
|
|
DO $$
|
|
DECLARE
|
|
t TEXT;
|
|
standard_tables TEXT[] := ARRAY[
|
|
'company_settings', 'chart_of_accounts', 'fiscal_periods', 'journal_entries',
|
|
'transactions', 'bank_connections', 'bank_file_imports',
|
|
'customers', 'invoices', 'invoice_reminders', 'invoice_payments',
|
|
'suppliers', 'supplier_invoices', 'supplier_invoice_payments',
|
|
'receipts', 'document_attachments', 'invoice_inbox_items',
|
|
'categorization_templates', 'deadlines', 'cost_centers', 'projects',
|
|
'sie_imports', 'sie_account_mappings', 'calendar_feeds',
|
|
'chat_sessions', 'chat_messages',
|
|
'extension_data', 'api_keys', 'skatteverket_tokens',
|
|
'provider_consents', 'automation_webhooks', 'email_connections'
|
|
];
|
|
BEGIN
|
|
FOREACH t IN ARRAY standard_tables
|
|
LOOP
|
|
IF EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name = t
|
|
) THEN
|
|
EXECUTE format('DROP POLICY IF EXISTS "%s_insert" ON public.%I', t, t);
|
|
EXECUTE format('DROP POLICY IF EXISTS "%s_update" ON public.%I', t, t);
|
|
EXECUTE format('DROP POLICY IF EXISTS "%s_delete" ON public.%I', t, t);
|
|
|
|
EXECUTE format(
|
|
'CREATE POLICY "%s_insert" ON public.%I '
|
|
'FOR INSERT WITH CHECK ('
|
|
'company_id = public.current_active_company_id() '
|
|
'AND public.current_user_can_write())',
|
|
t, t
|
|
);
|
|
EXECUTE format(
|
|
'CREATE POLICY "%s_update" ON public.%I '
|
|
'FOR UPDATE USING ('
|
|
'company_id = public.current_active_company_id() '
|
|
'AND public.current_user_can_write())',
|
|
t, t
|
|
);
|
|
EXECUTE format(
|
|
'CREATE POLICY "%s_delete" ON public.%I '
|
|
'FOR DELETE USING ('
|
|
'company_id = public.current_active_company_id() '
|
|
'AND public.current_user_can_write())',
|
|
t, t
|
|
);
|
|
END IF;
|
|
END LOOP;
|
|
END $$;
|
|
|
|
-- =============================================================================
|
|
-- 3. voucher_sequences (INSERT + UPDATE only, no DELETE policy)
|
|
-- =============================================================================
|
|
DROP POLICY IF EXISTS "voucher_sequences_insert" ON public.voucher_sequences;
|
|
DROP POLICY IF EXISTS "voucher_sequences_update" ON public.voucher_sequences;
|
|
CREATE POLICY "voucher_sequences_insert" ON public.voucher_sequences
|
|
FOR INSERT WITH CHECK (
|
|
company_id = public.current_active_company_id()
|
|
AND public.current_user_can_write()
|
|
);
|
|
CREATE POLICY "voucher_sequences_update" ON public.voucher_sequences
|
|
FOR UPDATE USING (
|
|
company_id = public.current_active_company_id()
|
|
AND public.current_user_can_write()
|
|
);
|
|
|
|
-- =============================================================================
|
|
-- 4. mapping_rules (standard, but with OR company_id IS NULL on SELECT only)
|
|
-- =============================================================================
|
|
-- The SELECT policy keeps its OR company_id IS NULL exception for system
|
|
-- rules — viewers can still read them. Writes drop the NULL branch because
|
|
-- viewers never create rules (and non-viewers only create company-scoped
|
|
-- rules, not system rules).
|
|
DROP POLICY IF EXISTS "mapping_rules_insert" ON public.mapping_rules;
|
|
DROP POLICY IF EXISTS "mapping_rules_update" ON public.mapping_rules;
|
|
DROP POLICY IF EXISTS "mapping_rules_delete" ON public.mapping_rules;
|
|
CREATE POLICY "mapping_rules_insert" ON public.mapping_rules
|
|
FOR INSERT WITH CHECK (
|
|
company_id = public.current_active_company_id()
|
|
AND public.current_user_can_write()
|
|
);
|
|
CREATE POLICY "mapping_rules_update" ON public.mapping_rules
|
|
FOR UPDATE USING (
|
|
company_id = public.current_active_company_id()
|
|
AND public.current_user_can_write()
|
|
);
|
|
CREATE POLICY "mapping_rules_delete" ON public.mapping_rules
|
|
FOR DELETE USING (
|
|
company_id = public.current_active_company_id()
|
|
AND public.current_user_can_write()
|
|
);
|
|
|
|
-- =============================================================================
|
|
-- 5. ai_usage_tracking (INSERT only)
|
|
-- =============================================================================
|
|
DROP POLICY IF EXISTS "ai_usage_tracking_insert" ON public.ai_usage_tracking;
|
|
CREATE POLICY "ai_usage_tracking_insert" ON public.ai_usage_tracking
|
|
FOR INSERT WITH CHECK (
|
|
company_id = public.current_active_company_id()
|
|
AND public.current_user_can_write()
|
|
);
|
|
|
|
-- =============================================================================
|
|
-- 6. pending_operations (UPDATE only — inserts via service role)
|
|
-- =============================================================================
|
|
DROP POLICY IF EXISTS "pending_operations_update" ON public.pending_operations;
|
|
CREATE POLICY "pending_operations_update" ON public.pending_operations
|
|
FOR UPDATE USING (
|
|
company_id = public.current_active_company_id()
|
|
AND public.current_user_can_write()
|
|
);
|
|
|
|
-- =============================================================================
|
|
-- 7. payment_match_log (INSERT only, nullable company_id)
|
|
-- =============================================================================
|
|
DROP POLICY IF EXISTS "payment_match_log_insert" ON public.payment_match_log;
|
|
CREATE POLICY "payment_match_log_insert" ON public.payment_match_log
|
|
FOR INSERT WITH CHECK (
|
|
(company_id = public.current_active_company_id() OR company_id IS NULL)
|
|
AND public.current_user_can_write()
|
|
);
|
|
|
|
-- =============================================================================
|
|
-- 8. Child tables — join to parent via EXISTS
|
|
-- =============================================================================
|
|
-- journal_entry_lines
|
|
DROP POLICY IF EXISTS "journal_entry_lines_insert" ON public.journal_entry_lines;
|
|
DROP POLICY IF EXISTS "journal_entry_lines_update" ON public.journal_entry_lines;
|
|
DROP POLICY IF EXISTS "journal_entry_lines_delete" ON public.journal_entry_lines;
|
|
CREATE POLICY "journal_entry_lines_insert" ON public.journal_entry_lines
|
|
FOR INSERT WITH CHECK (
|
|
EXISTS (SELECT 1 FROM public.journal_entries je
|
|
WHERE je.id = journal_entry_lines.journal_entry_id
|
|
AND je.company_id = public.current_active_company_id())
|
|
AND public.current_user_can_write()
|
|
);
|
|
CREATE POLICY "journal_entry_lines_update" ON public.journal_entry_lines
|
|
FOR UPDATE USING (
|
|
EXISTS (SELECT 1 FROM public.journal_entries je
|
|
WHERE je.id = journal_entry_lines.journal_entry_id
|
|
AND je.company_id = public.current_active_company_id())
|
|
AND public.current_user_can_write()
|
|
);
|
|
CREATE POLICY "journal_entry_lines_delete" ON public.journal_entry_lines
|
|
FOR DELETE USING (
|
|
EXISTS (SELECT 1 FROM public.journal_entries je
|
|
WHERE je.id = journal_entry_lines.journal_entry_id
|
|
AND je.company_id = public.current_active_company_id())
|
|
AND public.current_user_can_write()
|
|
);
|
|
|
|
-- invoice_items
|
|
DROP POLICY IF EXISTS "invoice_items_insert" ON public.invoice_items;
|
|
DROP POLICY IF EXISTS "invoice_items_update" ON public.invoice_items;
|
|
DROP POLICY IF EXISTS "invoice_items_delete" ON public.invoice_items;
|
|
CREATE POLICY "invoice_items_insert" ON public.invoice_items
|
|
FOR INSERT WITH CHECK (
|
|
EXISTS (SELECT 1 FROM public.invoices i
|
|
WHERE i.id = invoice_items.invoice_id
|
|
AND i.company_id = public.current_active_company_id())
|
|
AND public.current_user_can_write()
|
|
);
|
|
CREATE POLICY "invoice_items_update" ON public.invoice_items
|
|
FOR UPDATE USING (
|
|
EXISTS (SELECT 1 FROM public.invoices i
|
|
WHERE i.id = invoice_items.invoice_id
|
|
AND i.company_id = public.current_active_company_id())
|
|
AND public.current_user_can_write()
|
|
);
|
|
CREATE POLICY "invoice_items_delete" ON public.invoice_items
|
|
FOR DELETE USING (
|
|
EXISTS (SELECT 1 FROM public.invoices i
|
|
WHERE i.id = invoice_items.invoice_id
|
|
AND i.company_id = public.current_active_company_id())
|
|
AND public.current_user_can_write()
|
|
);
|
|
|
|
-- supplier_invoice_items
|
|
DROP POLICY IF EXISTS "supplier_invoice_items_insert" ON public.supplier_invoice_items;
|
|
DROP POLICY IF EXISTS "supplier_invoice_items_update" ON public.supplier_invoice_items;
|
|
DROP POLICY IF EXISTS "supplier_invoice_items_delete" ON public.supplier_invoice_items;
|
|
CREATE POLICY "supplier_invoice_items_insert" ON public.supplier_invoice_items
|
|
FOR INSERT WITH CHECK (
|
|
EXISTS (SELECT 1 FROM public.supplier_invoices si
|
|
WHERE si.id = supplier_invoice_items.supplier_invoice_id
|
|
AND si.company_id = public.current_active_company_id())
|
|
AND public.current_user_can_write()
|
|
);
|
|
CREATE POLICY "supplier_invoice_items_update" ON public.supplier_invoice_items
|
|
FOR UPDATE USING (
|
|
EXISTS (SELECT 1 FROM public.supplier_invoices si
|
|
WHERE si.id = supplier_invoice_items.supplier_invoice_id
|
|
AND si.company_id = public.current_active_company_id())
|
|
AND public.current_user_can_write()
|
|
);
|
|
CREATE POLICY "supplier_invoice_items_delete" ON public.supplier_invoice_items
|
|
FOR DELETE USING (
|
|
EXISTS (SELECT 1 FROM public.supplier_invoices si
|
|
WHERE si.id = supplier_invoice_items.supplier_invoice_id
|
|
AND si.company_id = public.current_active_company_id())
|
|
AND public.current_user_can_write()
|
|
);
|
|
|
|
-- receipt_line_items
|
|
DROP POLICY IF EXISTS "receipt_line_items_insert" ON public.receipt_line_items;
|
|
DROP POLICY IF EXISTS "receipt_line_items_update" ON public.receipt_line_items;
|
|
DROP POLICY IF EXISTS "receipt_line_items_delete" ON public.receipt_line_items;
|
|
CREATE POLICY "receipt_line_items_insert" ON public.receipt_line_items
|
|
FOR INSERT WITH CHECK (
|
|
EXISTS (SELECT 1 FROM public.receipts r
|
|
WHERE r.id = receipt_line_items.receipt_id
|
|
AND r.company_id = public.current_active_company_id())
|
|
AND public.current_user_can_write()
|
|
);
|
|
CREATE POLICY "receipt_line_items_update" ON public.receipt_line_items
|
|
FOR UPDATE USING (
|
|
EXISTS (SELECT 1 FROM public.receipts r
|
|
WHERE r.id = receipt_line_items.receipt_id
|
|
AND r.company_id = public.current_active_company_id())
|
|
AND public.current_user_can_write()
|
|
);
|
|
CREATE POLICY "receipt_line_items_delete" ON public.receipt_line_items
|
|
FOR DELETE USING (
|
|
EXISTS (SELECT 1 FROM public.receipts r
|
|
WHERE r.id = receipt_line_items.receipt_id
|
|
AND r.company_id = public.current_active_company_id())
|
|
AND public.current_user_can_write()
|
|
);
|
|
|
|
-- =============================================================================
|
|
-- 9. voucher_gap_explanations (preserves owner/admin team_members check)
|
|
-- =============================================================================
|
|
-- The existing policy already blocks non-(owner|admin) via team_members, so
|
|
-- viewers are de facto blocked. We add current_user_can_write() as a
|
|
-- defensive second layer so a future refactor of team_members can't
|
|
-- accidentally re-open viewer writes.
|
|
DROP POLICY IF EXISTS "voucher_gap_explanations_insert" ON public.voucher_gap_explanations;
|
|
DROP POLICY IF EXISTS "voucher_gap_explanations_update" ON public.voucher_gap_explanations;
|
|
CREATE POLICY "voucher_gap_explanations_insert" ON public.voucher_gap_explanations
|
|
FOR INSERT WITH CHECK (
|
|
company_id = public.current_active_company_id()
|
|
AND public.current_user_can_write()
|
|
AND EXISTS (
|
|
SELECT 1 FROM public.team_members tm
|
|
JOIN public.companies c ON c.team_id = tm.team_id
|
|
WHERE c.id = company_id
|
|
AND tm.user_id = auth.uid()
|
|
AND tm.role IN ('owner', 'admin')
|
|
)
|
|
);
|
|
CREATE POLICY "voucher_gap_explanations_update" ON public.voucher_gap_explanations
|
|
FOR UPDATE USING (
|
|
company_id = public.current_active_company_id()
|
|
AND public.current_user_can_write()
|
|
AND EXISTS (
|
|
SELECT 1 FROM public.team_members tm
|
|
JOIN public.companies c ON c.team_id = tm.team_id
|
|
WHERE c.id = company_id
|
|
AND tm.user_id = auth.uid()
|
|
AND tm.role IN ('owner', 'admin')
|
|
)
|
|
);
|
|
|
|
-- =============================================================================
|
|
-- 10. Conditional tables (may not exist on fresh DBs)
|
|
-- =============================================================================
|
|
DO $$ BEGIN
|
|
IF EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name = 'account_balances'
|
|
) THEN
|
|
EXECUTE 'DROP POLICY IF EXISTS "account_balances_insert" ON public.account_balances';
|
|
EXECUTE 'DROP POLICY IF EXISTS "account_balances_update" ON public.account_balances';
|
|
EXECUTE 'DROP POLICY IF EXISTS "account_balances_delete" ON public.account_balances';
|
|
EXECUTE 'CREATE POLICY "account_balances_insert" ON public.account_balances FOR INSERT WITH CHECK (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
|
EXECUTE 'CREATE POLICY "account_balances_update" ON public.account_balances FOR UPDATE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
|
EXECUTE 'CREATE POLICY "account_balances_delete" ON public.account_balances FOR DELETE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
|
END IF;
|
|
END $$;
|
|
|
|
DO $$ BEGIN
|
|
IF EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name = 'salary_payments'
|
|
) THEN
|
|
EXECUTE 'DROP POLICY IF EXISTS "salary_payments_insert" ON public.salary_payments';
|
|
EXECUTE 'DROP POLICY IF EXISTS "salary_payments_update" ON public.salary_payments';
|
|
EXECUTE 'DROP POLICY IF EXISTS "salary_payments_delete" ON public.salary_payments';
|
|
EXECUTE 'CREATE POLICY "salary_payments_insert" ON public.salary_payments FOR INSERT WITH CHECK (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
|
EXECUTE 'CREATE POLICY "salary_payments_update" ON public.salary_payments FOR UPDATE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
|
EXECUTE 'CREATE POLICY "salary_payments_delete" ON public.salary_payments FOR DELETE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
|
END IF;
|
|
END $$;
|
|
|
|
DO $$ BEGIN
|
|
IF EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = 'public' AND table_name = 'mileage_entries'
|
|
) THEN
|
|
EXECUTE 'DROP POLICY IF EXISTS "mileage_entries_insert" ON public.mileage_entries';
|
|
EXECUTE 'DROP POLICY IF EXISTS "mileage_entries_update" ON public.mileage_entries';
|
|
EXECUTE 'DROP POLICY IF EXISTS "mileage_entries_delete" ON public.mileage_entries';
|
|
EXECUTE 'CREATE POLICY "mileage_entries_insert" ON public.mileage_entries FOR INSERT WITH CHECK (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
|
EXECUTE 'CREATE POLICY "mileage_entries_update" ON public.mileage_entries FOR UPDATE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
|
EXECUTE 'CREATE POLICY "mileage_entries_delete" ON public.mileage_entries FOR DELETE USING (company_id = public.current_active_company_id() AND public.current_user_can_write())';
|
|
END IF;
|
|
END $$;
|