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
+6 -2
View File
@@ -7,7 +7,8 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Trash2, AlertTriangle, Loader2 } from 'lucide-react'
import { Plus, Trash2, AlertTriangle, Loader2, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
@@ -62,6 +63,7 @@ export default function JournalEntryForm({
submitUrl,
embedded,
}: Props) {
const { canWrite } = useCanWrite()
const { toast } = useToast()
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [selectedPeriod, setSelectedPeriod] = useState('')
@@ -615,8 +617,10 @@ export default function JournalEntryForm({
<div className="flex flex-col items-end gap-1">
<Button
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || isSubmitting || isUploading}
disabled={!isBalanced || !description || !selectedPeriod || isSubmitting || isUploading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
Granska & skapa
</Button>
{(!description || !selectedPeriod || isUploading) && (
+13 -2
View File
@@ -10,7 +10,8 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, CheckCircle, XCircle } from 'lucide-react'
import { Loader2, CheckCircle, XCircle, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { CreateCustomerInput, CustomerType } from '@/types'
const schema = z.object({
@@ -42,6 +43,7 @@ export default function CustomerForm({
isLoading,
initialData,
}: CustomerFormProps) {
const { canWrite } = useCanWrite()
const { toast } = useToast()
const [isValidatingVat, setIsValidatingVat] = useState(false)
const [vatValidationResult, setVatValidationResult] = useState<{
@@ -305,12 +307,21 @@ export default function CustomerForm({
{/* Submit */}
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isLoading}>
<Button
type="submit"
disabled={isLoading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
Spara kund
</>
) : (
'Spara kund'
)}
+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>
+9 -1
View File
@@ -21,6 +21,8 @@ import {
} from '@/components/ui/select'
import { Deadline, DeadlineType, DeadlinePriority } from '@/types'
import { formatDateISO, DEADLINE_TYPE_LABELS, PRIORITY_LABELS } from '@/lib/calendar/utils'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { Lock } from 'lucide-react'
interface DeadlineFormProps {
open: boolean
@@ -41,6 +43,7 @@ export function DeadlineForm({
initialDate,
customers,
}: DeadlineFormProps) {
const { canWrite } = useCanWrite()
const [confirmDelete, setConfirmDelete] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [formData, setFormData] = useState({
@@ -300,7 +303,12 @@ export function DeadlineForm({
>
Avbryt
</Button>
<Button type="submit" disabled={isLoading || !formData.title}>
<Button
type="submit"
disabled={isLoading || !formData.title || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
{isLoading ? 'Sparar...' : initialData?.id ? 'Spara' : 'Skapa'}
</Button>
</div>
+20 -4
View File
@@ -7,7 +7,8 @@ import { DeadlineCard } from './DeadlineCard'
import { DeadlineFilters } from './DeadlineFilters'
import { DeadlineForm } from './DeadlineForm'
import { isDeadlineOverdue } from '@/lib/calendar/utils'
import { Plus } from 'lucide-react'
import { Plus, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
interface DeadlineListProps {
deadlines: Deadline[]
@@ -26,6 +27,7 @@ export function DeadlineList({
onDeadlineEdit,
onDeadlineDelete,
}: DeadlineListProps) {
const { canWrite } = useCanWrite()
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'completed'>('pending')
const [typeFilter, setTypeFilter] = useState<DeadlineType | 'all'>('all')
const [showForm, setShowForm] = useState(false)
@@ -105,8 +107,16 @@ export function DeadlineList({
onTypeChange={setTypeFilter}
onReset={handleResetFilters}
/>
<Button onClick={() => setShowForm(true)}>
<Plus className="h-4 w-4 mr-2" />
<Button
onClick={() => setShowForm(true)}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? (
<Plus className="h-4 w-4 mr-2" />
) : (
<Lock className="h-4 w-4 mr-2" />
)}
Ny deadline
</Button>
</div>
@@ -124,8 +134,14 @@ export function DeadlineList({
size="sm"
className="mt-4"
onClick={() => setShowForm(true)}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
{canWrite ? (
<Plus className="h-3.5 w-3.5 mr-1.5" />
) : (
<Lock className="h-3.5 w-3.5 mr-1.5" />
)}
Skapa en deadline
</Button>
</div>
+10 -1
View File
@@ -10,8 +10,10 @@ import {
FileText,
Link2,
Calendar,
Lock,
} from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { BankFileParseResult } from '@/lib/import/bank-file/types'
interface BankFileConfirmStepProps {
@@ -27,6 +29,7 @@ export default function BankFileConfirmStep({
onBack,
isLoading,
}: BankFileConfirmStepProps) {
const { canWrite } = useCanWrite()
const { transactions, stats, date_from, date_to } = parseResult
const refsCount = transactions.filter((t) => t.reference).length
@@ -123,13 +126,19 @@ export default function BankFileConfirmStep({
skip_duplicates: true,
auto_categorize: false,
})}
disabled={isLoading}
disabled={isLoading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Importerar...
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
Importera {stats.parsed_rows} transaktioner
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
+11 -2
View File
@@ -21,8 +21,10 @@ import {
Calendar,
FileText,
Database,
Lock,
} from 'lucide-react'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { ImportPreview, AccountMapping } from '@/lib/import/types'
interface ImportReviewStepProps {
@@ -47,6 +49,7 @@ export default function ImportReviewStep({
onBack,
isLoading,
}: ImportReviewStepProps) {
const { canWrite } = useCanWrite()
const [options, setOptions] = useState<ImportExecuteOptions>({
createFiscalPeriod: true,
importOpeningBalances: true,
@@ -295,9 +298,15 @@ export default function ImportReviewStep({
<Button variant="outline" className="min-h-11" onClick={onBack}>
Tillbaka
</Button>
<Button className="min-h-11" onClick={handleExecute}>
<Button
className="min-h-11"
onClick={handleExecute}
disabled={!canWrite || isLoading}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
Starta import
<ArrowRight className="ml-2 h-4 w-4" />
{canWrite && <ArrowRight className="ml-2 h-4 w-4" />}
</Button>
</div>
</div>
+123 -35
View File
@@ -1,8 +1,9 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -14,37 +15,89 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AlertTriangle, ExternalLink, Loader2 } from 'lucide-react'
import { RetentionNotice } from '@/components/ui/retention-notice'
import { ExternalLink, Loader2 } from 'lucide-react'
import { SupportLink } from '@/components/ui/support-link'
interface Blocker {
id: string
name: string
}
export function AccountDangerZone() {
const router = useRouter()
const [email, setEmail] = useState<string | null>(null)
const [blockers, setBlockers] = useState<Blocker[]>([])
const [blockersLoading, setBlockersLoading] = useState(true)
const [showDialog, setShowDialog] = useState(false)
const [confirmText, setConfirmText] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!cancelled) setEmail(user?.email ?? null)
try {
const res = await fetch('/api/company?owned=true&archived=false')
if (res.ok) {
const body = await res.json()
if (!cancelled) setBlockers(body.data ?? [])
}
} finally {
if (!cancelled) setBlockersLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [])
async function handleDelete() {
if (confirmText !== 'RADERA') return
if (!email) return
if (confirmText.trim().toLowerCase() !== email.toLowerCase()) return
setIsDeleting(true)
setError(null)
try {
const response = await fetch('/api/account/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm: 'RADERA' }),
body: JSON.stringify({ confirm_email: email }),
})
if (response.status === 409) {
const body = await response.json()
// Precondition tripped mid-flow — refresh the list and show inline.
setBlockers(body.blockers ?? [])
setError(body.error || 'Du måste radera eller överlåta dina företag först.')
setIsDeleting(false)
setShowDialog(false)
return
}
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Kunde inte radera kontot')
const body = await response.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte radera kontot')
}
router.push('/login')
} catch {
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte radera kontot')
setIsDeleting(false)
}
}
const hasBlockers = blockers.length > 0
const canDelete = !hasBlockers && !blockersLoading
return (
<>
<section className="space-y-4 border-t border-border/8 pt-8">
@@ -52,25 +105,38 @@ export function AccountDangerZone() {
Radera konto
</h2>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="flex gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="space-y-2 text-sm">
<p className="font-medium text-destructive">Denna åtgärd kan inte ångras</p>
<p className="text-muted-foreground">
All din data raderas permanent — bokföring, fakturor, verifikationer, dokument och inställningar.
</p>
<p className="text-muted-foreground">
Enligt bokföringslagen (BFL 7 kap. 2§) ska räkenskapsinformation bevaras i 7 år.
Du ansvarar själv för att exportera och arkivera din bokföringsdata.
</p>
<p className="text-muted-foreground">
Har du frågor?{' '}
<SupportLink variant="inline" subject="Fråga om kontoradering" />
</p>
</div>
{hasBlockers && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-4 space-y-3">
<p className="text-sm font-medium">Företag du äger</p>
<p className="text-sm text-muted-foreground">
Radera eller överlåt alla företag innan du raderar kontot.
</p>
<ul className="space-y-2">
{blockers.map((b) => (
<li
key={b.id}
className="flex items-center justify-between rounded-md border border-border/40 bg-background px-3 py-2"
>
<span className="text-sm font-medium">{b.name}</span>
<Button variant="ghost" size="sm" asChild>
<Link href="/settings/company">Hantera</Link>
</Button>
</li>
))}
</ul>
</div>
</div>
)}
<RetentionNotice variant="account" />
<p className="text-sm text-muted-foreground">
Har du frågor?{' '}
<SupportLink variant="inline" subject="Fråga om kontoradering" />
</p>
{error && !showDialog && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button variant="outline" className="w-full sm:w-auto" asChild>
@@ -83,37 +149,55 @@ export function AccountDangerZone() {
variant="destructive"
className="w-full sm:w-auto"
onClick={() => setShowDialog(true)}
disabled={!canDelete}
>
Radera mitt konto
</Button>
</div>
</section>
<Dialog open={showDialog} onOpenChange={(open) => {
setShowDialog(open)
if (!open) setConfirmText('')
}}>
<Dialog
open={showDialog}
onOpenChange={(open) => {
if (isDeleting) return
setShowDialog(open)
if (!open) {
setConfirmText('')
setError(null)
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Radera konto permanent</DialogTitle>
<DialogDescription>
All din data raderas permanent. Skriv <strong>RADERA</strong> nedan för att bekräfta.
Ditt konto avidentifieras och du loggas ut från alla enheter.
Du kan inte skapa ett nytt konto med samma e-postadress — kontakta support
om du vill återaktivera kontot i framtiden.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-confirm">Bekräfta genom att skriva RADERA</Label>
<Label htmlFor="delete-confirm">
Skriv din e-postadress (<strong>{email}</strong>) för att bekräfta
</Label>
<Input
id="delete-confirm"
type="email"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="RADERA"
placeholder={email ?? ''}
autoComplete="off"
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => { setShowDialog(false); setConfirmText('') }}
onClick={() => {
setShowDialog(false)
setConfirmText('')
setError(null)
}}
disabled={isDeleting}
>
Avbryt
@@ -121,7 +205,11 @@ export function AccountDangerZone() {
<Button
variant="destructive"
onClick={handleDelete}
disabled={confirmText !== 'RADERA' || isDeleting}
disabled={
!email ||
confirmText.trim().toLowerCase() !== email.toLowerCase() ||
isDeleting
}
>
{isDeleting ? (
<>
@@ -129,7 +217,7 @@ export function AccountDangerZone() {
Raderar...
</>
) : (
'Radera permanent'
'Radera konto'
)}
</Button>
</DialogFooter>
+162
View File
@@ -0,0 +1,162 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useCompany } from '@/contexts/CompanyContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { RetentionNotice } from '@/components/ui/retention-notice'
import { ExternalLink, Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
/**
* Danger zone for the currently-active company. Only visible to owners.
*
* Archive = soft delete: companies.archived_at is stamped via
* POST /api/company/[id]/delete. All bookkeeping data is retained per
* BFL 7 kap. 2§; the row just disappears from the user's UI.
*
* TODO(bankid): once users have a linked BankID identity, wrap the
* confirm step in a BankID signature gate. Guarded behind a
* capabilities.bankIdLinked boolean fetched from the user profile.
*/
export function CompanyDangerZone() {
const router = useRouter()
const { toast } = useToast()
const { company, role } = useCompany()
const [showDialog, setShowDialog] = useState(false)
const [confirmText, setConfirmText] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
if (!company || role !== 'owner') return null
async function handleDelete() {
if (!company) return
if (confirmText.trim() !== company.name.trim()) return
setIsDeleting(true)
try {
const res = await fetch(`/api/company/${company.id}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm_name: confirmText }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || 'Kunde inte radera företaget')
}
toast({ title: 'Företaget raderades', description: company.name })
// Stay inside settings. If the user had another company, the dashboard
// layout will resolve it and /settings/account still renders as
// normal. If this was their last company, the layout falls into the
// no-company shell rooted at /settings/account.
router.push('/settings/account')
router.refresh()
} catch (err) {
toast({
title: 'Kunde inte radera företaget',
description: err instanceof Error ? err.message : 'Försök igen.',
variant: 'destructive',
})
setIsDeleting(false)
}
}
return (
<>
<section className="space-y-4 border-t border-border/8 pt-8">
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
Radera företag
</h2>
<RetentionNotice variant="company" />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Button variant="outline" className="w-full sm:w-auto" asChild>
<Link href="/reports?type=full-archive">
<ExternalLink className="mr-2 h-4 w-4" />
Exportera fullständigt arkiv
</Link>
</Button>
<Button
variant="destructive"
className="w-full sm:w-auto"
onClick={() => setShowDialog(true)}
>
Radera företag
</Button>
</div>
</section>
<Dialog
open={showDialog}
onOpenChange={(open) => {
if (isDeleting) return
setShowDialog(open)
if (!open) setConfirmText('')
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Radera {company.name}</DialogTitle>
<DialogDescription>
Företaget döljs från gnubok. Bokföringen behålls säkert i 7 år enligt BFL.
Skriv företagets namn exakt för att bekräfta.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="company-delete-confirm">
Skriv <strong>{company.name}</strong> för att bekräfta
</Label>
<Input
id="company-delete-confirm"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={company.name}
autoComplete="off"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowDialog(false)
setConfirmText('')
}}
disabled={isDeleting}
>
Avbryt
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={confirmText.trim() !== company.name.trim() || isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Raderar...
</>
) : (
'Radera företag'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
+14 -2
View File
@@ -2,8 +2,9 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Loader2, Check } from 'lucide-react'
import { Loader2, Check, Lock } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
type SaveResult =
| Record<string, unknown>
@@ -17,6 +18,7 @@ interface SettingsFormWrapperProps {
export function SettingsFormWrapper({ children, onSave, className }: SettingsFormWrapperProps) {
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [isSaving, setIsSaving] = useState(false)
const [saved, setSaved] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
@@ -82,12 +84,22 @@ export function SettingsFormWrapper({ children, onSave, className }: SettingsFor
Sparat
</span>
)}
<Button type="submit" disabled={isSaving} size="sm">
<Button
type="submit"
disabled={isSaving || !canWrite}
size="sm"
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Sparar...
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-3.5 w-3.5" />
Spara ändringar
</>
) : (
'Spara ändringar'
)}
+13 -2
View File
@@ -8,7 +8,8 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2 } from 'lucide-react'
import { Loader2, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { CreateSupplierInput, SupplierType } from '@/types'
const schema = z.object({
@@ -46,6 +47,7 @@ export default function SupplierForm({
isLoading,
initialData,
}: SupplierFormProps) {
const { canWrite } = useCanWrite()
const {
register,
handleSubmit,
@@ -275,12 +277,21 @@ export default function SupplierForm({
{/* Submit */}
<div className="flex justify-end gap-2">
<Button type="submit" disabled={isLoading}>
<Button
type="submit"
disabled={isLoading || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
Spara leverantör
</>
) : (
'Spara leverantör'
)}
@@ -3,7 +3,8 @@
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import Link from 'next/link'
import { Upload, Wand, Plus, CheckSquare, FileText } from 'lucide-react'
import { Upload, Wand, Plus, CheckSquare, FileText, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { ViewMode } from './transaction-types'
interface TransactionStatusBarProps {
@@ -29,6 +30,7 @@ export default function TransactionStatusBar({
isBatchMode,
onToggleBatchMode,
}: TransactionStatusBarProps) {
const { canWrite } = useCanWrite()
return (
<div className="space-y-4">
{/* Header with title + actions */}
@@ -79,8 +81,17 @@ export default function TransactionStatusBar({
</Button>
</>
)}
<Button size="sm" onClick={onOpenCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
<Button
size="sm"
onClick={onOpenCreateDialog}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? (
<Plus className="mr-2 h-4 w-4" />
) : (
<Lock className="mr-2 h-4 w-4" />
)}
Ny transaktion
</Button>
</div>
+56
View File
@@ -0,0 +1,56 @@
'use client'
import { AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
interface RetentionNoticeProps {
variant: 'company' | 'account'
className?: string
}
/**
* Shared BFL retention notice shown before destructive actions that
* affect bookkeeping data. Keeps the legal copy consistent between the
* account danger zone and the company danger zone.
*
* Swedish Bokföringslagen (BFL) 7 kap. 2§ requires räkenskapsinformation
* to be retained for 7 years. gnubok is the system of record, so deleting
* a company or an account does not remove the underlying data — it only
* hides it from the UI and anonymizes PII where applicable.
*/
export function RetentionNotice({ variant, className }: RetentionNoticeProps) {
const copy =
variant === 'company'
? {
title: 'Bokföringen behålls i 7 år',
body:
'Enligt bokföringslagen (BFL 7 kap. 2§) sparas räkenskapsinformation i 7 år. ' +
'När du raderar företaget döljs det i gnubok, men verifikationer, dokument och ' +
'bokföring behålls säkert tills lagkravet löpt ut.',
}
: {
title: 'Kontoraderingen är permanent',
body:
'Ditt konto avidentifieras och du loggas ut från alla enheter. Räkenskaps­information ' +
'från företag du ägt behålls säkert i 7 år enligt BFL 7 kap. 2§. Du kan inte skapa ett ' +
'nytt konto med samma e-postadress — kontakta support om du vill återaktivera kontot ' +
'i framtiden. Ladda gärna ner ett fullständigt arkiv innan du fortsätter.',
}
return (
<div
className={cn(
'rounded-lg border border-destructive/30 bg-destructive/5 p-4',
className
)}
>
<div className="flex gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="space-y-1 text-sm">
<p className="font-medium text-destructive">{copy.title}</p>
<p className="text-muted-foreground">{copy.body}</p>
</div>
</div>
</div>
)
}