feat: BankID authentication via TIC Identity API (#192)
* feat: add BankID authentication via TIC Identity API Integrate BankID as a login/signup method using the TIC Identity API. Users can authenticate with BankID QR codes (desktop) or deep links (mobile), link BankID to existing accounts, and skip TOTP MFA when BankID is linked. Removes Step 0 (role choice) from onboarding for all users. Adds enrichment data support for pre-filling company details from Bolagsverket during signup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — server-side rate limit, unlink clears MFA bypass - Add per-IP rate limit (5s cooldown) on /bankid/start to prevent unbounded billable TIC sessions from unauthenticated callers - Add /bankid/unlink endpoint that deletes bankid_identities AND clears app_metadata.bankid_linked so MFA enforcement resumes after unlink - Update BankIdSettings to call server-side unlink instead of client-side delete Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: move rate limiter to module scope, add BankID logo and year-end skill Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6486e0d9e2
commit
6d75b9a1bf
@@ -0,0 +1,308 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { BankIdQrCode } from './BankIdQrCode'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Smartphone, Monitor } from 'lucide-react'
|
||||
|
||||
type BankIdStatus = 'idle' | 'scanning' | 'complete' | 'failed' | 'no_account'
|
||||
|
||||
interface BankIdSession {
|
||||
sessionId: string
|
||||
autoStartToken: string
|
||||
qrStartToken: string
|
||||
qrStartSecret: string
|
||||
}
|
||||
|
||||
export interface BankIdResult {
|
||||
tokenHash?: string
|
||||
type?: string
|
||||
isNewUser?: boolean
|
||||
error?: 'no_account' | 'already_linked' | 'session_invalid'
|
||||
givenName?: string
|
||||
surname?: string
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
interface BankIdAuthProps {
|
||||
mode: 'login' | 'signup' | 'link'
|
||||
onComplete: (result: BankIdResult) => void
|
||||
}
|
||||
|
||||
const API_BASE = '/api/extensions/ext/tic/bankid'
|
||||
|
||||
function isMobile(): boolean {
|
||||
if (typeof navigator === 'undefined') return false
|
||||
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/**
|
||||
* BankID authentication flow component.
|
||||
* Handles QR code display (desktop) or app deep link (mobile),
|
||||
* polling, and result handling.
|
||||
*/
|
||||
export function BankIdAuth({ mode, onComplete }: BankIdAuthProps) {
|
||||
const [status, setStatus] = useState<BankIdStatus>('idle')
|
||||
const [session, setSession] = useState<BankIdSession | null>(null)
|
||||
const [hintMessage, setHintMessage] = useState<string>('')
|
||||
const [errorMessage, setErrorMessage] = useState<string>('')
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const lastStartRef = useRef<number>(0)
|
||||
const onCompleteRef = useRef(onComplete)
|
||||
onCompleteRef.current = onComplete
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort()
|
||||
abortRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => cleanup, [cleanup])
|
||||
|
||||
const startSession = useCallback(async () => {
|
||||
// Prevent rapid restarts (each start = billable TIC session)
|
||||
const now = Date.now()
|
||||
if (now - lastStartRef.current < 5000) return
|
||||
lastStartRef.current = now
|
||||
|
||||
cleanup()
|
||||
setStatus('scanning')
|
||||
setHintMessage('Starta BankID-appen')
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/start`, { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'Failed to start BankID')
|
||||
}
|
||||
|
||||
const { data } = await res.json()
|
||||
const newSession: BankIdSession = data
|
||||
setSession(newSession)
|
||||
|
||||
// On mobile, open BankID app
|
||||
if (isMobile()) {
|
||||
const returnUrl = encodeURIComponent(window.location.href)
|
||||
window.location.href = `bankid:///?autostarttoken=${newSession.autoStartToken}&redirect=${returnUrl}`
|
||||
}
|
||||
|
||||
// Start polling
|
||||
abortRef.current = new AbortController()
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(`${API_BASE}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: newSession.sessionId }),
|
||||
signal: abortRef.current?.signal,
|
||||
})
|
||||
|
||||
if (!pollRes.ok) return
|
||||
|
||||
const pollJson = await pollRes.json()
|
||||
const pollData = pollJson.data
|
||||
|
||||
if (!pollData) {
|
||||
console.warn('[bankid] poll returned no data:', pollJson)
|
||||
return
|
||||
}
|
||||
|
||||
// Update hint message from TIC API
|
||||
if (pollData.message) {
|
||||
setHintMessage(pollData.message)
|
||||
}
|
||||
|
||||
// Handle token refresh (order regeneration ~25s)
|
||||
if (pollData.qrStartToken && pollData.qrStartSecret) {
|
||||
setSession((prev) =>
|
||||
prev
|
||||
? { ...prev, qrStartToken: pollData.qrStartToken, qrStartSecret: pollData.qrStartSecret }
|
||||
: prev
|
||||
)
|
||||
}
|
||||
|
||||
if (pollData.status === 'complete') {
|
||||
cleanup()
|
||||
setStatus('complete')
|
||||
|
||||
if (mode === 'login') {
|
||||
// For login, call /complete to exchange for Supabase session
|
||||
try {
|
||||
const completeRes = await fetch(`${API_BASE}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: newSession.sessionId,
|
||||
mode: 'login',
|
||||
}),
|
||||
})
|
||||
const completeJson = await completeRes.json()
|
||||
|
||||
if (!completeRes.ok) {
|
||||
onCompleteRef.current({
|
||||
error: completeJson.error,
|
||||
givenName: completeJson.givenName,
|
||||
surname: completeJson.surname,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onCompleteRef.current({
|
||||
tokenHash: completeJson.data.tokenHash,
|
||||
type: completeJson.data.type,
|
||||
isNewUser: completeJson.data.isNewUser,
|
||||
})
|
||||
} catch {
|
||||
onCompleteRef.current({ error: 'session_invalid' })
|
||||
}
|
||||
} else if (mode === 'link') {
|
||||
// For link, call /link to associate BankID with current user
|
||||
try {
|
||||
const linkRes = await fetch(`${API_BASE}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: newSession.sessionId }),
|
||||
})
|
||||
const linkJson = await linkRes.json()
|
||||
|
||||
if (!linkRes.ok) {
|
||||
onCompleteRef.current({ error: linkJson.error })
|
||||
return
|
||||
}
|
||||
|
||||
onCompleteRef.current({})
|
||||
} catch {
|
||||
onCompleteRef.current({ error: 'session_invalid' })
|
||||
}
|
||||
} else {
|
||||
// For signup, return user data + sessionId so parent can collect email
|
||||
onCompleteRef.current({
|
||||
givenName: pollData.user?.givenName,
|
||||
surname: pollData.user?.surname,
|
||||
sessionId: newSession.sessionId,
|
||||
})
|
||||
}
|
||||
} else if (pollData.status === 'failed' || pollData.status === 'cancelled') {
|
||||
cleanup()
|
||||
setStatus('failed')
|
||||
setErrorMessage(pollData.message || 'BankID-identifieringen misslyckades')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') return
|
||||
// Polling error — will retry next tick
|
||||
}
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
setStatus('failed')
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Ett oväntat fel uppstod')
|
||||
}
|
||||
}, [cleanup, mode])
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
if (session) {
|
||||
fetch(`${API_BASE}/${session.sessionId}`, { method: 'DELETE' }).catch(() => {})
|
||||
}
|
||||
cleanup()
|
||||
setStatus('idle')
|
||||
setSession(null)
|
||||
}, [session, cleanup])
|
||||
|
||||
if (status === 'idle') {
|
||||
const label = mode === 'login'
|
||||
? 'Logga in med BankID'
|
||||
: mode === 'link'
|
||||
? 'Koppla BankID'
|
||||
: 'Skapa konto med BankID'
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={startSession}
|
||||
variant="outline"
|
||||
className="w-full gap-2 border-[1.5px] py-6 text-base"
|
||||
>
|
||||
<BankIdIcon />
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
<Button onClick={startSession} variant="outline" className="gap-2">
|
||||
<BankIdIcon />
|
||||
Forsok igen
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const openBankIdOnDevice = () => {
|
||||
if (!session) return
|
||||
// Open BankID app on the same device via deep link
|
||||
// redirect=null tells BankID not to redirect after completion
|
||||
window.location.href = `bankid:///?autostarttoken=${session.autoStartToken}&redirect=null`
|
||||
}
|
||||
|
||||
// Scanning / waiting for user
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{session && !isMobile() && (
|
||||
<>
|
||||
<BankIdQrCode
|
||||
qrStartToken={session.qrStartToken}
|
||||
qrStartSecret={session.qrStartSecret}
|
||||
/>
|
||||
<Button
|
||||
onClick={openBankIdOnDevice}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
>
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
BankID pa den har enheten
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isMobile() && (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Smartphone className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Oppnar BankID-appen...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground">{hintMessage}</p>
|
||||
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BankIdIcon() {
|
||||
return (
|
||||
<Image
|
||||
src="/logos/bankid-seeklogo.svg"
|
||||
alt="BankID"
|
||||
width={20}
|
||||
height={20}
|
||||
className="dark:invert"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
interface BankIdQrCodeProps {
|
||||
qrStartToken: string
|
||||
qrStartSecret: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated BankID QR code component.
|
||||
* Computes HMAC-SHA256 every second per BankID spec:
|
||||
* bankid.{qrStartToken}.{time}.{hmac_sha256(qrStartSecret, time)}
|
||||
*/
|
||||
export function BankIdQrCode({ qrStartToken, qrStartSecret }: BankIdQrCodeProps) {
|
||||
const [svgData, setSvgData] = useState<string>('')
|
||||
const elapsedRef = useRef(0)
|
||||
const tokenRef = useRef(qrStartToken)
|
||||
const secretRef = useRef(qrStartSecret)
|
||||
|
||||
// Update refs when tokens change (order regeneration)
|
||||
useEffect(() => {
|
||||
tokenRef.current = qrStartToken
|
||||
secretRef.current = qrStartSecret
|
||||
elapsedRef.current = 0
|
||||
}, [qrStartToken, qrStartSecret])
|
||||
|
||||
const generateQr = useCallback(async () => {
|
||||
const token = tokenRef.current
|
||||
const secret = secretRef.current
|
||||
const time = elapsedRef.current
|
||||
|
||||
try {
|
||||
// Compute HMAC-SHA256 using Web Crypto API
|
||||
const encoder = new TextEncoder()
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
)
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(time.toString()))
|
||||
const qrAuthCode = Array.from(new Uint8Array(signature))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
|
||||
const qrData = `bankid.${token}.${time}.${qrAuthCode}`
|
||||
|
||||
const svg = await QRCode.toString(qrData, {
|
||||
type: 'svg',
|
||||
margin: 1,
|
||||
width: 200,
|
||||
color: { dark: '#141414', light: '#ffffff' },
|
||||
})
|
||||
setSvgData(svg)
|
||||
} catch {
|
||||
// Silently fail — next tick will retry
|
||||
}
|
||||
|
||||
elapsedRef.current++
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Generate immediately, then every second
|
||||
generateQr()
|
||||
const interval = setInterval(generateQr, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [generateQr])
|
||||
|
||||
if (!svgData) {
|
||||
return (
|
||||
<div className="flex h-[200px] w-[200px] items-center justify-center rounded-lg border bg-white">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex rounded-lg border bg-white p-2"
|
||||
dangerouslySetInnerHTML={{ __html: svgData }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { User, Users } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Step0Props {
|
||||
onChooseSelf: () => void
|
||||
onChooseConsultant: () => void
|
||||
}
|
||||
|
||||
const options = [
|
||||
{
|
||||
key: 'self',
|
||||
label: 'Jag bokför själv',
|
||||
description: 'Du driver ditt eget företag och vill hantera bokföringen.',
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
key: 'consultant',
|
||||
label: 'Jag bokför åt någon annan',
|
||||
description: 'Du är konsult eller byrå och bokför åt dina kunder.',
|
||||
icon: Users,
|
||||
},
|
||||
] as const
|
||||
|
||||
export default function Step0RoleChoice({ onChooseSelf, onChooseConsultant }: Step0Props) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid gap-3">
|
||||
{options.map((option) => {
|
||||
const Icon = option.icon
|
||||
const handler = option.key === 'self' ? onChooseSelf : onChooseConsultant
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={handler}
|
||||
className="text-left w-full"
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'relative p-5 transition-all cursor-pointer',
|
||||
'hover:border-primary/50 hover:shadow-sm',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2.5 rounded-lg bg-muted/50">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ interface Step2Props {
|
||||
onNext: (data: FormData) => void
|
||||
onBack: () => void
|
||||
isSaving: boolean
|
||||
orgNumberLocked?: boolean
|
||||
}
|
||||
|
||||
export default function Step2CompanyDetails({
|
||||
@@ -44,6 +45,7 @@ export default function Step2CompanyDetails({
|
||||
onNext,
|
||||
onBack,
|
||||
isSaving,
|
||||
orgNumberLocked,
|
||||
}: Step2Props) {
|
||||
const {
|
||||
register,
|
||||
@@ -167,6 +169,8 @@ export default function Step2CompanyDetails({
|
||||
id="org_number"
|
||||
placeholder={isAB ? 'XXXXXX-XXXX' : 'ÅÅMMDD-XXXX (ditt personnummer vid EF)'}
|
||||
{...register('org_number')}
|
||||
readOnly={orgNumberLocked}
|
||||
className={orgNumberLocked ? 'bg-muted cursor-not-allowed' : undefined}
|
||||
/>
|
||||
{errors.org_number && (
|
||||
<p className="text-sm text-destructive">{errors.org_number.message}</p>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { BankIdAuth } from '@/components/auth/BankIdAuth'
|
||||
import type { BankIdResult } from '@/components/auth/BankIdAuth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Shield, ShieldCheck, Loader2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
|
||||
interface BankIdIdentity {
|
||||
given_name: string | null
|
||||
surname: string | null
|
||||
linked_at: string
|
||||
}
|
||||
|
||||
export function BankIdSettings() {
|
||||
const [identity, setIdentity] = useState<BankIdIdentity | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isLinking, setIsLinking] = useState(false)
|
||||
const [isUnlinking, setIsUnlinking] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
const fetchIdentity = useCallback(async () => {
|
||||
const supabase = createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) { setIsLoading(false); return }
|
||||
|
||||
const { data } = await supabase
|
||||
.from('bankid_identities')
|
||||
.select('given_name, surname, linked_at')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
setIdentity(data)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchIdentity()
|
||||
}, [fetchIdentity])
|
||||
|
||||
const handleLinkComplete = async (result: BankIdResult) => {
|
||||
if (result.error) {
|
||||
const message = result.error === 'already_linked'
|
||||
? 'Detta BankID ar redan kopplat till ett annat konto.'
|
||||
: 'Kunde inte koppla BankID.'
|
||||
toast({ title: message, variant: 'destructive' })
|
||||
setIsLinking(false)
|
||||
return
|
||||
}
|
||||
|
||||
toast({ title: 'BankID kopplat till ditt konto' })
|
||||
setIsLinking(false)
|
||||
fetchIdentity()
|
||||
}
|
||||
|
||||
const handleUnlink = async () => {
|
||||
if (!confirm('Vill du koppla bort BankID fran ditt konto?')) return
|
||||
|
||||
setIsUnlinking(true)
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/tic/bankid/unlink', { method: 'POST' })
|
||||
if (!res.ok) throw new Error('Unlink failed')
|
||||
|
||||
setIdentity(null)
|
||||
toast({ title: 'BankID bortkopplat' })
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte koppla bort BankID', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsUnlinking(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLinking) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Koppla BankID</CardTitle>
|
||||
<CardDescription>Skanna QR-koden med BankID-appen</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center">
|
||||
<BankIdAuth mode="link" onComplete={handleLinkComplete} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
{identity ? (
|
||||
<ShieldCheck className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
BankID
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{identity
|
||||
? 'Ditt konto ar kopplat till BankID.'
|
||||
: 'Koppla BankID for sakrare inloggning.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{identity ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{identity.given_name} {identity.surname}
|
||||
</span>
|
||||
<span className="ml-2">
|
||||
Kopplat {new Date(identity.linked_at).toLocaleDateString('sv-SE')}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleUnlink}
|
||||
disabled={isUnlinking}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
{isUnlinking ? 'Kopplar bort...' : 'Koppla bort'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsLinking(true)}
|
||||
>
|
||||
Koppla BankID
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -10,9 +10,12 @@ 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'
|
||||
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === 'true'
|
||||
const mfaRequired = isMfaRequired()
|
||||
const bankIdEnabled = isBankIdEnabled()
|
||||
|
||||
export function SecuritySettings() {
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
@@ -132,6 +135,7 @@ export function SecuritySettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{bankIdEnabled && <BankIdSettings />}
|
||||
{/* Change password */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
Reference in New Issue
Block a user