feat(visitor): Implement Digital Badge System
- 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:
parent
0a631f462a
commit
15e1a8b199
7 changed files with 321 additions and 7 deletions
|
|
@ -261,6 +261,7 @@ export async function visitorRoutes(fastify: FastifyInstance) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
badgeNumber,
|
badgeNumber,
|
||||||
|
visitId: log.id,
|
||||||
log
|
log
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
|
||||||
91
docs/specs/008_visitor_management.md
Normal file
91
docs/specs/008_visitor_management.md
Normal 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.
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"konva": "^9.3.6",
|
"konva": "^9.3.6",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-i18next": "^16.4.1",
|
"react-i18next": "^16.4.1",
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ export const visitorsApi = {
|
||||||
signature?: string;
|
signature?: string;
|
||||||
ndaAccepted?: boolean;
|
ndaAccepted?: boolean;
|
||||||
notes?: string;
|
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);
|
const response = await api.post(`/api/visitors/${id}/check-in`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
173
frontend/src/pages/BadgePage.tsx
Normal file
173
frontend/src/pages/BadgePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState, useEffect } from 'react';
|
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 { User, Building, Clock, CheckCircle, XCircle, UserPlus, LogOut, Search, Shield, AlertTriangle } from 'lucide-react';
|
||||||
import { visitorsApi, Visitor, ActiveVisitor } from '../lib/visitorsApi';
|
import { visitorsApi, Visitor, ActiveVisitor } from '../lib/visitorsApi';
|
||||||
|
|
||||||
|
|
@ -12,7 +13,7 @@ export default function VisitorKioskPage() {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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
|
// Form state for new visitor
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
|
|
@ -69,6 +70,7 @@ export default function VisitorKioskPage() {
|
||||||
|
|
||||||
setSuccessData({
|
setSuccessData({
|
||||||
badgeNumber: result.badgeNumber,
|
badgeNumber: result.badgeNumber,
|
||||||
|
visitId: result.visitId,
|
||||||
message: `Welcome, ${visitor.name}!`
|
message: `Welcome, ${visitor.name}!`
|
||||||
});
|
});
|
||||||
setMode('success');
|
setMode('success');
|
||||||
|
|
@ -88,6 +90,7 @@ export default function VisitorKioskPage() {
|
||||||
const result = await visitorsApi.checkIn(visitor.id, {});
|
const result = await visitorsApi.checkIn(visitor.id, {});
|
||||||
setSuccessData({
|
setSuccessData({
|
||||||
badgeNumber: result.badgeNumber,
|
badgeNumber: result.badgeNumber,
|
||||||
|
visitId: result.visitId,
|
||||||
message: `Welcome back, ${visitor.name}!`
|
message: `Welcome back, ${visitor.name}!`
|
||||||
});
|
});
|
||||||
setMode('success');
|
setMode('success');
|
||||||
|
|
@ -400,13 +403,44 @@ export default function VisitorKioskPage() {
|
||||||
<CheckCircle className="text-emerald-400" size={48} />
|
<CheckCircle className="text-emerald-400" size={48} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-bold text-white mb-2">{successData.message}</h2>
|
<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">
|
{successData.badgeNumber && ( // Only show QR for Check-IN, not Check-OUT
|
||||||
<p className="text-slate-400 text-sm mb-2">Your Badge Number</p>
|
<div className="mt-8 space-y-6">
|
||||||
<p className="text-4xl font-bold font-mono text-emerald-400">{successData.badgeNumber}</p>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!successData.badgeNumber && ( // Auto-redirect for Check-OUT only
|
||||||
<p className="mt-6 text-slate-400">Returning to home in 5 seconds...</p>
|
<p className="mt-6 text-slate-400">Returning to home in 5 seconds...</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ const ReportsPage = lazy(() => import('./pages/ReportsPage'));
|
||||||
const LayoutDesignerPage = lazy(() => import('./features/layout-designer/LayoutDesignerPage'));
|
const LayoutDesignerPage = lazy(() => import('./features/layout-designer/LayoutDesignerPage'));
|
||||||
const VisitorKioskPage = lazy(() => import('./pages/VisitorKioskPage'));
|
const VisitorKioskPage = lazy(() => import('./pages/VisitorKioskPage'));
|
||||||
const VisitorManagementPage = lazy(() => import('./pages/VisitorManagementPage'));
|
const VisitorManagementPage = lazy(() => import('./pages/VisitorManagementPage'));
|
||||||
|
const BadgePage = lazy(() => import('./pages/BadgePage'));
|
||||||
|
|
||||||
// Phase 10: Compliance & Audit
|
// Phase 10: Compliance & Audit
|
||||||
const AuditLogPage = lazy(() => import('./pages/AuditLogPage'));
|
const AuditLogPage = lazy(() => import('./pages/AuditLogPage'));
|
||||||
|
|
@ -63,6 +64,19 @@ export const router = createBrowserRouter([
|
||||||
),
|
),
|
||||||
errorElement: <RouterErrorPage />,
|
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: '/',
|
path: '/',
|
||||||
element: (
|
element: (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue