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
+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>
)
}