Files
accounted/components/extensions/ExtensionSandboxLockState.tsx
T
Jakob Wennberg f49dc3438d fix(sandbox): lock what the sandbox cannot actually do (#1318)
* fix(sandbox): lock what the sandbox cannot actually do

Three surfaces in the sandbox advertised capability the sandbox blocks
outright, or rendered a staged preview wrong.

Skatteverket promo card: hidden for sandbox companies. The sandbox landing
page tells users Skatteverket is off, and the authorize route 403s via
guardSandbox, so the dashboard nudge was a dead end. Same precedent as
TaxSettingsContent, which already hides its Skatteverket section on
is_sandbox.

Dokumentinkorg: locked with a state that says what the workspace does and
sends the user to registration. Checked before the capability gate on
purpose: the seed_trial trigger grants every new company (sandbox
included) 30 days of every paid capability, so the existing paywall waved
a demo company straight through. The CTA signs the anonymous session out
first, mirroring SandboxBanner.

Staged categorize_transaction preview: the seed wrote its kontering under
the generic preview_lines key, but categorize_transaction is the one type
with a dedicated preview component, and it reads `lines`. The card fell
through to its legacy summary branch and rendered blank Debetkonto and
Kreditkonto plus "NaN kr" from formatCurrency(undefined). The seeded blob
now mirrors what gnubok_categorize_transaction stages, extracted into
buildSandboxPendingOperations so both shapes are unit-testable.
CategorizePreview also learns to read preview_lines and to show a missing
amount as a gap, so a live 24h sandbox stops showing NaN before its data
expires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): don't leave for /register when sign-out failed

CodeRabbit review: the ExtensionSandboxLockState CTA ignored the
signOut() result, so a failure routed to /register with the anonymous
session still live, which registers INTO the sandbox instead of leaving
it: exactly what the sign-out exists to prevent. Surface the failure and
stay put so the user can retry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:05:27 +02:00

81 lines
2.8 KiB
TypeScript

'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast'
import { createClient } from '@/lib/supabase/client'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
interface ExtensionSandboxLockStateProps {
iconName?: string
title: string
/** One line on what the workspace actually does, so a locked page still explains itself. */
description: string
/** Why it is locked here and what unlocks it. */
note: string
ctaLabel: string
}
/**
* Sandbox lock for an extension workspace whose value is an external service
* (invoice-inbox: AI field extraction plus the forwarding mail address). The
* sandbox blocks those services outright (lib/sandbox/guard.ts), so an
* unlocked workspace looks functional and then quietly does nothing.
*
* Deliberately not ExtensionUpsellState: an anonymous demo user has no billing
* to upgrade, they need an account. The CTA signs the anonymous session out
* first, mirroring SandboxBanner: /register on top of a live anonymous session
* registers into the sandbox instead of leaving it.
*
* Same RSC constraint as ExtensionUpsellState: every prop stays a plain string
* and the icon is resolved client-side from its name, because passing a
* resolved component across the server/client boundary 500s the page.
*/
export function ExtensionSandboxLockState({
iconName,
title,
description,
note,
ctaLabel,
}: ExtensionSandboxLockStateProps) {
const [isLeaving, setIsLeaving] = useState(false)
const router = useRouter()
const t = useTranslations('extensions')
const { toast } = useToast()
async function handleCreateAccount() {
setIsLeaving(true)
const supabase = createClient()
const { error } = await supabase.auth.signOut()
if (error) {
// Navigating anyway would land on /register with the anonymous session
// still live, which registers INTO the sandbox: the exact outcome the
// sign-out exists to prevent. Stay put and let the user retry.
toast({
title: t('sandbox_locked_signout_error_title'),
description: t('sandbox_locked_signout_error_description'),
variant: 'destructive',
})
setIsLeaving(false)
return
}
router.push('/register')
}
const Icon = iconName ? resolveIcon(iconName) : undefined
return (
<EmptyState icon={Icon} title={title} description={description}>
<div className="flex flex-col items-center gap-4">
<p className="max-w-sm text-sm text-muted-foreground text-balance">{note}</p>
<Button onClick={handleCreateAccount} disabled={isLeaving}>
{ctaLabel}
</Button>
</div>
</EmptyState>
)
}