diff --git a/CLAUDE.md b/CLAUDE.md index 53a63f79..43c9f916 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ gnubok is a Swedish-focused accounting SaaS for sole traders (enskild firma) and limited companies (aktiebolag). It implements double-entry bookkeeping compliant with Swedish accounting law (Bokforingslagen), including VAT handling, tax reporting, and 7-year document retention. -**Tech stack**: Next.js 16 (App Router), React 19, TypeScript (strict), Supabase (PostgreSQL + RLS + magic link auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting. +**Tech stack**: Next.js 16 (App Router), React 19, TypeScript (strict), Supabase (PostgreSQL + RLS + email/password + TOTP MFA auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting. **Integrations**: Enable Banking (PSD2), Anthropic SDK, LangChain, OpenAI (embeddings), Resend (email), JSZip (archive export). @@ -29,7 +29,7 @@ npm run setup:extensions # Regenerate extension registry from extensions.config. ``` app/ - (auth)/ Login, auth callback + (auth)/ Login, register, auth callback, MFA enroll/verify, password reset (onboarding)/ 6-step setup wizard (dashboard)/ Authenticated routes (invoices, customers, transactions, bookkeeping, reports, suppliers, supplier-invoices, @@ -75,6 +75,7 @@ lib/ reports/ Financial reports (trial balance, income statement, balance sheet, VAT declaration, SIE export, general ledger, NE-bilaga, INK2, SRU export, full archive ZIP export) + auth/ MFA helpers (mfa.ts) and API auth guard (require-auth.ts) supabase/ Client setup (client.ts = browser, server.ts = server) tax/ Tax calculations, deadlines, Swedish holidays vat/ VIES validation, moms box mapping @@ -99,6 +100,46 @@ extensions.config.json Extension opt-in configuration --- +## Authentication + +Authentication uses Supabase Auth with **email+password** (primary) and **magic link** (fallback). MFA via TOTP is supported. + +### Auth Flow + +1. **Login** (`/login`) — Email+password with magic link toggle. After password login, checks MFA status. +2. **Register** (`/register`) — Email+password signup. Supabase sends confirmation email. Strong password required (8+ chars, uppercase, lowercase, number, special character). +3. **Password reset** (`/reset-password`) — Via recovery link from login page. +4. **MFA verify** (`/mfa/verify`) — 6-digit TOTP code input after login when MFA is enrolled. +5. **MFA enroll** (`/mfa/enroll`) — QR code + manual secret for authenticator app setup. + +### MFA Enforcement + +MFA is enforced **application-side** (middleware + API routes), **not** in RLS policies. This is controlled by two env vars: + +| Env Var | Hosted (Vercel) | Self-hosted (Docker) | +|---------|-----------------|----------------------| +| `NEXT_PUBLIC_REQUIRE_MFA` | `true` | `false` (default) | +| `NEXT_PUBLIC_SELF_HOSTED` | not set | `true` | + +- **Self-hosted**: MFA is never enforced (`NEXT_PUBLIC_SELF_HOSTED=true` overrides). Users can still enable it voluntarily via Settings → Säkerhet. +- **Hosted**: When `NEXT_PUBLIC_REQUIRE_MFA=true`, middleware redirects users without MFA to `/mfa/enroll` after onboarding, and users with MFA to `/mfa/verify` until AAL2 is achieved. + +### Key Files + +| File | Purpose | +|------|---------| +| `lib/auth/mfa.ts` | `isMfaRequired()` — checks env vars | +| `lib/auth/require-auth.ts` | `requireAuth()` — API route guard (auth + MFA) | +| `lib/supabase/middleware.ts` | Session refresh + MFA enforcement | +| `app/(auth)/auth/callback/route.ts` | PKCE/magic link callback with MFA redirect | +| `components/settings/SecuritySettings.tsx` | Password change + MFA enable/disable UI | + +### Supabase MFA Setup + +Enable TOTP in Supabase Dashboard → `Authentication` → `Multi-Factor Authentication`. No database migration needed — Supabase manages `auth.mfa_factors`, `auth.mfa_challenges`, and `auth.mfa_amr_claims` internally. + +--- + ## Core Bookkeeping Engine The engine (`lib/bookkeeping/engine.ts`) is the most critical system. All accounting flows route through it. @@ -361,7 +402,7 @@ export async function POST(request: Request) { Hosted on **Vercel**. Cron jobs in `vercel.json` (banking sync daily 05:00, deadlines 06:00, reminders 08:00, push notifications 09:00, tax deadlines yearly Jan 2, document verify weekly Sunday 03:00). -**Core env vars**: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `NEXT_PUBLIC_APP_URL`, `CRON_SECRET`. Extension env vars only needed when that extension is enabled. +**Core env vars**: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `NEXT_PUBLIC_APP_URL`, `CRON_SECRET`. **Auth env vars**: `NEXT_PUBLIC_REQUIRE_MFA` (set `true` on hosted), `NEXT_PUBLIC_SELF_HOSTED` (set `true` for Docker). Extension env vars only needed when that extension is enabled. ## Other Never create a NUL/nul file: \gnubok\NUL diff --git a/Dockerfile b/Dockerfile index c24dd89c..5073236d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,8 @@ ENV NEXT_PUBLIC_SUPABASE_URL=__NEXT_PUBLIC_SUPABASE_URL__ ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=__NEXT_PUBLIC_SUPABASE_ANON_KEY__ ENV NEXT_PUBLIC_APP_URL=__NEXT_PUBLIC_APP_URL__ ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=__NEXT_PUBLIC_VAPID_PUBLIC_KEY__ +ENV NEXT_PUBLIC_SELF_HOSTED=__NEXT_PUBLIC_SELF_HOSTED__ +ENV NEXT_PUBLIC_REQUIRE_MFA=__NEXT_PUBLIC_REQUIRE_MFA__ ENV NEXT_TELEMETRY_DISABLED=1 diff --git a/app/(auth)/auth/callback/route.ts b/app/(auth)/auth/callback/route.ts index f199e68c..6e0a0dbc 100644 --- a/app/(auth)/auth/callback/route.ts +++ b/app/(auth)/auth/callback/route.ts @@ -47,9 +47,19 @@ export async function GET(request: NextRequest) { if (authenticated) { let redirectPath = next - // Check if user has completed onboarding const { data: { user } } = await supabase.auth.getUser() if (user) { + // Check MFA status — redirect to verify if factor is enrolled but session is AAL1 + const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() + if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') { + const response = NextResponse.redirect(new URL('/mfa/verify', origin)) + for (const { name, value, options } of pendingCookies) { + response.cookies.set({ name, value, ...options }) + } + return response + } + + // Check if user has completed onboarding const { data: settings } = await supabase .from('company_settings') .select('onboarding_complete') diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index b7121f86..6d0570b1 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,27 +1,79 @@ 'use client' import { useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' import { createClient } from '@/lib/supabase/client' 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, Mail, ArrowLeft } from 'lucide-react' +import { Loader2, Mail, ArrowLeft, KeyRound } from 'lucide-react' import Image from 'next/image' import { getErrorMessage } from '@/lib/errors/get-error-message' +type AuthMode = 'password' | 'magic-link' + export default function LoginPage() { const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [authMode, setAuthMode] = useState('password') const [isLoading, setIsLoading] = useState(false) const [isEmailSent, setIsEmailSent] = useState(false) + const [showResetPassword, setShowResetPassword] = useState(false) const { toast } = useToast() + const router = useRouter() const supabase = createClient() - const handleLogin = async (e: React.FormEvent) => { + const handlePasswordLogin = async (e: React.FormEvent) => { + e.preventDefault() + setIsLoading(true) + + const formData = new FormData(e.currentTarget) + const emailValue = (formData.get('email') as string) || email + const passwordValue = (formData.get('password') as string) || password + + try { + const { error } = await supabase.auth.signInWithPassword({ + email: emailValue, + password: passwordValue, + }) + + if (error) { + toast({ + title: 'Inloggning misslyckades', + description: error.message === 'Invalid login credentials' + ? 'Fel e-post eller lösenord.' + : getErrorMessage(error, { context: 'auth' }), + variant: 'destructive', + }) + return + } + + // Check MFA status + const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() + if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') { + router.push('/mfa/verify') + return + } + + router.push('/') + router.refresh() + } catch (error) { + toast({ + title: 'Inloggning misslyckades', + description: getErrorMessage(error, { context: 'auth' }), + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + const handleMagicLink = async (e: React.FormEvent) => { e.preventDefault() setIsLoading(true) - // Read from DOM to handle browser autofill (which may not trigger onChange) const formData = new FormData(e.currentTarget) const emailValue = (formData.get('email') as string) || email @@ -35,7 +87,7 @@ export default function LoginPage() { if (error) { toast({ - title: 'Inloggning misslyckades', + title: 'Kunde inte skicka länk', description: getErrorMessage(error, { context: 'auth' }), variant: 'destructive', }) @@ -50,7 +102,7 @@ export default function LoginPage() { }) } catch (error) { toast({ - title: 'Inloggning misslyckades', + title: 'Kunde inte skicka länk', description: getErrorMessage(error, { context: 'auth' }), variant: 'destructive', }) @@ -59,6 +111,45 @@ export default function LoginPage() { } } + const handleResetPassword = async (e: React.FormEvent) => { + e.preventDefault() + setIsLoading(true) + + const formData = new FormData(e.currentTarget) + const emailValue = (formData.get('email') as string) || email + + try { + const { error } = await supabase.auth.resetPasswordForEmail(emailValue, { + redirectTo: `${window.location.origin}/auth/callback?next=/reset-password`, + }) + + if (error) { + toast({ + title: 'Kunde inte skicka återställningslänk', + description: getErrorMessage(error, { context: 'auth' }), + variant: 'destructive', + }) + return + } + + setEmail(emailValue) + setIsEmailSent(true) + toast({ + title: 'Återställningslänk skickad!', + description: 'Kolla din inkorg för att återställa lösenordet.', + }) + } catch (error) { + toast({ + title: 'Kunde inte skicka återställningslänk', + description: getErrorMessage(error, { context: 'auth' }), + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + // Email sent confirmation screen if (isEmailSent) { return (
@@ -72,14 +163,14 @@ export default function LoginPage() {

Kolla din e-post

- Vi har skickat en inloggningslänk till{' '} + Vi har skickat en {showResetPassword ? 'återställningslänk' : 'inloggningslänk'} till{' '} {email}

- Klicka på länken i e-posten för att logga in. + Klicka på länken i e-posten för att {showResetPassword ? 'återställa ditt lösenord' : 'logga in'}. Länken är giltig i 1 timme.

@@ -87,10 +178,73 @@ export default function LoginPage() { +
+ + ) + } + + // Reset password form + if (showResetPassword) { + return ( +
+
+
+
+
+ +
+
+

Återställ lösenord

+

+ Ange din e-postadress så skickar vi en återställningslänk +

+
+ +
+
+
+ + setEmail(e.target.value)} + required + disabled={isLoading} + className="h-11" + /> +
+ +
+
+ +
@@ -110,45 +264,132 @@ export default function LoginPage() { priority />

- Logga in med din e-post för att hantera din ekonomi + Logga in för att hantera din ekonomi

-
-
- - setEmail(e.target.value)} - required - disabled={isLoading} - className="h-11" - /> + {authMode === 'password' ? ( + +
+ + setEmail(e.target.value)} + required + disabled={isLoading} + className="h-11" + /> +
+
+
+ + +
+ setPassword(e.target.value)} + required + disabled={isLoading} + className="h-11" + /> +
+ + + ) : ( +
+
+ + setEmail(e.target.value)} + required + disabled={isLoading} + className="h-11" + /> +
+ +
+ )} + +
+
+
- - +
+ eller +
+
+ +
-

+

+ Har du inget konto?{' '} + + Skapa konto + +

+ +

Genom att logga in godkänner du våra{' '} villkor diff --git a/app/(auth)/mfa/enroll/page.tsx b/app/(auth)/mfa/enroll/page.tsx new file mode 100644 index 00000000..975ed680 --- /dev/null +++ b/app/(auth)/mfa/enroll/page.tsx @@ -0,0 +1,262 @@ +'use client' + +import { useState, useRef } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { createClient } from '@/lib/supabase/client' +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' + +export default function MfaEnrollPage() { + const [qrCode, setQrCode] = useState(null) + const [secret, setSecret] = useState(null) + const [factorId, setFactorId] = useState(null) + const [code, setCode] = useState('') + const [isEnrolling, setIsEnrolling] = useState(false) + const [isVerifying, setIsVerifying] = useState(false) + const [copied, setCopied] = useState(false) + const inputRef = useRef(null) + const { toast } = useToast() + const router = useRouter() + const searchParams = useSearchParams() + const supabase = createClient() + + const returnTo = searchParams.get('returnTo') || '/' + + const handleEnroll = async () => { + setIsEnrolling(true) + + try { + const { data, error } = await supabase.auth.mfa.enroll({ + factorType: 'totp', + friendlyName: 'gnubok', + }) + + if (error) { + toast({ + title: 'Kunde inte aktivera 2FA', + description: 'Försök igen senare.', + variant: 'destructive', + }) + setIsEnrolling(false) + return + } + + setQrCode(data.totp.qr_code) + setSecret(data.totp.secret) + setFactorId(data.id) + + // Focus the code input after render + setTimeout(() => inputRef.current?.focus(), 100) + } catch { + toast({ + title: 'Kunde inte aktivera 2FA', + description: 'Ett oväntat fel uppstod.', + variant: 'destructive', + }) + } finally { + setIsEnrolling(false) + } + } + + const handleVerify = async (e: React.FormEvent) => { + e.preventDefault() + if (!factorId || code.length !== 6) return + + setIsVerifying(true) + + try { + const { data: challenge, error: challengeError } = await supabase.auth.mfa.challenge({ + factorId, + }) + + if (challengeError) { + toast({ + title: 'Verifiering misslyckades', + description: 'Kunde inte starta verifiering. Försök igen.', + variant: 'destructive', + }) + setIsVerifying(false) + return + } + + const { error: verifyError } = await supabase.auth.mfa.verify({ + factorId, + challengeId: challenge.id, + code, + }) + + if (verifyError) { + toast({ + title: 'Fel kod', + description: 'Kontrollera att koden stämmer och försök igen.', + variant: 'destructive', + }) + setCode('') + inputRef.current?.focus() + setIsVerifying(false) + return + } + + toast({ + title: 'Tvåfaktorsautentisering aktiverad', + description: 'Ditt konto är nu skyddat med 2FA.', + }) + + router.push(returnTo) + router.refresh() + } catch { + toast({ + title: 'Verifiering misslyckades', + description: 'Ett oväntat fel uppstod. Försök igen.', + variant: 'destructive', + }) + } finally { + setIsVerifying(false) + } + } + + const copySecret = async () => { + if (!secret) return + await navigator.clipboard.writeText(secret) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + // Step 1: Show enroll button + if (!qrCode) { + return ( +

+ ) + } + + // Step 2: Show QR code and verification + return ( +
+
+
+
+
+ +
+
+

Skanna QR-koden

+

+ Öppna din autentiseringsapp och skanna koden nedan +

+
+ +
+ {/* QR Code */} +
+
+
+ + {/* Manual secret */} +
+

+ Kan du inte skanna? Ange denna nyckel manuellt: +

+
+ + {secret} + + +
+
+ + {/* Verification code */} +
+
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + required + disabled={isVerifying} + className="h-11 text-center text-lg tracking-[0.5em] font-mono" + /> +
+ +
+
+
+
+ ) +} diff --git a/app/(auth)/mfa/verify/page.tsx b/app/(auth)/mfa/verify/page.tsx new file mode 100644 index 00000000..20a2ea7c --- /dev/null +++ b/app/(auth)/mfa/verify/page.tsx @@ -0,0 +1,157 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { useRouter } from 'next/navigation' +import { createClient } from '@/lib/supabase/client' +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, LogOut } from 'lucide-react' + +export default function MfaVerifyPage() { + const [code, setCode] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [factorId, setFactorId] = useState(null) + const inputRef = useRef(null) + const { toast } = useToast() + const router = useRouter() + const supabase = createClient() + + useEffect(() => { + async function loadFactor() { + const { data } = await supabase.auth.mfa.listFactors() + const verifiedFactor = data?.totp?.find(f => f.status === 'verified') + if (verifiedFactor) { + setFactorId(verifiedFactor.id) + } else { + // No MFA factor enrolled — shouldn't be here + router.push('/') + } + } + loadFactor() + inputRef.current?.focus() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleVerify = async (e: React.FormEvent) => { + e.preventDefault() + if (!factorId || code.length !== 6) return + + setIsLoading(true) + + try { + const { data: challenge, error: challengeError } = await supabase.auth.mfa.challenge({ + factorId, + }) + + if (challengeError) { + toast({ + title: 'Verifiering misslyckades', + description: 'Kunde inte starta verifiering. Försök igen.', + variant: 'destructive', + }) + setIsLoading(false) + return + } + + const { error: verifyError } = await supabase.auth.mfa.verify({ + factorId, + challengeId: challenge.id, + code, + }) + + if (verifyError) { + toast({ + title: 'Fel kod', + description: 'Kontrollera koden och försök igen.', + variant: 'destructive', + }) + setCode('') + inputRef.current?.focus() + setIsLoading(false) + return + } + + router.push('/') + router.refresh() + } catch { + toast({ + title: 'Verifiering misslyckades', + description: 'Ett oväntat fel uppstod. Försök igen.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + const handleLogout = async () => { + await supabase.auth.signOut() + router.push('/login') + } + + return ( +
+
+
+
+
+ +
+
+

Tvåfaktorsverifiering

+

+ Ange den 6-siffriga koden från din autentiseringsapp +

+
+ +
+
+
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + required + disabled={isLoading} + className="h-11 text-center text-lg tracking-[0.5em] font-mono" + /> +
+ +
+
+ + +
+
+ ) +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 00000000..82d41ed3 --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,230 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { createClient } from '@/lib/supabase/client' +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, Mail, ArrowLeft } from 'lucide-react' +import Image from 'next/image' +import { getErrorMessage } from '@/lib/errors/get-error-message' + +export default function RegisterPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [isRegistered, setIsRegistered] = useState(false) + const { toast } = useToast() + const supabase = createClient() + + function isStrongPassword(pw: string): boolean { + return pw.length >= 8 + && /[a-z]/.test(pw) + && /[A-Z]/.test(pw) + && /[0-9]/.test(pw) + && /[^a-zA-Z0-9]/.test(pw) + } + + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault() + setIsLoading(true) + + const formData = new FormData(e.currentTarget) + const emailValue = (formData.get('email') as string) || email + const passwordValue = (formData.get('password') as string) || password + const confirmValue = (formData.get('confirm_password') as string) || confirmPassword + + if (!isStrongPassword(passwordValue)) { + toast({ + title: 'Lösenordet är för svagt', + description: 'Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.', + variant: 'destructive', + }) + setIsLoading(false) + return + } + + if (passwordValue !== confirmValue) { + toast({ + title: 'Lösenorden matchar inte', + description: 'Kontrollera att du skrev samma lösenord i båda fälten.', + variant: 'destructive', + }) + setIsLoading(false) + return + } + + try { + const { error } = await supabase.auth.signUp({ + email: emailValue, + password: passwordValue, + options: { + emailRedirectTo: `${window.location.origin}/auth/callback`, + }, + }) + + if (error) { + toast({ + title: 'Registrering misslyckades', + description: getErrorMessage(error, { context: 'auth' }), + variant: 'destructive', + }) + return + } + + setEmail(emailValue) + setIsRegistered(true) + } catch (error) { + toast({ + title: 'Registrering misslyckades', + description: getErrorMessage(error, { context: 'auth' }), + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + if (isRegistered) { + return ( +
+
+
+
+ +
+
+ +
+

Bekräfta din e-post

+

+ Vi har skickat en bekräftelselänk till{' '} + {email} +

+
+ +
+

+ Klicka på länken i e-posten för att aktivera ditt konto. + Länken är giltig i 24 timmar. +

+
+ + +
+
+ ) + } + + return ( +
+ ) +} diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx new file mode 100644 index 00000000..dd6f130b --- /dev/null +++ b/app/(auth)/reset-password/page.tsx @@ -0,0 +1,142 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { createClient } from '@/lib/supabase/client' +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, KeyRound } from 'lucide-react' + +export default function ResetPasswordPage() { + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [isLoading, setIsLoading] = useState(false) + const { toast } = useToast() + const router = useRouter() + const supabase = createClient() + + const handleResetPassword = async (e: React.FormEvent) => { + e.preventDefault() + setIsLoading(true) + + const strong = password.length >= 8 + && /[a-z]/.test(password) + && /[A-Z]/.test(password) + && /[0-9]/.test(password) + && /[^a-zA-Z0-9]/.test(password) + + if (!strong) { + toast({ + title: 'Lösenordet är för svagt', + description: 'Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.', + variant: 'destructive', + }) + setIsLoading(false) + return + } + + if (password !== confirmPassword) { + toast({ + title: 'Lösenorden matchar inte', + description: 'Kontrollera att du skrev samma lösenord i båda fälten.', + variant: 'destructive', + }) + setIsLoading(false) + return + } + + try { + const { error } = await supabase.auth.updateUser({ password }) + + if (error) { + toast({ + title: 'Kunde inte uppdatera lösenord', + description: error.message, + variant: 'destructive', + }) + return + } + + toast({ + title: 'Lösenord uppdaterat', + description: 'Ditt lösenord har ändrats.', + }) + + router.push('/') + router.refresh() + } catch { + toast({ + title: 'Något gick fel', + description: 'Försök igen senare.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + return ( +
+
+
+
+
+ +
+
+

Nytt lösenord

+

+ Ange ditt nya lösenord nedan +

+
+ +
+
+
+ + setPassword(e.target.value)} + required + minLength={8} + disabled={isLoading} + className="h-11" + /> +
+
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + disabled={isLoading} + className="h-11" + /> +
+ +
+
+
+
+ ) +} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 50dec271..b191f13d 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -34,6 +34,7 @@ import { useTheme } from 'next-themes' import type { CompanySettings } from '@/types' import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings' import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry' +import { SecuritySettings } from '@/components/settings/SecuritySettings' const BankingPanel = getSettingsPanel('enable-banking') @@ -268,6 +269,9 @@ export default function SettingsPage() { Kalender )} + + Säkerhet + Utseende @@ -526,6 +530,11 @@ export default function SettingsPage() { )} + {/* Security settings */} + + + + {/* Appearance settings */} diff --git a/components/settings/SecuritySettings.tsx b/components/settings/SecuritySettings.tsx new file mode 100644 index 00000000..247faf00 --- /dev/null +++ b/components/settings/SecuritySettings.tsx @@ -0,0 +1,270 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { createClient } from '@/lib/supabase/client' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +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, ShieldOff, KeyRound } from 'lucide-react' +import { isMfaRequired } from '@/lib/auth/mfa' + +const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true' +const mfaRequired = isMfaRequired() + +export function SecuritySettings() { + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [isChangingPassword, setIsChangingPassword] = useState(false) + const [hasMfa, setHasMfa] = useState(false) + const [isLoadingMfa, setIsLoadingMfa] = useState(true) + const [isUnenrolling, setIsUnenrolling] = useState(false) + const [mfaFactorId, setMfaFactorId] = useState(null) + const { toast } = useToast() + const router = useRouter() + const supabase = createClient() + + useEffect(() => { + async function loadMfaStatus() { + const { data } = await supabase.auth.mfa.listFactors() + const verifiedFactor = data?.totp?.find(f => f.status === 'verified') + setHasMfa(!!verifiedFactor) + setMfaFactorId(verifiedFactor?.id ?? null) + setIsLoadingMfa(false) + } + loadMfaStatus() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault() + setIsChangingPassword(true) + + const strong = newPassword.length >= 8 + && /[a-z]/.test(newPassword) + && /[A-Z]/.test(newPassword) + && /[0-9]/.test(newPassword) + && /[^a-zA-Z0-9]/.test(newPassword) + + if (!strong) { + toast({ + title: 'Lösenordet är för svagt', + description: 'Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.', + variant: 'destructive', + }) + setIsChangingPassword(false) + return + } + + if (newPassword !== confirmPassword) { + toast({ + title: 'Lösenorden matchar inte', + description: 'Kontrollera att du skrev samma lösenord i båda fälten.', + variant: 'destructive', + }) + setIsChangingPassword(false) + return + } + + try { + const { error } = await supabase.auth.updateUser({ password: newPassword }) + + if (error) { + toast({ + title: 'Kunde inte uppdatera lösenord', + description: error.message, + variant: 'destructive', + }) + return + } + + toast({ + title: 'Lösenord uppdaterat', + description: 'Ditt lösenord har ändrats.', + }) + setCurrentPassword('') + setNewPassword('') + setConfirmPassword('') + } catch { + toast({ + title: 'Något gick fel', + description: 'Försök igen senare.', + variant: 'destructive', + }) + } finally { + setIsChangingPassword(false) + } + } + + const handleUnenrollMfa = async () => { + if (!mfaFactorId) return + setIsUnenrolling(true) + + try { + const { error } = await supabase.auth.mfa.unenroll({ factorId: mfaFactorId }) + + if (error) { + toast({ + title: 'Kunde inte inaktivera 2FA', + description: error.message, + variant: 'destructive', + }) + return + } + + toast({ + title: 'Tvåfaktorsautentisering inaktiverad', + description: '2FA har tagits bort från ditt konto.', + }) + setHasMfa(false) + setMfaFactorId(null) + } catch { + toast({ + title: 'Något gick fel', + description: 'Försök igen senare.', + variant: 'destructive', + }) + } finally { + setIsUnenrolling(false) + } + } + + return ( +
+ {/* Change password */} + + + + + Ändra lösenord + + + Uppdatera ditt lösenord. Om du loggar in med e-postlänk kan du sätta ett lösenord här. + + + +
+
+ + setNewPassword(e.target.value)} + required + minLength={8} + disabled={isChangingPassword} + /> +
+
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + disabled={isChangingPassword} + /> +
+ +
+
+
+ + {/* MFA — hidden for self-hosted */} + {!isSelfHosted && ( + + + + + Tvåfaktorsautentisering (2FA) + + + Skydda ditt konto med en autentiseringsapp. Vid varje inloggning behöver du ange en kod + utöver ditt lösenord. + + + + {isLoadingMfa ? ( +
+ + Laddar... +
+ ) : hasMfa ? ( +
+
+ +
+

2FA är aktiverad

+

+ Ditt konto skyddas med tvåfaktorsautentisering. +

+
+
+ {!mfaRequired && ( + + )} + {mfaRequired && ( +

+ Tvåfaktorsautentisering är obligatorisk och kan inte inaktiveras. +

+ )} +
+ ) : ( +
+
+ +
+

2FA är inte aktiverad

+

+ Vi rekommenderar att du aktiverar tvåfaktorsautentisering. +

+
+
+ +
+ )} +
+
+ )} +
+ ) +} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index a6014d7b..b1be8795 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -35,6 +35,8 @@ if [ -d /app/.next/static ]; then -e "s|__NEXT_PUBLIC_SUPABASE_ANON_KEY__|${NEXT_PUBLIC_SUPABASE_ANON_KEY}|g" \ -e "s|__NEXT_PUBLIC_APP_URL__|${NEXT_PUBLIC_APP_URL}|g" \ -e "s|__NEXT_PUBLIC_VAPID_PUBLIC_KEY__|${NEXT_PUBLIC_VAPID_PUBLIC_KEY:-}|g" \ + -e "s|__NEXT_PUBLIC_SELF_HOSTED__|${NEXT_PUBLIC_SELF_HOSTED:-true}|g" \ + -e "s|__NEXT_PUBLIC_REQUIRE_MFA__|${NEXT_PUBLIC_REQUIRE_MFA:-false}|g" \ {} + fi diff --git a/lib/auth/mfa.ts b/lib/auth/mfa.ts new file mode 100644 index 00000000..db977f3b --- /dev/null +++ b/lib/auth/mfa.ts @@ -0,0 +1,11 @@ +/** + * MFA (Multi-Factor Authentication) helpers. + * + * MFA is only required on the hosted version, never for self-hosted deployments. + * Enforcement is application-side (middleware + API routes), not RLS. + */ + +export function isMfaRequired(): boolean { + if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false + return process.env.NEXT_PUBLIC_REQUIRE_MFA === 'true' +} diff --git a/lib/auth/require-auth.ts b/lib/auth/require-auth.ts new file mode 100644 index 00000000..7579eacd --- /dev/null +++ b/lib/auth/require-auth.ts @@ -0,0 +1,40 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { isMfaRequired } from './mfa' +import type { User, SupabaseClient } from '@supabase/supabase-js' + +type AuthResult = + | { user: User; supabase: SupabaseClient; error: null } + | { user: null; supabase: SupabaseClient; error: NextResponse } + +/** + * Auth + MFA guard for API routes. + * + * Returns the authenticated user and Supabase client, or a JSON error response. + * When MFA is required (hosted deployment), verifies AAL2 assurance level. + */ +export async function requireAuth(): Promise { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return { + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + } + } + + if (isMfaRequired()) { + const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() + if (aal?.nextLevel === 'aal2' && aal?.currentLevel !== 'aal2') { + return { + user: null, + supabase, + error: NextResponse.json({ error: 'MFA verification required' }, { status: 403 }), + } + } + } + + return { user, supabase, error: null } +} diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts index fe089b0b..853ee693 100644 --- a/lib/supabase/middleware.ts +++ b/lib/supabase/middleware.ts @@ -1,5 +1,6 @@ import { createServerClient } from '@supabase/ssr' import { NextResponse, type NextRequest } from 'next/server' +import { isMfaRequired } from '@/lib/auth/mfa' export async function updateSession(request: NextRequest) { let supabaseResponse = NextResponse.next({ @@ -48,11 +49,15 @@ export async function updateSession(request: NextRequest) { await supabase.auth.signOut() } - // Auth routes - allow access - if (pathname.startsWith('/login') || pathname.startsWith('/auth')) { - // If user is logged in and trying to access login, redirect to dashboard or onboarding + // Public auth routes — allow access + if ( + pathname.startsWith('/login') || + pathname.startsWith('/register') || + pathname.startsWith('/auth') || + pathname.startsWith('/reset-password') + ) { + // If user is logged in and trying to access auth pages, redirect to dashboard or onboarding if (user) { - // Check if onboarding is complete const { data: settings } = await supabase .from('company_settings') .select('onboarding_complete') @@ -75,6 +80,32 @@ export async function updateSession(request: NextRequest) { return NextResponse.redirect(url) } + // MFA pages — accessible to authenticated users (AAL1+), skip MFA enforcement + if (pathname.startsWith('/mfa/')) { + return supabaseResponse + } + + // MFA enforcement (application-side only, not RLS) + if (isMfaRequired()) { + const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() + + // User has MFA enrolled but hasn't verified this session → redirect to verify + if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') { + return NextResponse.redirect(new URL('/mfa/verify', request.url)) + } + + // MFA required but user has no factor enrolled yet → force enrollment + // (skip during onboarding — let them finish setup first) + if (!pathname.startsWith('/onboarding')) { + const { data: factors } = await supabase.auth.mfa.listFactors() + const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified') + + if (!hasVerifiedFactor) { + return NextResponse.redirect(new URL('/mfa/enroll', request.url)) + } + } + } + // Onboarding route - only accessible if not complete if (pathname.startsWith('/onboarding')) { const { data: settings } = await supabase