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:
co-authored by
Claude Fable 5
parent
a566a42aec
commit
19cbb0094b
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 /).
|
||||
|
||||
Reference in New Issue
Block a user