Add admin card reader monitor
This commit is contained in:
@@ -771,6 +771,18 @@ const updateMemberStatus = async (membershipId, status) => {
|
||||
|
||||
const serializeAdminMember = (member) => ({ ...member, accessStatus: memberAccessStatus(member) })
|
||||
|
||||
const serializeAccessLog = (log, allMembers = members()) => {
|
||||
const member = log.membershipId ? allMembers.find((item) => item.membershipId === log.membershipId) : null
|
||||
return {
|
||||
...log,
|
||||
member: member ? {
|
||||
membershipId: member.membershipId,
|
||||
fullName: member.fullName,
|
||||
status: memberAccessStatus(member),
|
||||
} : null,
|
||||
}
|
||||
}
|
||||
|
||||
const serializeAdmin = (pubkey = '') => {
|
||||
const allMembers = members()
|
||||
return {
|
||||
@@ -783,7 +795,7 @@ const serializeAdmin = (pubkey = '') => {
|
||||
membershipMonthlyUsd,
|
||||
membershipPeriodOptions,
|
||||
cards: state.cards.map(({ cardSecretHash, uidHash, ...card }) => card),
|
||||
accessLogs: state.accessLogs.slice(0, 200),
|
||||
accessLogs: state.accessLogs.slice(0, 200).map((log) => serializeAccessLog(log, allMembers)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1315,6 +1327,7 @@ const handleApi = async (req, res) => {
|
||||
doorId,
|
||||
decision: allow && unlock.ok ? 'allow' : 'deny',
|
||||
reason,
|
||||
unlock,
|
||||
})
|
||||
return json(res, allow && unlock.ok ? 200 : 403, {
|
||||
success: allow && unlock.ok,
|
||||
@@ -1561,6 +1574,7 @@ const handleApi = async (req, res) => {
|
||||
doorId,
|
||||
decision: allow ? 'allow' : 'deny',
|
||||
reason: unlock.attempted && !unlock.ok ? `${reason}_${unlock.reason}` : reason,
|
||||
unlock,
|
||||
})
|
||||
if (card) await saveCards()
|
||||
return json(res, 200, {
|
||||
|
||||
157
src/App.vue
157
src/App.vue
@@ -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">
|
||||
|
||||
189
src/style.css
189
src/style.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user