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:
@@ -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) && (
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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äkenskapsinformation ' +
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user