diff --git a/backend/src/routes/visitors.routes.ts b/backend/src/routes/visitors.routes.ts index 199ccfe..1fe2472 100644 --- a/backend/src/routes/visitors.routes.ts +++ b/backend/src/routes/visitors.routes.ts @@ -261,6 +261,7 @@ export async function visitorRoutes(fastify: FastifyInstance) { return { success: true, badgeNumber, + visitId: log.id, log }; } catch (error: any) { diff --git a/docs/specs/008_visitor_management.md b/docs/specs/008_visitor_management.md new file mode 100644 index 0000000..d76399c --- /dev/null +++ b/docs/specs/008_visitor_management.md @@ -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. diff --git a/frontend/package.json b/frontend/package.json index ac000b1..ca0ffc1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/lib/visitorsApi.ts b/frontend/src/lib/visitorsApi.ts index 4fbc8ad..f4c74c3 100644 --- a/frontend/src/lib/visitorsApi.ts +++ b/frontend/src/lib/visitorsApi.ts @@ -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; }, diff --git a/frontend/src/pages/BadgePage.tsx b/frontend/src/pages/BadgePage.tsx new file mode 100644 index 0000000..acc7535 --- /dev/null +++ b/frontend/src/pages/BadgePage.tsx @@ -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(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 ( +
+ +
+ ); + } + + if (error || !data) { + return ( +
+
+ +
+

Invalid Badge

+

This badge is no longer active or does not exist.

+
+ ID: {id} +
+
+ ); + } + + 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 ( +
+ {/* Badge Card */} +
+ + {/* Header Status Bar */} +
+ {isValid && !isExpired ? ( + <> + + ACTIVE VISITOR + + ) : ( + <> + + INVALID / EXPIRED + + )} +
+ +
+ {/* Photo / Avatar */} +
+ {visitor.photoUrl ? ( + {visitor.name} + ) : ( + + )} +
+ + {/* Name & Type */} +

+ {visitor.name} +

+ + {visitor.type} + + + {/* Meta Data Grid */} +
+ {visitor.company && ( +
+

Company

+

{visitor.company}

+
+ )} + +
+
+

Badge ID

+

{log.badgeNumber}

+
+
+

Zones

+

+ {log.zones && log.zones.length > 0 ? log.zones.length : 'All Access'} +

+
+
+ + {log.escort && ( +
+

Escort Required

+
+ + {log.escort.name} +
+
+ )} +
+
+ + {/* Footer Time */} +
+

Last Validated

+
+ + {currentTime.toLocaleTimeString()} +
+
+
+ +

+ 777 Wolfpack Facility Access Control +

+
+ ); +} diff --git a/frontend/src/pages/VisitorKioskPage.tsx b/frontend/src/pages/VisitorKioskPage.tsx index e48ca67..b5593b7 100644 --- a/frontend/src/pages/VisitorKioskPage.tsx +++ b/frontend/src/pages/VisitorKioskPage.tsx @@ -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(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() {

{successData.message}

- {successData.badgeNumber && ( -
-

Your Badge Number

-

{successData.badgeNumber}

+ + {successData.badgeNumber && ( // Only show QR for Check-IN, not Check-OUT +
+
+
+ +
+
+ +
+

Scan for Digital Badge

+

Use your phone to carry your badge with you.

+
+ +
+

Badge Number

+

{successData.badgeNumber}

+
+ +
+ +
)} -

Returning to home in 5 seconds...

+ + {!successData.badgeNumber && ( // Auto-redirect for Check-OUT only +

Returning to home in 5 seconds...

+ )}
)} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 32e0823..542f1f2 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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: , }, + { + path: '/badges/:id', + element: ( + +
+
+ }> + +
+ ), + errorElement: , + }, { path: '/', element: (