Files
harheimertc/pages/mitgliederbereich/profil.vue
Torsten Schulz (local) badf91afef
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 49s
Update Passkey Registration to comply with @simplewebauthn/browser v13+ API
Refactor the Passkey registration logic in multiple components to utilize the new API structure requiring { optionsJSON: options }. Enhance debug logging to validate options, including checks for user ID format and challenge type. This update aims to improve compliance with the latest library requirements and provide better insights during the registration process.
2026-01-08 17:10:13 +01:00

440 lines
13 KiB
Vue

<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-6">
Mein Profil
</h1>
<div class="w-24 h-1 bg-primary-600 mb-8" />
<div class="bg-white rounded-xl shadow-lg p-8 border border-gray-100">
<!-- Loading State -->
<div
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2
:size="40"
class="animate-spin text-primary-600"
/>
</div>
<!-- Profile Form -->
<form
v-else
class="space-y-6"
@submit.prevent="handleSave"
>
<!-- Name -->
<div>
<label
for="name"
class="block text-sm font-medium text-gray-700 mb-2"
>
Name
</label>
<input
id="name"
v-model="formData.name"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
:disabled="isSaving"
>
</div>
<!-- E-Mail -->
<div>
<label
for="email"
class="block text-sm font-medium text-gray-700 mb-2"
>
E-Mail-Adresse
</label>
<input
id="email"
v-model="formData.email"
type="email"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
:disabled="isSaving"
>
</div>
<!-- Telefon -->
<div>
<label
for="phone"
class="block text-sm font-medium text-gray-700 mb-2"
>
Telefonnummer
</label>
<input
id="phone"
v-model="formData.phone"
type="tel"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
:disabled="isSaving"
>
</div>
<!-- Passwort ändern -->
<div class="border-t border-gray-200 pt-6 mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">
Passwort ändern
</h3>
<div class="space-y-4">
<div>
<label
for="currentPassword"
class="block text-sm font-medium text-gray-700 mb-2"
>
Aktuelles Passwort
</label>
<input
id="currentPassword"
v-model="passwordData.current"
type="password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label
for="newPassword"
class="block text-sm font-medium text-gray-700 mb-2"
>
Neues Passwort
</label>
<input
id="newPassword"
v-model="passwordData.new"
type="password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label
for="confirmPassword"
class="block text-sm font-medium text-gray-700 mb-2"
>
Passwort bestätigen
</label>
<input
id="confirmPassword"
v-model="passwordData.confirm"
type="password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
:disabled="isSaving"
>
</div>
</div>
</div>
<!-- Passkeys -->
<div class="border-t border-gray-200 pt-6 mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-2">
Passkeys
</h3>
<p class="text-sm text-gray-600 mb-4">
Passkeys erlauben eine Anmeldung ohne Passwort (z.B. per Fingerabdruck/FaceID/Windows Hello).
</p>
<div
v-if="passkeyError"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm mb-3"
>
<AlertCircle :size="20" class="mr-2" />
{{ passkeyError }}
</div>
<div class="flex flex-wrap gap-3 mb-4">
<button
type="button"
class="px-4 py-2 bg-gray-900 hover:bg-gray-800 text-white font-semibold rounded-lg transition-colors disabled:bg-gray-400"
:disabled="isSaving || passkeyLoading || !isPasskeySupported"
@click="addPasskey"
>
{{ passkeyLoading ? 'Passkey wird erstellt...' : (isPasskeySupported ? 'Passkey hinzufügen' : 'Passkeys nicht unterstützt') }}
</button>
<button
type="button"
class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:bg-gray-100 disabled:text-gray-400"
:disabled="isSaving || passkeyLoading"
@click="loadPasskeys"
>
Aktualisieren
</button>
</div>
<div v-if="passkeys.length === 0" class="text-sm text-gray-600">
Noch keine Passkeys hinterlegt.
</div>
<ul v-else class="space-y-2">
<li
v-for="pk in passkeys"
:key="pk.credentialId"
class="flex items-center justify-between p-3 border border-gray-200 rounded-lg"
>
<div class="min-w-0">
<div class="font-medium text-gray-900 truncate">
{{ pk.name || 'Passkey' }}
</div>
<div class="text-xs text-gray-600">
Erstellt: {{ formatDate(pk.createdAt) }}<span v-if="pk.lastUsedAt"> · Zuletzt genutzt: {{ formatDate(pk.lastUsedAt) }}</span>
</div>
</div>
<button
type="button"
class="ml-4 px-3 py-1.5 text-sm border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:bg-gray-100 disabled:text-gray-400"
:disabled="isSaving || passkeyLoading"
@click="removePasskey(pk.credentialId)"
>
Entfernen
</button>
</li>
</ul>
</div>
<!-- Error/Success Messages -->
<div
v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
>
<AlertCircle
:size="20"
class="mr-2"
/>
{{ errorMessage }}
</div>
<div
v-if="successMessage"
class="flex items-center p-3 rounded-md bg-green-50 text-green-700 text-sm"
>
<Check
:size="20"
class="mr-2"
/>
{{ successMessage }}
</div>
<!-- Submit Button -->
<div class="flex justify-end space-x-4">
<button
type="button"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
:disabled="isSaving"
@click="loadProfile"
>
Zurücksetzen
</button>
<button
type="submit"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
:disabled="isSaving"
>
<Loader2
v-if="isSaving"
:size="20"
class="animate-spin mr-2"
/>
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { AlertCircle, Check, Loader2 } from 'lucide-vue-next'
const authStore = useAuthStore()
const isLoading = ref(true)
const isSaving = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const passkeys = ref([])
const passkeyLoading = ref(false)
const passkeyError = ref('')
const isPasskeySupported = ref(false)
if (process.client) {
isPasskeySupported.value = !!window.PublicKeyCredential
}
const formData = ref({
name: '',
email: '',
phone: ''
})
const passwordData = ref({
current: '',
new: '',
confirm: ''
})
const loadProfile = async () => {
isLoading.value = true
errorMessage.value = ''
successMessage.value = ''
try {
const response = await $fetch('/api/profile')
formData.value = {
name: response.user.name,
email: response.user.email,
phone: response.user.phone || ''
}
} catch {
errorMessage.value = 'Fehler beim Laden des Profils.'
} finally {
isLoading.value = false
}
}
const loadPasskeys = async () => {
passkeyError.value = ''
try {
const res = await $fetch('/api/auth/passkeys/list')
passkeys.value = res.passkeys || []
} catch (e) {
passkeyError.value = e?.data?.message || 'Fehler beim Laden der Passkeys.'
}
}
const addPasskey = async () => {
passkeyError.value = ''
passkeyLoading.value = true
try {
const name = window.prompt('Name für den Passkey (z.B. "iPhone", "Laptop"):', 'Passkey') || 'Passkey'
const res = await $fetch('/api/auth/passkeys/registration-options', { method: 'POST' })
const mod = await import('@simplewebauthn/browser')
// @simplewebauthn/browser v13+ erwartet { optionsJSON: options }
const credential = await mod.startRegistration({ optionsJSON: res.options })
await $fetch('/api/auth/passkeys/register', {
method: 'POST',
body: { credential, name }
})
await loadPasskeys()
successMessage.value = 'Passkey hinzugefügt.'
} catch (e) {
passkeyError.value = e?.data?.message || e?.message || 'Passkey konnte nicht hinzugefügt werden.'
} finally {
passkeyLoading.value = false
}
}
const removePasskey = async (credentialId) => {
passkeyError.value = ''
passkeyLoading.value = true
try {
await $fetch('/api/auth/passkeys/remove', {
method: 'POST',
body: { credentialId }
})
await loadPasskeys()
successMessage.value = 'Passkey entfernt.'
} catch (e) {
passkeyError.value = e?.data?.message || 'Passkey konnte nicht entfernt werden.'
} finally {
passkeyLoading.value = false
}
}
const formatDate = (iso) => {
if (!iso) return '—'
try {
return new Date(iso).toLocaleString('de-DE')
} catch {
return iso
}
}
const handleSave = async () => {
isSaving.value = true
errorMessage.value = ''
successMessage.value = ''
// Validate password change if provided
if (passwordData.value.current || passwordData.value.new || passwordData.value.confirm) {
if (!passwordData.value.current) {
errorMessage.value = 'Bitte geben Sie Ihr aktuelles Passwort ein.'
isSaving.value = false
return
}
if (!passwordData.value.new) {
errorMessage.value = 'Bitte geben Sie ein neues Passwort ein.'
isSaving.value = false
return
}
if (passwordData.value.new !== passwordData.value.confirm) {
errorMessage.value = 'Die neuen Passwörter stimmen nicht überein.'
isSaving.value = false
return
}
if (passwordData.value.new.length < 6) {
errorMessage.value = 'Das neue Passwort muss mindestens 6 Zeichen lang sein.'
isSaving.value = false
return
}
}
try {
await $fetch('/api/profile', {
method: 'PUT',
body: {
name: formData.value.name,
email: formData.value.email,
phone: formData.value.phone,
currentPassword: passwordData.value.current || undefined,
newPassword: passwordData.value.new || undefined
}
})
successMessage.value = 'Profil erfolgreich aktualisiert!'
// Clear password fields
passwordData.value = {
current: '',
new: '',
confirm: ''
}
// Update auth store if email changed
await authStore.checkAuth()
// Scroll to top to show success message
window.scrollTo({ top: 0, behavior: 'smooth' })
} catch (error) {
errorMessage.value = error.data?.message || 'Fehler beim Speichern des Profils.'
} finally {
isSaving.value = false
}
}
onMounted(() => {
loadProfile()
loadPasskeys()
})
definePageMeta({
middleware: 'auth',
layout: 'default'
})
useHead({
title: 'Mein Profil - Harheimer TC',
})
</script>