* fix(settings): say on the Danger zone rows that a typed confirmation follows (#2214) A Discord user reported that the Danger zone button looked like it could be triggered by a stray swipe on a phone, and did not dare press it. Every action there already opens a dialog that requires the company name (or the account e-mail) typed in before anything happens, but nothing on the row said so, and the only way to learn it was to press the link. Both company rows (Starta om migrering, Radera företag) and the account deletion row now carry one line under the note: "Inget händer direkt: du bekräftar i nästa steg genom att skriva företagets namn" (e-mail for the account). Keys added to sv and en. Closes #2214 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy * chore: carry the DECISIONS.md line for this PR in #2247 instead (append-only log conflicts on every merge) --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
213 lines
7.7 KiB
TypeScript
213 lines
7.7 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'
|
|
import { CompanyMigrationResetDialog } from '@/components/settings/CompanyMigrationResetDialog'
|
|
import { CompanyMigrationArchiveRow } from '@/components/settings/CompanyMigrationArchiveRow'
|
|
|
|
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 [showResetDialog, setShowResetDialog] = 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 (
|
|
<>
|
|
<CompanyMigrationArchiveRow companyId={company.id} />
|
|
|
|
<SettingsDangerZone label={t('danger_heading')}>
|
|
<SettingsRow label={t('reset_row_label')}>
|
|
{/* Both destructive rows say up front that a typed confirmation
|
|
follows. A reader who does not dare press the link cannot find
|
|
that out any other way, and on a phone the link sits where a
|
|
stray tap while scrolling lands (issue #2214). */}
|
|
<div className="flex min-w-0 flex-col gap-0.5">
|
|
<SettingsRowNote>{t('reset_row_note')}</SettingsRowNote>
|
|
<SettingsRowNote>{t('danger_confirm_hint')}</SettingsRowNote>
|
|
</div>
|
|
<SettingsRowEnd>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowResetDialog(true)}
|
|
className="text-sm font-medium text-destructive underline underline-offset-2 transition-colors duration-150 hover:text-destructive/80"
|
|
>
|
|
{t('reset_row_action')}
|
|
</button>
|
|
</SettingsRowEnd>
|
|
</SettingsRow>
|
|
<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" />}
|
|
>
|
|
<div className="flex min-w-0 flex-col gap-0.5">
|
|
<SettingsRowNote>{tRetention('company_title')}</SettingsRowNote>
|
|
<SettingsRowNote>{t('danger_confirm_hint')}</SettingsRowNote>
|
|
</div>
|
|
<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>
|
|
{/* data-ph-mask: the title interpolates the company name */}
|
|
<DialogTitle data-ph-mask="">{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"
|
|
// ph-no-capture: the placeholder is the company name, and
|
|
// replay masking covers values, not attributes.
|
|
className="ph-no-capture"
|
|
/>
|
|
</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>
|
|
|
|
<CompanyMigrationResetDialog
|
|
companyId={company.id}
|
|
companyName={company.name}
|
|
open={showResetDialog}
|
|
onOpenChange={setShowResetDialog}
|
|
/>
|
|
</>
|
|
)
|
|
}
|