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:
co-authored by
Claude Opus 4.6
parent
e93f5e06d5
commit
928a145f9a
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user