fix(entitlements): gate the AI-only invoice-inbox for non-payers (#924)

The Dokumentinkorg (invoice-inbox) leaked past the paywall: visible in the
sidebar, command palette, and home "Att gora" list, its page directly
reachable, and every non-AI HTTP route open. Its whole value is AI field
extraction (Claude Sonnet 4.6 via Bedrock), already the paid chokepoint
elsewhere, so gate the whole surface on CAPABILITY.ai.

- EXTENSION_REQUIRED_CAPABILITY map + resolvers (keys.ts, sectors.ts) as the
  single source the nav item, the page, and the API dispatcher all read.
- Hide the sidebar item, command-palette entry, and home inbox row for
  non-payers; subtract inbox_document from the "Att gora" total via one shared
  visibleWorklistTotal helper (KPI tile + header cannot drift), clamped to >= 0.
- Block the /e/[sector]/[slug] page (fail-closed) with an upsell EmptyState.
- Enforce the capability in the extension API dispatcher (the single chokepoint
  that already enforces MFA), so every company-context inbox route 403s. The
  skipAuth /inbound webhook stays open (freeze-and-retain).
- FORCE_PAYWALL=true override so the real gate is exercisable in local dev.
- Tests: gating resolver, FORCE_PAYWALL, dispatcher 403/allow/webhook-exempt,
  visibleWorklistTotal, and enable-banking /connect + /sync 403.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-07 23:20:39 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent a566a42aec
commit 19cbb0094b
20 changed files with 480 additions and 25 deletions
+23 -8
View File
@@ -25,6 +25,8 @@ import {
type LucideIcon,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useCompany } from '@/contexts/CompanyContext'
import { requiredCapabilityForExtension } from '@/lib/entitlements/keys'
type Entry = {
id: string
@@ -79,6 +81,19 @@ export default function CommandPalette() {
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const { capabilities } = useCompany()
// Drop entries that jump to a paywalled extension workspace the active
// company can't reach (e.g. the AI-only Dokumentinkorg). The page itself is
// gated server-side; this keeps ⌘K from offering a dead destination.
const allowedByCapability = useMemo(() => {
return (entry: Entry) => {
const m = entry.href.match(/^\/e\/([^/]+)\/([^/?#]+)/)
if (!m) return true
const required = requiredCapabilityForExtension(m[1], m[2])
return !required || capabilities.includes(required)
}
}, [capabilities])
function handleOpenChange(next: boolean) {
setOpen(next)
@@ -108,14 +123,14 @@ export default function CommandPalette() {
const q = query.trim().toLowerCase()
const filteredActions = useMemo(
() => (q ? ACTION_ENTRIES.filter(e => matches(e, q)) : ACTION_ENTRIES),
[q],
)
const filteredPages = useMemo(
() => (q ? PAGE_ENTRIES.filter(e => matches(e, q)) : PAGE_ENTRIES.slice(0, 6)),
[q],
)
const filteredActions = useMemo(() => {
const visible = ACTION_ENTRIES.filter(allowedByCapability)
return q ? visible.filter(e => matches(e, q)) : visible
}, [q, allowedByCapability])
const filteredPages = useMemo(() => {
const visible = PAGE_ENTRIES.filter(allowedByCapability)
return q ? visible.filter(e => matches(e, q)) : visible.slice(0, 6)
}, [q, allowedByCapability])
const annaFallback: Entry | null = q && filteredActions.length === 0 && filteredPages.length === 0
? {
+20 -6
View File
@@ -10,6 +10,9 @@ import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { visibleWorklistTotal } from '@/lib/worklist/visible-total'
import {
ArrowLeftRight,
ArrowRight,
@@ -96,6 +99,10 @@ export default function AttGoraSection({
}: AttGoraSectionProps) {
const t = useTranslations('dashboard')
const { toast } = useToast()
// The Dokumentinkorg is a paid (AI) surface: a non-payer's home to-do list
// must not offer a row that jumps to the gated workspace. Mirrors the sidebar
// + command palette gate; the page itself enforces it server-side.
const hasAi = useCapability(CAPABILITY.ai)
const [counts, setCounts] = useState(worklist.counts)
const [total, setTotal] = useState(worklist.total)
@@ -166,7 +173,8 @@ export default function AttGoraSection({
}
}
const bokforRows = counts.book_transaction > 0 || counts.inbox_document > 0 || matches.length > 0
const showInboxDocuments = hasAi && counts.inbox_document > 0
const bokforRows = counts.book_transaction > 0 || showInboxDocuments || matches.length > 0
const granskaRows =
counts.supplier_invoice_approval > 0 ||
counts.verifikat_missing_document > 0 ||
@@ -177,10 +185,16 @@ export default function AttGoraSection({
expiringBankConnections.length > 0
const allClear = !bokforRows && !granskaRows && !bevakaRows
// The header total must equal what the section actually shows: the worklist
// total plus expiring bank connections, which are dashboard-only (not a
// lib/worklist category). Every count that feeds this number has a row.
const displayTotal = total + expiringBankConnections.length
// The header total must equal what the section actually shows, computed off
// the same visibleWorklistTotal helper as the dashboard KPI tile so the two
// can never drift: the hidden paid inbox row is subtracted for non-payers,
// else the header would count work the section no longer renders.
const displayTotal = visibleWorklistTotal({
total,
inboxDocumentCount: counts.inbox_document,
hasAi,
extra: expiringBankConnections.length,
})
return (
<section aria-label={t('att_gora_title')}>
@@ -289,7 +303,7 @@ export default function AttGoraSection({
</div>
</div>
)}
{counts.inbox_document > 0 && (
{showInboxDocuments && (
<WorklistRow
href="/e/general/invoice-inbox"
icon={Inbox}
+9 -4
View File
@@ -20,6 +20,7 @@ import {
} from 'lucide-react'
import type { Deadline, OnboardingProgress } from '@/types'
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
import { visibleWorklistTotalFrom } from '@/lib/worklist/visible-total'
const setupFreshStartKey = (companyId: string) => `erp_setup_fresh_start:${companyId}`
@@ -109,10 +110,14 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
}).format(amount)
}
// One number, one source: the worklist total plus expiring bank connections
// (dashboard-only, not a lib/worklist category). Must match AttGoraSection's
// header so the tile and the section never disagree.
const todoCount = worklist.total + (summary.expiringBankConnections?.length ?? 0)
// One number, one source (visibleWorklistTotal): the worklist total plus
// expiring bank connections (dashboard-only), minus the hidden paid inbox row
// for non-payers. Must match AttGoraSection's header off the same helper.
const todoCount = visibleWorklistTotalFrom(
worklist,
hasAi,
summary.expiringBankConnections?.length ?? 0,
)
return (
<div className="stagger-enter space-y-8">
+11 -2
View File
@@ -56,6 +56,7 @@ import AgentAvatar from '@/components/agent/AgentAvatar'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { useCompany } from '@/contexts/CompanyContext'
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
import { EXTENSION_REQUIRED_CAPABILITY, type CapabilityKey } from '@/lib/entitlements/keys'
import type { EntityType } from '@/types'
void _ENABLED_EXTENSION_IDS
@@ -146,6 +147,10 @@ interface NavItem {
// company_settings.dimensions_enabled (UI-visibility gate only; the pages
// and APIs work regardless, dimensions plan §2).
requiresDimensions?: boolean
// Paywall surfaces: hidden unless the active company holds this paid
// capability. Cosmetic only, the page and API gates are the real
// enforcement; this just keeps the sidebar honest for non-payers.
requiredCapability?: CapabilityKey
hidden?: boolean
comingSoon?: boolean
devBadge?: boolean
@@ -164,7 +169,7 @@ const navItems: NavItem[] = [
// enskild firma that hires staff gets payroll. Owner self-payroll stays
// blocked at the engine/DB layer (EF owner takes egna uttag, not lön). #782
{ href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'arbeta' },
{ href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'arbeta' },
{ href: '/e/general/invoice-inbox', labelKey: 'invoice_inbox', icon: Inbox, group: 'arbeta', requiredCapability: EXTENSION_REQUIRED_CAPABILITY['general/invoice-inbox'] },
{ href: '/transactions', labelKey: 'transactions', icon: ArrowLeftRight, group: 'arbeta' },
{ href: '/pending', labelKey: 'review', icon: ClipboardCheck, group: 'arbeta' },
{ href: '/invoices', labelKey: 'invoices', icon: ReceiptText, group: 'arbeta' },
@@ -225,7 +230,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const pathname = usePathname()
const router = useRouter()
const supabase = useRealtimeSupabase()
const { company } = useCompany()
const { company, capabilities } = useCompany()
// Agent identity drives the "Assistent" nav icon: when the user has
// built their assistant we show its chosen avatar instead of the
// generic Sparkles glyph.
@@ -399,6 +404,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
// Dimension surfaces are hidden until the company opts in via the
// bookkeeping settings toggle (company_settings.dimensions_enabled).
if (item.requiresDimensions && !dimensionsEnabled) return false
// Paywalled surfaces (e.g. the AI-only Dokumentinkorg) are hidden unless
// the active company holds the capability. The page + API gates enforce
// the paywall; this keeps the sidebar from advertising a dead workspace.
if (item.requiredCapability && !capabilities.includes(item.requiredCapability)) return false
// Hide the Assistent (/chat) tab until the agent is built: mirrors the
// floating AgentTrigger and avoids a nav entry that only bounces to the
// home checklist (chat/layout redirects unverified users to /).