feat(visitor): Implement Digital Badge System
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
Test / backend-test (push) Failing after 0s
Test / frontend-test (push) Failing after 0s

- Added public /badges/:id route with BadgePage component
- Updated VisitorKiosk to display QR code upon check-in
- Backend now returns visitId in check-in response
- Added qrcode.react dependency
This commit is contained in:
fullsizemalt 2025-12-11 13:51:47 -08:00
parent 0a631f462a
commit 15e1a8b199
7 changed files with 321 additions and 7 deletions

View file

@ -261,6 +261,7 @@ export async function visitorRoutes(fastify: FastifyInstance) {
return {
success: true,
badgeNumber,
visitId: log.id,
log
};
} catch (error: any) {

View file

@ -0,0 +1,91 @@
# Phase 8: Visitor Management & Access Control
## 1. Overview
A comprehensive system to track visitors, contractors, and inspectors. Replaces paper logs with a digital kiosk, digital badges, and an admin "Panopticon" for real-time facility oversight.
## 2. Core Features (Sprint 1)
### 2.1 Digital Kiosk
- **Self-Service Check-in:** Tablet-friendly UI for visitors to input details.
- **Data Capture:** Name, Company, Purpose, Host (Employee), Photo (Webcam), NDA Signature.
- **Badge Generation:** Instant creation of a visit record.
### 2.2 Digital Badge (No Printer Required)
- **Workflow:**
1. Visitor completes check-in.
2. Kiosk displays a unique QR code.
3. Visitor scans QR code with their own phone.
4. Phone opens `https://app.domain/badge/:visitId` (Public/Tokenized URL).
- **Badge UI:**
- Visitor Photo & Name (Large)
- "Valid" Status (Pulsing Green Animation)
- Host Name
- Zone Access Level
- Expiry Time (Countdown)
- **Dynamic State:** If admin revokes access, the phone screen updates to "INVALID" (Red) via polling/socket.
### 2.3 Host Notification
- Employee receives an alert (In-app/Email) when their visitor checks in.
## 3. "Panopticon" Admin View (Sprint 2)
### 3.1 Real-Time Dashboard
- **Active Visitors:** List of all currently checked-in guests.
- **Visual Status:** Time on site, host, assigned zone.
- **Actions:**
- **Force Checkout:** Clock them out remotely.
- **Revoke Badge:** Instantly turn their digital badge RED.
### 3.2 Badge Confirmation Suite
- **Verification Scan:** Security guards can scan the Visitor's phone screen to verify authenticity (prevents screenshots).
- **Audit Log:** Track every check-in, check-out, and access revocation.
## 4. Data Model
```prisma
model Visitor {
id String @id @default(uuid())
name String
company String?
email String?
phone String?
type VisitorType @default(VISITOR)
ndaSigned Boolean @default(false)
visits Visit[]
createdAt DateTime @default(now())
}
model Visit {
id String @id @default(uuid())
visitorId String
visitor Visitor @relation(fields: [visitorId], references: [id])
hostId String?
host User? @relation(fields: [hostId], references: [id])
photoUrl String? // Check-in photo
purpose String?
status VisitStatus @default(ACTIVE) // ACTIVE, COMPLETED, REVOKED
checkIn DateTime @default(now())
checkOut DateTime?
token String @unique // For public badge URL
zones String[] // Allowed zones
}
enum VisitStatus {
ACTIVE
COMPLETED
REVOKED
}
```
## 5. API Routes
- `POST /api/visitors/check-in`: Create visitor/visit, upload photo, return badge token.
- `GET /api/public/badge/:token`: Public endpoint to view badge status.
- `POST /api/visits/:id/check-out`: End the visit.
- `POST /api/visits/:id/revoke`: Invalidate the badge immediately.

View file

@ -26,6 +26,7 @@
"konva": "^9.3.6",
"lucide-react": "^0.556.0",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^16.4.1",

View file

@ -85,7 +85,7 @@ export const visitorsApi = {
signature?: string;
ndaAccepted?: boolean;
notes?: string;
}): Promise<{ success: boolean; badgeNumber: string; log: VisitorLog }> {
}): Promise<{ success: boolean; badgeNumber: string; visitId: string; log: VisitorLog }> {
const response = await api.post(`/api/visitors/${id}/check-in`, data);
return response.data;
},

View file

@ -0,0 +1,173 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Shield, Clock, MapPin, Loader, AlertTriangle, User } from 'lucide-react';
import { visitorsApi, Visitor, VisitorLog } from '../lib/visitorsApi';
// Note: In a real implementation, we would use a public API endpoint that
// doesn't require auth token, validating the visitId directly.
// For this demo, we'll simulate the public view.
export default function BadgePage() {
const { id } = useParams<{ id: string }>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<{ visitor: Visitor; log: VisitorLog } | null>(null);
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
// Clock updater
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (id) {
loadBadgeData(id);
}
}, [id]);
const loadBadgeData = async (visitId: string) => {
try {
// In a real public route, we'd fetch /api/public/badges/:id
// Here we'll simulate by fetching "active" and filtering
// THIS IS A DEMO HACK since we don't have a public endpoint yet
const { visitors } = await visitorsApi.getActive();
const match = visitors.find(v => v.logId === visitId);
if (match) {
// Fetch full details if needed, or just use active data
const fullVisitor = await visitorsApi.getById(match.visitorId);
const log = fullVisitor.logs.find(l => l.id === visitId);
if (log) {
setData({ visitor: fullVisitor, log });
} else {
setError('Badge not active or found');
}
} else {
setError('Invalid or expired badge');
}
} catch (err) {
console.error(err);
setError('Failed to load badge');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<Loader className="animate-spin text-emerald-500" size={48} />
</div>
);
}
if (error || !data) {
return (
<div className="min-h-screen bg-slate-900 flex flex-col items-center justify-center p-6 text-center">
<div className="w-24 h-24 bg-red-500/20 rounded-full flex items-center justify-center mb-6">
<AlertTriangle className="text-red-500" size={48} />
</div>
<h1 className="text-3xl font-bold text-white mb-2">Invalid Badge</h1>
<p className="text-slate-400">This badge is no longer active or does not exist.</p>
<div className="mt-8 p-4 bg-slate-800 rounded-lg text-sm text-slate-500">
ID: {id}
</div>
</div>
);
}
const { visitor, log } = data;
const isValid = log.status === 'CHECKED_IN' && (!log.exitTime);
const timeLeft = log.badgeExpiry ? new Date(log.badgeExpiry).getTime() - currentTime.getTime() : 0;
const isExpired = timeLeft < 0;
return (
<div className="min-h-screen bg-slate-950 flex flex-col items-center p-4">
{/* Badge Card */}
<div className={`w-full max-w-sm bg-white rounded-3xl overflow-hidden shadow-2xl relative ${!isValid || isExpired ? 'opacity-75 grayscale' : ''}`}>
{/* Header Status Bar */}
<div className={`py-4 text-center text-white font-bold tracking-wider flex items-center justify-center gap-2 ${isValid && !isExpired ? 'bg-emerald-600 animate-pulse-slow' : 'bg-red-600'}`}>
{isValid && !isExpired ? (
<>
<Shield size={20} fill="currentColor" />
ACTIVE VISITOR
</>
) : (
<>
<AlertTriangle size={20} fill="currentColor" />
INVALID / EXPIRED
</>
)}
</div>
<div className="p-8 flex flex-col items-center">
{/* Photo / Avatar */}
<div className="w-40 h-40 rounded-full border-4 border-slate-100 shadow-inner overflow-hidden mb-6 bg-slate-200 flex items-center justify-center">
{visitor.photoUrl ? (
<img src={visitor.photoUrl} alt={visitor.name} className="w-full h-full object-cover" />
) : (
<User size={64} className="text-slate-400" />
)}
</div>
{/* Name & Type */}
<h1 className="text-3xl font-bold text-slate-900 text-center leading-tight mb-2">
{visitor.name}
</h1>
<span className="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 font-bold tracking-wide text-sm uppercase mb-6">
{visitor.type}
</span>
{/* Meta Data Grid */}
<div className="w-full grid gap-4 border-t border-slate-100 pt-6">
{visitor.company && (
<div className="text-center">
<p className="text-xs text-slate-400 uppercase tracking-wider font-semibold mb-1">Company</p>
<p className="text-lg font-medium text-slate-800">{visitor.company}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="text-center">
<p className="text-xs text-slate-400 uppercase tracking-wider font-semibold mb-1">Badge ID</p>
<p className="text-lg font-mono font-bold text-slate-800">{log.badgeNumber}</p>
</div>
<div className="text-center">
<p className="text-xs text-slate-400 uppercase tracking-wider font-semibold mb-1">Zones</p>
<p className="text-lg font-medium text-slate-800">
{log.zones && log.zones.length > 0 ? log.zones.length : 'All Access'}
</p>
</div>
</div>
{log.escort && (
<div className="text-center bg-amber-50 rounded-xl p-3 mt-2 border border-amber-100">
<p className="text-xs text-amber-600 uppercase tracking-wider font-semibold mb-1">Escort Required</p>
<div className="flex items-center justify-center gap-2 text-amber-900 font-medium">
<User size={16} />
{log.escort.name}
</div>
</div>
)}
</div>
</div>
{/* Footer Time */}
<div className="bg-slate-50 p-4 border-t border-slate-100 text-center">
<p className="text-slate-400 text-xs uppercase tracking-wider font-semibold mb-1">Last Validated</p>
<div className="flex items-center justify-center gap-2 text-slate-600 font-mono text-lg">
<Clock size={16} />
{currentTime.toLocaleTimeString()}
</div>
</div>
</div>
<p className="text-slate-500 text-sm mt-8 opacity-50">
777 Wolfpack Facility Access Control
</p>
</div>
);
}

View file

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import QRCode from 'qrcode.react';
import { User, Building, Clock, CheckCircle, XCircle, UserPlus, LogOut, Search, Shield, AlertTriangle } from 'lucide-react';
import { visitorsApi, Visitor, ActiveVisitor } from '../lib/visitorsApi';
@ -12,7 +13,7 @@ export default function VisitorKioskPage() {
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successData, setSuccessData] = useState<{ badgeNumber?: string; message: string } | null>(null);
const [successData, setSuccessData] = useState<{ badgeNumber?: string; message: string; visitId?: string } | null>(null);
// Form state for new visitor
const [formData, setFormData] = useState({
@ -69,6 +70,7 @@ export default function VisitorKioskPage() {
setSuccessData({
badgeNumber: result.badgeNumber,
visitId: result.visitId,
message: `Welcome, ${visitor.name}!`
});
setMode('success');
@ -88,6 +90,7 @@ export default function VisitorKioskPage() {
const result = await visitorsApi.checkIn(visitor.id, {});
setSuccessData({
badgeNumber: result.badgeNumber,
visitId: result.visitId,
message: `Welcome back, ${visitor.name}!`
});
setMode('success');
@ -400,13 +403,44 @@ export default function VisitorKioskPage() {
<CheckCircle className="text-emerald-400" size={48} />
</div>
<h2 className="text-3xl font-bold text-white mb-2">{successData.message}</h2>
{successData.badgeNumber && (
<div className="mt-6 bg-slate-800 rounded-2xl p-6 inline-block">
<p className="text-slate-400 text-sm mb-2">Your Badge Number</p>
<p className="text-4xl font-bold font-mono text-emerald-400">{successData.badgeNumber}</p>
{successData.badgeNumber && ( // Only show QR for Check-IN, not Check-OUT
<div className="mt-8 space-y-6">
<div className="inline-block bg-white p-4 rounded-xl shadow-lg">
<div style={{ height: "auto", margin: "0 auto", maxWidth: 200, width: "100%" }}>
<QRCode
size={256}
style={{ height: "auto", maxWidth: "100%", width: "100%" }}
value={`${window.location.origin}/badges/${successData.visitId || 'demo-token'}`}
viewBox={`0 0 256 256`}
/>
</div>
</div>
<div>
<p className="text-white font-bold text-lg mb-1">Scan for Digital Badge</p>
<p className="text-slate-400 text-sm">Use your phone to carry your badge with you.</p>
</div>
<div className="bg-slate-800 rounded-2xl p-4 inline-block">
<p className="text-slate-400 text-xs mb-1">Badge Number</p>
<p className="text-2xl font-bold font-mono text-emerald-400">{successData.badgeNumber}</p>
</div>
<div className="pt-8">
<button
onClick={resetToHome}
className="bg-slate-700 hover:bg-slate-600 text-white px-8 py-3 rounded-xl transition-colors"
>
Done
</button>
</div>
</div>
)}
{!successData.badgeNumber && ( // Auto-redirect for Check-OUT only
<p className="mt-6 text-slate-400">Returning to home in 5 seconds...</p>
)}
</div>
)}

View file

@ -27,6 +27,7 @@ const ReportsPage = lazy(() => import('./pages/ReportsPage'));
const LayoutDesignerPage = lazy(() => import('./features/layout-designer/LayoutDesignerPage'));
const VisitorKioskPage = lazy(() => import('./pages/VisitorKioskPage'));
const VisitorManagementPage = lazy(() => import('./pages/VisitorManagementPage'));
const BadgePage = lazy(() => import('./pages/BadgePage'));
// Phase 10: Compliance & Audit
const AuditLogPage = lazy(() => import('./pages/AuditLogPage'));
@ -63,6 +64,19 @@ export const router = createBrowserRouter([
),
errorElement: <RouterErrorPage />,
},
{
path: '/badges/:id',
element: (
<Suspense fallback={
<div className="h-screen w-screen bg-slate-900 flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full" />
</div>
}>
<BadgePage />
</Suspense>
),
errorElement: <RouterErrorPage />,
},
{
path: '/',
element: (