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 -1
View File
@@ -5,7 +5,8 @@ import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { AccountNumber } from '@/components/ui/account-number'
import { Loader2, ArrowLeft, Paperclip, AlertTriangle } from 'lucide-react'
import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import JournalEntryStatusBadge, { sourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
@@ -14,6 +15,7 @@ import type { JournalEntry, JournalEntryLine } from '@/types'
export default function JournalEntryDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const { canWrite } = useCanWrite()
const [entry, setEntry] = useState<JournalEntry | null>(null)
const [chain, setChain] = useState<JournalEntry[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -124,7 +126,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
size="sm"
className="w-full sm:w-auto"
onClick={() => setShowCorrection(true)}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
Skapa ändringsverifikation
</Button>
)}
+14 -3
View File
@@ -23,7 +23,9 @@ import {
Trash2,
Loader2,
Receipt,
Lock,
} from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
const customerTypeLabels: Record<CustomerType, string> = {
@@ -63,6 +65,7 @@ export default function CustomerDetailPage({
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [customer, setCustomer] = useState<CustomerWithRelations | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
@@ -203,8 +206,14 @@ export default function CustomerDetailPage({
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setIsEditOpen(true)}>
<Edit2 className="h-4 w-4 mr-1" />
<Button
variant="outline"
size="sm"
onClick={() => setIsEditOpen(true)}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? <Edit2 className="h-4 w-4 mr-1" /> : <Lock className="h-4 w-4 mr-1" />}
Redigera
</Button>
<Button
@@ -212,8 +221,10 @@ export default function CustomerDetailPage({
size="sm"
onClick={handleDelete}
className="text-destructive hover:text-destructive"
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
<Trash2 className="h-4 w-4 mr-1" />
{canWrite ? <Trash2 className="h-4 w-4 mr-1" /> : <Lock className="h-4 w-4 mr-1" />}
Ta bort
</Button>
</div>
+12 -3
View File
@@ -8,11 +8,12 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Search, Users } from 'lucide-react'
import { Plus, Search, Users, Lock } from 'lucide-react'
import CustomerForm from '@/components/customers/CustomerForm'
import { EmptyCustomers } from '@/components/ui/empty-state'
import Link from 'next/link'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
const customerTypeLabels: Record<CustomerType, string> = {
@@ -33,6 +34,7 @@ function getInitials(name: string): string {
export default function CustomersPage() {
const { company } = useCompany()
const { canWrite } = useCanWrite()
const [customers, setCustomers] = useState<Customer[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
@@ -112,8 +114,15 @@ export default function CustomersPage() {
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
<Button
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 kund
</Button>
</DialogTrigger>
+14 -2
View File
@@ -18,7 +18,8 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2 } from 'lucide-react'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Supplier, BASAccount, VatTreatment, EntityType } from '@/types'
interface LineItem {
@@ -72,6 +73,7 @@ function inferVatTreatment(items: LineItem[], reverseCharge: boolean): VatTreatm
export default function NewExpensePage() {
const router = useRouter()
const { canWrite } = useCanWrite()
const { toast } = useToast()
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
@@ -704,12 +706,22 @@ export default function NewExpensePage() {
<Button type="button" variant="outline" className="w-full sm:w-auto" onClick={() => router.push('/expenses')}>
Avbryt
</Button>
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
<Button
type="submit"
disabled={isSubmitting || !canWrite}
className="w-full sm:w-auto"
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Registrerar...
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
{isEF ? 'Registrera utgift' : 'Granska & registrera'}
</>
) : isEF ? (
'Registrera utgift'
) : (
+17 -5
View File
@@ -12,8 +12,9 @@ import { PageHeader } from '@/components/ui/page-header'
import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { Plus, Search, Wallet, Clock, AlertCircle } from 'lucide-react'
import { Plus, Search, Wallet, Clock, AlertCircle, Lock } from 'lucide-react'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { SupplierInvoice } from '@/types'
type ExpenseInvoice = SupplierInvoice & { supplier?: { id: string; name: string } }
@@ -62,6 +63,7 @@ function getRelativeTimeLabel(dueDateStr: string, status: string): { text: strin
export default function ExpensesPage() {
const { company } = useCompany()
const { canWrite } = useCanWrite()
const [invoices, setInvoices] = useState<ExpenseInvoice[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
@@ -119,12 +121,22 @@ export default function ExpensesPage() {
title="Utgifter"
description="Registrera och hantera dina utgifter"
action={
<Link href="/expenses/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
canWrite ? (
<Link href="/expenses/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Ny utgift
</Button>
</Link>
) : (
<Button
disabled
title="Du har endast läsbehörighet i detta företag"
>
<Lock className="mr-2 h-4 w-4" />
Ny utgift
</Button>
</Link>
)
}
/>
+10 -2
View File
@@ -12,7 +12,8 @@ import { Separator } from '@/components/ui/separator'
import { useToast } from '@/components/ui/use-toast'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
import { Loader2, ArrowLeft, AlertTriangle } from 'lucide-react'
import { Loader2, ArrowLeft, AlertTriangle, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Invoice, InvoiceItem, Customer } from '@/types'
interface InvoiceWithRelations extends Invoice {
@@ -21,6 +22,7 @@ interface InvoiceWithRelations extends Invoice {
}
export default function CreateCreditNotePage({ params }: { params: Promise<{ id: string }> }) {
const { canWrite } = useCanWrite()
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
@@ -325,13 +327,19 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
<Button
variant="destructive"
onClick={handleSubmit}
disabled={isSubmitting || confirmText !== invoice.invoice_number}
disabled={isSubmitting || confirmText !== invoice.invoice_number || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar...
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
Skapa kreditfaktura
</>
) : (
'Skapa kreditfaktura'
)}
+36 -9
View File
@@ -26,7 +26,9 @@ import {
AlertTriangle,
MessageSquare,
Trash2,
Lock,
} from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog'
import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
import {
@@ -62,6 +64,7 @@ interface InvoiceWithRelations extends Invoice {
}
export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { canWrite } = useCanWrite()
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
@@ -379,9 +382,15 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{/* Actions */}
<div className="flex flex-wrap items-center gap-2">
{isProforma && invoice.status !== 'cancelled' && (
<Button onClick={convertToInvoice} disabled={isConverting}>
<Button
onClick={convertToInvoice}
disabled={isConverting || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{isConverting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : !canWrite ? (
<Lock className="mr-2 h-4 w-4" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
@@ -390,26 +399,44 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)}
{invoice.status === 'draft' && !isDeliveryNote && (
customerHasEmail ? (
<Button onClick={() => openSendDialog('email')}>
<Mail className="mr-2 h-4 w-4" />
<Button
onClick={() => openSendDialog('email')}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? <Mail className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Skicka via e-post
</Button>
) : (
<Button variant="secondary" onClick={() => openSendDialog('manual')}>
<Send className="mr-2 h-4 w-4" />
<Button
variant="secondary"
onClick={() => openSendDialog('manual')}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? <Send className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Skickad manuellt
</Button>
)
)}
{isDeliveryNote && invoice.status === 'draft' && (
<Button variant="secondary" onClick={() => updateStatus('sent')} disabled={isUpdating}>
<Send className="mr-2 h-4 w-4" />
<Button
variant="secondary"
onClick={() => updateStatus('sent')}
disabled={isUpdating || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? <Send className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Markera som skickad
</Button>
)}
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
<Button onClick={() => setShowPaymentDialog(true)} disabled={isUpdating}>
<CheckCircle className="mr-2 h-4 w-4" />
<Button
onClick={() => setShowPaymentDialog(true)}
disabled={isUpdating || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? <CheckCircle className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Markera som betald
</Button>
)}
+17 -3
View File
@@ -19,7 +19,8 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark } from 'lucide-react'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -57,6 +58,7 @@ const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
export default function NewInvoicePage() {
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
const supabase = createClient()
const [customers, setCustomers] = useState<Customer[]>([])
@@ -763,7 +765,14 @@ export default function NewInvoicePage() {
</Card>
{/* Actions — desktop only */}
<Button type="submit" className="w-full hidden lg:block" size="lg" disabled={isSubmitting}>
<Button
type="submit"
className="w-full hidden lg:block"
size="lg"
disabled={isSubmitting || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
Granska & skapa
</Button>
</div>
@@ -776,7 +785,12 @@ export default function NewInvoicePage() {
<p className="text-xs text-muted-foreground">Totalt</p>
<p className="text-lg font-bold tabular-nums">{formatCurrency(total, watchCurrency)}</p>
</div>
<Button type="submit" disabled={isSubmitting}>
<Button
type="submit"
disabled={isSubmitting || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
Granska & skapa
</Button>
</div>
+17 -5
View File
@@ -13,9 +13,10 @@ import { PageHeader } from '@/components/ui/page-header'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { Plus, Search, Receipt } from 'lucide-react'
import { Plus, Search, Receipt, Lock } from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Invoice, InvoiceStatus } from '@/types'
const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; borderColor: string }> = {
@@ -51,6 +52,7 @@ function getRelativeTimeLabel(dueDateStr: string, status: InvoiceStatus): { text
export default function InvoicesPage() {
const { company } = useCompany()
const { canWrite } = useCanWrite()
const [invoices, setInvoices] = useState<Invoice[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
@@ -115,12 +117,22 @@ export default function InvoicesPage() {
title="Fakturor"
description="Skicka fakturor, följ betalningar och skapa kreditnotor"
action={
<Link href="/invoices/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
canWrite ? (
<Link href="/invoices/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Ny faktura
</Button>
</Link>
) : (
<Button
disabled
title="Du har endast läsbehörighet i detta företag"
>
<Lock className="mr-2 h-4 w-4" />
Ny faktura
</Button>
</Link>
)
}
/>
+64 -6
View File
@@ -1,7 +1,8 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { cookies } from 'next/headers'
import { headers } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav'
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
import { RecaptIdentify } from '@/components/RecaptIdentify'
import { SentryIdentify } from '@/components/SentryIdentify'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
@@ -10,6 +11,13 @@ import { CompanyProvider } from '@/contexts/CompanyContext'
import { getActiveCompanyId } from '@/lib/company/context'
import type { EntityType, CompanyRole, Team } from '@/types'
/**
* Routes inside the dashboard group that must remain reachable when the
* user has no active company. Keep in sync with the middleware's
* no-company allowlist.
*/
const NO_COMPANY_ALLOWED_PATHS = ['/settings/account']
export default async function DashboardLayout({
children,
}: {
@@ -23,9 +31,19 @@ export default async function DashboardLayout({
redirect('/login')
}
const cookieStore = await cookies()
const companyId = cookieStore.get('gnubok-company-id')?.value
?? await getActiveCompanyId(supabase, user.id)
// Resolve active company from user_preferences (authoritative). The
// `gnubok-company-id` cookie is intentionally no longer consulted here —
// `getActiveCompanyId` reads from user_preferences, matching what RLS
// sees via `current_active_company_id()`. Keeping both sides on the same
// source avoids cross-tab / cookie divergence.
const companyId = await getActiveCompanyId(supabase, user.id)
// Read the pathname forwarded by middleware so we can branch on it.
const headerStore = await headers()
const pathname = headerStore.get('x-pathname') ?? ''
const isNoCompanyAllowed = NO_COMPANY_ALLOWED_PATHS.some((p) =>
pathname.startsWith(p)
)
// Fetch team membership + team info
const { data: teamMembership } = await supabase
@@ -47,9 +65,47 @@ export default async function DashboardLayout({
const isTeamMember = !!teamMembership
// No companies — redirect to onboarding
// No companies — redirect to onboarding, except for allowed escape-hatch
// routes (so the user can still reach /settings/account to delete their
// account after archiving their last company).
if (!companyId) {
redirect('/onboarding')
if (!isNoCompanyAllowed) {
redirect('/onboarding')
}
return (
<CompanyProvider
value={{
company: null,
role: null,
companies: [],
isTeamMember,
team,
}}
>
<CompanyTabSync />
<div className="min-h-screen bg-background">
<DashboardNav
companyName="gnubok"
entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
<main
id="main-content"
className="safe-area-main-padding md:!pb-0 md:pl-[232px]"
role="main"
>
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
{children}
</div>
</main>
<SentryIdentify userId={user.id} email={user.email} />
</div>
</CompanyProvider>
)
}
// Fetch company + membership for context provider
@@ -79,6 +135,7 @@ export default async function DashboardLayout({
return (
<CompanyProvider value={companyContextValue}>
<CompanyTabSync />
<div className="min-h-screen bg-background">
<DashboardNav
companyName="gnubok"
@@ -145,6 +202,7 @@ export default async function DashboardLayout({
return (
<CompanyProvider value={companyContextValue}>
<CompanyTabSync />
<div className="min-h-screen bg-background">
{/* Skip to content link for keyboard/screen reader users */}
<a
@@ -1,5 +1,6 @@
'use client'
import { CompanyDangerZone } from '@/components/settings/CompanyDangerZone'
import { CompanyInfoForm } from '@/components/settings/CompanyInfoForm'
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
import { LogoUpload } from '@/components/settings/LogoUpload'
@@ -49,6 +50,8 @@ export default function CompanySettingsPage() {
<div className="border-t border-border/8 pt-8">
<CompanyMembersSection />
</div>
<CompanyDangerZone />
</div>
)
}
@@ -9,7 +9,8 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2 } from 'lucide-react'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import Link from 'next/link'
import { AccountNumber } from '@/components/ui/account-number'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
@@ -40,6 +41,7 @@ const statusLabels: Record<string, string> = {
}
export default function SupplierInvoiceDetailPage() {
const { canWrite } = useCanWrite()
const params = useParams()
const router = useRouter()
const { toast } = useToast()
@@ -189,24 +191,43 @@ export default function SupplierInvoiceDetailPage() {
<div className="flex flex-wrap gap-2">
{invoice.status === 'registered' && (
<>
<Button onClick={handleApprove} disabled={isProcessing}>
<CheckCircle className="mr-2 h-4 w-4" />
<Button
onClick={handleApprove}
disabled={isProcessing || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? <CheckCircle className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Godkänn
</Button>
<Button variant="destructive" size="icon" onClick={handleDelete} disabled={isProcessing}>
<Trash2 className="h-4 w-4" />
<Button
variant="destructive"
size="icon"
onClick={handleDelete}
disabled={isProcessing || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? <Trash2 className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
</Button>
</>
)}
{['approved', 'overdue', 'partially_paid'].includes(invoice.status) && (
<>
<Button onClick={() => setIsPayDialogOpen(true)} disabled={isProcessing}>
<CreditCard className="mr-2 h-4 w-4" />
<Button
onClick={() => setIsPayDialogOpen(true)}
disabled={isProcessing || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? <CreditCard className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Markera betald
</Button>
{invoice.status !== 'partially_paid' && (
<Button variant="outline" onClick={handleCredit} disabled={isProcessing}>
<FileText className="mr-2 h-4 w-4" />
<Button
variant="outline"
onClick={handleCredit}
disabled={isProcessing || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? <FileText className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Kreditfaktura
</Button>
)}
@@ -11,7 +11,8 @@ import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
import { ArrowLeft, Plus, Trash2, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
@@ -62,6 +63,7 @@ function inferVatTreatment(items: LineItem[], reverseCharge: boolean): VatTreatm
export default function NewSupplierInvoicePage() {
const router = useRouter()
const { canWrite } = useCanWrite()
const { toast } = useToast()
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
@@ -579,7 +581,12 @@ export default function NewSupplierInvoicePage() {
<Button type="button" variant="outline" onClick={() => router.push('/supplier-invoices')}>
Avbryt
</Button>
<Button type="submit" disabled={isSubmitting}>
<Button
type="submit"
disabled={isSubmitting || !canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
Granska & registrera
</Button>
</div>
+18 -6
View File
@@ -5,8 +5,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Plus, FileInput } from 'lucide-react'
import { Plus, FileInput, Lock } from 'lucide-react'
import Link from 'next/link'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { SupplierInvoice } from '@/types'
function formatAmount(amount: number): string {
@@ -34,6 +35,7 @@ const statusLabels: Record<string, string> = {
}
export default function SupplierInvoicesPage() {
const { canWrite } = useCanWrite()
const [invoices, setInvoices] = useState<(SupplierInvoice & { supplier?: { id: string; name: string } })[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState('all')
@@ -78,12 +80,22 @@ export default function SupplierInvoicesPage() {
Registrera och hantera inkommande fakturor
</p>
</div>
<Link href="/supplier-invoices/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
{canWrite ? (
<Link href="/supplier-invoices/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Registrera faktura
</Button>
</Link>
) : (
<Button
disabled
title="Du har endast läsbehörighet i detta företag"
>
<Lock className="mr-2 h-4 w-4" />
Registrera faktura
</Button>
</Link>
)}
</div>
{/* Summary cards */}
@@ -182,7 +194,7 @@ export default function SupplierInvoicesPage() {
? 'Registrera din första leverantörsfaktura'
: 'Inga fakturor i denna kategori'}
</p>
{activeTab === 'all' && (
{activeTab === 'all' && canWrite && (
<Link href="/supplier-invoices/new">
<Button className="mt-4">
<Plus className="mr-2 h-4 w-4" />
+18 -5
View File
@@ -7,7 +7,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, Edit, Trash2, FileText } from 'lucide-react'
import { ArrowLeft, Edit, Trash2, FileText, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import SupplierForm from '@/components/suppliers/SupplierForm'
import Link from 'next/link'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
@@ -24,6 +25,7 @@ function formatAmount(amount: number): string {
}
export default function SupplierDetailPage() {
const { canWrite } = useCanWrite()
const params = useParams()
const router = useRouter()
const { toast } = useToast()
@@ -153,12 +155,23 @@ export default function SupplierDetailPage() {
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setIsEditOpen(true)}>
<Edit className="mr-2 h-4 w-4" />
<Button
variant="outline"
onClick={() => setIsEditOpen(true)}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? <Edit className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
Redigera
</Button>
<Button variant="destructive" size="icon" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
<Button
variant="destructive"
size="icon"
onClick={handleDelete}
disabled={!canWrite}
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{canWrite ? <Trash2 className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
</Button>
</div>
</div>
+23 -5
View File
@@ -8,10 +8,11 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Search, Building2, Globe } from 'lucide-react'
import { Plus, Search, Building2, Globe, Lock } from 'lucide-react'
import SupplierForm from '@/components/suppliers/SupplierForm'
import Link from 'next/link'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Supplier, SupplierType, CreateSupplierInput } from '@/types'
const supplierTypeLabels: Record<SupplierType, string> = {
@@ -28,6 +29,7 @@ const supplierTypeIcons: Record<SupplierType, React.ElementType> = {
export default function SuppliersPage() {
const { company } = useCompany()
const { canWrite } = useCanWrite()
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
@@ -108,8 +110,15 @@ export default function SuppliersPage() {
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
<Button
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 leverantör
</Button>
</DialogTrigger>
@@ -169,8 +178,17 @@ export default function SuppliersPage() {
<p className="text-muted-foreground text-center mt-1">
Lägg till din första leverantör för att börja registrera inköpsfakturor
</p>
<Button className="mt-4" onClick={() => setIsDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
<Button
className="mt-4"
onClick={() => setIsDialogOpen(true)}
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 leverantör
</Button>
</div>
+40 -1
View File
@@ -1,4 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import Link from 'next/link'
import { Settings } from 'lucide-react'
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { SentryIdentify } from '@/components/SentryIdentify'
export default async function OnboardingLayout({
@@ -9,11 +11,48 @@ export default async function OnboardingLayout({
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// Only show the settings escape hatch for users who have completed
// onboarding at least once — i.e. they have a company_members row, even if
// it points to an archived company. Absolute first-time users don't need
// it and it clutters the welcome screen.
//
// Must use the service client: RLS on company_members goes through
// user_company_ids(), which filters out archived companies, so an
// authenticated query would return nothing for a user who archived their
// last company and make the escape hatch disappear exactly when it's
// needed most. Scoped to user_id = user.id, so no cross-user exposure.
let hasCompletedOnboarding = false
if (user) {
const service = createServiceClient()
const { data } = await service
.from('company_members')
.select('company_id')
.eq('user_id', user.id)
.limit(1)
.maybeSingle()
hasCompletedOnboarding = !!data
}
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="w-full max-w-lg px-5">
{children}
</div>
{/* Escape hatch: a user who archived their last company can still
reach account settings (and the delete-account flow) from here.
Hidden for absolute first-time users (no memberships ever). */}
{user && hasCompletedOnboarding && (
<Link
href="/settings/account"
aria-label="Kontoinställningar"
title="Kontoinställningar"
className="fixed bottom-5 right-5 z-50 flex h-10 w-10 items-center justify-center rounded-full border border-border bg-background/80 text-muted-foreground shadow-sm backdrop-blur transition-colors hover:border-foreground/40 hover:text-foreground"
>
<Settings className="h-4 w-4" />
</Link>
)}
{user && <SentryIdentify userId={user.id} email={user.email} />}
</div>
)
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { POST } from '../route'
const mockCreateClient = vi.mocked(createClient)
const mockCreateServiceClient = vi.mocked(createServiceClient)
function mockAuth(
user: { id: string; email: string | null } | null,
rpcResult: { data?: unknown; error?: unknown } = { data: null, error: null }
) {
const signOut = vi.fn().mockResolvedValue({ error: null })
const rpc = vi.fn().mockResolvedValue(rpcResult)
mockCreateClient.mockResolvedValue({
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user } }),
signOut,
},
rpc,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
return { rpc, signOut }
}
function mockServiceClient(blockers: { id: string; name: string }[] = []) {
const updateUserById = vi.fn().mockResolvedValue({ data: {}, error: null })
const adminSignOut = vi.fn().mockResolvedValue({ data: null, error: null })
const chain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
is: vi.fn().mockResolvedValue({
data: blockers.map((b) => ({ companies: { id: b.id, name: b.name } })),
error: null,
}),
}
mockCreateServiceClient.mockReturnValue({
from: vi.fn().mockReturnValue(chain),
auth: {
admin: {
updateUserById,
signOut: adminSignOut,
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
return { updateUserById, adminSignOut }
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('POST /api/account/delete', () => {
it('returns 401 when unauthenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/account/delete', {
method: 'POST',
body: { confirm_email: 'u@example.com' },
})
const { status } = await parseJsonResponse(await POST(req))
expect(status).toBe(401)
})
it('returns 400 when confirm_email does not match', async () => {
mockAuth({ id: 'user-1', email: 'right@example.com' })
const req = createMockRequest('/api/account/delete', {
method: 'POST',
body: { confirm_email: 'wrong@example.com' },
})
const { status, body } = await parseJsonResponse(await POST(req))
expect(status).toBe(400)
expect(body).toHaveProperty('error')
})
it('returns 409 with blockers when RPC raises P0001', async () => {
mockAuth(
{ id: 'user-1', email: 'u@example.com' },
{ data: null, error: { code: 'P0001', message: 'blocked' } }
)
mockServiceClient([{ id: 'c1', name: 'Acme AB' }])
const req = createMockRequest('/api/account/delete', {
method: 'POST',
body: { confirm_email: 'u@example.com' },
})
const { status, body } = await parseJsonResponse<{
error: string
blockers: { id: string; name: string }[]
}>(await POST(req))
expect(status).toBe(409)
expect(body.blockers).toEqual([{ id: 'c1', name: 'Acme AB' }])
})
it('anonymizes, bans, signs out, and emits event on happy path', async () => {
const { rpc } = mockAuth({ id: 'user-1', email: 'u@example.com' })
const { updateUserById, adminSignOut } = mockServiceClient()
const emitted: unknown[] = []
eventBus.on('account.deleted', (payload) => {
emitted.push(payload)
})
const req = createMockRequest('/api/account/delete', {
method: 'POST',
body: { confirm_email: 'u@example.com' },
})
const { status, body } = await parseJsonResponse<{ success: boolean }>(
await POST(req)
)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(rpc).toHaveBeenCalledWith('anonymize_user_account', {
target_user_id: 'user-1',
})
expect(updateUserById).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({
user_metadata: {},
app_metadata: {},
ban_duration: expect.any(String),
})
)
// Email must NOT be scrubbed — retaining it is what blocks re-signup
// with the same address. Recovery goes through support instead.
const updatePayload = updateUserById.mock.calls[0][1]
expect(updatePayload).not.toHaveProperty('email')
expect(adminSignOut).toHaveBeenCalledWith('user-1', 'global')
expect(emitted).toHaveLength(1)
expect(emitted[0]).toMatchObject({ userId: 'user-1' })
})
})
+106 -25
View File
@@ -1,56 +1,137 @@
import { createClient } from '@/lib/supabase/server'
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { eventBus } from '@/lib/events'
import { createLogger } from '@/lib/logger'
const log = createLogger('api/account/delete')
ensureInitialized()
const DeleteAccountSchema = z.object({
confirm: z.literal('RADERA'),
confirm_email: z.string().email(),
})
/**
* POST /api/account/delete
*
* Anonymizes the calling user's account. The auth.users row is retained
* (banned for ~100 years) as a tombstone so FKs into BFL-retained
* bookkeeping data (companies.created_by, audit_log.user_id, etc.) stay
* valid. Memberships are removed, profile PII is stripped, and a global
* signout forces all sessions to end.
*
* Precondition: the user must own zero non-archived companies. The RPC
* enforces this at the DB level and raises SQLSTATE P0001 with a message
* if the precondition fails — we return 409 in that case.
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const result = await validateBody(request, DeleteAccountSchema)
if (!result.success) return result.response
const { confirm_email } = result.data
const parsed = DeleteAccountSchema.safeParse(body)
if (!parsed.success) {
if (!user.email || confirm_email.trim().toLowerCase() !== user.email.toLowerCase()) {
return NextResponse.json(
{ error: 'Skriv RADERA för att bekräfta kontoborttagning' },
{ error: 'E-postadressen stämmer inte överens med ditt konto.' },
{ status: 400 }
)
}
try {
// RPC disables protective triggers, deletes from auth.users (CASCADE
// cleans up all public tables), then re-enables triggers — all in one tx.
const { error } = await supabase.rpc('delete_user_account', {
target_user_id: user.id,
})
// Anonymize in the DB. Runs as SECURITY DEFINER and checks auth.uid()
// internally, so we don't need service role here.
const { error: rpcError } = await supabase.rpc('anonymize_user_account', {
target_user_id: user.id,
})
if (rpcError) {
// P0001 = precondition violation from our RPC: user still owns active
// companies. Re-fetch the blockers and return 409 so the UI can show
// the list inline.
if (rpcError.code === 'P0001') {
const service = createServiceClient()
const { data: blockers } = await service
.from('company_members')
.select('company_id, companies!inner(id, name, archived_at)')
.eq('user_id', user.id)
.eq('role', 'owner')
.is('companies.archived_at', null)
const list = (blockers ?? []).map((b) => {
const company = (b.companies as unknown) as { id: string; name: string }
return { id: company.id, name: company.name }
})
if (error) {
console.error('Failed to delete user:', error)
return NextResponse.json(
{ error: 'Kunde inte radera kontot. Försök igen.' },
{ status: 500 }
{
error: 'Du måste radera eller överlåta dina företag innan du kan radera kontot.',
blockers: list,
},
{ status: 409 }
)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Account deletion error:', error)
log.error('anonymize_user_account failed', { userId: user.id, error: rpcError.message })
return NextResponse.json(
{ error: 'Kunde inte radera kontot. Försök igen.' },
{ status: 500 }
)
}
// Wipe PII in auth.users metadata and ban the tombstone row ~100 years.
// DB functions can't reach supabase.auth.admin, so we do it here.
//
// Note: auth.users.email is intentionally NOT scrubbed. The original
// address is retained as a legitimate-interest tombstone so that:
// (1) re-signup with the same email is blocked by Supabase's unique
// constraint — deletion must feel permanent, not trivially
// reversible by re-registering
// (2) support can verify identity when a former user asks to recover
// BFL-retained räkenskapsinformation
// This must be documented in the privacy policy under legitimate
// interest (GDPR Art. 6(1)(f)). The email is never read by the app
// after this point — login is impossible (row is banned) and the
// profile is anonymized, so no UI ever surfaces it.
//
// user_metadata / app_metadata ARE wiped — they may contain display
// name, avatar, or provider info that isn't needed for recovery.
// The admin API replaces (not merges) these, so passing {} clears them.
const service = createServiceClient()
try {
await service.auth.admin.updateUserById(user.id, {
user_metadata: {},
app_metadata: {},
ban_duration: '876000h',
})
} catch (err) {
log.error('Failed to wipe metadata and ban anonymized user', { userId: user.id, err })
}
try {
await service.auth.admin.signOut(user.id, 'global')
} catch (err) {
log.error('Failed to global sign out anonymized user', { userId: user.id, err })
}
const deletedAt = new Date().toISOString()
await eventBus.emit({
type: 'account.deleted',
payload: { userId: user.id, deletedAt },
})
// Best-effort: clear the caller's session cookie too.
await supabase.auth.signOut().catch(() => {})
// Request body is consumed; avoid unused-var lint.
void request
return NextResponse.json({ success: true })
}
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { UpdateAccountSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function DELETE(
request: Request,
@@ -16,6 +17,9 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Fetch the account to check if it's a system account
@@ -75,6 +79,9 @@ export async function PUT(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, UpdateAccountSchema)
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* POST /api/bookkeeping/accounts/activate
@@ -18,6 +19,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
+4
View File
@@ -4,6 +4,7 @@ import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { CreateAccountSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -52,6 +53,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const validation = await validateBody(request, CreateAccountSchema)
if (!validation.success) return validation.response
const body = validation.data
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { closePeriod } from '@/lib/core/bookkeeping/period-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function POST(
request: Request,
@@ -15,6 +16,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
try {
@@ -5,6 +5,7 @@ import {
executeCurrencyRevaluation,
} from '@/lib/bookkeeping/currency-revaluation'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* GET: Preview currency revaluation for a fiscal period
@@ -61,6 +62,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
try {
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { lockPeriod } from '@/lib/core/bookkeeping/period-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function POST(
request: Request,
@@ -15,6 +16,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
try {
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { z } from 'zod'
const UpdateFiscalPeriodSchema = z.object({
@@ -23,6 +24,9 @@ export async function PATCH(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, UpdateFiscalPeriodSchema)
@@ -6,6 +6,7 @@ import {
executeYearEndClosing,
} from '@/lib/core/bookkeeping/year-end-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* GET: Validate readiness and preview year-end closing
@@ -54,6 +55,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
try {
@@ -4,6 +4,7 @@ import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-durati
import { validateBody } from '@/lib/api/validate'
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function GET() {
const supabase = await createClient()
@@ -36,6 +37,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateFiscalPeriodSchema)
@@ -17,6 +17,11 @@ vi.mock('@/lib/init', () => ({
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockCorrectEntry = vi.fn()
@@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
@@ -20,6 +21,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CorrectJournalEntrySchema)
@@ -18,6 +18,11 @@ vi.mock('@/lib/init', () => ({
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockReverseEntry = vi.fn()
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
@@ -18,6 +19,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
try {
@@ -18,6 +18,11 @@ vi.mock('@/lib/init', () => ({
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockCreateJournalEntry = vi.fn()
@@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
@@ -79,6 +80,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateJournalEntrySchema)
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { CreateMappingRuleSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function GET() {
const supabase = await createClient()
@@ -36,6 +37,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, CreateMappingRuleSchema)
@@ -15,6 +15,11 @@ vi.mock('@/lib/supabase/server', () => ({
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { GET, POST } from '../route'
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { validateBody, validateQuery } from '@/lib/api/validate'
import { VoucherGapQuerySchema, SaveGapExplanationSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -111,6 +112,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, SaveGapExplanationSchema)
+10
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { UpdateCalendarFeedInput } from '@/types'
/**
@@ -60,6 +61,9 @@ export async function POST() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Check if feed already exists
@@ -117,6 +121,9 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const body: UpdateCalendarFeedInput = await request.json()
@@ -156,6 +163,9 @@ export async function DELETE() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Generate a new token by updating with a new UUID
@@ -0,0 +1,205 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
import { createServiceClient } from '@/lib/supabase/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { POST } from '../route'
const mockRequireAuth = vi.mocked(requireAuth)
const mockCreateServiceClient = vi.mocked(createServiceClient)
type Row = { data: unknown; error: unknown }
function mockService(rowsByTable: Record<string, Row[]>) {
const insertSpy = vi.fn().mockResolvedValue({ data: null, error: null })
const updateSpies: Record<string, ReturnType<typeof vi.fn>> = {}
const from = vi.fn().mockImplementation((table: string) => {
const queue = rowsByTable[table] ?? []
const next = queue.shift() ?? { data: null, error: null }
const updateFn = vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({ data: null, error: null }),
then: (resolve: (v: unknown) => void) => resolve({ data: null, error: null }),
}),
})
updateSpies[table] = updateFn
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
maybeSingle: vi.fn().mockResolvedValue(next),
}),
maybeSingle: vi.fn().mockResolvedValue(next),
}),
}),
update: updateFn,
insert: insertSpy,
}
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockCreateServiceClient.mockReturnValue({ from } as any)
return { from, insertSpy, updateSpies }
}
function mockAuth(userId: string | null) {
if (userId) {
mockRequireAuth.mockResolvedValue({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
user: { id: userId, email: 'u@example.com' } as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: {} as any,
error: null,
})
} else {
mockRequireAuth.mockResolvedValue({
user: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: {} as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: { status: 401 } as any,
})
}
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('POST /api/company/[id]/delete', () => {
it('returns 401 when unauthenticated', async () => {
mockAuth(null)
const req = createMockRequest('/api/company/c1/delete', {
method: 'POST',
body: { confirm_name: 'Acme' },
})
const res = await POST(req, createMockRouteParams({ id: 'c1' }))
// requireAuth already returned a NextResponse error
expect(res).toBeDefined()
})
it('returns 400 when confirm_name does not match', async () => {
mockAuth('user-1')
mockService({
companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }],
company_members: [{ data: { role: 'owner' }, error: null }],
})
const req = createMockRequest('/api/company/c1/delete', {
method: 'POST',
body: { confirm_name: 'Wrong Name' },
})
const { status, body } = await parseJsonResponse(
await POST(req, createMockRouteParams({ id: 'c1' }))
)
expect(status).toBe(400)
expect(body).toHaveProperty('error')
})
it('returns 403 when caller is member but not owner', async () => {
mockAuth('user-1')
mockService({
companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }],
company_members: [{ data: { role: 'member' }, error: null }],
})
const req = createMockRequest('/api/company/c1/delete', {
method: 'POST',
body: { confirm_name: 'Acme AB' },
})
const { status } = await parseJsonResponse(
await POST(req, createMockRouteParams({ id: 'c1' }))
)
expect(status).toBe(403)
})
it('returns 404 when company is already archived', async () => {
mockAuth('user-1')
mockService({
companies: [
{
data: {
id: 'c1',
name: 'Acme AB',
archived_at: '2026-01-01T00:00:00Z',
},
error: null,
},
],
})
const req = createMockRequest('/api/company/c1/delete', {
method: 'POST',
body: { confirm_name: 'Acme AB' },
})
const { status } = await parseJsonResponse(
await POST(req, createMockRouteParams({ id: 'c1' }))
)
expect(status).toBe(404)
})
it('archives company, writes audit log, emits event on happy path', async () => {
mockAuth('user-1')
const { insertSpy, updateSpies } = mockService({
companies: [{ data: { id: 'c1', name: 'Acme AB', archived_at: null }, error: null }],
company_members: [{ data: { role: 'owner' }, error: null }],
})
const emitted: unknown[] = []
eventBus.on('company.deleted', (payload) => {
emitted.push(payload)
})
const req = createMockRequest('/api/company/c1/delete', {
method: 'POST',
body: { confirm_name: 'Acme AB' },
})
const { status, body } = await parseJsonResponse<{
data: { companyId: string; archivedAt: string }
}>(await POST(req, createMockRouteParams({ id: 'c1' })))
expect(status).toBe(200)
expect(body.data.companyId).toBe('c1')
expect(body.data.archivedAt).toBeTruthy()
// Verified the companies.update was called (archived_at + archived_by)
expect(updateSpies.companies).toHaveBeenCalledWith(
expect.objectContaining({
archived_at: expect.any(String),
archived_by: 'user-1',
})
)
// Audit log row written
expect(insertSpy).toHaveBeenCalledWith(
expect.objectContaining({
action: 'DELETE',
table_name: 'companies',
record_id: 'c1',
user_id: 'user-1',
})
)
// Event emitted
expect(emitted).toHaveLength(1)
expect(emitted[0]).toMatchObject({ companyId: 'c1', userId: 'user-1' })
})
})
+156
View File
@@ -0,0 +1,156 @@
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { ensureInitialized } from '@/lib/init'
import { requireAuth } from '@/lib/auth/require-auth'
import { validateBody } from '@/lib/api/validate'
import { eventBus } from '@/lib/events'
import { createLogger } from '@/lib/logger'
const log = createLogger('api/company/delete')
ensureInitialized()
const DeleteCompanySchema = z.object({
confirm_name: z.string().min(1),
})
/**
* POST /api/company/[id]/delete
*
* Soft-delete a company. Sets archived_at + archived_by. The underlying
* bookkeeping data is retained for 7 years per BFL 7 kap. 2§. All reads
* flow through user_company_ids() which filters archived rows, so the
* company disappears from the user's UI immediately.
*
* Rules:
* - Only callers with role='owner' in company_members may delete.
* - The body must include confirm_name matching companies.name exactly.
* - Already-archived companies return 404 (treated as not found).
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id: companyId } = await params
const auth = await requireAuth()
if (auth.error) return auth.error
const { user, supabase } = auth
const result = await validateBody(request, DeleteCompanySchema)
if (!result.success) return result.response
const { confirm_name } = result.data
// Service client for writes that must bypass RLS (audit_log insert,
// user_preferences clearing). All queries still filter by company_id
// and user.id for defense in depth.
const service = createServiceClient()
// 1. Fetch company — must exist and be active. Query via service client
// because we need the raw row regardless of RLS visibility, but we still
// enforce membership below.
const { data: company, error: fetchError } = await service
.from('companies')
.select('id, name, archived_at')
.eq('id', companyId)
.maybeSingle()
if (fetchError) {
log.error('Failed to fetch company', { companyId, error: fetchError.message })
return NextResponse.json({ error: 'Kunde inte hämta företag.' }, { status: 500 })
}
if (!company || company.archived_at) {
return NextResponse.json({ error: 'Företaget hittades inte.' }, { status: 404 })
}
// 2. Caller must be an owner of this company
const { data: membership, error: membershipError } = await service
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (membershipError) {
log.error('Failed to fetch membership', { companyId, error: membershipError.message })
return NextResponse.json({ error: 'Kunde inte verifiera behörighet.' }, { status: 500 })
}
if (!membership) {
return NextResponse.json({ error: 'Företaget hittades inte.' }, { status: 404 })
}
if (membership.role !== 'owner') {
return NextResponse.json(
{ error: 'Endast ägaren kan radera ett företag.' },
{ status: 403 }
)
}
// 3. Confirm name matches exactly (case-sensitive trim)
if (confirm_name.trim() !== company.name.trim()) {
return NextResponse.json(
{ error: 'Företagsnamnet stämmer inte överens.' },
{ status: 400 }
)
}
// 4. Soft delete
const archivedAt = new Date().toISOString()
const { error: updateError } = await service
.from('companies')
.update({ archived_at: archivedAt, archived_by: user.id })
.eq('id', companyId)
if (updateError) {
log.error('Failed to archive company', { companyId, error: updateError.message })
return NextResponse.json({ error: 'Kunde inte radera företaget.' }, { status: 500 })
}
// 5. Clear user_preferences.active_company_id if it pointed here so the
// middleware falls through to another membership next request.
await service
.from('user_preferences')
.update({ active_company_id: null })
.eq('user_id', user.id)
.eq('active_company_id', companyId)
// 6. Write audit log row. companies has no auto-audit trigger, so do it
// explicitly. Service client bypasses audit_log RLS (no INSERT policy).
await service.from('audit_log').insert({
user_id: user.id,
company_id: companyId,
action: 'DELETE',
table_name: 'companies',
record_id: companyId,
actor_id: user.id,
old_state: { archived_at: null },
new_state: { archived_at: archivedAt, archived_by: user.id },
description: `Company archived: ${company.name}`,
})
// 7. Emit event
await eventBus.emit({
type: 'company.deleted',
payload: { companyId, userId: user.id, archivedAt },
})
// 8. Build response and clear company cookie if it matched
const response = NextResponse.json({ data: { companyId, archivedAt } })
const cookieCompanyId = request.headers.get('cookie')?.match(/gnubok-company-id=([^;]+)/)?.[1]
if (cookieCompanyId === companyId) {
response.cookies.set('gnubok-company-id', '', {
path: '/',
maxAge: 0,
})
}
// Ignore supabase server-client cookie warnings; the response is what
// the browser sees. RLS session isn't relevant here.
void supabase
return response
}
+36
View File
@@ -0,0 +1,36 @@
import { createClient } from '@/lib/supabase/server'
import { getActiveCompanyId } from '@/lib/company/context'
import { NextResponse } from 'next/server'
/**
* GET /api/company/current
*
* Returns the active company id for the authenticated user. Used by the
* client-side CompanyTabSync listener to detect cross-tab divergence (e.g.
* when a tab was hidden/backgrounded during a switch in another tab) and
* force a hard reload on mismatch.
*
* Never cached — the whole point is that the response reflects the current
* authoritative value in user_preferences.
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{
status: 401,
headers: { 'Cache-Control': 'private, no-store' },
},
)
}
const companyId = await getActiveCompanyId(supabase, user.id)
return NextResponse.json(
{ companyId },
{ headers: { 'Cache-Control': 'private, no-store' } },
)
}
+4
View File
@@ -1,6 +1,7 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* DELETE /api/company/members/[id]
@@ -16,6 +17,9 @@ export async function DELETE(
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { id: memberId } = await params
const serviceClient = await createServiceClient()
@@ -1,6 +1,7 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* DELETE /api/company/members/invite/[id]
@@ -15,6 +16,9 @@ export async function DELETE(
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { id: inviteId } = await params
const serviceClient = await createServiceClient()
+27 -1
View File
@@ -1,6 +1,8 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { generateInviteToken, getInviteExpiry } from '@/lib/auth/invite-tokens'
import { getEmailService } from '@/lib/email/service'
import {
@@ -9,6 +11,12 @@ import {
generateInviteEmailText,
} from '@/lib/email/invite-templates'
// Loads the email extension so getEmailService() returns the Resend
// implementation instead of the noop default. Without this, the invite email
// is silently skipped in dev whenever this route is hit before any other
// init'd route in the process.
ensureInitialized()
/**
* POST /api/company/members/invite
* Invite a user to the current company (e.g., a client as viewer).
@@ -19,6 +27,9 @@ export async function POST(request: Request) {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const serviceClient = await createServiceClient()
@@ -136,6 +147,12 @@ export async function POST(request: Request) {
inviteUrl,
}
console.log('[company/members/invite] sending email', {
to: email,
company: emailData.companyName,
from: user.email,
})
const result = await emailService.sendEmail({
to: email,
subject: generateInviteEmailSubject(emailData),
@@ -143,9 +160,18 @@ export async function POST(request: Request) {
text: generateInviteEmailText(emailData),
})
if (!result.success) {
if (result.success) {
console.log('[company/members/invite] email sent', {
to: email,
messageId: result.messageId,
})
} else {
console.error('[company/members/invite] email send failed:', result.error)
}
} else {
console.warn('[company/members/invite] email service not configured — skipping send', {
to: email,
})
}
// In development, return the invite URL directly (no email service)
+52
View File
@@ -0,0 +1,52 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* GET /api/company?owned=true&archived=false
*
* Returns companies the caller has access to, filtered by query:
* - owned=true → only companies where caller's role is 'owner'
* - archived=false → only non-archived companies (default)
*
* Used by the account danger zone to show a blockers list before
* allowing account deletion.
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const url = new URL(request.url)
const ownedOnly = url.searchParams.get('owned') === 'true'
const includeArchived = url.searchParams.get('archived') === 'true'
let query = supabase
.from('company_members')
.select('role, companies!inner(id, name, archived_at)')
.eq('user_id', user.id)
if (ownedOnly) query = query.eq('role', 'owner')
if (!includeArchived) query = query.is('companies.archived_at', null)
const { data, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const companies = (data ?? []).map((row) => {
const company = (row.companies as unknown) as {
id: string
name: string
archived_at: string | null
}
return {
id: company.id,
name: company.name,
archived_at: company.archived_at,
role: row.role as string,
}
})
return NextResponse.json({ data: companies })
}
+7
View File
@@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate'
import { UpdateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { createLogger } from '@/lib/logger'
const log = createLogger('api/customers/[id]')
@@ -70,6 +71,9 @@ export async function PATCH(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, UpdateCustomerSchema)
@@ -172,6 +176,9 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { error, count } = await supabase
@@ -0,0 +1,87 @@
/**
* Representative viewer-403 test.
*
* Verifies that POST /api/customers returns 403 when the caller's
* requireWritePermission check returns an error response. This is a
* canary test — if it breaks, the wiring between mutating routes and
* requireWritePermission has drifted.
*
* The full per-role behavior of requireWritePermission itself is
* covered in lib/auth/__tests__/require-write.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
const mockAuthGetUser = vi.fn()
const mockFrom = vi.fn()
const mockSupabase = {
auth: { getUser: mockAuthGetUser },
from: mockFrom,
}
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWritePermissionMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWritePermissionMock(...args),
}))
import { POST } from '../route'
describe('POST /api/customers — viewer role gate', () => {
const mockUser = { id: 'user-1', email: 'viewer@test.se' }
beforeEach(() => {
vi.clearAllMocks()
mockAuthGetUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 403 with Swedish message when requireWritePermission rejects', async () => {
requireWritePermissionMock.mockResolvedValue({
ok: false,
response: NextResponse.json(
{ error: 'Du har endast läsbehörighet i detta företag.' },
{ status: 403 },
),
})
const request = createMockRequest('/api/customers', {
method: 'POST',
body: { name: 'Test customer', customer_type: 'company' },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(403)
expect(body.error).toContain('läsbehörighet')
})
it('calls requireWritePermission with the authenticated user id', async () => {
requireWritePermissionMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'blocked' }, { status: 403 }),
})
const request = createMockRequest('/api/customers', {
method: 'POST',
body: { name: 'Test customer', customer_type: 'company' },
})
await POST(request)
expect(requireWritePermissionMock).toHaveBeenCalledWith(mockSupabase, 'user-1')
})
})
+4
View File
@@ -6,6 +6,7 @@ import { validateBody } from '@/lib/api/validate'
import { CreateCustomerSchema } from '@/lib/api/schemas'
import { validateVatNumber } from '@/lib/vat/vies-client'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { createLogger } from '@/lib/logger'
import type { Customer } from '@/types'
@@ -46,6 +47,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, CreateCustomerSchema)
+4
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* POST /api/deadlines/[id]/complete
@@ -21,6 +22,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// First, get current deadline state
+7
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { CreateDeadlineInput } from '@/types'
/**
@@ -60,6 +61,9 @@ export async function PUT(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const body: Partial<CreateDeadlineInput> = await request.json()
@@ -124,6 +128,9 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { error } = await supabase
+4
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { DeadlineStatus } from '@/types'
/**
@@ -20,6 +21,9 @@ export async function PATCH(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
+4
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { CreateDeadlineSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* GET /api/deadlines
@@ -82,6 +83,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateDeadlineSchema)
+4
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
@@ -26,6 +27,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
+4
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { verifyIntegrity } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
@@ -22,6 +23,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
+4
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { createNewVersion, validateDocumentFile } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
@@ -25,6 +26,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { id } = await params
+4
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { runDocumentMatchingSweep } from '@/lib/documents/batch-match'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function POST(request: Request) {
const supabase = await createClient()
@@ -11,6 +12,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Optional: pass specific inbox item IDs to match
+4
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { uploadDocument, validateDocumentFile } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
@@ -25,6 +26,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
try {
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function GET(
request: Request,
@@ -56,6 +57,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
@@ -101,6 +105,9 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function GET(
_request: Request,
@@ -41,6 +42,9 @@ export async function PATCH(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
@@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init'
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
import { generateExternalId } from '@/lib/import/bank-file/parser'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { ParsedBankTransaction, BankFileFormatId } from '@/lib/import/bank-file/types'
import type { Transaction } from '@/types'
@@ -34,6 +35,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const body: ExecuteRequest = await request.json()
+4
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* GET /api/import/sie/[id]
@@ -65,6 +66,9 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Check current status before deleting
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { SIEAccount } from '@/lib/import/types'
/**
@@ -64,6 +65,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
try {
+4
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
import { suggestMappings } from '@/lib/import/account-mapper'
import { executeSIEImport, checkDuplicateImport } from '@/lib/import/sie-import'
@@ -27,6 +28,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
try {
+10
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { saveMappings } from '@/lib/import/sie-import'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { AccountMapping } from '@/lib/import/types'
/**
@@ -49,6 +50,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
@@ -84,6 +88,9 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const body = await request.json()
@@ -133,6 +140,9 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { searchParams } = new URL(request.url)
+4
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { Invoice } from '@/types'
ensureInitialized()
@@ -26,6 +27,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Fetch proforma with items
@@ -19,6 +19,11 @@ vi.mock('@/lib/init', () => ({
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockCreateInvoicePaymentJournalEntry = vi.fn()
+4
View File
@@ -8,6 +8,7 @@ import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
ensureInitialized()
@@ -36,6 +37,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice
+4
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { EntityType, Invoice } from '@/types'
ensureInitialized()
@@ -27,6 +28,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice
+4
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* DELETE /api/invoices/[id]
@@ -22,6 +23,9 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Fetch invoice to verify ownership and status
@@ -24,6 +24,10 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockRenderToBuffer = vi.fn()
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
+4
View File
@@ -13,6 +13,7 @@ import {
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
ensureInitialized()
@@ -30,6 +31,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Check if email is configured
+4
View File
@@ -22,6 +22,10 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockGetVatRules = vi.fn()
const mockCalculateVat = vi.fn()
const mockGetAvailableVatRates = vi.fn()
+4
View File
@@ -10,6 +10,7 @@ import {
createCreditNoteJournalEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
@@ -58,6 +59,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
let rawBody: unknown
+4
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { KPIPreferences } from '@/types'
const EXTENSION_ID = 'core/kpi'
@@ -31,6 +32,9 @@ export async function PUT(request: Request) {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
let body: unknown
@@ -20,6 +20,10 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
// Mock the counterparty templates (non-critical side effect)
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
@@ -792,6 +793,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Fetch the pending operation
@@ -16,6 +16,10 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { POST } from '../../reject/route'
describe('POST /api/pending-operations/:id/reject', () => {
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* POST /api/pending-operations/:id/reject
@@ -19,6 +20,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { data: op, error: fetchError } = await supabase
@@ -5,6 +5,7 @@ import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate'
import { BankLinkSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
@@ -16,6 +17,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, BankLinkSchema)
+4
View File
@@ -5,6 +5,7 @@ import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate'
import { RunReconciliationSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
ensureInitialized()
@@ -16,6 +17,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, RunReconciliationSchema)
@@ -4,6 +4,7 @@ import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate'
import { BankUnlinkSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function POST(request: Request) {
const supabase = await createClient()
@@ -13,6 +14,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, BankUnlinkSchema)
+4
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* DELETE /api/settings/api-keys/[id] — Revoke an API key (soft delete)
@@ -17,6 +18,9 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { error } = await supabase
+4
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateApiKey, hashApiKey, DEFAULT_SCOPES, validateScopes } from '@/lib/auth/api-keys'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { ApiKeyScope } from '@/lib/auth/api-keys'
/**
@@ -42,6 +43,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
let name = 'Unnamed key'
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function GET() {
const supabase = await createClient()
@@ -26,6 +27,9 @@ export async function DELETE(request: Request) {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
let id: string | undefined
+7
View File
@@ -1,6 +1,7 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
const MAX_SIZE = 2 * 1024 * 1024 // 2MB
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
@@ -10,6 +11,9 @@ export async function POST(request: Request) {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 })
@@ -68,6 +72,9 @@ export async function DELETE() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 })
+4
View File
@@ -4,6 +4,7 @@ import { didTaxFieldsChange, regenerateTaxDeadlinesForUser } from '@/lib/tax/dea
import { validateBody } from '@/lib/api/validate'
import { UpdateSettingsSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function GET() {
const supabase = await createClient()
@@ -51,6 +52,9 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Fetch current settings to check for tax-relevant changes
@@ -21,6 +21,10 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { eventBus } from '@/lib/events'
import { POST } from '../route'
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { SupplierInvoice } from '@/types'
ensureInitialized()
@@ -20,6 +21,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const { data: invoice } = await supabase
@@ -4,6 +4,7 @@ import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types'
ensureInitialized()
@@ -21,6 +22,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Fetch original invoice with supplier and items
@@ -22,6 +22,10 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockCreateSupplierInvoicePaymentEntry = vi.fn()
const mockCreateSupplierInvoiceCashEntry = vi.fn()
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
@@ -9,6 +9,7 @@ import {
import { validateBody } from '@/lib/api/validate'
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
ensureInitialized()
@@ -26,6 +27,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, MarkSupplierInvoicePaidSchema)
+7
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function GET(
_request: Request,
@@ -46,6 +47,9 @@ export async function PUT(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Only allow editing registered invoices
@@ -99,6 +103,9 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Only allow deleting registered invoices without journal entries
@@ -21,6 +21,10 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockFindFiscalPeriod = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
+4
View File
@@ -6,6 +6,7 @@ import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
ensureInitialized()
@@ -55,6 +56,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CreateSupplierInvoiceSchema)
+7
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { UpdateSupplierSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function GET(
_request: Request,
@@ -70,6 +71,9 @@ export async function PUT(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, UpdateSupplierSchema)
@@ -125,6 +129,9 @@ export async function DELETE(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Check for linked invoices
+4
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { CreateSupplierSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
export async function GET() {
const supabase = await createClient()
@@ -37,6 +38,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const result = await validateBody(request, CreateSupplierSchema)
+4
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
/**
* POST /api/tax-deadlines/generate
@@ -16,6 +17,9 @@ export async function POST() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Fetch company settings
@@ -13,6 +13,11 @@ vi.mock('@/lib/supabase/server', () => ({
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { DELETE } from '../route'
@@ -20,6 +20,11 @@ vi.mock('@/lib/init', () => ({
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockCreateJournalEntry = vi.fn()
+4
View File
@@ -6,6 +6,7 @@ import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { validateBody } from '@/lib/api/validate'
import { BookTransactionSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { Transaction } from '@/types'
ensureInitialized()
@@ -23,6 +24,9 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, BookTransactionSchema)

Some files were not shown because too many files have changed in this diff Show More