Almost usable admin panel

This commit is contained in:
2025-08-30 09:54:51 +00:00
parent 75bcdb8b18
commit 9402aae829
7 changed files with 713 additions and 77 deletions
@@ -0,0 +1,84 @@
<template>
<div class="credential-list">
<div v-if="loading"><p>Loading credentials...</p></div>
<div v-else-if="!credentials?.length"><p>No passkeys found.</p></div>
<div v-else>
<div
v-for="credential in credentials"
:key="credential.credential_uuid"
:class="['credential-item', { 'current-session': credential.is_current_session }]"
>
<div class="credential-header">
<div class="credential-icon">
<img
v-if="getCredentialAuthIcon(credential)"
:src="getCredentialAuthIcon(credential)"
:alt="getCredentialAuthName(credential)"
class="auth-icon"
width="32"
height="32"
>
<span v-else class="auth-emoji">🔑</span>
</div>
<div class="credential-info">
<h4>{{ getCredentialAuthName(credential) }}</h4>
</div>
<div class="credential-dates">
<span class="date-label">Created:</span>
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
<span class="date-label" v-if="credential.last_used">Last used:</span>
<span class="date-value" v-if="credential.last_used">{{ formatDate(credential.last_used) }}</span>
</div>
<div class="credential-actions" v-if="allowDelete">
<button
@click="$emit('delete', credential)"
class="btn-delete-credential"
:disabled="credential.is_current_session"
:title="credential.is_current_session ? 'Cannot delete current session credential' : 'Delete passkey'"
>🗑</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { formatDate } from '@/utils/helpers'
const props = defineProps({
credentials: { type: Array, default: () => [] },
aaguidInfo: { type: Object, default: () => ({}) },
loading: { type: Boolean, default: false },
allowDelete: { type: Boolean, default: false },
})
const getCredentialAuthName = (credential) => {
const info = props.aaguidInfo?.[credential.aaguid]
return info ? info.name : 'Unknown Authenticator'
}
const getCredentialAuthIcon = (credential) => {
const info = props.aaguidInfo?.[credential.aaguid]
if (!info) return null
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
return info[iconKey] || null
}
</script>
<style scoped>
.credential-list { display: flex; flex-direction: column; gap: .75rem; margin-top: .5rem; }
.credential-item { border: 1px solid #ddd; border-radius: 8px; padding: .5rem .75rem; background: #fff; }
.credential-header { display: flex; align-items: center; gap: 1rem; }
.credential-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }
.auth-icon { border-radius: 6px; }
.credential-info { flex: 1 1 auto; }
.credential-info h4 { margin: 0; font-size: .9rem; }
.credential-dates { display: grid; grid-auto-flow: column; gap: .4rem; font-size: .65rem; align-items: center; }
.date-label { font-weight: 600; }
.credential-actions { margin-left: auto; }
.btn-delete-credential { background: none; border: none; cursor: pointer; font-size: .9rem; }
.btn-delete-credential:disabled { opacity: .3; cursor: not-allowed; }
</style>
@@ -0,0 +1,87 @@
<template>
<div class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<h2 id="regTitle" style="margin:0; font-size:1.25rem;">📱 Device Registration Link</h2>
<button class="icon-btn" @click="$emit('close')" aria-label="Close"></button>
</div>
<div class="device-link-section">
<div class="qr-container">
<a v-if="url" :href="url" @click.prevent="copy" class="qr-link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
<p>{{ displayUrl }}</p>
</a>
<div v-else>
<em>Generating link...</em>
</div>
<p>
<strong>Scan and visit the URL on another device.</strong><br>
<small> Expires in 24 hours and one-time use.</small>
</p>
<div v-if="expires" style="font-size:12px; margin-top:6px;">Expires: {{ new Date(expires).toLocaleString() }}</div>
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:.5rem; margin-top:10px;">
<button class="btn-secondary" @click="$emit('close')">Close</button>
<button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, computed, nextTick } from 'vue'
import QRCode from 'qrcode/lib/browser'
const props = defineProps({
endpoint: { type: String, required: true }, // POST endpoint returning {url, expires}
autoCopy: { type: Boolean, default: true }
})
const emit = defineEmits(['close','generated','copied'])
const url = ref(null)
const expires = ref(null)
const qrCanvas = ref(null)
const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '')
async function fetchLink() {
try {
const res = await fetch(props.endpoint, { method: 'POST' })
const data = await res.json()
if (data.detail) throw new Error(data.detail)
url.value = data.url
expires.value = data.expires
emit('generated', { url: data.url, expires: data.expires })
await nextTick()
drawQR()
if (props.autoCopy) copy()
} catch (e) {
url.value = null
expires.value = null
console.error('Failed to create link', e)
}
}
async function drawQR() {
if (!url.value) return
await nextTick()
if (!qrCanvas.value) return
QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, err => { if (err) console.error(err) })
}
async function copy() {
if (!url.value) return
try { await navigator.clipboard.writeText(url.value); emit('copied', url.value); emit('close') } catch (_) { /* ignore */ }
}
onMounted(fetchLink)
watch(url, () => drawQR(), { flush: 'post' })
</script>
<style scoped>
.icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; }
.icon-btn:hover { opacity:1; }
/* Minimal extra styling; main look comes from global styles */
.qr-link { text-decoration:none; color:inherit; }
</style>