feat: support contact links and SIE import UX polish (#138)

* feat: event log, pending operations, and MCP staging

- Event log system: persist bus events to event_log table for external
  automation platforms. Batch insert for transaction.synced. Daily
  cleanup cron at 02:00 UTC.
- Pending operations: MCP write tools (categorize, create customer,
  create invoice) now stage to pending_operations instead of executing
  directly. Users review and commit/reject from /pending in the web UI.
- Granskning page: card-based review UI with expandable previews,
  commit/reject dialogs. Only shown in nav when pending ops exist.
- Commit route re-executes using core lib functions (no extension
  imports). Guards against stale state (double-commit, deleted entities).

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

* feat: stage new MCP write tools after main merge

Add staging for 4 new write tools from #133:
- mark_invoice_paid, send_invoice, mark_invoice_sent,
  match_transaction_invoice
- Expand pending_operations CHECK constraint
- Add commit executors with full execution logic
- Add UI labels and generic preview component
- Remove confirm parameter from categorize (single-call staging)
- Fix UUID in pending op title (fetch transaction description)
- Hide Granskning nav when no pending ops

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

* fix: address PR review feedback

- Fix TS build error: use `select('*, customer:customers(*)')` for
  match_transaction_invoice to avoid array type inference
- Add status guard to commitSendInvoice (prevents duplicate sends)
- Replace auth.admin.getUserById with user email from session auth
- Restore optimistic lock check in commitMatchTransactionInvoice
- Fix tool description typo: expense_software → expense_office

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

* feat: add support contact links and improve SIE import UX

Add a SupportLink component with a contact dialog throughout the app
(nav, help page, settings, MFA, error pages, empty states). Improve
SIE import flow with phased loading states, structured skip breakdowns,
and an elapsed-time counter. Fix MFA enroll stale factor cleanup and
URL encoding for settings return path.

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

* fix: address PR review — open redirect, XSS, test cleanup, fallback email

- Validate returnTo is a relative path in MFA enroll (prevents open redirect)
- Add afterEach import to event-log-handler tests (fixes handler leak)
- HTML-escape user-supplied subject and message in support email body
- Replace hardcoded personal email with support@gnubok.se fallback

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-26 11:14:17 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent f3ae3cd361
commit 5cb8c3c1e4
18 changed files with 510 additions and 46 deletions
+32 -3
View File
@@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, ShieldCheck, Copy, Check } from 'lucide-react'
import { Loader2, ShieldCheck, Copy, Check, ArrowLeft } from 'lucide-react'
export default function MfaEnrollPage() {
return (
@@ -31,12 +31,23 @@ function MfaEnrollContent() {
const searchParams = useSearchParams()
const supabase = createClient()
const returnTo = searchParams.get('returnTo') || '/'
const rawReturnTo = searchParams.get('returnTo') || '/'
const returnTo = rawReturnTo.startsWith('/') && !rawReturnTo.startsWith('//') ? rawReturnTo : '/'
const handleEnroll = async () => {
setIsEnrolling(true)
try {
// Clean up any stale unverified factors from previous abandoned attempts
const { data: existingFactors } = await supabase.auth.mfa.listFactors()
if (existingFactors?.totp) {
for (const factor of existingFactors.totp) {
if (factor.status !== 'verified') {
await supabase.auth.mfa.unenroll({ factorId: factor.id })
}
}
}
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'gnubok',
@@ -45,7 +56,7 @@ function MfaEnrollContent() {
if (error) {
toast({
title: 'Kunde inte aktivera 2FA',
description: 'Försök igen senare.',
description: error.message,
variant: 'destructive',
})
setIsEnrolling(false)
@@ -174,6 +185,15 @@ function MfaEnrollContent() {
</Button>
</div>
</div>
<Button
variant="ghost"
className="w-full mt-4 text-muted-foreground"
onClick={() => router.push(returnTo)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
</div>
</div>
)
@@ -264,6 +284,15 @@ function MfaEnrollContent() {
</Button>
</form>
</div>
<Button
variant="ghost"
className="w-full mt-4 text-muted-foreground"
onClick={() => router.push(returnTo)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
</div>
</div>
)
+8
View File
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, ShieldCheck, LogOut } from 'lucide-react'
import { SupportLink } from '@/components/ui/support-link'
export default function MfaVerifyPage() {
const [code, setCode] = useState('')
@@ -151,6 +152,13 @@ export default function MfaVerifyPage() {
<LogOut className="mr-2 h-4 w-4" />
Logga ut
</Button>
<p className="text-xs text-muted-foreground text-center mt-4">
Förlorat din autentiseringsapp?{' '}
<SupportLink variant="muted" subject="MFA-problem — kan inte logga in" className="inline">
Kontakta support
</SupportLink>
</p>
</div>
</div>
)
@@ -2,6 +2,7 @@ import Link from 'next/link'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { ArrowLeft, Lock } from 'lucide-react'
import { SupportLink } from '@/components/ui/support-link'
export default function YearEndPage() {
return (
@@ -25,6 +26,11 @@ export default function YearEndPage() {
<p className="text-muted-foreground max-w-md">
Årsbokslut är under utveckling och kommer att finnas tillgängligt i en kommande version.
</p>
<div className="mt-4">
<SupportLink variant="muted" subject="Fråga om årsbokslut">
Behöver du hjälp med bokslut? Kontakta oss
</SupportLink>
</div>
</CardContent>
</Card>
</div>
+4 -2
View File
@@ -2,6 +2,7 @@
import { useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { SupportLink } from '@/components/ui/support-link'
export default function DashboardError({
error,
@@ -18,8 +19,9 @@ export default function DashboardError({
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4">
<h2 className="text-xl font-semibold">Nagot gick fel</h2>
<p className="text-muted-foreground text-sm max-w-md text-center">
Ett oväntat fel uppstod. Försök igen eller kontakta support om problemet
kvarstår.
Ett oväntat fel uppstod. Försök igen eller{' '}
<SupportLink variant="inline" subject="Oväntat fel">kontakta support</SupportLink>{' '}
om problemet kvarstår.
</p>
<Button onClick={reset}>Försök igen</Button>
</div>
+18
View File
@@ -18,6 +18,8 @@ import {
ChevronUp,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { SupportLink } from '@/components/ui/support-link'
import { Mail } from 'lucide-react'
interface GlossaryTerm {
term: string
@@ -462,6 +464,22 @@ export default function HelpPage() {
</div>
</CardContent>
</Card>
{/* Support section */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Mail className="h-5 w-5 text-muted-foreground" />
<CardTitle>Behöver du mer hjälp?</CardTitle>
</div>
<CardDescription>
Hittar du inte svaret? Kontakta oss så hjälper vi dig.
</CardDescription>
</CardHeader>
<CardContent>
<SupportLink variant="inline" subject="Fråga från hjälpsidan" />
</CardContent>
</Card>
</div>
)
}
+5
View File
@@ -36,6 +36,7 @@ import type { CompanySettings } from '@/types'
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
import { SupportLink } from '@/components/ui/support-link'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
import { SecuritySettings } from '@/components/settings/SecuritySettings'
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
@@ -761,6 +762,10 @@ export default function SettingsPage() {
<p className="text-muted-foreground">
Enligt bokföringslagen (BFL 7 kap. 2§) ska räkenskapsinformation bevaras i 7 år. Du ansvarar själv för att exportera och arkivera din bokföringsdata innan du raderar kontot.
</p>
<p className="text-muted-foreground">
Har du frågor?{' '}
<SupportLink variant="inline" subject="Fråga om kontoradering" />
</p>
</div>
</div>
</div>
+65
View File
@@ -0,0 +1,65 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getEmailService } from '@/lib/email/service'
import { SUPPORT_RECIPIENT_EMAIL } from '@/lib/support'
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
let body: { subject?: string; message?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
const message = body.message?.trim()
if (!message || message.length < 5) {
return NextResponse.json({ error: 'Meddelandet måste vara minst 5 tecken' }, { status: 400 })
}
if (message.length > 5000) {
return NextResponse.json({ error: 'Meddelandet får vara max 5000 tecken' }, { status: 400 })
}
const subject = body.subject?.trim() || 'Supportärende'
const emailService = getEmailService()
if (!emailService.isConfigured()) {
return NextResponse.json(
{ error: 'E-posttjänsten är inte konfigurerad just nu. Försök igen senare.' },
{ status: 503 }
)
}
const safeSubject = escapeHtml(subject)
const safeMessage = escapeHtml(message).replace(/\n/g, '<br />')
const result = await emailService.sendEmail({
to: SUPPORT_RECIPIENT_EMAIL,
subject: `[gnubok support] ${subject}`,
replyTo: user.email,
html: `
<p><strong>Från:</strong> ${escapeHtml(user.email || '')}</p>
<p><strong>User ID:</strong> ${user.id}</p>
<p><strong>Ämne:</strong> ${safeSubject}</p>
<hr />
<p>${safeMessage}</p>
`,
text: `Från: ${user.email}\nUser ID: ${user.id}\nÄmne: ${subject}\n\n${message}`,
})
if (!result.success) {
return NextResponse.json(
{ error: 'Kunde inte skicka meddelandet. Försök igen.' },
{ status: 500 }
)
}
return NextResponse.json({ data: { sent: true } })
}
+11 -4
View File
@@ -28,6 +28,7 @@ import {
ClipboardCheck,
} from 'lucide-react'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { SupportLink } from '@/components/ui/support-link'
import type { EntityType } from '@/types'
interface ExtensionNavItem {
@@ -304,8 +305,11 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
</nav>
</div>
{/* Logout button */}
<div className="flex-shrink-0 px-3 py-3 border-t border-border/30">
{/* Support + Logout */}
<div className="flex-shrink-0 px-3 py-3 border-t border-border/30 space-y-1">
<div className="px-3 py-1.5">
<SupportLink variant="muted" />
</div>
<Button
variant="ghost"
className="w-full justify-start text-muted-foreground hover:text-foreground text-[13px] h-9 px-3"
@@ -531,8 +535,11 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra
</div>
</div>
{/* Logout */}
<div className="px-2 py-2 mt-1 border-t border-border/30">
{/* Support + Logout */}
<div className="px-2 py-2 mt-1 border-t border-border/30 space-y-1">
<div className="px-3 py-2">
<SupportLink variant="muted" />
</div>
<Button
variant="ghost"
className="w-full justify-start text-muted-foreground active:text-foreground text-sm h-11 px-3"
+2 -2
View File
@@ -138,8 +138,8 @@ export default function AccountMappingStep({
<CardHeader>
<CardTitle>Kontomappning</CardTitle>
<CardDescription>
Granska och justera mappningarna mellan importerade konton och din kontoplan.
Alla konton måste vara mappade innan importen kan genomföras.
Varje konto i SIE-filen kopplas till ett konto i din kontoplan.
De flesta matchas automatiskt — granska de osäkra nedan.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
+59 -5
View File
@@ -11,6 +11,7 @@ import {
FileText,
ExternalLink,
RotateCcw,
Info,
} from 'lucide-react'
import type { ImportResult } from '@/lib/import/types'
@@ -21,7 +22,12 @@ interface ImportResultStepProps {
export default function ImportResultStep({ result, onNewImport }: ImportResultStepProps) {
const hasErrors = result.errors.length > 0
const hasWarnings = result.warnings.length > 0
const skipped = result.details?.skippedVouchers
// Filter out raw "hoppades över" warnings when we have structured data
const otherWarnings = skipped && skipped.total > 0
? result.warnings.filter((w) => !w.includes('hoppades över'))
: result.warnings
return (
<div className="space-y-6">
@@ -116,18 +122,66 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
</Card>
)}
{/* Warnings */}
{hasWarnings && (
{/* Skipped vouchers — structured breakdown */}
{skipped && skipped.total > 0 && (
<Card className="border-muted-foreground/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-muted-foreground">
<Info className="h-5 w-5" />
Hoppade över {skipped.total} verifikationer
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{skipped.empty > 0 && (
<div className="text-sm">
<p className="font-medium">{skipped.empty} tomma verifikationer</p>
<p className="text-muted-foreground">
Platshållare utan bokföringsrader — vanligt i Fortnox och Visma. Påverkar inte din bokföring.
</p>
</div>
)}
{skipped.unbalanced > 0 && (
<div className="text-sm">
<p className="font-medium">{skipped.unbalanced} obalanserade verifikationer</p>
<p className="text-muted-foreground">
Debet och kredit stämmer inte överens i källsystemet. Saldon har justerats automatiskt.
</p>
</div>
)}
{skipped.singleLine > 0 && (
<div className="text-sm">
<p className="font-medium">{skipped.singleLine} enradsverifikationer</p>
<p className="text-muted-foreground">
Verifikationer med bara en rad (t.ex. periodiseringar). Kräver minst två rader för dubbelbokning.
</p>
</div>
)}
{skipped.unmapped > 0 && (
<div className="text-sm">
<p className="font-medium">{skipped.unmapped} verifikationer med ej kopplade konton</p>
<p className="text-muted-foreground">
Innehåller konton som inte kunde kopplas till din kontoplan.
</p>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Other warnings (filtered) */}
{otherWarnings.length > 0 && (
<Card className="border-warning/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-warning">
<AlertCircle className="h-5 w-5" />
Varningar ({result.warnings.length})
Varningar ({otherWarnings.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-48 overflow-y-auto">
{result.warnings.map((warning, i) => (
{otherWarnings.map((warning, i) => (
<div key={i} className="text-sm flex gap-2">
<AlertCircle className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
<span>{warning}</span>
+50 -14
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
@@ -22,6 +22,7 @@ import {
FileText,
Database,
} from 'lucide-react'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import type { ImportPreview, AccountMapping } from '@/lib/import/types'
interface ImportReviewStepProps {
@@ -52,6 +53,23 @@ export default function ImportReviewStep({
importTransactions: true,
voucherSeries: 'B',
})
const [elapsed, setElapsed] = useState(0)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Block browser close/refresh during import
useUnsavedChanges(isLoading)
// Elapsed time counter during import
useEffect(() => {
if (isLoading) {
setElapsed(0)
intervalRef.current = setInterval(() => setElapsed((s) => s + 1), 1000)
} else {
if (intervalRef.current) clearInterval(intervalRef.current)
setElapsed(0)
}
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
}, [isLoading])
const handleExecute = () => {
onExecute(options)
@@ -69,6 +87,33 @@ export default function ImportReviewStep({
const hasOpeningBalances = preview.openingBalanceTotal > 0
const hasTransactions = preview.voucherCount > 0
// Full-screen loading takeover during import execution
if (isLoading) {
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-8 pb-8">
<div className="flex flex-col items-center text-center space-y-6">
<Loader2 className="h-10 w-10 text-primary animate-spin" />
<div className="space-y-1">
<p className="font-medium text-lg">Importerar bokföring...</p>
<p className="text-sm text-muted-foreground">
{preview.voucherCount} verifikationer bearbetas
</p>
</div>
<div className="text-2xl font-display font-medium tabular-nums text-muted-foreground">
{elapsed}s
</div>
<p className="text-sm text-muted-foreground max-w-sm">
Stäng inte sidan. Importen kan ta upp till några minuter beroende på antalet verifikationer.
</p>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Summary */}
@@ -247,21 +292,12 @@ export default function ImportReviewStep({
{/* Actions */}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack} disabled={isLoading}>
<Button variant="outline" className="min-h-11" onClick={onBack}>
Tillbaka
</Button>
<Button className="min-h-11" onClick={handleExecute} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Importerar...
</>
) : (
<>
Starta import
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
<Button className="min-h-11" onClick={handleExecute}>
Starta import
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
+8 -3
View File
@@ -238,7 +238,7 @@ export default function SIEPreviewStep({
)}
{/* Create missing accounts */}
{missingAccounts.length > 0 && (
{missingAccounts.length > 0 ? (
<Card className="border-primary/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -246,8 +246,8 @@ export default function SIEPreviewStep({
Skapa saknade konton
</CardTitle>
<CardDescription>
{missingAccounts.length} konton från SIE-filen finns inte i din kontoplan.
Du kan skapa dem automatiskt för att behålla samma kontostruktur som i ditt gamla system.
{missingAccounts.length} konton från SIE-filen finns inte i din kontoplan ännu.
Klicka nedan för att skapa dem — de kopplas sedan automatiskt i nästa steg.
</CardDescription>
</CardHeader>
<CardContent>
@@ -279,6 +279,11 @@ export default function SIEPreviewStep({
</div>
</CardContent>
</Card>
) : preview.mappingStatus.mapped === preview.mappingStatus.total && preview.mappingStatus.total > 0 && (
<div className="flex items-center gap-2 rounded-lg border border-success/50 bg-success/5 px-4 py-3 text-sm">
<CheckCircle className="h-4 w-4 text-success flex-shrink-0" />
<span>Alla konton skapade och automatiskt kopplade</span>
</div>
)}
{/* Issues */}
+55 -11
View File
@@ -1,9 +1,16 @@
'use client'
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Upload, FileText, AlertCircle, CheckCircle } from 'lucide-react'
import { Upload, FileText, AlertCircle, CheckCircle, Loader2 } from 'lucide-react'
const LOADING_PHASES = [
{ message: 'Läser fil...', progress: 10 },
{ message: 'Identifierar teckenkodning...', progress: 30 },
{ message: 'Tolkar SIE-data...', progress: 55 },
{ message: 'Matchar konton mot BAS-kontoplanen...', progress: 85 },
] as const
interface SIEUploadStepProps {
onFileSelect: (file: File) => void
@@ -14,6 +21,23 @@ interface SIEUploadStepProps {
export default function SIEUploadStep({ onFileSelect, isLoading, error }: SIEUploadStepProps) {
const [isDragging, setIsDragging] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [loadingPhase, setLoadingPhase] = useState(0)
// Cycle through loading phases on timers
useEffect(() => {
if (!isLoading) {
setLoadingPhase(0)
return
}
const timers = [
setTimeout(() => setLoadingPhase(1), 2000),
setTimeout(() => setLoadingPhase(2), 4000),
setTimeout(() => setLoadingPhase(3), 7000),
]
return () => timers.forEach(clearTimeout)
}, [isLoading])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
@@ -47,6 +71,33 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error }: SIEUpl
}
}, [onFileSelect])
const phase = LOADING_PHASES[loadingPhase]
// Full-card takeover while analyzing the file
if (isLoading && selectedFile) {
return (
<div className="space-y-6">
<Card>
<CardContent className="pt-8 pb-8">
<div className="flex flex-col items-center text-center space-y-6">
<Loader2 className="h-10 w-10 text-primary animate-spin" />
<div className="space-y-1">
<p className="font-medium text-lg">{phase.message}</p>
<p className="text-sm text-muted-foreground">
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
</p>
</div>
<Progress value={phase.progress} className="w-64 mx-auto transition-all duration-1000" />
<p className="text-xs text-muted-foreground">
Lämna inte sidan förrän analysen är klar
</p>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<Card>
@@ -64,10 +115,9 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error }: SIEUpl
{/* Drop zone */}
<div
className={`
relative border-2 border-dashed rounded-lg p-8 text-center transition-colors
relative border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer hover:border-primary/50
${isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
${error ? 'border-destructive bg-destructive/5' : ''}
${isLoading ? 'pointer-events-none opacity-50' : 'cursor-pointer hover:border-primary/50'}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -83,13 +133,7 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error }: SIEUpl
disabled={isLoading}
/>
{isLoading ? (
<div className="space-y-4">
<FileText className="mx-auto h-12 w-12 text-muted-foreground animate-pulse" />
<p className="text-muted-foreground">Analyserar fil...</p>
<Progress value={33} className="w-48 mx-auto" />
</div>
) : selectedFile ? (
{selectedFile ? (
<div className="space-y-4">
<CheckCircle className="mx-auto h-12 w-12 text-success" />
<div>
+1 -1
View File
@@ -254,7 +254,7 @@ export function SecuritySettings() {
</div>
</div>
<Button
onClick={() => router.push('/mfa/enroll?returnTo=/settings?tab=security')}
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings?tab=security')}`)}
>
<ShieldCheck className="mr-2 h-4 w-4" />
Aktivera 2FA
+13
View File
@@ -15,6 +15,7 @@ import {
Plus,
type LucideIcon,
} from 'lucide-react'
import { SupportLink } from '@/components/ui/support-link'
interface EmptyStateProps {
icon?: LucideIcon
@@ -25,6 +26,7 @@ interface EmptyStateProps {
onAction?: () => void
secondaryActionLabel?: string
secondaryActionHref?: string
supportHint?: boolean
className?: string
children?: React.ReactNode
}
@@ -41,6 +43,7 @@ export function EmptyState({
onAction,
secondaryActionLabel,
secondaryActionHref,
supportHint,
className,
children,
}: EmptyStateProps) {
@@ -56,6 +59,14 @@ export function EmptyState({
<h3 className="text-lg font-medium mb-2">{title}</h3>
<p className="text-sm text-muted-foreground max-w-sm mb-6 text-balance">{description}</p>
{supportHint && (
<div className="mb-6">
<SupportLink variant="muted" subject="Behöver hjälp att komma igång">
Behöver du hjälp? Kontakta support
</SupportLink>
</div>
)}
{(actionLabel || children) && (
<div className="flex flex-col sm:flex-row items-center gap-3">
{actionHref && actionLabel && (
@@ -121,6 +132,7 @@ export function EmptyTransactions() {
description="Importera kontoutdrag från din bank för att automatiskt bokföra och få koll på ekonomin."
actionLabel="Importera transaktioner"
actionHref="/import"
supportHint
/>
)
}
@@ -155,6 +167,7 @@ export function NoBankConnected() {
description="Importera kontoutdrag från din bank för att automatiskt bokföra och få bättre koll på ekonomin."
actionLabel="Importera transaktioner"
actionHref="/import"
supportHint
/>
)
}
+167
View File
@@ -0,0 +1,167 @@
'use client'
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { Mail, Loader2, Send } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
interface SupportLinkProps {
variant?: 'inline' | 'muted'
subject?: string
children?: React.ReactNode
className?: string
}
export function SupportLink({
variant = 'inline',
subject,
children,
className,
}: SupportLinkProps) {
const [open, setOpen] = useState(false)
const [message, setMessage] = useState('')
const [isSending, setIsSending] = useState(false)
const [sent, setSent] = useState(false)
const { toast } = useToast()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (message.trim().length < 5) return
setIsSending(true)
try {
const res = await fetch('/api/support/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject, message: message.trim() }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Kunde inte skicka meddelandet')
}
setSent(true)
setTimeout(() => {
setOpen(false)
setSent(false)
setMessage('')
}, 2000)
} catch (error) {
toast({
title: 'Kunde inte skicka',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
} finally {
setIsSending(false)
}
}
function handleOpenChange(next: boolean) {
setOpen(next)
if (!next) {
setSent(false)
setMessage('')
}
}
const trigger =
variant === 'muted' ? (
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer',
className
)}
>
<Mail className="h-3 w-3" />
{children ?? 'Kontakta support'}
</button>
) : (
<button
type="button"
className={cn(
'inline-flex items-center gap-1 text-primary hover:text-primary/80 underline-offset-4 hover:underline transition-colors text-sm cursor-pointer',
className
)}
>
{children ?? 'Kontakta support'}
</button>
)
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Kontakta support</DialogTitle>
<DialogDescription>
Beskriv ditt ärende så återkommer vi så snart vi kan.
</DialogDescription>
</DialogHeader>
{sent ? (
<div className="flex flex-col items-center py-6 gap-3">
<div className="p-3 rounded-full bg-success/10">
<Send className="h-6 w-6 text-success" />
</div>
<p className="text-sm font-medium">Tack! Vi har mottagit ditt meddelande.</p>
</div>
) : (
<form onSubmit={handleSubmit}>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Beskriv vad du behöver hjälp med..."
className="min-h-[120px] resize-none"
maxLength={5000}
disabled={isSending}
autoFocus
/>
<p className="text-xs text-muted-foreground mt-1.5">
{message.length}/5000 tecken
</p>
<DialogFooter className="mt-4">
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
disabled={isSending}
>
Avbryt
</Button>
<Button
type="submit"
disabled={isSending || message.trim().length < 5}
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skickar...
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Skicka
</>
)}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
)
}
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { makeTransaction, makeInvoice, makeCustomer, makeFiscalPeriod } from '@/tests/helpers'
+5
View File
@@ -0,0 +1,5 @@
/**
* Support recipient — server-side only.
* Used by the /api/support/contact route. Never exposed to the client.
*/
export const SUPPORT_RECIPIENT_EMAIL = process.env.SUPPORT_RECIPIENT_EMAIL || 'support@gnubok.se'