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
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user