diff --git a/app/(auth)/mfa/enroll/page.tsx b/app/(auth)/mfa/enroll/page.tsx index 50c08839..16694131 100644 --- a/app/(auth)/mfa/enroll/page.tsx +++ b/app/(auth)/mfa/enroll/page.tsx @@ -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() { + + ) @@ -264,6 +284,15 @@ function MfaEnrollContent() { + + ) diff --git a/app/(auth)/mfa/verify/page.tsx b/app/(auth)/mfa/verify/page.tsx index 20a2ea7c..45d8112e 100644 --- a/app/(auth)/mfa/verify/page.tsx +++ b/app/(auth)/mfa/verify/page.tsx @@ -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() { Logga ut + +

+ Förlorat din autentiseringsapp?{' '} + + Kontakta support + +

) diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx index 70960348..ea9557b4 100644 --- a/app/(dashboard)/bookkeeping/year-end/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/page.tsx @@ -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() {

Årsbokslut är under utveckling och kommer att finnas tillgängligt i en kommande version.

+
+ + Behöver du hjälp med bokslut? Kontakta oss + +
diff --git a/app/(dashboard)/error.tsx b/app/(dashboard)/error.tsx index c920c6a5..08adf3e7 100644 --- a/app/(dashboard)/error.tsx +++ b/app/(dashboard)/error.tsx @@ -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({

Nagot gick fel

- 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{' '} + kontakta support{' '} + om problemet kvarstår.

diff --git a/app/(dashboard)/help/page.tsx b/app/(dashboard)/help/page.tsx index ad9e1406..64b05525 100644 --- a/app/(dashboard)/help/page.tsx +++ b/app/(dashboard)/help/page.tsx @@ -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() { + + {/* Support section */} + + +
+ + Behöver du mer hjälp? +
+ + Hittar du inte svaret? Kontakta oss så hjälper vi dig. + +
+ + + +
) } diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 32ea6eea..538eb0a3 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -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() {

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.

+

+ Har du frågor?{' '} + +

diff --git a/app/api/support/contact/route.ts b/app/api/support/contact/route.ts new file mode 100644 index 00000000..34a108c1 --- /dev/null +++ b/app/api/support/contact/route.ts @@ -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, '&').replace(//g, '>').replace(/"/g, '"') +} + +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, '
') + + const result = await emailService.sendEmail({ + to: SUPPORT_RECIPIENT_EMAIL, + subject: `[gnubok support] ${subject}`, + replyTo: user.email, + html: ` +

Från: ${escapeHtml(user.email || '')}

+

User ID: ${user.id}

+

Ämne: ${safeSubject}

+
+

${safeMessage}

+ `, + 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 } }) +} diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 9a8e54d6..7b4c3ce9 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -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 - {/* Logout button */} -
+ {/* Support + Logout */} +
+
+ +
- {/* Logout */} -
+ {/* Support + Logout */} +
+
+ +
-
diff --git a/components/import/SIEPreviewStep.tsx b/components/import/SIEPreviewStep.tsx index ae4b912e..00e18181 100644 --- a/components/import/SIEPreviewStep.tsx +++ b/components/import/SIEPreviewStep.tsx @@ -238,7 +238,7 @@ export default function SIEPreviewStep({ )} {/* Create missing accounts */} - {missingAccounts.length > 0 && ( + {missingAccounts.length > 0 ? ( @@ -246,8 +246,8 @@ export default function SIEPreviewStep({ Skapa saknade konton - {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. @@ -279,6 +279,11 @@ export default function SIEPreviewStep({
+ ) : preview.mappingStatus.mapped === preview.mappingStatus.total && preview.mappingStatus.total > 0 && ( +
+ + Alla konton skapade och automatiskt kopplade +
)} {/* Issues */} diff --git a/components/import/SIEUploadStep.tsx b/components/import/SIEUploadStep.tsx index 1d7a3871..20db96e5 100644 --- a/components/import/SIEUploadStep.tsx +++ b/components/import/SIEUploadStep.tsx @@ -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(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 ( +
+ + +
+ +
+

{phase.message}

+

+ {selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB) +

+
+ +

+ Lämna inte sidan förrän analysen är klar +

+
+
+
+
+ ) + } + return (
@@ -64,10 +115,9 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error }: SIEUpl {/* Drop zone */}
- {isLoading ? ( -
- -

Analyserar fil...

- -
- ) : selectedFile ? ( + {selectedFile ? (
diff --git a/components/settings/SecuritySettings.tsx b/components/settings/SecuritySettings.tsx index d8873a10..c0901df3 100644 --- a/components/settings/SecuritySettings.tsx +++ b/components/settings/SecuritySettings.tsx @@ -254,7 +254,7 @@ export function SecuritySettings() {
+ ) : ( + + ) + + return ( + + {trigger} + + + Kontakta support + + Beskriv ditt ärende så återkommer vi så snart vi kan. + + + + {sent ? ( +
+
+ +
+

Tack! Vi har mottagit ditt meddelande.

+
+ ) : ( +
+