Files
accounted/components/settings/AccountingFrameworkForm.tsx
T
Mattsson 32d9978f1b Fix/chrome pdf preview csp (#572)
* feat: add option to exclude year-end closing entries in SIE export and related reports

* delete docs

* fix: allow Chrome's PDF viewer in verifikat document preview

The /api/documents/:id/inline route shipped with
`object-src 'none'` in its CSP, which blocked Chrome's built-in PDF
viewer (it renders inline PDFs via an internal <embed>). Users on
Chrome saw "Det här innehållet har blockerats" when expanding a PDF
attachment in the bookkeeping view; Firefox (PDF.js) and Edge (own
viewer) were unaffected, and JPGs worked because <img> isn't subject
to object-src.

Drops the CSP for this route to the minimum needed for embeddability:
`frame-ancestors 'self'`. X-Content-Type-Options: nosniff plus the
fixed Content-Type from the handler already block MIME confusion;
X-Frame-Options: SAMEORIGIN + frame-ancestors still block clickjacking.

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

* feat(auth): add webmail deep link to email confirmation screens

Mirrors Stripe's signup UX: after asking the user to verify their email,
detect their webmail provider from the domain and show a button that
opens the inbox in a new tab. Gmail gets a from:<sender> search
pre-populated; Outlook/Yahoo/iCloud/Proton open the inbox directly.
Unknown / custom domains fall back to the existing copy.

Sender address is configurable via NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM
(default noreply@gnubok.se) so white-label installs can match their
Supabase Auth SMTP config.

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

* fix(auth): unblock first-time password set for BankID users with MFA

Supabase rejects updateUser({password}) and mfa.unenroll with "AAL2 session
is required" whenever a TOTP factor is enrolled. BankID magic-link logins
produce AAL1, and middleware skips MFA enforcement for bankid_linked users,
so they had no path to AAL2 — leaving them unable to set a backup password
or disable MFA without going through the email-recovery escape hatch.

- /api/account/password: branch on app_metadata.has_password. First-time set
  writes via service.auth.admin.updateUserById (no existing credential to
  protect, AAL2 guard does not apply). Change-password keeps the user-session
  updateUser so AAL2 still fires for credential rotation.
- /mfa/verify: accept a safeReturnTo query param and route there after
  successful verify, so step-up flows can land back where they came from.
- SecuritySettings: detect the AAL2 error from both change-password and
  mfa.unenroll and redirect through /mfa/verify?returnTo=/settings/account
  instead of toasting a dead-end error.

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

* Add tests and rounding utility for öre precision in bokslut calculations

- Implemented `roundOre` function for rounding SEK amounts to two decimal places, ensuring consistent monetary calculations.
- Introduced `ORE_TOLERANCE` constant for comparing rounded amounts, facilitating invariant checks in financial entries.
- Created comprehensive tests for `roundOre`, covering typical cases, edge cases, and idempotency.
- Added year-end invariants tests to verify database-level guarantees for closing entries, ensuring they balance to the öre and reject discrepancies.
- Developed end-to-end tests for the dispositions chain, validating the correctness of calculations across various scenarios.

* fix: update PDF rendering to remove Swish QR code generation and set default to disable Swish visibility

* fix: enhance security by rejecting data URIs in safeReturnTo function tests

* fix: improve rounding logic in roundOre function and add customer_type migration

* fix: add customer_type column to customers and enforce CHECK constraint

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:29:41 +02:00

180 lines
5.8 KiB
TypeScript

'use client'
import { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import type { AccountingFramework } from '@/types'
import { Loader2 } from 'lucide-react'
interface AccountingFrameworkFormProps {
/** Current framework on the company row. */
current: AccountingFramework
/** Bubble up after a successful save so parent state can refresh. */
onSaved?: (next: AccountingFramework) => void
}
/**
* K2/K3 selector for AB. Lives on the bookkeeping settings page. Renders nothing
* for non-AB entities — the parent gates this component by entity_type.
*
* UX rules (regulatory area — kept in Swedish):
* - Default is K2 (matches the column default and BFNAR 2016:10 baseline).
* - Switching K2 → K3 fires a confirmation dialog. The recommendation per
* BFN is that the choice is permanent for the company once made; we
* surface that as a warning, not a block, so the user can still revert.
* - The save is its own request (PATCH /api/company/current) — separate
* from /api/settings because the column lives on companies, not on
* company_settings.
*/
export function AccountingFrameworkForm({ current, onSaved }: AccountingFrameworkFormProps) {
const { toast } = useToast()
const [selected, setSelected] = useState<AccountingFramework>(current)
const [pending, setPending] = useState<AccountingFramework | null>(null)
const [saving, setSaving] = useState(false)
async function persist(next: AccountingFramework) {
setSaving(true)
try {
const res = await fetch('/api/company/current', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accounting_framework: next }),
})
const body = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte spara',
description: body?.error ?? 'Försök igen.',
variant: 'destructive',
})
setSelected(current)
return
}
toast({
title: 'Sparat',
description:
next === 'k3'
? 'Bolaget redovisar nu enligt K3 (BFNAR 2012:1).'
: 'Bolaget redovisar nu enligt K2 (BFNAR 2016:10).',
})
onSaved?.(next)
} catch {
toast({
title: 'Kunde inte spara',
description: 'Försök igen.',
variant: 'destructive',
})
setSelected(current)
} finally {
setSaving(false)
setPending(null)
}
}
function handleChange(next: string) {
const value = next as AccountingFramework
if (value === selected) return
// K2 → K3 is the consequential direction: confirm before persisting.
if (selected === 'k2' && value === 'k3') {
setPending(value)
return
}
setSelected(value)
void persist(value)
}
return (
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Redovisningsregelverk
</h2>
<div className="space-y-2">
<Label htmlFor="accounting_framework">Regelverk</Label>
<Select
value={selected}
onValueChange={handleChange}
disabled={saving}
>
<SelectTrigger id="accounting_framework" className="w-full max-w-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="k2">K2 (BFNAR 2016:10) mindre företag</SelectItem>
<SelectItem value="k3">K3 (BFNAR 2012:1) större företag</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
K2 är standard för mindre bolag och innebär förenklade regler. K3 krävs när
bolaget når två av tre tröskelvärden (nettoomsättning &gt; 80 MSEK, tillgångar
&gt; 40 MSEK, eller fler än 50 anställda). K3 ställer högre krav: kassaflödesanalys,
komponentavskrivning materiella anläggningstillgångar och redovisning av
uppskjuten skatt obeskattade reserver (79,4 % eget kapital / 20,6 % skuld).
</p>
</div>
<Dialog
open={pending !== null}
onOpenChange={(open) => {
if (!open) setPending(null)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Byta till K3?</DialogTitle>
<DialogDescription className="space-y-2 pt-2">
<span className="block">
K3 medför löpande att kassaflödesanalys upprättas, komponentavskrivning
används och uppskjuten skatt redovisas separat (konto 2240 / 8940).
</span>
<span className="block">
Bytet är permanent enligt rekommendation. Fortsätt?
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setPending(null)}
disabled={saving}
>
Avbryt
</Button>
<Button
onClick={() => {
if (!pending) return
setSelected(pending)
void persist(pending)
}}
disabled={saving}
>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sparar
</>
) : (
'Byt till K3'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
)
}