Add admin card reader monitor

This commit is contained in:
Dorian
2026-05-20 14:51:32 -05:00
parent 496cf8e9d6
commit 320e3ef53c
3 changed files with 361 additions and 1 deletions

View File

@@ -216,6 +216,11 @@ const editTab = ref('hero')
const adminActionMessage = ref('')
const adminActionError = ref('')
const cardCredentialInputs = reactive({})
const isCardReaderOpen = ref(false)
const cardReaderBaselineId = ref('')
const cardReaderLastSeenId = ref('')
const cardReaderPulse = ref(false)
const cardReaderError = ref('')
const adminUser = ref('')
const adminError = ref('')
const adminLoginMethod = ref('')
@@ -250,6 +255,7 @@ let adminToastTimer
let parallaxFrame = 0
let adminEvents = null
let adminEventsReconnectTimer = 0
let cardReaderPollTimer = 0
let isHeroRotationPreloading = false
const heroBackgroundPreloads = new Map()
@@ -310,6 +316,33 @@ const filteredAdminMembers = computed(() => {
const newestMember = computed(() =>
[...members.value].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))[0] || null,
)
const latestAccessLog = computed(() => accessLogs.value[0] || null)
const cardReaderHasAttempt = computed(() => Boolean(latestAccessLog.value && latestAccessLog.value.id !== cardReaderBaselineId.value))
const cardReaderStatus = computed(() => {
const log = latestAccessLog.value
if (!cardReaderHasAttempt.value || !log) return 'waiting'
return log.decision === 'allow' ? 'allowed' : 'denied'
})
const cardReaderStatusLabel = computed(() => {
if (cardReaderStatus.value === 'allowed') return 'Access allowed'
if (cardReaderStatus.value === 'denied') return 'Access denied'
return 'Waiting for card'
})
const cardReaderMemberLabel = computed(() => latestAccessLog.value?.member?.fullName || 'Unknown card')
const cardReaderCardLabel = computed(() => latestAccessLog.value?.cardPublicId || latestAccessLog.value?.cardId || 'Unregistered')
const cardReaderDoorLabel = computed(() => latestAccessLog.value?.doorId || 'front-door')
const cardReaderUnlockLabel = computed(() => {
if (!cardReaderHasAttempt.value) return 'Awaiting next scan'
const unlock = latestAccessLog.value?.unlock
if (unlock?.attempted && unlock.ok) return 'Unlock accepted'
if (unlock?.attempted && !unlock.ok) return unlock.reason || 'Unlock failed'
if (unlock && !unlock.attempted) return unlock.reason || 'Door stayed locked'
const reason = latestAccessLog.value?.reason || ''
if (reason.includes('unlock_failed')) return 'Unlock failed'
if (reason.includes('unlock_webhook_not_configured')) return 'Unlock not configured'
if (latestAccessLog.value?.decision === 'allow') return 'Unlock requested'
return 'Door stayed locked'
})
const paidPayments = computed(() => payments.value.filter((payment) => payment.status === 'paid'))
const revenuePaidPayments = computed(() => paidPayments.value.filter((payment) => payment.provider !== 'comp'))
const compPayments = computed(() => paidPayments.value.filter((payment) => payment.provider === 'comp'))
@@ -1284,6 +1317,43 @@ const refreshAdminState = async () => {
await loadMembers()
}
const stopCardReaderPolling = () => {
window.clearInterval(cardReaderPollTimer)
cardReaderPollTimer = 0
}
const pollCardReader = async () => {
if (!isCardReaderOpen.value || !isAdminAuthenticated.value) return
try {
await refreshAdminState()
cardReaderError.value = ''
} catch (error) {
cardReaderError.value = error instanceof Error ? error.message : 'Could not refresh card reader events.'
}
}
const startCardReaderPolling = () => {
stopCardReaderPolling()
cardReaderPollTimer = window.setInterval(pollCardReader, 1800)
}
const openCardReader = async () => {
isAdminMenuOpen.value = false
isCardReaderOpen.value = true
cardReaderError.value = ''
await refreshAdminState().catch((error) => {
cardReaderError.value = error instanceof Error ? error.message : 'Could not load card reader events.'
})
cardReaderBaselineId.value = latestAccessLog.value?.id || ''
cardReaderLastSeenId.value = latestAccessLog.value?.id || ''
startCardReaderPolling()
}
const closeCardReader = () => {
isCardReaderOpen.value = false
stopCardReaderPolling()
}
const disconnectAdminEvents = () => {
window.clearTimeout(adminEventsReconnectTimer)
adminEventsReconnectTimer = 0
@@ -2145,6 +2215,15 @@ const formatDate = (dateString) =>
year: 'numeric',
})
const formatDateTime = (dateString) =>
new Date(dateString).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
})
const formatUsd = (value) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
@@ -2306,6 +2385,7 @@ watch(currentHeroBackground, (background) => {
onBeforeUnmount(() => {
window.clearInterval(backgroundTimer)
window.clearTimeout(adminToastTimer)
stopCardReaderPolling()
document.body.classList.remove('menu-open')
disconnectAdminEvents()
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
@@ -2338,6 +2418,18 @@ watch([adminActionMessage, adminActionError], ([message, error]) => {
}, error ? 5200 : 3200)
})
watch(latestAccessLog, (log) => {
if (!isCardReaderOpen.value || !log || log.id === cardReaderLastSeenId.value) return
cardReaderLastSeenId.value = log.id
cardReaderPulse.value = false
window.requestAnimationFrame(() => {
cardReaderPulse.value = true
window.setTimeout(() => {
cardReaderPulse.value = false
}, 900)
})
})
watch(paymentInvoiceQrData, async (value) => {
if (!value) {
paymentInvoiceQrUrl.value = ''
@@ -2766,6 +2858,7 @@ watch(mobileMenuOpen, (open) => {
</button>
<div v-if="isAdminMenuOpen" class="admin-profile-menu">
<p>{{ adminShortKey }}</p>
<button class="admin-profile-menu-action" type="button" @click="openCardReader">Card Reader</button>
<button type="button" @click="logoutAdmin">Logout</button>
</div>
</div>
@@ -3052,6 +3145,7 @@ watch(mobileMenuOpen, (open) => {
</button>
<div v-if="isAdminMenuOpen" class="admin-profile-menu">
<p>{{ adminShortKey }}</p>
<button class="admin-profile-menu-action" type="button" @click="openCardReader">Card Reader</button>
<button type="button" @click="logoutAdmin">Logout</button>
</div>
</div>
@@ -3392,6 +3486,69 @@ watch(mobileMenuOpen, (open) => {
</div>
</section>
<div v-if="isCardReaderOpen" class="modal-backdrop" @click.self="closeCardReader">
<div class="backup-modal card-reader-modal" role="dialog" aria-modal="true" aria-labelledby="card-reader-title">
<div class="modal-header">
<div>
<p class="section-kicker">Live Door Feed</p>
<h2 id="card-reader-title">Card Reader</h2>
</div>
<button class="modal-close" type="button" aria-label="Close" @click="closeCardReader"></button>
</div>
<div class="card-reader-body">
<div class="card-reader-status" :class="[cardReaderStatus, { pulse: cardReaderPulse }]">
<span class="card-reader-live-dot"></span>
<strong>{{ cardReaderStatusLabel }}</strong>
<p>{{ cardReaderHasAttempt ? formatDateTime(latestAccessLog.seenAt) : 'Listening for the next PN532 scan.' }}</p>
</div>
<div class="card-reader-grid">
<article>
<span>Person</span>
<strong>{{ cardReaderHasAttempt ? cardReaderMemberLabel : 'Waiting' }}</strong>
<p>{{ cardReaderHasAttempt ? (latestAccessLog?.member?.membershipId || 'No member matched') : 'No scan yet' }}</p>
</article>
<article>
<span>Card</span>
<strong>{{ cardReaderHasAttempt ? cardReaderCardLabel : 'Waiting' }}</strong>
<p>{{ cardReaderHasAttempt ? (latestAccessLog?.cardId || 'No card record') : 'No scan yet' }}</p>
</article>
<article>
<span>Door</span>
<strong>{{ cardReaderDoorLabel }}</strong>
<p>{{ cardReaderUnlockLabel }}</p>
</article>
<article>
<span>Response</span>
<strong>{{ cardReaderHasAttempt ? (latestAccessLog?.reason || 'No response') : 'No response yet' }}</strong>
<p>{{ cardReaderHasAttempt ? (latestAccessLog?.decision || 'listening') : 'listening' }}</p>
</article>
</div>
<div class="card-reader-history">
<div class="admin-section-heading">
<p class="section-kicker">Recent Attempts</p>
<h3>Last card reads</h3>
</div>
<div v-if="accessLogs.length" class="card-reader-log-list">
<article v-for="log in accessLogs.slice(0, 8)" :key="log.id" class="card-reader-log-row" :class="{ latest: log.id === latestAccessLog?.id }">
<span :class="log.decision === 'allow' ? 'text-emerald-300' : 'text-red-200'">{{ log.decision }}</span>
<div>
<strong>{{ log.member?.fullName || 'Unknown card' }}</strong>
<p>{{ log.cardPublicId || log.cardId || 'Unregistered' }} · {{ log.reason }} · {{ log.doorId || 'door' }}</p>
</div>
<small>{{ formatDateTime(log.seenAt) }}</small>
</article>
</div>
<div v-else class="empty-admin">No card reads yet.</div>
</div>
<p v-if="cardReaderError" class="validation-message text-sm text-red-200">{{ cardReaderError }}</p>
</div>
</div>
</div>
<div v-if="isEventEditorOpen" class="modal-backdrop event-editor-modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="event-editor-title">
<form class="backup-modal event-editor-modal" @submit.prevent="createEventFromModal">
<div class="modal-header">

View File

@@ -1745,6 +1745,10 @@ body.menu-open {
background: rgba(255, 255, 255, 0.07);
}
.admin-profile-menu .admin-profile-menu-action {
color: #f2a900;
}
.admin-stat strong,
.admin-stat span {
display: block;
@@ -3194,6 +3198,182 @@ body.menu-open {
box-shadow: 0 40px 140px rgba(0, 0, 0, 0.7);
}
.card-reader-modal {
width: min(100%, 42rem);
}
.card-reader-body {
display: grid;
gap: 1rem;
padding: 1.25rem;
}
.card-reader-status {
position: relative;
display: grid;
gap: 0.35rem;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.045);
padding: 1.1rem 1.1rem 1.1rem 3rem;
}
.card-reader-status::before {
position: absolute;
inset: 0;
border-left: 4px solid rgba(255, 255, 255, 0.22);
content: '';
}
.card-reader-status.allowed::before {
border-left-color: #34d399;
}
.card-reader-status.denied::before {
border-left-color: #f87171;
}
.card-reader-status.pulse {
animation: cardReaderPulse 0.9s ease;
}
.card-reader-live-dot {
position: absolute;
left: 1.15rem;
top: 1.35rem;
width: 0.72rem;
height: 0.72rem;
border-radius: 999px;
background: #f2a900;
box-shadow: 0 0 0 0 rgba(242, 169, 0, 0.45);
animation: liveDot 1.4s infinite;
}
.card-reader-status.allowed .card-reader-live-dot {
background: #34d399;
}
.card-reader-status.denied .card-reader-live-dot {
background: #f87171;
}
.card-reader-status strong {
font-size: 1.35rem;
font-weight: 950;
letter-spacing: 0;
}
.card-reader-status p {
margin: 0;
color: rgba(255, 255, 255, 0.58);
font-size: 0.86rem;
font-weight: 700;
}
.card-reader-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.card-reader-grid article {
min-width: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.035);
padding: 0.9rem;
}
.card-reader-grid span,
.card-reader-log-row span {
display: block;
color: rgba(255, 255, 255, 0.46);
font-size: 0.67rem;
font-weight: 950;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.card-reader-grid strong {
display: block;
overflow-wrap: anywhere;
margin-top: 0.25rem;
font-size: 0.96rem;
font-weight: 950;
}
.card-reader-grid p {
overflow-wrap: anywhere;
margin: 0.2rem 0 0;
color: rgba(255, 255, 255, 0.55);
font-size: 0.78rem;
font-weight: 700;
}
.card-reader-history {
display: grid;
gap: 0.8rem;
padding-top: 0.25rem;
}
.card-reader-log-list {
display: grid;
gap: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.card-reader-log-row {
display: grid;
grid-template-columns: 4.8rem minmax(0, 1fr) auto;
gap: 0.8rem;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.09);
background: rgba(255, 255, 255, 0.025);
padding: 0.78rem 0.9rem;
}
.card-reader-log-row:last-child {
border-bottom: 0;
}
.card-reader-log-row.latest {
background: rgba(242, 169, 0, 0.08);
}
.card-reader-log-row strong {
display: block;
overflow-wrap: anywhere;
font-size: 0.86rem;
font-weight: 900;
}
.card-reader-log-row p,
.card-reader-log-row small {
margin: 0.18rem 0 0;
color: rgba(255, 255, 255, 0.52);
font-size: 0.74rem;
font-weight: 700;
}
.card-reader-log-row small {
white-space: nowrap;
}
@keyframes liveDot {
0% { box-shadow: 0 0 0 0 rgba(242, 169, 0, 0.45); }
70% { box-shadow: 0 0 0 0.55rem rgba(242, 169, 0, 0); }
100% { box-shadow: 0 0 0 0 rgba(242, 169, 0, 0); }
}
@keyframes cardReaderPulse {
0% { transform: scale(0.99); }
45% { transform: scale(1.01); }
100% { transform: scale(1); }
}
.modal-header {
display: flex;
align-items: center;
@@ -3625,6 +3805,15 @@ body.menu-open {
max-height: calc(100svh - 13rem);
}
.card-reader-grid,
.card-reader-log-row {
grid-template-columns: 1fr;
}
.card-reader-log-row small {
white-space: normal;
}
.member-button {
padding: 0.62rem 0.7rem;
font-size: 0.62rem;