3bb178b5-949c-4434-a4e8-093caab5d33b
This commit is contained in:
1 parent
cd7591fd65
commit
72ec15c2ab
21 files changed
+1991
-130
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Menu, Search, Bell, ChevronRight } from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { LocaleSwitcher } from './locale-switcher'
|
||||
import { NotificationsBell } from './notifications-bell'
|
||||
@@ -51,6 +52,14 @@ export function TopBar() {
|
||||
const { view, setSidebarOpen, setCommandOpen } = useAos()
|
||||
const meta = TITLES[view]
|
||||
const crumbParts = meta.crumb.split(' / ')
|
||||
const { data: meData } = useFetch<{ user: { name: string; email: string; role: string } } | null>('/api/auth/me')
|
||||
const user = meData?.user
|
||||
const initials = (user?.name ?? '?')
|
||||
.split(' ')
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-md md:px-6">
|
||||
@@ -95,19 +104,19 @@ export function TopBar() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="gap-2 px-1.5">
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-xs">MB</AvatarFallback>
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-xs">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="flex flex-col">
|
||||
<span className="text-sm font-medium">Max Brandt</span>
|
||||
<span className="text-xs text-muted-foreground">max@nordlicht.digital</span>
|
||||
<span className="text-sm font-medium">{user?.name ?? '—'}</span>
|
||||
<span className="text-xs text-muted-foreground">{user?.email ?? ''}</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="flex items-center justify-between">
|
||||
<span>Rolle</span>
|
||||
<Badge variant="outline" className="text-[10px]">Owner</Badge>
|
||||
<Badge variant="outline" className="text-[10px] capitalize">{user?.role ?? '—'}</Badge>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>Profil</DropdownMenuItem>
|
||||
<DropdownMenuItem>Sitzungen</DropdownMenuItem>
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
Mail,
|
||||
Loader2,
|
||||
Lock,
|
||||
Copy,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
KeyRound,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface MeResponse {
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
emailVerifiedAt: string | null
|
||||
twoFactorEnabled: boolean
|
||||
twoFactorMethod: string | null
|
||||
lastLoginAt: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type EnableStep =
|
||||
| { kind: 'choose-method' }
|
||||
| { kind: 'totp-setup'; secret: string; qr: string; otpauthUrl: string }
|
||||
| { kind: 'enter-code'; method: 'totp' | 'email'; secret?: string }
|
||||
| { kind: 'backup-codes'; codes: string[] }
|
||||
|
||||
/**
|
||||
* TwoFactorSettings — self-contained 2FA management card.
|
||||
*
|
||||
* States:
|
||||
* - 2FA OFF → "Enable" button → method chooser (Email / Authenticator)
|
||||
* - Email: re-auth (password) → enable → show backup codes
|
||||
* - TOTP: setup (QR + secret) → enter code from app → re-auth → enable → backup codes
|
||||
* - 2FA ON → show method + status → "Disable" (re-auth required)
|
||||
*
|
||||
* Fetches /api/auth/me for the current status. Uses:
|
||||
* POST /api/auth/2fa/setup → TOTP provisioning
|
||||
* POST /api/auth/2fa/enable → finalize (re-auth + verify)
|
||||
* POST /api/auth/2fa/disable → turn off (re-auth required)
|
||||
*/
|
||||
export function TwoFactorSettings() {
|
||||
const { data, loading, refetch } = useFetch<MeResponse>('/api/auth/me')
|
||||
const [enableOpen, setEnableOpen] = useState(false)
|
||||
const [disableOpen, setDisableOpen] = useState(false)
|
||||
const [step, setStep] = useState<EnableStep>({ kind: 'choose-method' })
|
||||
const [password, setPassword] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
|
||||
const user = data?.user
|
||||
|
||||
const reset = () => {
|
||||
setStep({ kind: 'choose-method' })
|
||||
setPassword('')
|
||||
setCode('')
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
// --- TOTP setup: get QR + secret ---
|
||||
const startTotp = async () => {
|
||||
setBusy(true)
|
||||
const t = toast.loading('QR-Code wird generiert…')
|
||||
try {
|
||||
const r = await fetch('/api/auth/2fa/setup', { method: 'POST' })
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'Setup fehlgeschlagen')
|
||||
setStep({ kind: 'totp-setup', secret: res.secret, qr: res.qr, otpauthUrl: res.otpauthUrl })
|
||||
toast.success('QR-Code bereit. Mit Authenticator-App scannen.', { id: t })
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Enable 2FA (re-auth + verify code) ---
|
||||
const enable = async () => {
|
||||
if (!password) return
|
||||
if (step.kind === 'enter-code' && !code) return
|
||||
setBusy(true)
|
||||
const t = toast.loading('2FA wird aktiviert…')
|
||||
try {
|
||||
const method = step.kind === 'enter-code' ? step.method : 'email'
|
||||
const body: Record<string, string> = { method, password }
|
||||
if (step.kind === 'enter-code' && step.method === 'totp') {
|
||||
body.code = code
|
||||
body.secret = step.secret ?? ''
|
||||
}
|
||||
const r = await fetch('/api/auth/2fa/enable', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'Aktivierung fehlgeschlagen')
|
||||
toast.success(res.message ?? '2FA aktiviert', { id: t, duration: 6000 })
|
||||
if (res.backupCodes?.length) {
|
||||
setStep({ kind: 'backup-codes', codes: res.backupCodes })
|
||||
} else {
|
||||
setEnableOpen(false)
|
||||
reset()
|
||||
refetch()
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Disable 2FA ---
|
||||
const disable = async () => {
|
||||
if (!password) return
|
||||
setBusy(true)
|
||||
const t = toast.loading('2FA wird deaktiviert…')
|
||||
try {
|
||||
const r = await fetch('/api/auth/2fa/disable', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
const res = await r.json()
|
||||
if (!r.ok) throw new Error(res.error || 'Deaktivierung fehlgeschlagen')
|
||||
toast.success(res.message ?? '2FA deaktiviert', { id: t })
|
||||
setDisableOpen(false)
|
||||
setPassword('')
|
||||
refetch()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copy = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(label)
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
toast.success(`${label} kopiert`)
|
||||
})
|
||||
}
|
||||
|
||||
const enabled = user?.twoFactorEnabled
|
||||
const method = user?.twoFactorMethod
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<ShieldCheck className="size-3.5 text-muted-foreground" />
|
||||
Zwei-Faktor-Authentifizierung (2FA)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" /> Laden…
|
||||
</div>
|
||||
) : !user ? (
|
||||
<p className="text-xs text-muted-foreground">Nicht angemeldet.</p>
|
||||
) : enabled ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="gap-1 text-[10px] text-foreground">
|
||||
<Check className="size-3" /> Aktiv
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{method === 'totp' ? (
|
||||
<span className="inline-flex items-center gap-1"><Smartphone className="size-3" /> Authenticator-App</span>
|
||||
) : method === 'email' ? (
|
||||
<span className="inline-flex items-center gap-1"><Mail className="size-3" /> E-Mail-OTP</span>
|
||||
) : method}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="gap-1.5 text-xs" onClick={() => { setDisableOpen(true); setPassword('') }}>
|
||||
<Lock className="size-3" /> Deaktivieren
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Beim Anmelden wird zusätzlich zum Passwort ein einmaliger Code benötigt. Backup-Codes wurden bei der Einrichtung angezeigt — sicher aufbewahren.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Schützen Sie Ihr Konto mit einem zweiten Faktor. Optional — kann jederzeit aktiviert oder deaktiviert werden.
|
||||
</p>
|
||||
<Button size="sm" className="gap-1.5" onClick={() => { setEnableOpen(true); reset() }}>
|
||||
<ShieldCheck className="size-3.5" /> 2FA aktivieren
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* --- Enable flow --- */}
|
||||
<Dialog open={enableOpen} onOpenChange={(o) => { setEnableOpen(o); if (!o) reset() }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="size-4 text-muted-foreground" />
|
||||
2FA aktivieren
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step.kind === 'backup-codes'
|
||||
? 'Backup-Codes — sicher aufbewahren (einmalig sichtbar).'
|
||||
: 'Wählen Sie eine Methode und bestätigen Sie mit Ihrem Passwort.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step.kind === 'choose-method' && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setStep({ kind: 'enter-code', method: 'email' })}
|
||||
className="flex w-full items-center gap-3 rounded-md border border-border p-3 text-left transition hover:bg-muted/40 disabled:opacity-50"
|
||||
>
|
||||
<Mail className="size-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">E-Mail-OTP</p>
|
||||
<p className="text-[11px] text-muted-foreground">Code pro Anmeldung an Ihre E-Mail gesendet.</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={startTotp}
|
||||
className="flex w-full items-center gap-3 rounded-md border border-border p-3 text-left transition hover:bg-muted/40 disabled:opacity-50"
|
||||
>
|
||||
<Smartphone className="size-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Authenticator-App</p>
|
||||
<p className="text-[11px] text-muted-foreground">Google Authenticator, Authy, 1Password (TOTP).</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === 'totp-setup' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* QR code as data URI — no next/image optimization needed */}
|
||||
<img src={step.qr} alt="QR-Code für Authenticator" className="rounded-md border border-border" width={200} height={200} />
|
||||
<p className="text-center text-[11px] text-muted-foreground">
|
||||
Scannen Sie den QR-Code mit Ihrer Authenticator-App.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Oder manuell eingeben:</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md bg-muted px-2 py-1.5 font-mono text-[11px] break-all">
|
||||
{step.secret}
|
||||
</code>
|
||||
<Button size="sm" variant="ghost" className="shrink-0" onClick={() => copy(step.secret, 'Secret')}>
|
||||
{copied === 'Secret' ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" onClick={() => setStep({ kind: 'enter-code', method: 'totp', secret: step.secret })}>
|
||||
Weiter — Code eingeben
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === 'enter-code' && (
|
||||
<div className="space-y-3">
|
||||
{step.method === 'totp' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="code" className="text-xs">Code aus der Authenticator-App</Label>
|
||||
<Input
|
||||
id="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
inputMode="numeric"
|
||||
className="text-center text-lg tracking-[0.4em]"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step.method === 'email' && (
|
||||
<p className="rounded-md border border-border bg-muted/40 p-2.5 text-[11px] text-muted-foreground">
|
||||
Beim Aktivieren wird ein Bestätigungscode an Ihre E-Mail gesendet. Geben Sie unten Ihr Passwort ein, um die Einrichtung abzuschließen.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="enable-pw" className="text-xs">Passwort zur Bestätigung</Label>
|
||||
<Input
|
||||
id="enable-pw"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setEnableOpen(false)}>Abbrechen</Button>
|
||||
<Button size="sm" disabled={busy || !password || (step.method === 'totp' && code.length < 6)} onClick={enable} className="gap-1.5">
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ShieldCheck className="size-3.5" />}
|
||||
2FA aktivieren
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.kind === 'backup-codes' && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-[11px] text-muted-foreground">
|
||||
<p className="flex items-center gap-1.5 font-medium text-foreground">
|
||||
<AlertTriangle className="size-3" />
|
||||
Diese Codes sind nur jetzt sichtbar.
|
||||
</p>
|
||||
<p className="mt-1">Bei Verlust Ihres zweiten Faktors erlaubt jeder Code eine Anmeldung. Sicher aufbewahren (z.B. Passwort-Manager oder Ausdruck im Tresor).</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{step.codes.map((c) => (
|
||||
<code key={c} className="rounded bg-muted px-2 py-1.5 text-center font-mono text-sm">{c}</code>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => copy(step.codes.join('\n'), 'Backup-Codes')}>
|
||||
{copied === 'Backup-Codes' ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
Alle kopieren
|
||||
</Button>
|
||||
<Button size="sm" className="ml-auto" onClick={() => { setEnableOpen(false); reset(); refetch() }}>
|
||||
<KeyRound className="size-3.5" /> Fertig
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* --- Disable flow --- */}
|
||||
<Dialog open={disableOpen} onOpenChange={(o) => { setDisableOpen(o); if (!o) setPassword('') }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Lock className="size-4 text-muted-foreground" />
|
||||
2FA deaktivieren
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ihr Konto ist dann nur noch durch das Passwort geschützt. Bitte Passwort zur Bestätigung eingeben.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="disable-pw" className="text-xs">Passwort</Label>
|
||||
<Input
|
||||
id="disable-pw"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setDisableOpen(false)}>Abbrechen</Button>
|
||||
<Button size="sm" disabled={busy || !password} onClick={disable} className="gap-1.5 bg-foreground text-background hover:bg-foreground/90">
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Lock className="size-3.5" />}
|
||||
Deaktivieren
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { PageHeader } from '../primitives'
|
||||
import { TwoFactorSettings } from '../two-factor-settings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -198,6 +199,9 @@ export function SettingsView() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Account security — 2FA */}
|
||||
<TwoFactorSettings />
|
||||
|
||||
{/* Agency settings preview */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
|
||||
Reference in new issue
Block a user