diff --git a/server/server.js b/server/server.js index 1a75dfb..e790353 100644 --- a/server/server.js +++ b/server/server.js @@ -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, { diff --git a/src/App.vue b/src/App.vue index bf1c6b8..2f4dcc0 100644 --- a/src/App.vue +++ b/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) => {

{{ adminShortKey }}

+
@@ -3052,6 +3145,7 @@ watch(mobileMenuOpen, (open) => {

{{ adminShortKey }}

+
@@ -3392,6 +3486,69 @@ watch(mobileMenuOpen, (open) => { + +