Bug/template and sandbox (#589)

* Enhance booking template functionality and add sandbox extraction checks

* Implement Recapt integration for feedback submission and user identification

* Add Recapt identification component and bank sync status chip; update crontab entries

* Update .gitignore to ignore the entire scripts directory

* Refactor Recapt integration: add loader component, update privacy policy, and enhance bank sync status messages

* Fix .gitignore to correctly ignore the scripts directory
This commit is contained in:
Mattsson
2026-05-28 15:43:48 +02:00
committed by GitHub
parent 20989379bb
commit a9aff5a120
25 changed files with 927 additions and 70 deletions
+1 -1
View File
@@ -67,4 +67,4 @@ supabase/.temp/
# out of the box without running the generator.
supabase/.branches/
scripts\remap-krister-bas96-to-bas2025.ts
/scripts
+14
View File
@@ -4,6 +4,9 @@ import { headers } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav'
import { MainContainer } from '@/components/dashboard/MainContainer'
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
import { RecaptIdentify } from '@/components/RecaptIdentify'
import { RecaptLoader } from '@/components/RecaptLoader'
import { RecaptHideWidget } from '@/components/RecaptHideWidget'
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
import AgentTrigger from '@/components/agent/AgentTrigger'
import CommandPalette from '@/components/common/CommandPalette'
@@ -278,6 +281,17 @@ export default async function DashboardLayout({
<AgentTrigger />
<CommandPalette />
</div>
{!isSandbox && (
<>
<RecaptLoader />
<RecaptHideWidget />
<RecaptIdentify
userId={user.id}
email={user.email}
displayName={settings?.company_name || undefined}
/>
</>
)}
</AgentSheetProvider>
</CompanyProvider>
)
@@ -13,6 +13,7 @@ import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings
import { AccountDangerZone } from '@/components/settings/AccountDangerZone'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useSettings } from '@/components/settings/useSettings'
import { clearRecaptIdentity } from '@/lib/recapt'
import { useToast } from '@/components/ui/use-toast'
import { SUPPORTED_LOCALES, type Locale } from '@/i18n/config'
@@ -32,6 +33,7 @@ export default function AccountSettingsPage() {
useEffect(() => { setMounted(true) }, [])
async function handleLogout() {
clearRecaptIdentity()
await supabase.auth.signOut()
router.push('/login')
}
+1 -1
View File
@@ -445,7 +445,7 @@ function BalanceHero({
{saldo.lastSyncedAt && (
<p className="text-xs text-muted-foreground">
Senast synkad{' '}
Synkas automatiskt varje natt. Senast synkad{' '}
<span className="tabular-nums">
{new Date(saldo.lastSyncedAt).toLocaleString('sv-SE')}
</span>
+3
View File
@@ -24,6 +24,7 @@ import { ChevronDown, Search, Trash2, X } from 'lucide-react'
import TransactionForm from '@/components/transactions/TransactionForm'
import BatchCategorySelector from '@/components/transactions/BatchCategorySelector'
import TransactionStatusBar from '@/components/transactions/TransactionStatusBar'
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
import TransactionInboxCard from '@/components/transactions/TransactionInboxCard'
import TransactionHistoryList from '@/components/transactions/TransactionHistoryList'
import InboxZeroState from '@/components/transactions/InboxZeroState'
@@ -1513,6 +1514,8 @@ export default function TransactionsPage() {
onToggleBatchMode={() => (isBatchMode ? exitBatchMode() : setIsBatchMode(true))}
/>
<BankSyncStatusChip />
{/* Search + view dropdown */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
+11 -1
View File
@@ -18,7 +18,7 @@ export default function PrivacyPolicyPage() {
Integritetspolicy
</h1>
<p className="text-muted-foreground">
Senast uppdaterad: 2026-03-05
Senast uppdaterad: 2026-05-28
</p>
</div>
@@ -124,6 +124,16 @@ export default function PrivacyPolicyPage() {
<td className="py-2 pr-4">USA</td>
<td className="py-2">SCCs (standardavtalsklausuler)</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Recapt</td>
<td className="py-2 pr-4">
Produktanalys och användarfeedback. Laddas endast för
inloggade användare (ej sandbox/demo). Överförda
uppgifter: användar-ID, e-postadress och företagsnamn.
</td>
<td className="py-2 pr-4">EU</td>
<td className="py-2">SCCs vid eventuella underbiträden utanför EES</td>
</tr>
</tbody>
</table>
</div>
+38
View File
@@ -0,0 +1,38 @@
'use client'
import { useEffect } from 'react'
/**
* Hides Recapt's floating feedback bubble while keeping the SDK active so
* `window.recapt('identify', ...)` and programmatic `window.recapt('feedback',
* { message })` calls continue to work. Mounted globally in the root layout.
*/
export function RecaptHideWidget() {
useEffect(() => {
let attempts = 0
const maxAttempts = 50
const hide = (): boolean => {
if (typeof window.recapt !== 'function') return false
try {
window.recapt('feedback', { widget: 'hide' })
} catch {
// best-effort
}
return true
}
if (hide()) return
const interval = setInterval(() => {
attempts++
if (hide() || attempts >= maxAttempts) {
clearInterval(interval)
}
}, 100)
return () => clearInterval(interval)
}, [])
return null
}
+37
View File
@@ -0,0 +1,37 @@
'use client'
import { useEffect } from 'react'
export function RecaptIdentify({
userId,
email,
displayName,
}: {
userId: string
email?: string
displayName?: string
}) {
useEffect(() => {
let attempts = 0
const maxAttempts = 50
const interval = setInterval(() => {
if (typeof window.recapt === 'function') {
window.recapt('identify', {
uid: userId,
email,
nickname: displayName,
})
clearInterval(interval)
return
}
attempts++
if (attempts >= maxAttempts) {
clearInterval(interval)
}
}, 100)
return () => clearInterval(interval)
}, [userId, email, displayName])
return null
}
+31
View File
@@ -0,0 +1,31 @@
'use client'
import Script from 'next/script'
/**
* Loads the Recapt SDK for authenticated dashboard users only.
*
* Privacy guard rails:
* - Mounted inside the dashboard layout, so the script never loads on
* public pages (login, register, privacy policy, marketing). This
* prevents pre-consent IP/fingerprint collection on those routes.
* - The public key is sourced from NEXT_PUBLIC_RECAPT_PUBLIC_KEY so
* hosted and self-hosted deployments can each supply their own key
* (or disable Recapt entirely by leaving it unset).
* - data-persist / data-enable-user-comments are intentionally omitted
* from the default tag. Persistent cross-session tracking and
* unstructured free-text capture are opt-in product decisions, not
* defaults (GDPR Art. 25 — privacy by default).
*/
export function RecaptLoader() {
const publicKey = process.env.NEXT_PUBLIC_RECAPT_PUBLIC_KEY
if (!publicKey) return null
return (
<Script
src="https://cdn.recapt.app/browser/glimt.js"
strategy="afterInteractive"
data-public-key={publicKey}
/>
)
}
+2
View File
@@ -35,6 +35,7 @@ import {
import { getBranding } from '@/lib/branding/service'
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { clearRecaptIdentity } from '@/lib/recapt'
import { SupportLink } from '@/components/ui/support-link'
import {
DropdownMenu,
@@ -212,6 +213,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
}
const handleLogout = async () => {
clearRecaptIdentity()
await supabase.auth.signOut()
router.push(isSandbox ? '/sandbox' : '/login')
}
+93 -24
View File
@@ -18,7 +18,7 @@ import {
DialogTrigger,
} from '@/components/ui/dialog'
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Building2, Users, Globe } from 'lucide-react'
import { TEMPLATE_CATEGORY_LABELS } from '@/lib/bookkeeping/template-library'
import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
@@ -264,38 +264,43 @@ function TemplateSection({
<div className="space-y-1">
{templates.map((tt) => {
const isExpanded = expandedId === tt.id
const isConvertible = convertLibraryToBookingTemplate(tt) !== null
return (
<div
key={tt.id}
className="rounded-lg border"
>
<button
type="button"
onClick={() => onToggle(isExpanded ? null : tt.id)}
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/50 transition-colors"
>
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{tt.name}</span>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{TEMPLATE_CATEGORY_LABELS[tt.category]}
</Badge>
{tt.entity_type !== 'all' && (
<div className="flex items-center gap-3 p-3 hover:bg-muted/50 transition-colors">
<button
type="button"
onClick={() => onToggle(isExpanded ? null : tt.id)}
className="flex items-center gap-3 flex-1 min-w-0 text-left"
>
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{tt.name}</span>
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{entityLabels[tt.entity_type]}
{TEMPLATE_CATEGORY_LABELS[tt.category]}
</Badge>
)}
{tt.entity_type !== 'all' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{entityLabels[tt.entity_type]}
</Badge>
)}
{!isConvertible && (
<Badge variant="warning" className="text-[10px] px-1.5 py-0">
{t('unconvertible_badge')}
</Badge>
)}
</div>
</div>
</div>
</button>
{canDelete && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
onDelete(tt.id)
}}
onClick={() => onDelete(tt.id)}
disabled={deletingId === tt.id}
className="h-8 w-8 p-0 shrink-0"
>
@@ -306,7 +311,7 @@ function TemplateSection({
)}
</Button>
)}
</button>
</div>
{isExpanded && (
<div className="px-3 pb-3 pt-0">
{tt.description && (
@@ -369,8 +374,28 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
})
}
function updateLineType(index: number, newType: BookingTemplateLibraryLine['type']) {
setLines((prev) => {
const updated = [...prev]
const current = updated[index]
const next: BookingTemplateLibraryLine = { ...current, type: newType }
// Auto-pick a sensible default for the type-specific field so the
// converter (and applyTemplate) sees a complete line shape.
if (newType === 'vat' && next.vat_rate === undefined) {
next.vat_rate = 0.25
}
updated[index] = next
return updated
})
}
// Default new lines to a VAT line — the 2-line template starts with one
// business + one settlement, and the natural extension is a VAT leg.
// Defaulting to 'business' instead would silently break the converter
// (which requires exactly one business line) and the template would
// disappear from the transaction picker.
function addLine() {
setLines((prev) => [...prev, { account: '', label: '', side: 'debit', type: 'business', ratio: 1 }])
setLines((prev) => [...prev, { account: '', label: '', side: 'debit', type: 'vat', vat_rate: 0.25 }])
}
function removeLine(index: number) {
@@ -378,6 +403,28 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
setLines((prev) => prev.filter((_, i) => i !== index))
}
// Real-time check: can this draft be picked from the transaction sheet?
// If not, we show a hint — save remains allowed (templates may still be
// useful from the journal-entry form).
const isConvertible = (() => {
const draft: BookingTemplateLibrary = {
id: '',
company_id: null,
team_id: null,
created_by: null,
name,
description,
category,
entity_type: entityType,
lines,
is_system: false,
is_active: true,
created_at: '',
updated_at: '',
}
return convertLibraryToBookingTemplate(draft) !== null
})()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name || lines.some((l) => !l.account || !l.label)) {
@@ -464,7 +511,7 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
<SelectItem value="credit">{t('credit_label')}</SelectItem>
</SelectContent>
</Select>
<Select value={line.type} onValueChange={(v) => updateLine(i, 'type', v)}>
<Select value={line.type} onValueChange={(v) => updateLineType(i, v as BookingTemplateLibraryLine['type'])}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="business">{t('type_cost')}</SelectItem>
@@ -472,6 +519,20 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
<SelectItem value="settlement">{t('type_settlement')}</SelectItem>
</SelectContent>
</Select>
{line.type === 'vat' && (
<Select
value={String(line.vat_rate ?? 0.25)}
onValueChange={(v) => updateLine(i, 'vat_rate', Number(v))}
>
<SelectTrigger className="w-20" aria-label={t('vat_rate_label')}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0.25">{t('vat_rate_25')}</SelectItem>
<SelectItem value="0.12">{t('vat_rate_12')}</SelectItem>
<SelectItem value="0.06">{t('vat_rate_6')}</SelectItem>
<SelectItem value="0">{t('vat_rate_0')}</SelectItem>
</SelectContent>
</Select>
)}
<Button
type="button"
variant="ghost"
@@ -491,6 +552,14 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
</div>
</div>
{!isConvertible && (
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
<p className="text-xs text-warning-foreground leading-snug">
{t('unconvertible_hint')}
</p>
</div>
)}
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{t('create_button')}
@@ -0,0 +1,94 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
interface ConnectionRow {
id: string
status: string | null
last_synced_at: string | null
}
function useAgeFormatter() {
const t = useTranslations('transactions')
return (iso: string): string => {
const ms = Date.now() - new Date(iso).getTime()
const min = Math.floor(ms / 60000)
if (min < 1) return t('bank_sync_age_just_now')
if (min < 60) return t('bank_sync_age_minutes', { count: min })
const h = Math.floor(min / 60)
if (h < 24) return t('bank_sync_age_hours', { count: h })
const d = Math.floor(h / 24)
return t('bank_sync_age_days', { count: d })
}
}
export default function BankSyncStatusChip() {
const t = useTranslations('transactions')
const formatAge = useAgeFormatter()
const { company } = useCompany()
const [rows, setRows] = useState<ConnectionRow[] | null>(null)
useEffect(() => {
if (!company?.id) return
let cancelled = false
const supabase = createClient()
supabase
.from('bank_connections')
.select('id, status, last_synced_at')
.eq('company_id', company.id)
.then(({ data }) => {
if (!cancelled) setRows(data ?? [])
})
return () => {
cancelled = true
}
}, [company?.id])
if (!rows || rows.length === 0) return null
const needsAttention = rows.filter(
(r) => r.status === 'expired' || r.status === 'error',
)
if (needsAttention.length > 0) {
return (
<Link
href="/settings/banking"
className="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 bg-destructive/5 px-2.5 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
>
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{needsAttention.length === 1
? t('bank_sync_attention_one')
: t('bank_sync_attention_many', { count: needsAttention.length })}
</span>
</Link>
)
}
const mostRecent = rows
.map((r) => r.last_synced_at)
.filter((s): s is string => Boolean(s))
.sort()
.pop()
return (
<div className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground">
<RefreshCw className="h-3.5 w-3.5" />
<span>
{t('bank_sync_auto_nightly')}
{mostRecent && (
<>
{t('bank_sync_last_separator')}
<span className="tabular-nums">{formatAge(mostRecent)}</span>
</>
)}
</span>
</div>
)
}
+2 -1
View File
@@ -1,4 +1,5 @@
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/ext/enable-banking/sync/cron
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/skattekonto/sync/cron
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
+2
View File
@@ -1,3 +1,5 @@
0 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/enable-banking/sync/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/skatteverket/skattekonto/sync/cron
0 6 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/deadlines/status/cron
0 0 2 1 * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/tax-deadlines/cron
0 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/events/cleanup/cron
@@ -123,6 +123,7 @@ describe('POST /items/:id/retry-extraction', () => {
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
error: null,
})
enqueue({ data: { is_sandbox: false }, error: null }) // sandbox check
enqueue({
data: { storage_path: 'path/to.pdf', mime_type: 'application/pdf', file_name: 'invoice.pdf' },
error: null,
@@ -150,6 +151,7 @@ describe('POST /items/:id/retry-extraction', () => {
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
error: null,
})
enqueue({ data: { is_sandbox: false }, error: null }) // sandbox check
enqueue({
data: { storage_path: 'path/to.pdf', mime_type: 'application/pdf', file_name: 'invoice.pdf' },
error: null,
@@ -0,0 +1,238 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { PDFDocument } from 'pdf-lib'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
} from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
// Mock the Bedrock call. The whole point of this file is to assert it is
// never invoked on sandbox companies.
vi.mock('@/extensions/general/invoice-inbox/lib/extract-invoice-fields', async () => {
const actual = await vi.importActual<
typeof import('@/extensions/general/invoice-inbox/lib/extract-invoice-fields')
>('@/extensions/general/invoice-inbox/lib/extract-invoice-fields')
return {
...actual,
extractInvoiceFields: vi.fn(),
}
})
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: vi.fn().mockResolvedValue({ id: 'doc-1' }),
}))
vi.mock('@/lib/rate-limits/inbox', () => ({
checkInboxUploadRateLimit: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: vi.fn().mockResolvedValue(undefined),
}))
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
function findRoute(method: string, path: string) {
return invoiceInboxExtension.apiRoutes!.find(
(r) => r.method === method && r.path === path,
)!
}
const uploadRoute = findRoute('POST', '/upload')
const attachRoute = findRoute('POST', '/items/:id/attach-document')
const retryRoute = findRoute('POST', '/items/:id/retry-extraction')
function buildCtx(supabase: unknown): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'invoice-inbox',
supabase: supabase as ExtensionContext['supabase'],
emit: vi.fn(),
settings: { get: vi.fn(), set: vi.fn() },
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
services: {},
} as ExtensionContext
}
async function makePdfBuffer(pageCount: number): Promise<Uint8Array> {
const pdf = await PDFDocument.create()
for (let i = 0; i < pageCount; i++) pdf.addPage([612, 792])
return pdf.save()
}
function makeMultipartRequest(form: FormData, path: string): Request {
return new Request(`http://localhost:3000${path}`, {
method: 'POST',
body: form,
})
}
// Supabase mock routed by table name. The /upload handler hits
// company_settings (sandbox check), invoice_inbox_items (insert), and
// suppliers (match) in that order via separate .from() chains.
function makeUploadSupabase(opts: {
isSandbox: boolean
captured: { row?: Record<string, unknown> }
}) {
const settingsChain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: { is_sandbox: opts.isSandbox }, error: null }),
}
const supplierChain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
ilike: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: null }),
}
const inboxChain = {
insert: vi.fn((row: Record<string, unknown>) => {
opts.captured.row = row
return {
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { id: 'inbox-1', status: 'received', matched_supplier_id: null, ...row },
error: null,
}),
}),
}
}),
update: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnThis(),
}),
}
return {
from: vi.fn((table: string) => {
if (table === 'company_settings') return settingsChain
if (table === 'invoice_inbox_items') return inboxChain
return supplierChain
}),
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Sandbox companies skip Bedrock extraction', () => {
describe('POST /upload', () => {
it('skips extraction and reports skip_reason=sandbox', async () => {
const captured: { row?: Record<string, unknown> } = {}
const supabase = makeUploadSupabase({ isSandbox: true, captured })
const bytes = await makePdfBuffer(1)
const file = new File([bytes as BlobPart], 'receipt.pdf', { type: 'application/pdf' })
const form = new FormData()
form.set('file', file)
const res = await uploadRoute.handler(makeMultipartRequest(form, '/upload'), buildCtx(supabase))
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
expect(status).toBe(200)
expect(extractInvoiceFields).not.toHaveBeenCalled()
expect(body.data.extraction_skipped).toBe(true)
expect(body.data.skip_reason).toBe('sandbox')
expect(captured.row?.extraction_skipped).toBe(true)
})
it('runs extraction normally for non-sandbox companies', async () => {
const captured: { row?: Record<string, unknown> } = {}
const supabase = makeUploadSupabase({ isSandbox: false, captured })
vi.mocked(extractInvoiceFields).mockResolvedValueOnce({
data: {
supplier: { name: null, orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
lineItems: [],
totals: { subtotal: null, vatAmount: null, total: null },
vatBreakdown: [],
confidence: 0,
},
rawText: 'ok',
})
const bytes = await makePdfBuffer(1)
const file = new File([bytes as BlobPart], 'receipt.pdf', { type: 'application/pdf' })
const form = new FormData()
form.set('file', file)
const res = await uploadRoute.handler(makeMultipartRequest(form, '/upload'), buildCtx(supabase))
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
expect(status).toBe(200)
expect(extractInvoiceFields).toHaveBeenCalledOnce()
expect(body.data.extraction_skipped).toBe(false)
expect(body.data.skip_reason).toBeNull()
})
})
describe('POST /items/:id/attach-document', () => {
it('skips extraction when company is a sandbox', async () => {
// Lookup order: item exists → upload doc → sandbox check → update row.
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'item-1',
document_id: null,
status: 'received',
correlation_id: null,
created_supplier_invoice_id: null,
},
error: null,
})
// sandbox check inside the try block
enqueue({ data: { is_sandbox: true }, error: null })
// update row
enqueue({ data: null, error: null })
// uploadDocument is mocked at the module level; no need to enqueue.
const bytes = await makePdfBuffer(1)
const file = new File([bytes as BlobPart], 'attach.pdf', { type: 'application/pdf' })
const form = new FormData()
form.set('file', file)
const req = new Request('http://localhost:3000/items/item-1/attach-document?_id=item-1', {
method: 'POST',
body: form,
})
const res = await attachRoute.handler(req, buildCtx(supabase))
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
expect(status).toBe(200)
expect(extractInvoiceFields).not.toHaveBeenCalled()
expect(body.data.extraction_skipped).toBe(true)
expect(body.data.skip_reason).toBe('sandbox')
})
})
describe('POST /items/:id/retry-extraction', () => {
it('returns 409 with a Swedish message when the company is a sandbox', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// item lookup
enqueue({
data: { id: 'item-1', document_id: 'doc-1', correlation_id: null, created_supplier_invoice_id: null },
error: null,
})
// sandbox check → true
enqueue({ data: { is_sandbox: true }, error: null })
const req = createMockRequest('/items/item-1/retry-extraction', {
method: 'POST',
searchParams: { _id: 'item-1' },
})
const res = await retryRoute.handler(req, buildCtx(supabase))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(409)
expect(body.error).toMatch(/sandlådan/i)
expect(extractInvoiceFields).not.toHaveBeenCalled()
})
})
})
+47 -10
View File
@@ -69,6 +69,23 @@ async function countPdfPages(buffer: ArrayBuffer): Promise<number | null> {
}
}
// Sandbox companies (24h anonymous demo accounts) skip the Bedrock extraction
// pipeline entirely. The document still uploads, the inbox row still lands,
// and the user can fill the fields in by hand — but no Claude tokens are
// spent on a throwaway account. See migration 20260311120000 for the column.
async function isSandboxCompany(
supabase: import('@supabase/supabase-js').SupabaseClient,
companyId: string,
): Promise<boolean> {
const { data, error } = await supabase
.from('company_settings')
.select('is_sandbox')
.eq('company_id', companyId)
.maybeSingle()
if (error || !data) return false
return data.is_sandbox === true
}
// Partial-update schema for the /items/:id/fields PATCH route. Only the
// scalar fields the UI exposes for inline editing — line items and
// vatBreakdown stay AI-managed for now and are preserved by the merge.
@@ -195,12 +212,17 @@ async function uploadAndExtract(
file.type === 'application/pdf' ? await countPdfPages(file.buffer) : null
const gatedByPageCount =
pageCount != null && pageCount > MAX_PAGES_FOR_AUTO_EXTRACT
const skipExtraction = !!opts.skipExtraction || gatedByPageCount
const skipReason: 'too_many_pages' | 'client_opt_out' | null = gatedByPageCount
? 'too_many_pages'
: opts.skipExtraction
? 'client_opt_out'
: null
const sandbox = await isSandboxCompany(supabase, companyId)
// Skip-reason priority: sandbox > page-count > client opt-out. Sandbox
// wins because it's a hard cost-control rule, not a heuristic.
const skipReason: 'too_many_pages' | 'client_opt_out' | 'sandbox' | null = sandbox
? 'sandbox'
: gatedByPageCount
? 'too_many_pages'
: opts.skipExtraction
? 'client_opt_out'
: null
const skipExtraction = skipReason !== null
// Bring-your-own-extraction: skip the Bedrock call entirely and seed an
// empty extraction skeleton. The caller is expected to PUT the parsed
@@ -772,12 +794,20 @@ export const invoiceInboxExtension: Extension = {
// Same page-count gate as /upload (issue #553) — attaching a 6-page
// sales report to an existing inbox row should not block on Bedrock.
// Sandbox companies skip Bedrock unconditionally.
const pageCount =
file.type === 'application/pdf' ? await countPdfPages(buffer) : null
const gatedByPageCount =
pageCount != null && pageCount > MAX_PAGES_FOR_AUTO_EXTRACT
const sandbox = await isSandboxCompany(ctx.supabase, ctx.companyId)
const skipReason: 'too_many_pages' | 'sandbox' | null = sandbox
? 'sandbox'
: gatedByPageCount
? 'too_many_pages'
: null
const skipExtraction = skipReason !== null
const { data: extracted } = gatedByPageCount
const { data: extracted } = skipExtraction
? { data: emptyResult() }
: await extractInvoiceFields({
buffer: Buffer.from(buffer),
@@ -790,7 +820,7 @@ export const invoiceInboxExtension: Extension = {
.update({
document_id: doc.id,
extracted_data: extracted as unknown as Record<string, unknown>,
extraction_skipped: gatedByPageCount,
extraction_skipped: skipExtraction,
})
.eq('id', id)
.eq('company_id', ctx.companyId)
@@ -827,8 +857,8 @@ export const invoiceInboxExtension: Extension = {
document_id: doc.id,
inbox_item_id: id,
extracted_data: extracted,
extraction_skipped: gatedByPageCount,
skip_reason: gatedByPageCount ? 'too_many_pages' : null,
extraction_skipped: skipExtraction,
skip_reason: skipReason,
page_count: pageCount,
},
})
@@ -1073,6 +1103,13 @@ export const invoiceInboxExtension: Extension = {
)
}
if (await isSandboxCompany(ctx.supabase, ctx.companyId)) {
return NextResponse.json(
{ error: 'AI-tolkning är inte tillgänglig i sandlådan.' },
{ status: 409 },
)
}
const { data: doc } = await ctx.supabase
.from('document_attachments')
.select('storage_path, mime_type, file_name')
@@ -1,6 +1,25 @@
import { describe, it, expect } from 'vitest'
import { applyTemplate, getTemplateScope, TEMPLATE_CATEGORY_LABELS } from '../template-library'
import type { BookingTemplateLibraryLine } from '@/types'
import { applyTemplate, convertLibraryToBookingTemplate, getTemplateScope, LIBRARY_TEMPLATE_PREFIX, TEMPLATE_CATEGORY_LABELS } from '../template-library'
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
function makeLibraryTemplate(lines: BookingTemplateLibraryLine[], overrides: Partial<BookingTemplateLibrary> = {}): BookingTemplateLibrary {
return {
id: 'tpl-1',
company_id: 'co-1',
team_id: null,
created_by: 'user-1',
name: 'Test template',
description: '',
category: 'other',
entity_type: 'all',
lines,
is_system: false,
is_active: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
}
}
describe('applyTemplate', () => {
it('creates simple two-line debit/credit entries', () => {
@@ -96,3 +115,89 @@ describe('TEMPLATE_CATEGORY_LABELS', () => {
expect(TEMPLATE_CATEGORY_LABELS.tax_account).toBe('Skattekonto')
})
})
describe('convertLibraryToBookingTemplate', () => {
it('converts a simple 2-line business + settlement template', () => {
const tpl = makeLibraryTemplate([
{ account: '6072', label: 'Representation', side: 'debit', type: 'business', ratio: 1 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1 },
])
const result = convertLibraryToBookingTemplate(tpl)
expect(result).not.toBeNull()
expect(result!.id).toBe(`${LIBRARY_TEMPLATE_PREFIX}tpl-1`)
expect(result!.direction).toBe('expense')
expect(result!.debit_account).toBe('6072')
expect(result!.credit_account).toBe('1930')
expect(result!.vat_treatment).toBeNull()
})
it('identifies direction "income" when business line is on credit', () => {
const tpl = makeLibraryTemplate([
{ account: '3001', label: 'Försäljning', side: 'credit', type: 'business', ratio: 1 },
{ account: '1930', label: 'Företagskonto', side: 'debit', type: 'settlement', ratio: 1 },
])
const result = convertLibraryToBookingTemplate(tpl)
expect(result).not.toBeNull()
expect(result!.direction).toBe('income')
expect(result!.debit_account).toBe('1930')
expect(result!.credit_account).toBe('3001')
})
it.each([
[0.25, 'standard_25'],
[0.12, 'reduced_12'],
[0.06, 'reduced_6'],
] as const)('extracts VAT treatment for rate %f', (rate, treatment) => {
const tpl = makeLibraryTemplate([
{ account: '4010', label: 'Varor', side: 'debit', type: 'business', ratio: 1 },
{ account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: rate },
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1 },
])
const result = convertLibraryToBookingTemplate(tpl)
expect(result).not.toBeNull()
expect(result!.vat_treatment).toBe(treatment)
expect(result!.vat_rate).toBe(rate)
})
it('detects reverse charge via 2614 fictitious output VAT', () => {
const tpl = makeLibraryTemplate([
{ account: '4056', label: 'EU-varor', side: 'debit', type: 'business', ratio: 1 },
{ account: '2614', label: 'Utg. moms omv.', side: 'credit', type: 'vat', vat_rate: 0.25 },
{ account: '2645', label: 'Ing. moms omv.', side: 'debit', type: 'vat', vat_rate: 0.25 },
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1 },
])
const result = convertLibraryToBookingTemplate(tpl)
expect(result).not.toBeNull()
expect(result!.vat_treatment).toBe('reverse_charge')
})
it('returns null when there are 2 business lines', () => {
const tpl = makeLibraryTemplate([
{ account: '6072', label: 'A', side: 'debit', type: 'business', ratio: 0.5 },
{ account: '6073', label: 'B', side: 'debit', type: 'business', ratio: 0.5 },
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1 },
])
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
})
it('returns null when there is no settlement line', () => {
const tpl = makeLibraryTemplate([
{ account: '6072', label: 'A', side: 'debit', type: 'business', ratio: 1 },
{ account: '2641', label: 'Moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
])
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
})
it('returns null when business and settlement are on the same side', () => {
const tpl = makeLibraryTemplate([
{ account: '6072', label: 'A', side: 'debit', type: 'business', ratio: 1 },
{ account: '1930', label: 'Bank', side: 'debit', type: 'settlement', ratio: 1 },
])
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
})
it('returns null when lines is not an array', () => {
const tpl = makeLibraryTemplate([], { lines: null as unknown as BookingTemplateLibraryLine[] })
expect(convertLibraryToBookingTemplate(tpl)).toBeNull()
})
})
+31
View File
@@ -0,0 +1,31 @@
// Recapt's identify SDK keeps the last-known uid in memory and in
// localStorage. Passing `uid: undefined` is not a documented logout
// signal — on some SDK versions it's coerced to the previous value.
// We send an explicit empty-string uid (the SDK's "anonymous" marker),
// then clear any persisted Recapt keys from localStorage so the next
// pageload doesn't re-identify the logged-out user from cache.
export function clearRecaptIdentity(): void {
if (typeof window === 'undefined') return
try {
if (typeof window.recapt === 'function') {
window.recapt('identify', {
uid: '',
email: undefined,
nickname: undefined,
})
}
// Defense-in-depth: wipe any Recapt-namespaced storage on logout so
// a shared device cannot resurrect the previous user's identity on
// the next page load.
if (typeof window.localStorage !== 'undefined') {
for (let i = window.localStorage.length - 1; i >= 0; i--) {
const key = window.localStorage.key(i)
if (key && (key.startsWith('recapt') || key.startsWith('glimt'))) {
window.localStorage.removeItem(key)
}
}
}
} catch {
// best-effort — we're already in a logout flow
}
}
+62 -14
View File
@@ -11,19 +11,30 @@ describe('submitFeedback', () => {
vi.unstubAllGlobals()
})
function stubRecapt(impl: (...args: unknown[]) => void) {
vi.stubGlobal('window', { recapt: impl })
}
function stubNoRecapt() {
vi.stubGlobal('window', {})
}
function stubFetchOk() {
const fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
vi.stubGlobal('fetch', fetchSpy)
return fetchSpy
}
it('posts subject and message to the contact endpoint', async () => {
it('sends to both Recapt and email when SDK is present', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
const fetchSpy = stubFetchOk()
const result = await submitFeedback({ subject: 'Hjälpsida', message: 'Hjälp tack' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
expect(result.channels.sort()).toEqual(['email', 'recapt'])
expect(recapt).toHaveBeenCalledWith('feedback', { message: '[Hjälpsida]\n\nHjälp tack' })
expect(fetchSpy).toHaveBeenCalledWith(
'/api/support/contact',
expect.objectContaining({
@@ -33,21 +44,57 @@ describe('submitFeedback', () => {
)
})
it('omits subject when not provided', async () => {
const fetchSpy = stubFetchOk()
it('omits subject prefix in Recapt payload when subject not provided', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
stubFetchOk()
const result = await submitFeedback({ message: 'plain' })
await submitFeedback({ message: 'plain' })
expect(result.ok).toBe(true)
expect(fetchSpy).toHaveBeenCalledWith(
'/api/support/contact',
expect.objectContaining({
body: JSON.stringify({ message: 'plain' }),
})
)
expect(recapt).toHaveBeenCalledWith('feedback', { message: 'plain' })
})
it('returns failure with server error message when the endpoint rejects', async () => {
it('still reports success via email when Recapt throws', async () => {
stubRecapt(() => {
throw new Error('boom')
})
stubFetchOk()
const result = await submitFeedback({ subject: 'X', message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
})
it('uses email only when Recapt SDK is absent', async () => {
stubNoRecapt()
const fetchSpy = stubFetchOk()
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
expect(fetchSpy).toHaveBeenCalledOnce()
})
it('reports success when Recapt succeeds even if email fails', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: async () => ({ error: 'down' }) })
)
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['recapt'])
})
it('returns failure with email error when both channels fail', async () => {
stubRecapt(() => {
throw new Error('boom')
})
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
@@ -63,7 +110,8 @@ describe('submitFeedback', () => {
expect(result.error).toBe('Mailtjänsten är inte konfigurerad')
})
it('returns failure when fetch itself throws', async () => {
it('returns failure when fetch itself throws and Recapt is absent', async () => {
stubNoRecapt()
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network down')))
const result = await submitFeedback({ message: 'msg' })
+45 -10
View File
@@ -3,7 +3,7 @@ export interface SubmitFeedbackInput {
subject?: string
}
export type SupportChannel = 'email'
export type SupportChannel = 'recapt' | 'email'
export interface SubmitFeedbackResult {
ok: boolean
@@ -11,23 +11,58 @@ export interface SubmitFeedbackResult {
error?: string
}
export async function submitFeedback(input: SubmitFeedbackInput): Promise<SubmitFeedbackResult> {
function composeMessage({ message, subject }: SubmitFeedbackInput): string {
if (!subject) return message
return `[${subject}]\n\n${message}`
}
async function submitViaEmail(
{ message, subject }: SubmitFeedbackInput
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
const res = await fetch('/api/support/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject: input.subject, message: input.message }),
body: JSON.stringify({ subject, message }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
return { ok: false, channels: [], error: data.error || 'Kunde inte skicka meddelandet' }
return { ok: false, error: data.error || 'Kunde inte skicka meddelandet' }
}
return { ok: true, channels: ['email'] }
return { ok: true }
} catch (err) {
return {
ok: false,
channels: [],
error: err instanceof Error ? err.message : 'Nätverksfel',
}
return { ok: false, error: err instanceof Error ? err.message : 'Nätverksfel' }
}
}
function submitViaRecapt(
input: SubmitFeedbackInput
): { ok: true } | { ok: false; error: string } | null {
const recapt = typeof window !== 'undefined' ? window.recapt : undefined
if (typeof recapt !== 'function') return null
try {
recapt('feedback', { message: composeMessage(input) })
return { ok: true }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Recapt-fel' }
}
}
export async function submitFeedback(input: SubmitFeedbackInput): Promise<SubmitFeedbackResult> {
const recaptResult = submitViaRecapt(input)
const emailResult = await submitViaEmail(input)
const channels: SupportChannel[] = []
if (recaptResult?.ok) channels.push('recapt')
if (emailResult.ok) channels.push('email')
if (channels.length > 0) {
return { ok: true, channels }
}
return {
ok: false,
channels: [],
error: emailResult.ok ? undefined : emailResult.error,
}
}
+17 -2
View File
@@ -1288,7 +1288,14 @@
"debit_label": "Debit",
"credit_label": "Credit",
"add_line": "Add line",
"create_button": "Create template"
"create_button": "Create template",
"vat_rate_label": "VAT rate",
"vat_rate_25": "25%",
"vat_rate_12": "12%",
"vat_rate_6": "6%",
"vat_rate_0": "0% (export/reverse-charge/exempt)",
"unconvertible_hint": "This template can't be picked directly when booking a transaction — it's used from a journal entry. To appear in the transaction list, it needs exactly one cost/revenue line and one settlement line on opposite sides.",
"unconvertible_badge": "Journal entries only"
},
"settings_counterparty_templates": {
"title": "Bookkeeping templates",
@@ -3167,7 +3174,15 @@
"deleted_title": "Deleted",
"deleted_description": "The transaction has been deleted",
"delete_failed_description": "The transaction could not be deleted. Please try again.",
"review_in_bookkeeping_description": "Review and post the journal entry in Bookkeeping."
"review_in_bookkeeping_description": "Review and post the journal entry in Bookkeeping.",
"bank_sync_attention_one": "1 bank connection needs renewal",
"bank_sync_attention_many": "{count} bank connections need renewal",
"bank_sync_auto_nightly": "Synced automatically each night",
"bank_sync_last_separator": " · last ",
"bank_sync_age_just_now": "just now",
"bank_sync_age_minutes": "{count} min ago",
"bank_sync_age_hours": "{count} h ago",
"bank_sync_age_days": "{count} d ago"
},
"bookkeeping": {
"title": "Bookkeeping",
+17 -2
View File
@@ -1288,7 +1288,14 @@
"debit_label": "Debet",
"credit_label": "Kredit",
"add_line": "Lägg till rad",
"create_button": "Skapa mall"
"create_button": "Skapa mall",
"vat_rate_label": "Momssats",
"vat_rate_25": "25 %",
"vat_rate_12": "12 %",
"vat_rate_6": "6 %",
"vat_rate_0": "0 % (export/omvänd/momsfri)",
"unconvertible_hint": "Den här mallen kan inte väljas direkt vid bokföring av en transaktion — den används från ett verifikat. För att visas i transaktionslistan krävs exakt en kostnads-/intäktsrad och en betalningsrad på motsatt sida.",
"unconvertible_badge": "Endast i verifikat"
},
"settings_counterparty_templates": {
"title": "Bokföringsmallar",
@@ -3167,7 +3174,15 @@
"deleted_title": "Borttagen",
"deleted_description": "Transaktionen har tagits bort",
"delete_failed_description": "Transaktionen kunde inte tas bort. Försök igen.",
"review_in_bookkeeping_description": "Granska och bokför verifikatet i Bokföring."
"review_in_bookkeeping_description": "Granska och bokför verifikatet i Bokföring.",
"bank_sync_attention_one": "1 bankanslutning behöver förnyas",
"bank_sync_attention_many": "{count} bankanslutningar behöver förnyas",
"bank_sync_auto_nightly": "Synkas automatiskt varje natt",
"bank_sync_last_separator": " · senast ",
"bank_sync_age_just_now": "just nu",
"bank_sync_age_minutes": "{count} min sedan",
"bank_sync_age_hours": "{count} tim sedan",
"bank_sync_age_days": "{count} d sedan"
},
"bookkeeping": {
"title": "Bokföring",
+7 -2
View File
@@ -11,9 +11,14 @@ const activepiecesUrl = process.env.ACTIVEPIECES_URL ?? "";
const cspDirectives = [
"default-src 'self'",
`connect-src 'self' ${supabaseUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com`,
// Recapt: scoped to the two specific hosts the SDK actually contacts —
// `cdn.recapt.app` for the script bundle and `api.recapt.app` for
// ingestion. The previous wildcard (`https://*.recapt.app`) allowed
// exfiltration to any subdomain of recapt.app and is intentionally
// narrowed.
`connect-src 'self' ${supabaseUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com https://api.recapt.app https://cdn.recapt.app`,
`style-src 'self' 'unsafe-inline' https://*.enablebanking.com`,
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com`,
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com https://cdn.recapt.app`,
"img-src 'self' data: blob: https:",
"font-src 'self'",
"worker-src 'self' blob:",
+23
View File
@@ -0,0 +1,23 @@
type RecaptFeedbackPayload =
| { message: string; rating?: number }
| { widget: 'show' | 'hide' | 'open' | 'close'; position?: string }
type RecaptIdentifyPayload = {
uid: string | undefined
email?: string
nickname?: string
}
interface RecaptFn {
(action: 'feedback', data: RecaptFeedbackPayload): void
(action: 'identify', data: RecaptIdentifyPayload): void
}
declare global {
interface Window {
Recapt?: unknown
recapt?: RecaptFn
}
}
export {}