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