'use client' import { useTranslations } from 'next-intl' 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' import { isBankIdEnabled } from '@/lib/auth/bankid' import { BankIdSettings } from '@/components/settings/BankIdSettings' import { userHasPassword } from '@/lib/auth/has-password' const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true' const mfaRequired = isMfaRequired() const bankIdEnabled = isBankIdEnabled() export function SecuritySettings() { const t = useTranslations('settings_security') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [isChangingPassword, setIsChangingPassword] = useState(false) const [hasMfa, setHasMfa] = useState(false) const [isLoadingMfa, setIsLoadingMfa] = useState(true) const [isUnenrolling, setIsUnenrolling] = useState(false) const [mfaFactorId, setMfaFactorId] = useState(null) const [hasPassword, setHasPassword] = useState(null) const { toast } = useToast() const router = useRouter() const supabase = createClient() useEffect(() => { async function loadStatus() { const [{ data: factors }, { data: userData }] = await Promise.all([ supabase.auth.mfa.listFactors(), supabase.auth.getUser(), ]) const verifiedFactor = factors?.totp?.find(f => f.status === 'verified') setHasMfa(!!verifiedFactor) setMfaFactorId(verifiedFactor?.id ?? null) setHasPassword(userData?.user ? userHasPassword(userData.user) : null) setIsLoadingMfa(false) } loadStatus() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const handleChangePassword = async (e: React.FormEvent) => { e.preventDefault() setIsChangingPassword(true) const strong = newPassword.length >= 8 && /[a-z]/.test(newPassword) && /[A-Z]/.test(newPassword) && /[0-9]/.test(newPassword) && /[^a-zA-Z0-9]/.test(newPassword) if (!strong) { toast({ title: t('toast_weak_password_title'), description: t('toast_weak_password_description'), variant: 'destructive', }) setIsChangingPassword(false) return } if (newPassword !== confirmPassword) { toast({ title: t('toast_mismatch_title'), description: t('toast_mismatch_description'), variant: 'destructive', }) setIsChangingPassword(false) return } try { const res = await fetch('/api/account/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: newPassword }), }) if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string } // Supabase rejects updateUser({password}) with this exact message when // the user has a TOTP factor enrolled but is at AAL1. Send them through // /mfa/verify to step up; on return they land back here and can retry. if (body.error?.includes('AAL2')) { router.push( `/mfa/verify?returnTo=${encodeURIComponent('/settings/account')}`, ) return } toast({ title: t('toast_update_failed_title'), description: body.error || t('toast_update_failed_description'), variant: 'destructive', }) return } toast({ title: t('toast_password_updated_title'), description: t('toast_password_updated_description'), }) setNewPassword('') setConfirmPassword('') setHasPassword(true) } catch { toast({ title: t('toast_generic_error_title'), description: t('toast_try_again'), 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) { // mfa.unenroll requires AAL2 — for BankID-linked users at AAL1 (the // shouldEnforceMfa skip path), this is the only way to step up. if (error.message?.includes('AAL2')) { router.push( `/mfa/verify?returnTo=${encodeURIComponent('/settings/account')}`, ) return } toast({ title: t('toast_unenroll_failed_title'), description: error.message, variant: 'destructive', }) return } toast({ title: t('toast_mfa_disabled_title'), description: t('toast_mfa_disabled_description'), }) setHasMfa(false) setMfaFactorId(null) } catch { toast({ title: t('toast_generic_error_title'), description: t('toast_try_again'), variant: 'destructive', }) } finally { setIsUnenrolling(false) } } return (
{bankIdEnabled && } {/* BankID-only users with no password — banner above everything else */} {hasPassword === false && ( Sätt ett lösenord Du loggade in med BankID och har inget lösenord ännu. Sätt ett lösenord för att kunna aktivera 2FA eller logga in när BankID inte är tillgängligt. )} {/* Change password — hidden when the user has no password (the banner above handles the set-initial-password flow). */} {hasPassword !== false && ( {t('change_password_title')} {t('change_password_description')}
setNewPassword(e.target.value)} required minLength={8} disabled={isChangingPassword} />
setConfirmPassword(e.target.value)} required minLength={8} disabled={isChangingPassword} />
)} {/* MFA — hidden for self-hosted */} {!isSelfHosted && ( {t('mfa_title')} {t('mfa_description')} {isLoadingMfa ? (
{t('loading')}
) : hasMfa ? (

{t('mfa_active_title')}

{t('mfa_active_description')}

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

{t('mfa_required_note')}

)}
) : (

{t('mfa_inactive_title')}

{t('mfa_inactive_description')}

{hasPassword === false ? ( ) : ( )}
)}
)}
) }