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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0f1c7c9365
commit
f49dc3438d
@@ -6,7 +6,7 @@ import { ArrowRight, FileCheck, X } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
|
||||
const dismissKey = (companyId: string) => `erp_skv_promo_dismissed:${companyId}`
|
||||
@@ -36,11 +36,14 @@ interface SkatteverketPromoCardProps {
|
||||
* otherwise only discover the integration deep inside the VAT/AGI flows.
|
||||
* Capability-less users never see it: the paywall upsell lives where the
|
||||
* intent is (SkatteverketPanel, AGIPanel), not on the dashboard.
|
||||
* Sandbox companies never see it either: Skatteverket is one of the external
|
||||
* services the sandbox blocks outright, so the CTA would lead nowhere.
|
||||
*/
|
||||
export function SkatteverketPromoCard({ companyId, connected }: SkatteverketPromoCardProps) {
|
||||
const t = useTranslations('dashboard')
|
||||
const extensionEnabled = ENABLED_EXTENSION_IDS.has('skatteverket')
|
||||
const hasCapability = useCapability(CAPABILITY.skatteverket)
|
||||
const isSandbox = useCompanyOptional()?.isSandbox ?? false
|
||||
|
||||
// Server snapshot says dismissed: the card appears only after hydration,
|
||||
// when localStorage is readable, so server and client never disagree.
|
||||
@@ -55,7 +58,7 @@ export function SkatteverketPromoCard({ companyId, connected }: SkatteverketProm
|
||||
window.dispatchEvent(new Event(DISMISS_EVENT))
|
||||
}, [companyId])
|
||||
|
||||
if (!extensionEnabled || !hasCapability || connected || dismissed) return null
|
||||
if (!extensionEnabled || !hasCapability || isSandbox || connected || dismissed) return null
|
||||
|
||||
return (
|
||||
<section>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user