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:
Mattsson
2026-04-11 17:06:32 +02:00
committed by GitHub
parent 73fb97052b
commit a1a816b4a5
137 changed files with 3945 additions and 316 deletions
+37 -14
View File
@@ -1,8 +1,7 @@
'use client'
import { useState, useRef, useEffect, useTransition, useCallback } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
@@ -11,9 +10,8 @@ import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react'
export default function CompanySwitcher() {
const { company, companies } = useCompany()
const router = useRouter()
const [open, setOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const [isPending, setIsPending] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 })
@@ -73,27 +71,52 @@ export default function CompanySwitcher() {
return () => document.removeEventListener('keydown', handleKey)
}, [open])
const handleSwitch = (companyId: string) => {
const handleSwitch = async (companyId: string) => {
if (company && companyId === company.id) {
setOpen(false)
return
}
startTransition(async () => {
const result = await switchCompany(companyId)
if (!result.error) {
setOpen(false)
router.refresh()
setIsPending(true)
const result = await switchCompany(companyId)
if (result.error) {
setIsPending(false)
return
}
// Notify every other open tab of the same user so they hard-reload
// onto the new company. BroadcastChannel is best-effort — if the
// browser doesn't support it (very old) we still hard-reload
// ourselves, and other tabs will self-correct via the visibilitychange
// / pageshow listeners in CompanyTabSync on their next focus event.
if (typeof BroadcastChannel !== 'undefined') {
try {
const channel = new BroadcastChannel('gnubok-company-switch')
channel.postMessage({ companyId })
channel.close()
} catch {
// Ignore — hard reload still happens below
}
})
}
// Hard navigation — tears down React state, router cache, in-flight
// fetches, blob URLs, etc. This is the whole point: nothing from the
// previous company can survive the switch.
window.location.assign('/')
}
// Always allow opening the dropdown (to show "Lägg till företag")
const hasMultiple = companies.length > 1
const canOpen = companies.length > 0
// No companies yet — hide the switcher entirely
// No companies yet — show a direct "Lägg till företag" link instead of
// the switcher so the user can still create one.
if (!company && companies.length === 0) {
return null
return (
<Link
href="/onboarding"
className="flex items-center gap-2 w-full text-left rounded-lg border border-dashed border-border/60 hover:border-foreground/30 hover:bg-muted/40 -mx-1 px-2 py-1.5 transition-all duration-150"
>
<Plus className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-[13px] text-muted-foreground truncate">Lägg till företag</span>
</Link>
)
}
return (
+97
View File
@@ -0,0 +1,97 @@
'use client'
import { useEffect } from 'react'
import { useCompany } from '@/contexts/CompanyContext'
/**
* CompanyTabSync — cross-tab active company enforcement.
*
* Mounted once inside the dashboard layout (via CompanyProvider), this
* component guarantees that every open tab of the same user always shows
* the same active company. It has three layers:
*
* 1. BroadcastChannel('gnubok-company-switch')
* When the user switches company in one tab, every other live tab
* receives the message and hard-reloads if its current company differs
* from the broadcasted one.
*
* 2. visibilitychange
* Catches tabs that were hidden/minimized during a switch. On focus,
* the tab checks /api/company/current and reloads on mismatch — before
* any pixel of stale data is painted to the user.
*
* 3. pageshow with event.persisted === true
* Catches tabs restored from the browser's bfcache (back/forward
* navigation). bfcache literally freezes the DOM and JS state, so
* neither BroadcastChannel nor visibilitychange fires. pageshow is the
* only reliable signal and is guaranteed to fire on bfcache restore.
*
* All three layers converge on the same action: window.location.assign('/')
* — a hard navigation that wipes React state, the router cache, in-flight
* requests, blob URLs, and every other in-tab leak vector.
*
* No-op when the user has no active company (renders nothing, attaches no
* listeners).
*/
export default function CompanyTabSync() {
const { company } = useCompany()
const currentCompanyId = company?.id ?? null
useEffect(() => {
const hardReload = () => {
window.location.assign('/')
}
// Layer 1: BroadcastChannel — live cross-tab sync
let channel: BroadcastChannel | null = null
if (typeof BroadcastChannel !== 'undefined') {
channel = new BroadcastChannel('gnubok-company-switch')
channel.onmessage = (event: MessageEvent<{ companyId: string | null }>) => {
const incomingId = event.data?.companyId ?? null
if (incomingId !== currentCompanyId) {
hardReload()
}
}
}
// Layer 2: visibilitychange — on focus, verify against server
const checkServer = async () => {
try {
const res = await fetch('/api/company/current', {
cache: 'no-store',
credentials: 'same-origin',
})
if (!res.ok) return
const data = (await res.json()) as { companyId: string | null }
if (data.companyId !== currentCompanyId) {
hardReload()
}
} catch {
// Network error / offline — do nothing (don't accidentally reload-loop)
}
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void checkServer()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
// Layer 3: pageshow (persisted === true) — bfcache restore
const handlePageShow = (event: PageTransitionEvent) => {
if (event.persisted) {
void checkServer()
}
}
window.addEventListener('pageshow', handlePageShow)
return () => {
channel?.close()
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('pageshow', handlePageShow)
}
}, [currentCompanyId])
return null
}
+250 -95
View File
@@ -30,6 +30,7 @@ import {
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { SupportLink } from '@/components/ui/support-link'
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
import { useCompany } from '@/contexts/CompanyContext'
import type { EntityType } from '@/types'
interface ExtensionNavItem {
@@ -87,13 +88,23 @@ const groupLabels: Record<string, string> = {
övrigt: 'Övrigt',
}
export default function DashboardNav({ companyName, entityType, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [] }: DashboardNavProps) {
export default function DashboardNav({ companyName: _companyName, entityType, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [] }: DashboardNavProps) {
const pathname = usePathname()
const router = useRouter()
const supabase = createClient()
const { company } = useCompany()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const [isClosing, setIsClosing] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// When the user has no active company (e.g. just archived their last
// one), every company-scoped route is unreachable. We keep them visible
// so the sidebar doesn't collapse, but render them as disabled.
// Only /settings remains navigable — from there the user can either
// create a new company or delete their account.
const hasCompany = !!company
const ALWAYS_ENABLED = new Set(['/settings'])
const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href)
// Auto-expand Övrigt when the user is on one of its pages, or when manually toggled
const isOnOvrigtPage = ['/help', '/settings', '/e/'].some(p => pathname.startsWith(p))
const [manualOvrigtExpanded, setManualOvrigtExpanded] = useState(false)
@@ -175,23 +186,40 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
{mainItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
active
? 'bg-primary/12 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
const enabled = isItemEnabled(item.href)
const content = (
<>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
{item.label}
</>
)
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-primary/12 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)
: 'text-muted-foreground/40 cursor-not-allowed'
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title="Lägg till ett företag för att aktivera"
>
{content}
</div>
)
})}
</div>
@@ -207,22 +235,14 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
{items.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
return (
<Link
key={item.href}
href={item.href}
className={cn(
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
active
? 'bg-primary/12 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
const content = (
<>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
@@ -233,7 +253,32 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
{badge > 99 ? '99+' : badge}
</span>
)}
</>
)
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-primary/12 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)
: 'text-muted-foreground/40 cursor-not-allowed'
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title="Lägg till ett företag för att aktivera"
>
{content}
</div>
)
})}
</div>
@@ -257,45 +302,80 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
{extensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
active
? 'bg-primary/12 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
// Extension nav items are always company-scoped.
const enabled = hasCompany
const content = (
<>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
{item.label}
</>
)
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-primary/12 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)
: 'text-muted-foreground/40 cursor-not-allowed'
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title="Lägg till ett företag för att aktivera"
>
{content}
</div>
)
})}
{övrigtItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
active
? 'bg-primary/12 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
const enabled = isItemEnabled(item.href)
const content = (
<>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
{item.label}
</>
)
const baseClass = cn(
'group flex items-center px-3 py-[7px] text-[13px] rounded-lg',
enabled
? cn(
'transition-colors duration-150',
active
? 'bg-primary/12 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)
: 'text-muted-foreground/40 cursor-not-allowed'
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div
key={item.href}
className={baseClass}
aria-disabled="true"
title="Lägg till ett företag för att aktivera"
>
{content}
</div>
)
})}
</div>
@@ -327,21 +407,13 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
{mobileNavItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
: null
return (
<Link
key={item.href}
href={item.href}
className={cn(
'relative flex flex-col items-center justify-center flex-1 h-full text-xs transition-colors duration-200',
active
? 'text-primary'
: 'text-muted-foreground'
)}
>
const content = (
<>
<div className="relative">
<Icon className={cn(
"h-5 w-5 mb-1",
@@ -357,7 +429,26 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
"truncate",
active && "font-medium"
)}>{item.label}</span>
</>
)
const baseClass = cn(
'relative flex flex-col items-center justify-center flex-1 h-full text-xs',
enabled
? cn(
'transition-colors duration-200',
active ? 'text-primary' : 'text-muted-foreground'
)
: 'text-muted-foreground/40'
)
return enabled ? (
<Link key={item.href} href={item.href} className={baseClass}>
{content}
</Link>
) : (
<div key={item.href} className={baseClass} aria-disabled="true">
{content}
</div>
)
})}
{/* Menu button */}
@@ -424,21 +515,37 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
{mainItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
const enabled = isItemEnabled(item.href)
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm">{item.label}</span>
</>
)
const baseClass = cn(
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
enabled
? cn(
'transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground active:bg-muted/60'
)
: 'text-muted-foreground/40'
)
return enabled ? (
<Link
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className={cn(
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground active:bg-muted/60'
)}
className={baseClass}
>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm">{item.label}</span>
{content}
</Link>
) : (
<div key={item.href} className={baseClass} aria-disabled="true">
{content}
</div>
)
})}
</div>
@@ -454,23 +561,14 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
{items.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
: null
return (
<Link
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className={cn(
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground active:bg-muted/60'
)}
>
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm flex-1">{item.label}</span>
{badge !== null && (
@@ -478,7 +576,32 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
{badge > 99 ? '99+' : badge}
</span>
)}
</>
)
const baseClass = cn(
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
enabled
? cn(
'transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground active:bg-muted/60'
)
: 'text-muted-foreground/40'
)
return enabled ? (
<Link
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className={baseClass}
>
{content}
</Link>
) : (
<div key={item.href} className={baseClass} aria-disabled="true">
{content}
</div>
)
})}
</div>
@@ -496,41 +619,73 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
{extensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
return (
const enabled = hasCompany
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm">{item.label}</span>
</>
)
const baseClass = cn(
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
enabled
? cn(
'transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground active:bg-muted/60'
)
: 'text-muted-foreground/40'
)
return enabled ? (
<Link
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className={cn(
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground active:bg-muted/60'
)}
className={baseClass}
>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm">{item.label}</span>
{content}
</Link>
) : (
<div key={item.href} className={baseClass} aria-disabled="true">
{content}
</div>
)
})}
{övrigtItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
const enabled = isItemEnabled(item.href)
const content = (
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm">{item.label}</span>
</>
)
const baseClass = cn(
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg',
enabled
? cn(
'transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground active:bg-muted/60'
)
: 'text-muted-foreground/40'
)
return enabled ? (
<Link
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className={cn(
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground active:bg-muted/60'
)}
className={baseClass}
>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm">{item.label}</span>
{content}
</Link>
) : (
<div key={item.href} className={baseClass} aria-disabled="true">
{content}
</div>
)
})}
</div>