From a1a816b4a59ffd4d842f449a08a237a39ab3803c Mon Sep 17 00:00:00 2001
From: Mattsson <111893710+mattssonn@users.noreply.github.com>
Date: Sat, 11 Apr 2026 17:06:32 +0200
Subject: [PATCH] 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.
---
app/(dashboard)/bookkeeping/[id]/page.tsx | 7 +-
app/(dashboard)/customers/[id]/page.tsx | 17 +-
app/(dashboard)/customers/page.tsx | 15 +-
app/(dashboard)/expenses/new/page.tsx | 16 +-
app/(dashboard)/expenses/page.tsx | 22 +-
app/(dashboard)/invoices/[id]/credit/page.tsx | 12 +-
app/(dashboard)/invoices/[id]/page.tsx | 45 +-
app/(dashboard)/invoices/new/page.tsx | 20 +-
app/(dashboard)/invoices/page.tsx | 22 +-
app/(dashboard)/layout.tsx | 70 +-
app/(dashboard)/settings/company/page.tsx | 3 +
.../supplier-invoices/[id]/page.tsx | 39 +-
.../supplier-invoices/new/page.tsx | 11 +-
app/(dashboard)/supplier-invoices/page.tsx | 24 +-
app/(dashboard)/suppliers/[id]/page.tsx | 23 +-
app/(dashboard)/suppliers/page.tsx | 28 +-
app/(onboarding)/layout.tsx | 41 +-
.../account/delete/__tests__/route.test.ts | 154 ++++
app/api/account/delete/route.ts | 131 +++-
.../bookkeeping/accounts/[number]/route.ts | 7 +
.../bookkeeping/accounts/activate/route.ts | 4 +
app/api/bookkeeping/accounts/route.ts | 4 +
.../fiscal-periods/[id]/close/route.ts | 4 +
.../[id]/currency-revaluation/route.ts | 4 +
.../fiscal-periods/[id]/lock/route.ts | 4 +
.../bookkeeping/fiscal-periods/[id]/route.ts | 4 +
.../fiscal-periods/[id]/year-end/route.ts | 4 +
app/api/bookkeeping/fiscal-periods/route.ts | 4 +
.../[id]/correct/__tests__/route.test.ts | 5 +
.../journal-entries/[id]/correct/route.ts | 4 +
.../[id]/reverse/__tests__/route.test.ts | 5 +
.../journal-entries/[id]/reverse/route.ts | 4 +
.../journal-entries/__tests__/route.test.ts | 5 +
app/api/bookkeeping/journal-entries/route.ts | 4 +
app/api/bookkeeping/mapping-rules/route.ts | 4 +
.../voucher-gaps/__tests__/route.test.ts | 5 +
app/api/bookkeeping/voucher-gaps/route.ts | 4 +
app/api/calendar/feed/route.ts | 10 +
.../[id]/delete/__tests__/route.test.ts | 205 ++++++
app/api/company/[id]/delete/route.ts | 156 ++++
app/api/company/current/route.ts | 36 +
app/api/company/members/[id]/route.ts | 4 +
app/api/company/members/invite/[id]/route.ts | 4 +
app/api/company/members/invite/route.ts | 28 +-
app/api/company/route.ts | 52 ++
app/api/customers/[id]/route.ts | 7 +
app/api/customers/__tests__/viewer.test.ts | 87 +++
app/api/customers/route.ts | 4 +
app/api/deadlines/[id]/complete/route.ts | 4 +
app/api/deadlines/[id]/route.ts | 7 +
app/api/deadlines/[id]/status/route.ts | 4 +
app/api/deadlines/route.ts | 4 +
app/api/documents/[id]/link/route.ts | 4 +
app/api/documents/[id]/verify/route.ts | 4 +
app/api/documents/[id]/versions/route.ts | 4 +
app/api/documents/match-sweep/route.ts | 4 +
app/api/documents/route.ts | 4 +
.../extensions/[sector]/[slug]/data/route.ts | 7 +
.../[sector]/[slug]/settings/route.ts | 4 +
app/api/import/bank-file/execute/route.ts | 4 +
app/api/import/sie/[id]/route.ts | 4 +
app/api/import/sie/create-accounts/route.ts | 4 +
app/api/import/sie/execute/route.ts | 4 +
app/api/import/sie/mappings/route.ts | 10 +
app/api/invoices/[id]/convert/route.ts | 4 +
.../[id]/mark-paid/__tests__/route.test.ts | 5 +
app/api/invoices/[id]/mark-paid/route.ts | 4 +
app/api/invoices/[id]/mark-sent/route.ts | 4 +
app/api/invoices/[id]/route.ts | 4 +
.../[id]/send/__tests__/route.test.ts | 4 +
app/api/invoices/[id]/send/route.ts | 4 +
app/api/invoices/__tests__/route.test.ts | 4 +
app/api/invoices/route.ts | 4 +
app/api/kpi/preferences/route.ts | 4 +
.../[id]/commit/__tests__/route.test.ts | 4 +
.../pending-operations/[id]/commit/route.ts | 4 +
.../[id]/reject/__tests__/route.test.ts | 4 +
.../pending-operations/[id]/reject/route.ts | 4 +
app/api/reconciliation/bank/link/route.ts | 4 +
app/api/reconciliation/bank/run/route.ts | 4 +
app/api/reconciliation/bank/unlink/route.ts | 4 +
app/api/settings/api-keys/[id]/route.ts | 4 +
app/api/settings/api-keys/route.ts | 4 +
.../settings/counterparty-templates/route.ts | 4 +
app/api/settings/logo/route.ts | 7 +
app/api/settings/route.ts | 4 +
.../[id]/approve/__tests__/route.test.ts | 4 +
.../supplier-invoices/[id]/approve/route.ts | 4 +
.../supplier-invoices/[id]/credit/route.ts | 4 +
.../[id]/mark-paid/__tests__/route.test.ts | 4 +
.../supplier-invoices/[id]/mark-paid/route.ts | 4 +
app/api/supplier-invoices/[id]/route.ts | 7 +
.../supplier-invoices/__tests__/route.test.ts | 4 +
app/api/supplier-invoices/route.ts | 4 +
app/api/suppliers/[id]/route.ts | 7 +
app/api/suppliers/route.ts | 4 +
app/api/tax-deadlines/generate/route.ts | 4 +
.../transactions/[id]/__tests__/route.test.ts | 5 +
.../[id]/book/__tests__/route.test.ts | 5 +
app/api/transactions/[id]/book/route.ts | 4 +
.../[id]/categorize/__tests__/route.test.ts | 4 +
app/api/transactions/[id]/categorize/route.ts | 4 +
.../match-invoice/__tests__/route.test.ts | 5 +
.../transactions/[id]/match-invoice/route.ts | 4 +
.../[id]/match-supplier-invoice/route.ts | 4 +
app/api/transactions/[id]/route.ts | 4 +
.../[id]/uncategorize/__tests__/route.test.ts | 5 +
.../transactions/[id]/uncategorize/route.ts | 4 +
.../batch-match-invoices/route.ts | 4 +
app/invite/[token]/page.tsx | 147 +++-
components/bookkeeping/JournalEntryForm.tsx | 8 +-
components/customers/CustomerForm.tsx | 15 +-
components/dashboard/CompanySwitcher.tsx | 51 +-
components/dashboard/CompanyTabSync.tsx | 97 +++
components/dashboard/DashboardNav.tsx | 345 ++++++---
components/deadlines/DeadlineForm.tsx | 10 +-
components/deadlines/DeadlineList.tsx | 24 +-
components/import/BankFileConfirmStep.tsx | 11 +-
components/import/ImportReviewStep.tsx | 13 +-
components/settings/AccountDangerZone.tsx | 158 ++++-
components/settings/CompanyDangerZone.tsx | 162 +++++
components/settings/SettingsFormWrapper.tsx | 16 +-
components/suppliers/SupplierForm.tsx | 15 +-
.../transactions/TransactionStatusBar.tsx | 17 +-
components/ui/retention-notice.tsx | 56 ++
lib/auth/__tests__/require-write.test.ts | 81 +++
lib/auth/require-write.ts | 63 ++
lib/company/actions.ts | 5 +-
lib/company/context.ts | 58 +-
lib/events/types.ts | 3 +
lib/hooks/use-can-write.ts | 23 +
lib/supabase/middleware.ts | 96 ++-
...00001_user_company_ids_filter_archived.sql | 36 +
.../20260411000002_profiles_anonymization.sql | 13 +
...60411000003_anonymize_user_account_rpc.sql | 68 ++
...411120000_active_company_rls_isolation.sql | 664 ++++++++++++++++++
.../20260411130000_viewer_role_read_only.sql | 364 ++++++++++
137 files changed, 3945 insertions(+), 316 deletions(-)
create mode 100644 app/api/account/delete/__tests__/route.test.ts
create mode 100644 app/api/company/[id]/delete/__tests__/route.test.ts
create mode 100644 app/api/company/[id]/delete/route.ts
create mode 100644 app/api/company/current/route.ts
create mode 100644 app/api/company/route.ts
create mode 100644 app/api/customers/__tests__/viewer.test.ts
create mode 100644 components/dashboard/CompanyTabSync.tsx
create mode 100644 components/settings/CompanyDangerZone.tsx
create mode 100644 components/ui/retention-notice.tsx
create mode 100644 lib/auth/__tests__/require-write.test.ts
create mode 100644 lib/auth/require-write.ts
create mode 100644 lib/hooks/use-can-write.ts
create mode 100644 supabase/migrations/20260411000001_user_company_ids_filter_archived.sql
create mode 100644 supabase/migrations/20260411000002_profiles_anonymization.sql
create mode 100644 supabase/migrations/20260411000003_anonymize_user_account_rpc.sql
create mode 100644 supabase/migrations/20260411120000_active_company_rls_isolation.sql
create mode 100644 supabase/migrations/20260411130000_viewer_role_read_only.sql
diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx
index 61a190f1..5b51cb46 100644
--- a/app/(dashboard)/bookkeeping/[id]/page.tsx
+++ b/app/(dashboard)/bookkeeping/[id]/page.tsx
@@ -5,7 +5,8 @@ import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { AccountNumber } from '@/components/ui/account-number'
-import { Loader2, ArrowLeft, Paperclip, AlertTriangle } from 'lucide-react'
+import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock } from 'lucide-react'
+import { useCanWrite } from '@/lib/hooks/use-can-write'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import JournalEntryStatusBadge, { sourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
@@ -14,6 +15,7 @@ import type { JournalEntry, JournalEntryLine } from '@/types'
export default function JournalEntryDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
+ const { canWrite } = useCanWrite()
const [entry, setEntry] = useState(null)
const [chain, setChain] = useState([])
const [isLoading, setIsLoading] = useState(true)
@@ -124,7 +126,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
size="sm"
className="w-full sm:w-auto"
onClick={() => setShowCorrection(true)}
+ disabled={!canWrite}
+ title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
+ {!canWrite && }
Skapa ändringsverifikation
)}
diff --git a/app/(dashboard)/customers/[id]/page.tsx b/app/(dashboard)/customers/[id]/page.tsx
index 0cc878fc..cfa6d9fa 100644
--- a/app/(dashboard)/customers/[id]/page.tsx
+++ b/app/(dashboard)/customers/[id]/page.tsx
@@ -23,7 +23,9 @@ import {
Trash2,
Loader2,
Receipt,
+ Lock,
} from 'lucide-react'
+import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
const customerTypeLabels: Record = {
@@ -63,6 +65,7 @@ export default function CustomerDetailPage({
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
+ const { canWrite } = useCanWrite()
const [customer, setCustomer] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
@@ -203,8 +206,14 @@ export default function CustomerDetailPage({
-
diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx
index 3d32b0ad..e0a3c799 100644
--- a/app/(dashboard)/customers/page.tsx
+++ b/app/(dashboard)/customers/page.tsx
@@ -8,11 +8,12 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
-import { Plus, Search, Users } from 'lucide-react'
+import { Plus, Search, Users, Lock } from 'lucide-react'
import CustomerForm from '@/components/customers/CustomerForm'
import { EmptyCustomers } from '@/components/ui/empty-state'
import Link from 'next/link'
import { useCompany } from '@/contexts/CompanyContext'
+import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
const customerTypeLabels: Record = {
@@ -33,6 +34,7 @@ function getInitials(name: string): string {
export default function CustomersPage() {
const { company } = useCompany()
+ const { canWrite } = useCanWrite()
const [customers, setCustomers] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
@@ -112,8 +114,15 @@ export default function CustomersPage() {
-
-
-
+ {canWrite ? (
+
+
+
+ Registrera faktura
+
+
+ ) : (
+
+
Registrera faktura
-
+ )}
{/* Summary cards */}
@@ -182,7 +194,7 @@ export default function SupplierInvoicesPage() {
? 'Registrera din första leverantörsfaktura'
: 'Inga fakturor i denna kategori'}
- {activeTab === 'all' && (
+ {activeTab === 'all' && canWrite && (
diff --git a/app/(dashboard)/suppliers/[id]/page.tsx b/app/(dashboard)/suppliers/[id]/page.tsx
index 2ed29c76..05525a6e 100644
--- a/app/(dashboard)/suppliers/[id]/page.tsx
+++ b/app/(dashboard)/suppliers/[id]/page.tsx
@@ -7,7 +7,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
-import { ArrowLeft, Edit, Trash2, FileText } from 'lucide-react'
+import { ArrowLeft, Edit, Trash2, FileText, Lock } from 'lucide-react'
+import { useCanWrite } from '@/lib/hooks/use-can-write'
import SupplierForm from '@/components/suppliers/SupplierForm'
import Link from 'next/link'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
@@ -24,6 +25,7 @@ function formatAmount(amount: number): string {
}
export default function SupplierDetailPage() {
+ const { canWrite } = useCanWrite()
const params = useParams()
const router = useRouter()
const { toast } = useToast()
@@ -153,12 +155,23 @@ export default function SupplierDetailPage() {
- setIsEditOpen(true)}>
-
+ setIsEditOpen(true)}
+ disabled={!canWrite}
+ title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
+ >
+ {canWrite ? : }
Redigera
-
-
+
+ {canWrite ? : }
diff --git a/app/(dashboard)/suppliers/page.tsx b/app/(dashboard)/suppliers/page.tsx
index 37350712..b7ef0efa 100644
--- a/app/(dashboard)/suppliers/page.tsx
+++ b/app/(dashboard)/suppliers/page.tsx
@@ -8,10 +8,11 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
-import { Plus, Search, Building2, Globe } from 'lucide-react'
+import { Plus, Search, Building2, Globe, Lock } from 'lucide-react'
import SupplierForm from '@/components/suppliers/SupplierForm'
import Link from 'next/link'
import { useCompany } from '@/contexts/CompanyContext'
+import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Supplier, SupplierType, CreateSupplierInput } from '@/types'
const supplierTypeLabels: Record = {
@@ -28,6 +29,7 @@ const supplierTypeIcons: Record = {
export default function SuppliersPage() {
const { company } = useCompany()
+ const { canWrite } = useCanWrite()
const [suppliers, setSuppliers] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
@@ -108,8 +110,15 @@ export default function SuppliersPage() {