feat: App lock with biometric/PIN
- LockScreen component with PIN pad - SecuritySettings for setup - Biometric (WebAuthn) support - PIN fallback (6 digits) - Auto-lock after 30s in background - Lock on app start if enabled - Settings page integration
This commit is contained in:
333
src/components/SecuritySettings.vue
Normal file
333
src/components/SecuritySettings.vue
Normal file
@@ -0,0 +1,333 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const step = ref<'menu' | 'setup-pin' | 'confirm-pin' | 'setup-biometric'>('menu')
|
||||
const currentPin = ref('')
|
||||
const confirmPin = ref('')
|
||||
const error = ref('')
|
||||
const pinDigits = ref<string[]>(['', '', '', '', '', ''])
|
||||
const currentDigit = ref(0)
|
||||
|
||||
const hasBiometric = ref(false)
|
||||
const hasPin = ref(false)
|
||||
const hasBiometricSupport = ref(false)
|
||||
const lockMethod = ref<'biometric' | 'pin' | 'none'>('none')
|
||||
|
||||
onMounted(async () => {
|
||||
// Check what's configured
|
||||
hasPin.value = !!localStorage.getItem('appPin')
|
||||
hasBiometric.value = !!localStorage.getItem('biometricCredentialId')
|
||||
lockMethod.value = localStorage.getItem('lockMethod') as any || 'none'
|
||||
|
||||
// Check if device supports biometric
|
||||
if (window.PublicKeyCredential) {
|
||||
try {
|
||||
hasBiometricSupport.value = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
} catch {
|
||||
hasBiometricSupport.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function addDigit(digit: string) {
|
||||
if (currentDigit.value < 6) {
|
||||
pinDigits.value[currentDigit.value] = digit
|
||||
currentDigit.value++
|
||||
|
||||
if (currentDigit.value === 6) {
|
||||
if (step.value === 'setup-pin') {
|
||||
currentPin.value = pinDigits.value.join('')
|
||||
clearPinInput()
|
||||
step.value = 'confirm-pin'
|
||||
} else if (step.value === 'confirm-pin') {
|
||||
confirmPin.value = pinDigits.value.join('')
|
||||
verifyPinMatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeDigit() {
|
||||
if (currentDigit.value > 0) {
|
||||
currentDigit.value--
|
||||
pinDigits.value[currentDigit.value] = ''
|
||||
}
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function clearPinInput() {
|
||||
pinDigits.value = ['', '', '', '', '', '']
|
||||
currentDigit.value = 0
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function verifyPinMatch() {
|
||||
if (currentPin.value === confirmPin.value) {
|
||||
localStorage.setItem('appPin', currentPin.value)
|
||||
localStorage.setItem('lockMethod', 'pin')
|
||||
hasPin.value = true
|
||||
lockMethod.value = 'pin'
|
||||
step.value = 'menu'
|
||||
currentPin.value = ''
|
||||
confirmPin.value = ''
|
||||
clearPinInput()
|
||||
} else {
|
||||
error.value = 'PINs stimmen nicht überein'
|
||||
setTimeout(() => {
|
||||
clearPinInput()
|
||||
step.value = 'setup-pin'
|
||||
currentPin.value = ''
|
||||
confirmPin.value = ''
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
async function setupBiometric() {
|
||||
step.value = 'setup-biometric'
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
if (!window.PublicKeyCredential) {
|
||||
throw new Error('WebAuthn nicht unterstützt')
|
||||
}
|
||||
|
||||
// Generate a random user ID
|
||||
const userId = new Uint8Array(16)
|
||||
crypto.getRandomValues(userId)
|
||||
|
||||
// Create credential
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: {
|
||||
challenge: new Uint8Array(32),
|
||||
rp: {
|
||||
name: 'SeCu',
|
||||
id: window.location.hostname
|
||||
},
|
||||
user: {
|
||||
id: userId,
|
||||
name: localStorage.getItem('userEmail') || 'user',
|
||||
displayName: 'SeCu User'
|
||||
},
|
||||
pubKeyCredParams: [
|
||||
{ type: 'public-key', alg: -7 }, // ES256
|
||||
{ type: 'public-key', alg: -257 } // RS256
|
||||
],
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: 'platform',
|
||||
userVerification: 'required',
|
||||
residentKey: 'preferred'
|
||||
},
|
||||
timeout: 60000,
|
||||
attestation: 'none'
|
||||
}
|
||||
}) as PublicKeyCredential
|
||||
|
||||
if (credential) {
|
||||
// Store credential ID
|
||||
const credentialId = btoa(String.fromCharCode(...new Uint8Array(credential.rawId)))
|
||||
localStorage.setItem('biometricCredentialId', credentialId)
|
||||
localStorage.setItem('lockMethod', 'biometric')
|
||||
hasBiometric.value = true
|
||||
lockMethod.value = 'biometric'
|
||||
step.value = 'menu'
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Biometric setup failed:', e)
|
||||
error.value = e.message || 'Biometrie-Einrichtung fehlgeschlagen'
|
||||
step.value = 'menu'
|
||||
}
|
||||
}
|
||||
|
||||
function disableLock() {
|
||||
if (confirm('App-Sperre wirklich deaktivieren?')) {
|
||||
localStorage.removeItem('appPin')
|
||||
localStorage.removeItem('biometricCredentialId')
|
||||
localStorage.removeItem('lockMethod')
|
||||
hasPin.value = false
|
||||
hasBiometric.value = false
|
||||
lockMethod.value = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
function setLockMethod(method: 'biometric' | 'pin') {
|
||||
localStorage.setItem('lockMethod', method)
|
||||
lockMethod.value = method
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="bg-gradient-to-r from-purple-600 to-indigo-600 p-6 text-white">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold">🔐 App-Sicherheit</h2>
|
||||
<button @click="emit('close')" class="text-white/70 hover:text-white">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
<!-- Menu -->
|
||||
<div v-if="step === 'menu'" class="space-y-4">
|
||||
<p class="text-gray-600 text-sm mb-4">
|
||||
Schütze deine App mit Fingerabdruck, Face ID oder PIN.
|
||||
</p>
|
||||
|
||||
<!-- Current Status -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-700">Aktueller Schutz:</span>
|
||||
<span :class="[
|
||||
'px-3 py-1 rounded-full text-sm font-medium',
|
||||
lockMethod === 'none' ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'
|
||||
]">
|
||||
{{ lockMethod === 'biometric' ? '👆 Biometrie' : lockMethod === 'pin' ? '🔢 PIN' : '❌ Aus' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="space-y-3">
|
||||
<!-- Biometric Option -->
|
||||
<button
|
||||
v-if="hasBiometricSupport"
|
||||
@click="hasBiometric ? setLockMethod('biometric') : setupBiometric()"
|
||||
:class="[
|
||||
'w-full p-4 rounded-lg border-2 text-left transition flex items-center',
|
||||
lockMethod === 'biometric'
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-purple-300'
|
||||
]"
|
||||
>
|
||||
<span class="text-2xl mr-4">👆</span>
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold">Fingerabdruck / Face ID</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ hasBiometric ? 'Eingerichtet' : 'Jetzt einrichten' }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="lockMethod === 'biometric'" class="text-purple-600">✓</span>
|
||||
</button>
|
||||
|
||||
<!-- PIN Option -->
|
||||
<button
|
||||
@click="hasPin ? setLockMethod('pin') : (step = 'setup-pin')"
|
||||
:class="[
|
||||
'w-full p-4 rounded-lg border-2 text-left transition flex items-center',
|
||||
lockMethod === 'pin'
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-purple-300'
|
||||
]"
|
||||
>
|
||||
<span class="text-2xl mr-4">🔢</span>
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold">PIN-Code (6 Ziffern)</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ hasPin ? 'Eingerichtet' : 'Jetzt einrichten' }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="lockMethod === 'pin'" class="text-purple-600">✓</span>
|
||||
</button>
|
||||
|
||||
<!-- Disable -->
|
||||
<button
|
||||
v-if="lockMethod !== 'none'"
|
||||
@click="disableLock"
|
||||
class="w-full p-4 rounded-lg border-2 border-red-200 text-left transition flex items-center hover:border-red-300 hover:bg-red-50"
|
||||
>
|
||||
<span class="text-2xl mr-4">🔓</span>
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold text-red-700">Sperre deaktivieren</div>
|
||||
<div class="text-sm text-gray-500">Nicht empfohlen</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Change PIN -->
|
||||
<button
|
||||
v-if="hasPin"
|
||||
@click="step = 'setup-pin'"
|
||||
class="w-full text-center text-purple-600 text-sm hover:underline mt-4"
|
||||
>
|
||||
PIN ändern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Setup PIN -->
|
||||
<div v-else-if="step === 'setup-pin' || step === 'confirm-pin'" class="text-center">
|
||||
<h3 class="text-lg font-semibold mb-2">
|
||||
{{ step === 'setup-pin' ? 'Neuen PIN eingeben' : 'PIN bestätigen' }}
|
||||
</h3>
|
||||
<p class="text-gray-500 text-sm mb-6">6 Ziffern</p>
|
||||
|
||||
<!-- PIN Dots -->
|
||||
<div class="flex justify-center space-x-3 mb-8">
|
||||
<div
|
||||
v-for="(digit, i) in pinDigits"
|
||||
:key="i"
|
||||
:class="[
|
||||
'w-4 h-4 rounded-full transition-all',
|
||||
digit ? 'bg-purple-600 scale-110' : 'bg-gray-300',
|
||||
error ? 'bg-red-400 animate-shake' : ''
|
||||
]"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<p v-if="error" class="text-red-500 text-sm mb-4">{{ error }}</p>
|
||||
|
||||
<!-- Number Pad -->
|
||||
<div class="grid grid-cols-3 gap-3 max-w-xs mx-auto">
|
||||
<button
|
||||
v-for="n in [1,2,3,4,5,6,7,8,9]"
|
||||
:key="n"
|
||||
@click="addDigit(String(n))"
|
||||
class="w-14 h-14 bg-gray-100 rounded-full text-xl font-semibold hover:bg-gray-200 transition"
|
||||
>
|
||||
{{ n }}
|
||||
</button>
|
||||
<button
|
||||
@click="step = 'menu'; clearPinInput()"
|
||||
class="w-14 h-14 text-gray-500 text-sm"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
@click="addDigit('0')"
|
||||
class="w-14 h-14 bg-gray-100 rounded-full text-xl font-semibold hover:bg-gray-200 transition"
|
||||
>
|
||||
0
|
||||
</button>
|
||||
<button
|
||||
@click="removeDigit"
|
||||
class="w-14 h-14 text-xl hover:bg-gray-100 rounded-full transition"
|
||||
>
|
||||
⌫
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup Biometric -->
|
||||
<div v-else-if="step === 'setup-biometric'" class="text-center py-8">
|
||||
<div class="text-6xl mb-4 animate-pulse">👆</div>
|
||||
<h3 class="text-lg font-semibold mb-2">Biometrie einrichten</h3>
|
||||
<p class="text-gray-500 text-sm mb-4">
|
||||
Bitte Fingerabdruck scannen oder Face ID verwenden...
|
||||
</p>
|
||||
<p v-if="error" class="text-red-500 text-sm">{{ error }}</p>
|
||||
<button
|
||||
@click="step = 'menu'"
|
||||
class="mt-4 text-gray-500 text-sm hover:underline"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user