feat: upgrade auth to email+password with optional TOTP MFA

- Replace magic-link-only login with email+password (primary) and magic link (toggle)
- Add registration page with strong password validation
- Add MFA enrollment (/mfa/enroll) with QR code and manual secret
- Add MFA verification (/mfa/verify) with 6-digit TOTP input
- Add password reset flow (/reset-password)
- Add middleware MFA enforcement gated by NEXT_PUBLIC_REQUIRE_MFA env var
- Self-hosted deployments (NEXT_PUBLIC_SELF_HOSTED=true) skip MFA entirely
- Add Security tab in Settings for password change and MFA management
- Add requireAuth() API route helper with MFA check
- Update CLAUDE.md with Authentication section and env var docs
- Update Dockerfile and docker-entrypoint.sh for new env var placeholders

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-08 10:17:49 +01:00
co-authored by Claude Opus 4.6
parent e93f5e06d5
commit 928a145f9a
14 changed files with 1497 additions and 49 deletions
+44 -3
View File
@@ -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
+2
View File
@@ -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
+11 -1
View File
@@ -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')
+282 -41
View File
@@ -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<AuthMode>('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<HTMLFormElement>) => {
const handlePasswordLogin = async (e: React.FormEvent<HTMLFormElement>) => {
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<HTMLFormElement>) => {
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<HTMLFormElement>) => {
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 (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
@@ -72,14 +163,14 @@ export default function LoginPage() {
<div className="text-center space-y-2">
<h1 className="text-2xl font-medium tracking-tight">Kolla din e-post</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
Vi har skickat en inloggningslänk till{' '}
Vi har skickat en {showResetPassword ? 'återställningslänk' : 'inloggningslänk'} till{' '}
<span className="font-medium text-foreground">{email}</span>
</p>
</div>
<div className="rounded-xl border bg-card p-4">
<p className="text-sm text-muted-foreground text-center leading-relaxed">
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.
</p>
</div>
@@ -87,10 +178,73 @@ export default function LoginPage() {
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={() => setIsEmailSent(false)}
onClick={() => {
setIsEmailSent(false)
setShowResetPassword(false)
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Använd en annan e-post
Tillbaka
</Button>
</div>
</div>
)
}
// Reset password form
if (showResetPassword) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<div className="flex justify-center mb-4">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<KeyRound className="h-7 w-7 text-primary" />
</div>
</div>
<h1 className="text-2xl font-medium tracking-tight">Återställ lösenord</h1>
<p className="text-muted-foreground text-sm mt-2">
Ange din e-postadress så skickar vi en återställningslänk
</p>
</div>
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<form onSubmit={handleResetPassword} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-postadress</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="namn@exempel.se"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
className="h-11"
/>
</div>
<Button type="submit" className="w-full h-11" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skickar...
</>
) : (
'Skicka återställningslänk'
)}
</Button>
</form>
</div>
<Button
variant="ghost"
className="w-full mt-4 text-muted-foreground"
onClick={() => setShowResetPassword(false)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka till inloggning
</Button>
</div>
</div>
@@ -110,45 +264,132 @@ export default function LoginPage() {
priority
/>
<p className="text-muted-foreground text-sm mt-3">
Logga in med din e-post för att hantera din ekonomi
Logga in för att hantera din ekonomi
</p>
</div>
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-postadress</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="namn@exempel.se"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
className="h-11"
/>
{authMode === 'password' ? (
<form onSubmit={handlePasswordLogin} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-postadress</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="namn@exempel.se"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
className="h-11"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Lösenord</Label>
<button
type="button"
onClick={() => setShowResetPassword(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
>
Glömt lösenord?
</button>
</div>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
placeholder="Ditt lösenord"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
className="h-11"
/>
</div>
<Button type="submit" className="w-full h-11" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loggar in...
</>
) : (
'Logga in'
)}
</Button>
</form>
) : (
<form onSubmit={handleMagicLink} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-postadress</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="namn@exempel.se"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
className="h-11"
/>
</div>
<Button type="submit" className="w-full h-11" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skickar...
</>
) : (
'Skicka inloggningslänk'
)}
</Button>
</form>
)}
<div className="relative my-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t" />
</div>
<Button
type="submit"
className="w-full h-11"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skickar...
</>
) : (
'Skicka inloggningslänk'
)}
</Button>
</form>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">eller</span>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => setAuthMode(authMode === 'password' ? 'magic-link' : 'password')}
>
{authMode === 'password' ? (
<>
<Mail className="mr-2 h-4 w-4" />
Logga in med e-postlänk
</>
) : (
<>
<KeyRound className="mr-2 h-4 w-4" />
Logga in med lösenord
</>
)}
</Button>
</div>
<p className="mt-6 text-center text-xs text-muted-foreground leading-relaxed">
<p className="mt-6 text-center text-sm text-muted-foreground">
Har du inget konto?{' '}
<Link
href="/register"
className="font-medium text-foreground underline underline-offset-2 hover:text-primary transition-colors"
>
Skapa konto
</Link>
</p>
<p className="mt-4 text-center text-xs text-muted-foreground leading-relaxed">
Genom att logga in godkänner du våra{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
villkor
+262
View File
@@ -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<string | null>(null)
const [secret, setSecret] = useState<string | null>(null)
const [factorId, setFactorId] = useState<string | null>(null)
const [code, setCode] = useState('')
const [isEnrolling, setIsEnrolling] = useState(false)
const [isVerifying, setIsVerifying] = useState(false)
const [copied, setCopied] = useState(false)
const inputRef = useRef<HTMLInputElement>(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<HTMLFormElement>) => {
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 (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<div className="flex justify-center mb-4">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<ShieldCheck className="h-7 w-7 text-primary" />
</div>
</div>
<h1 className="text-2xl font-medium tracking-tight">Aktivera tvåfaktorsautentisering</h1>
<p className="text-muted-foreground text-sm mt-2">
Skydda ditt konto med en autentiseringsapp som Google Authenticator eller Authy
</p>
</div>
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<div className="space-y-4">
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-sm text-muted-foreground leading-relaxed">
Du behöver en autentiseringsapp på din telefon. Appen genererar en
tidsbegränsad kod som du anger vid varje inloggning.
</p>
</div>
<Button
className="w-full h-11"
onClick={handleEnroll}
disabled={isEnrolling}
>
{isEnrolling ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Förbereder...
</>
) : (
'Fortsätt'
)}
</Button>
</div>
</div>
</div>
</div>
)
}
// Step 2: Show QR code and verification
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<ShieldCheck className="h-7 w-7 text-primary" />
</div>
</div>
<h1 className="text-2xl font-medium tracking-tight">Skanna QR-koden</h1>
<p className="text-muted-foreground text-sm mt-2">
Öppna din autentiseringsapp och skanna koden nedan
</p>
</div>
<div className="rounded-xl border bg-card p-6 space-y-6" style={{ boxShadow: 'var(--shadow-md)' }}>
{/* QR Code */}
<div className="flex justify-center">
<div
className="rounded-lg border bg-white p-3"
dangerouslySetInnerHTML={{ __html: qrCode }}
/>
</div>
{/* Manual secret */}
<div className="space-y-2">
<p className="text-xs text-muted-foreground text-center">
Kan du inte skanna? Ange denna nyckel manuellt:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border bg-muted/50 px-3 py-2 text-xs font-mono text-center break-all select-all">
{secret}
</code>
<Button
variant="outline"
size="icon"
className="shrink-0"
onClick={copySecret}
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Verification code */}
<form onSubmit={handleVerify} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="code">Ange koden från appen</Label>
<Input
ref={inputRef}
id="code"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
autoComplete="one-time-code"
placeholder="000000"
value={code}
onChange={(e) => 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"
/>
</div>
<Button
type="submit"
className="w-full h-11"
disabled={isVerifying || code.length !== 6}
>
{isVerifying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifierar...
</>
) : (
'Aktivera 2FA'
)}
</Button>
</form>
</div>
</div>
</div>
)
}
+157
View File
@@ -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<string | null>(null)
const inputRef = useRef<HTMLInputElement>(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<HTMLFormElement>) => {
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 (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<div className="flex justify-center mb-4">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<ShieldCheck className="h-7 w-7 text-primary" />
</div>
</div>
<h1 className="text-2xl font-medium tracking-tight">Tvåfaktorsverifiering</h1>
<p className="text-muted-foreground text-sm mt-2">
Ange den 6-siffriga koden från din autentiseringsapp
</p>
</div>
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<form onSubmit={handleVerify} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="code">Verifieringskod</Label>
<Input
ref={inputRef}
id="code"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
autoComplete="one-time-code"
placeholder="000000"
value={code}
onChange={(e) => 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"
/>
</div>
<Button
type="submit"
className="w-full h-11"
disabled={isLoading || code.length !== 6}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifierar...
</>
) : (
'Verifiera'
)}
</Button>
</form>
</div>
<Button
variant="ghost"
className="w-full mt-4 text-muted-foreground"
onClick={handleLogout}
>
<LogOut className="mr-2 h-4 w-4" />
Logga ut
</Button>
</div>
</div>
)
}
+230
View File
@@ -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<HTMLFormElement>) => {
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 (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up space-y-8">
<div className="flex justify-center">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<Mail className="h-7 w-7 text-primary" />
</div>
</div>
<div className="text-center space-y-2">
<h1 className="text-2xl font-medium tracking-tight">Bekräfta din e-post</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
Vi har skickat en bekräftelselänk till{' '}
<span className="font-medium text-foreground">{email}</span>
</p>
</div>
<div className="rounded-xl border bg-card p-4">
<p className="text-sm text-muted-foreground text-center leading-relaxed">
Klicka på länken i e-posten för att aktivera ditt konto.
Länken är giltig i 24 timmar.
</p>
</div>
<Button variant="ghost" className="w-full text-muted-foreground" asChild>
<Link href="/login">
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka till inloggning
</Link>
</Button>
</div>
</div>
)
}
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<Image
src="/gnubokiceon-removebg-preview.png"
alt="Gnubok"
width={240}
height={240}
className="mx-auto mb-2"
priority
/>
<p className="text-muted-foreground text-sm mt-3">
Skapa ett konto för att komma igång
</p>
</div>
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<form onSubmit={handleRegister} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-postadress</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="namn@exempel.se"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Lösenord</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
placeholder="Minst 8 tecken, Aa1!"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isLoading}
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm_password">Bekräfta lösenord</Label>
<Input
id="confirm_password"
name="confirm_password"
type="password"
autoComplete="new-password"
placeholder="Upprepa lösenordet"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
disabled={isLoading}
className="h-11"
/>
</div>
<Button type="submit" className="w-full h-11" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar konto...
</>
) : (
'Skapa konto'
)}
</Button>
</form>
</div>
<p className="mt-6 text-center text-sm text-muted-foreground">
Har du redan ett konto?{' '}
<Link
href="/login"
className="font-medium text-foreground underline underline-offset-2 hover:text-primary transition-colors"
>
Logga in
</Link>
</p>
<p className="mt-4 text-center text-xs text-muted-foreground leading-relaxed">
Genom att skapa konto godkänner du våra{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
villkor
</a>{' '}
och{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
integritetspolicy
</a>
.
</p>
</div>
</div>
)
}
+142
View File
@@ -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<HTMLFormElement>) => {
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 (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<div className="flex justify-center mb-4">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<KeyRound className="h-7 w-7 text-primary" />
</div>
</div>
<h1 className="text-2xl font-medium tracking-tight">Nytt lösenord</h1>
<p className="text-muted-foreground text-sm mt-2">
Ange ditt nya lösenord nedan
</p>
</div>
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<form onSubmit={handleResetPassword} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="password">Nytt lösenord</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
placeholder="Minst 8 tecken, Aa1!"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isLoading}
className="h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm_password">Bekräfta lösenord</Label>
<Input
id="confirm_password"
type="password"
autoComplete="new-password"
placeholder="Upprepa lösenordet"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
disabled={isLoading}
className="h-11"
/>
</div>
<Button type="submit" className="w-full h-11" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara nytt lösenord'
)}
</Button>
</form>
</div>
</div>
</div>
)
}
+9
View File
@@ -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
</TabsTrigger>
)}
<TabsTrigger value="security">
Säkerhet
</TabsTrigger>
<TabsTrigger value="appearance">
Utseende
</TabsTrigger>
@@ -526,6 +530,11 @@ export default function SettingsPage() {
</TabsContent>
)}
{/* Security settings */}
<TabsContent value="security">
<SecuritySettings />
</TabsContent>
{/* Appearance settings */}
<TabsContent value="appearance">
<Card>
+270
View File
@@ -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<string | null>(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<HTMLFormElement>) => {
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 (
<div className="space-y-6">
{/* Change password */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="h-5 w-5" />
Ändra lösenord
</CardTitle>
<CardDescription>
Uppdatera ditt lösenord. Om du loggar in med e-postlänk kan du sätta ett lösenord här.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4 max-w-md">
<div className="space-y-2">
<Label htmlFor="new_password">Nytt lösenord</Label>
<Input
id="new_password"
type="password"
autoComplete="new-password"
placeholder="Minst 8 tecken"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
disabled={isChangingPassword}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm_new_password">Bekräfta nytt lösenord</Label>
<Input
id="confirm_new_password"
type="password"
autoComplete="new-password"
placeholder="Upprepa lösenordet"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
disabled={isChangingPassword}
/>
</div>
<Button type="submit" disabled={isChangingPassword}>
{isChangingPassword ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Uppdatera lösenord'
)}
</Button>
</form>
</CardContent>
</Card>
{/* MFA — hidden for self-hosted */}
{!isSelfHosted && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />
Tvåfaktorsautentisering (2FA)
</CardTitle>
<CardDescription>
Skydda ditt konto med en autentiseringsapp. Vid varje inloggning behöver du ange en kod
utöver ditt lösenord.
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingMfa ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Laddar...
</div>
) : hasMfa ? (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-lg border bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900">
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-500" />
<div>
<p className="font-medium text-green-900 dark:text-green-100">2FA är aktiverad</p>
<p className="text-sm text-green-700 dark:text-green-400">
Ditt konto skyddas med tvåfaktorsautentisering.
</p>
</div>
</div>
{!mfaRequired && (
<Button
variant="outline"
onClick={handleUnenrollMfa}
disabled={isUnenrolling}
>
{isUnenrolling ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Inaktiverar...
</>
) : (
<>
<ShieldOff className="mr-2 h-4 w-4" />
Inaktivera 2FA
</>
)}
</Button>
)}
{mfaRequired && (
<p className="text-xs text-muted-foreground">
Tvåfaktorsautentisering är obligatorisk och kan inte inaktiveras.
</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-lg border">
<ShieldOff className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">2FA är inte aktiverad</p>
<p className="text-sm text-muted-foreground">
Vi rekommenderar att du aktiverar tvåfaktorsautentisering.
</p>
</div>
</div>
<Button
onClick={() => router.push('/mfa/enroll?returnTo=/settings?tab=security')}
>
<ShieldCheck className="mr-2 h-4 w-4" />
Aktivera 2FA
</Button>
</div>
)}
</CardContent>
</Card>
)}
</div>
)
}
+2
View File
@@ -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
+11
View File
@@ -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'
}
+40
View File
@@ -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<AuthResult> {
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 }
}
+35 -4
View File
@@ -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