Files
accounted/components/settings/CompanyDangerZone.tsx
T
MattssonandClaude Fable 5 f633349c8d feat(analytics): show form field labels in session replays (#1416)
* feat(analytics): show form field labels in session replays

Replays showed the sidebar after #1412 but form pages were still fully
masked, so you could not tell WHICH field a user was interacting with.
Tag the shared Label primitive (components/ui/label.tsx, used by every
form in the app) with data-ph-unmask: field labels are static i18n
chrome, and maskAllInputs keeps every typed value hidden.

The one Label whose text is user data, the user-defined dimension name
in LineDimensionFields, gets data-ph-mask, which wins even on the same
element because maskTextFn checks it first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(analytics): re-mask three Labels that render user data

Completing the audit the PR review asked for: a multiline sweep over
every Label child found three call sites whose label text is user
data, missed by the first single-line pass. Danger-zone confirm
labels interpolate the user's email (AccountDangerZone) and the
company name (CompanyDangerZone), and the periodisering auto-detect
row label is counterparty name + invoice number. All three now carry
data-ph-mask. Currency-code and row-count interpolations were
reviewed and left visible: categorical UI state, not books data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:18:07 +02:00

175 lines
5.8 KiB
TypeScript

'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation'
import { useCompany } from '@/contexts/CompanyContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { RetentionNotice } from '@/components/ui/retention-notice'
import {
SettingsDangerZone,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
} from '@/components/settings/SettingsRows'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getBranding } from '@/lib/branding/service'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const branding = getBranding()
/**
* Danger zone for the currently-active company. Only visible to owners.
*
* Archive = soft delete: companies.archived_at is stamped via
* POST /api/company/[id]/delete. All bookkeeping data is retained per
* BFL 7 kap. 2§; the row just disappears from the user's UI.
*
* TODO(bankid): once users have a linked BankID identity, wrap the
* confirm step in a BankID signature gate. Guarded behind a
* capabilities.bankIdLinked boolean fetched from the user profile.
*/
export function CompanyDangerZone() {
const t = useTranslations('settings_company')
const tRetention = useTranslations('retention_notice')
const router = useRouter()
const { toast } = useToast()
const { company, role } = useCompany()
const [showDialog, setShowDialog] = useState(false)
const [confirmText, setConfirmText] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
if (!company || role !== 'owner') return null
async function handleDelete() {
if (!company) return
if (confirmText.trim() !== company.name.trim()) return
setIsDeleting(true)
try {
const res = await fetch(`/api/company/${company.id}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm_name: confirmText }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || t('danger_delete_failed_default'))
}
toast({ title: t('danger_deleted_title'), description: company.name })
// Stay inside settings. If the user had another company, the dashboard
// layout will resolve it and /settings/account still renders as
// normal. If this was their last company, the layout falls into the
// no-company shell rooted at /settings/account.
router.push('/settings/account')
router.refresh()
} catch (err) {
toast({
title: t('danger_delete_failed_title'),
description: err instanceof Error ? getUserErrorMessage(err) : t('danger_try_again'),
variant: 'destructive',
})
setIsDeleting(false)
}
}
return (
<>
<SettingsDangerZone label={t('danger_heading')}>
<SettingsRow
label={t('danger_button')}
borderless
// The full BFL retention copy (incl. the backup link) lives behind
// the "?": the visible row stays one quiet line.
help={<RetentionNotice variant="company" className="border-0 bg-transparent p-0" />}
>
<SettingsRowNote>{tRetention('company_title')}</SettingsRowNote>
<SettingsRowEnd>
<button
type="button"
onClick={() => setShowDialog(true)}
className="text-sm font-medium text-destructive underline underline-offset-2 transition-colors duration-150 hover:text-destructive/80"
>
{t('danger_button')}
</button>
</SettingsRowEnd>
</SettingsRow>
</SettingsDangerZone>
<Dialog
open={showDialog}
onOpenChange={(open) => {
if (isDeleting) return
setShowDialog(open)
if (!open) setConfirmText('')
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('danger_dialog_title', { companyName: company.name })}</DialogTitle>
<DialogDescription>
{t('danger_dialog_description', { appName: branding.appName.toLowerCase() })}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{/* data-ph-mask: the label interpolates the company name */}
<Label data-ph-mask="" htmlFor="company-delete-confirm">
{t.rich('danger_confirm_label', {
companyName: company.name,
strong: (chunks) => <strong>{chunks}</strong>,
})}
</Label>
<Input
id="company-delete-confirm"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={company.name}
autoComplete="off"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowDialog(false)
setConfirmText('')
}}
disabled={isDeleting}
>
{t('danger_cancel')}
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={confirmText.trim() !== company.name.trim() || isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('danger_deleting')}
</>
) : (
t('danger_button')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}