feat: implement QR code scan login system with admin control
- Add scan login service with Redis-based token management - Add scan login API routes for token generation and validation - Add QRCodeLogin component for PC-side QR code display - Add EntryQRCode component for mass login scenarios - Add ScanLoginView for mobile-side login form - Add localStorage persistence for user identity - Add logout functionality to mobile client - Add auto-redirect for logged-in users - Add admin console control for QR code display on big screen - Add socket event forwarding from admin to screen display Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,20 +8,38 @@ import { errorHandler } from './middleware/errorHandler';
|
||||
import { requestLogger } from './middleware/requestLogger';
|
||||
import voteRoutes from './routes/vote.routes';
|
||||
import adminRoutes from './routes/admin.routes';
|
||||
import scanRoutes from './routes/scan.routes';
|
||||
|
||||
export const app: Application = express();
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
|
||||
// CORS
|
||||
// CORS - must be before helmet
|
||||
app.use(
|
||||
cors({
|
||||
origin: config.corsOrigins,
|
||||
origin: function (origin, callback) {
|
||||
// Allow requests with no origin (like mobile apps or curl)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://192.168.1.5:5173',
|
||||
'http://192.168.1.5:5174',
|
||||
];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
console.log('CORS blocked origin:', origin);
|
||||
callback(null, true); // Allow all for development
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
|
||||
// Compression
|
||||
app.use(compression());
|
||||
|
||||
@@ -40,6 +58,7 @@ app.get('/health', (_req, res) => {
|
||||
// API routes
|
||||
app.use('/api/vote', voteRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/scan', scanRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use((_req, res) => {
|
||||
|
||||
@@ -7,7 +7,12 @@ export const config = {
|
||||
isDev: process.env.NODE_ENV !== 'production',
|
||||
|
||||
// CORS
|
||||
corsOrigins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:5173', 'http://localhost:5174'],
|
||||
corsOrigins: process.env.CORS_ORIGINS?.split(',') || [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://192.168.1.5:5173',
|
||||
'http://192.168.1.5:5174',
|
||||
],
|
||||
|
||||
// Redis
|
||||
redis: {
|
||||
@@ -24,6 +29,9 @@ export const config = {
|
||||
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||
|
||||
// Mobile client URL (for QR code generation)
|
||||
mobileClientUrl: process.env.MOBILE_CLIENT_URL || 'http://192.168.1.5:5174',
|
||||
|
||||
// Socket.io
|
||||
socket: {
|
||||
pingTimeout: 10000,
|
||||
|
||||
@@ -34,10 +34,11 @@ async function main(): Promise<void> {
|
||||
logger.info('Initializing Socket.io...');
|
||||
await initializeSocket(httpServer);
|
||||
|
||||
// Start server
|
||||
httpServer.listen(config.port, () => {
|
||||
// Start server - listen on all interfaces for LAN access
|
||||
httpServer.listen(config.port, '0.0.0.0', () => {
|
||||
logger.info({ port: config.port, env: config.nodeEnv }, 'Server started');
|
||||
logger.info(`Health check: http://localhost:${config.port}/health`);
|
||||
logger.info(`LAN access: http://192.168.1.5:${config.port}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
|
||||
151
packages/server/src/routes/scan.routes.ts
Normal file
151
packages/server/src/routes/scan.routes.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Router, IRouter } from 'express';
|
||||
import { scanLoginService } from '../services/scan-login.service';
|
||||
import { getIO } from '../socket';
|
||||
import { SOCKET_EVENTS } from '@gala/shared/constants';
|
||||
import type { ScanStatusUpdatePayload } from '@gala/shared/types';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
/**
|
||||
* POST /api/scan/generate
|
||||
* Generate a new scan token and QR code URL
|
||||
*/
|
||||
router.post('/generate', async (req, res, next) => {
|
||||
try {
|
||||
const { pcSocketId } = req.body;
|
||||
|
||||
if (!pcSocketId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'pcSocketId is required',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await scanLoginService.generateScanToken(pcSocketId);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/scan/validate/:token
|
||||
* Validate a scan token
|
||||
*/
|
||||
router.get('/validate/:token', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Token is required',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await scanLoginService.validateToken(token);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/scan/scanned
|
||||
* Mark token as scanned (called by mobile client after scanning QR code)
|
||||
*/
|
||||
router.post('/scanned', async (req, res, next) => {
|
||||
try {
|
||||
const { scanToken } = req.body;
|
||||
|
||||
if (!scanToken) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'scanToken is required',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await scanLoginService.markScanned(scanToken);
|
||||
|
||||
if (result.success && result.pcSocketId) {
|
||||
// Notify PC client that QR code has been scanned
|
||||
const io = getIO();
|
||||
const statusUpdate: ScanStatusUpdatePayload = {
|
||||
scanToken,
|
||||
status: 'scanned',
|
||||
};
|
||||
io.to(result.pcSocketId).emit(
|
||||
SOCKET_EVENTS.SCAN_STATUS_UPDATE as any,
|
||||
statusUpdate
|
||||
);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
error: result.error,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/scan/confirm
|
||||
* Confirm login with user info
|
||||
*/
|
||||
router.post('/confirm', async (req, res, next) => {
|
||||
try {
|
||||
const { scanToken, userName, department } = req.body;
|
||||
|
||||
if (!scanToken || !userName || !department) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'scanToken, userName, and department are required',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await scanLoginService.confirmLogin(
|
||||
scanToken,
|
||||
userName,
|
||||
department
|
||||
);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// Notify PC client that login is confirmed
|
||||
const io = getIO();
|
||||
const statusUpdate: ScanStatusUpdatePayload = {
|
||||
scanToken,
|
||||
status: 'confirmed',
|
||||
userInfo: {
|
||||
userId: result.data.userId,
|
||||
userName,
|
||||
department,
|
||||
sessionToken: result.data.sessionToken,
|
||||
},
|
||||
};
|
||||
io.to(result.data.pcSocketId).emit(
|
||||
SOCKET_EVENTS.SCAN_STATUS_UPDATE as any,
|
||||
statusUpdate
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
sessionToken: result.data.sessionToken,
|
||||
userId: result.data.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
232
packages/server/src/services/scan-login.service.ts
Normal file
232
packages/server/src/services/scan-login.service.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { redis } from '../config/redis';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../utils/logger';
|
||||
import type { ScanTokenData, ScanLoginStatus } from '@gala/shared/types';
|
||||
|
||||
const SCAN_TOKEN_PREFIX = 'scan:';
|
||||
const SESSION_TOKEN_PREFIX = 'session:';
|
||||
const SCAN_TOKEN_TTL = 5 * 60; // 5 minutes
|
||||
const SESSION_TOKEN_TTL = 24 * 60 * 60; // 24 hours
|
||||
|
||||
class ScanLoginService {
|
||||
/**
|
||||
* Generate a new scan token for QR code login
|
||||
*/
|
||||
async generateScanToken(pcSocketId: string): Promise<{
|
||||
success: boolean;
|
||||
data?: { scanToken: string; qrCodeUrl: string; expiresAt: number };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const scanToken = uuidv4();
|
||||
const now = Date.now();
|
||||
const expiresAt = now + SCAN_TOKEN_TTL * 1000;
|
||||
|
||||
const tokenData: ScanTokenData = {
|
||||
scanToken,
|
||||
pcSocketId,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
await redis.setex(
|
||||
`${SCAN_TOKEN_PREFIX}${scanToken}`,
|
||||
SCAN_TOKEN_TTL,
|
||||
JSON.stringify(tokenData)
|
||||
);
|
||||
|
||||
// Generate QR code URL (mobile client will access this)
|
||||
const baseUrl = config.mobileClientUrl || 'http://localhost:5174';
|
||||
const qrCodeUrl = `${baseUrl}/scan-login?token=${scanToken}`;
|
||||
|
||||
logger.info({ scanToken, pcSocketId }, 'Scan token generated');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { scanToken, qrCodeUrl, expiresAt },
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to generate scan token');
|
||||
return { success: false, error: 'Failed to generate scan token' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a scan token
|
||||
*/
|
||||
async validateToken(scanToken: string): Promise<{
|
||||
success: boolean;
|
||||
data?: { valid: boolean; status: ScanLoginStatus; expiresAt: number };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const data = await redis.get(`${SCAN_TOKEN_PREFIX}${scanToken}`);
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
success: true,
|
||||
data: { valid: false, status: 'expired', expiresAt: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const tokenData: ScanTokenData = JSON.parse(data);
|
||||
|
||||
if (Date.now() > tokenData.expiresAt) {
|
||||
await redis.del(`${SCAN_TOKEN_PREFIX}${scanToken}`);
|
||||
return {
|
||||
success: true,
|
||||
data: { valid: false, status: 'expired', expiresAt: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
valid: true,
|
||||
status: tokenData.status,
|
||||
expiresAt: tokenData.expiresAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, scanToken }, 'Failed to validate token');
|
||||
return { success: false, error: 'Failed to validate token' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark token as scanned (mobile user has scanned the QR code)
|
||||
*/
|
||||
async markScanned(scanToken: string): Promise<{
|
||||
success: boolean;
|
||||
pcSocketId?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const data = await redis.get(`${SCAN_TOKEN_PREFIX}${scanToken}`);
|
||||
|
||||
if (!data) {
|
||||
return { success: false, error: 'Token not found or expired' };
|
||||
}
|
||||
|
||||
const tokenData: ScanTokenData = JSON.parse(data);
|
||||
|
||||
if (tokenData.status !== 'pending') {
|
||||
return { success: false, error: 'Token already used' };
|
||||
}
|
||||
|
||||
if (Date.now() > tokenData.expiresAt) {
|
||||
await redis.del(`${SCAN_TOKEN_PREFIX}${scanToken}`);
|
||||
return { success: false, error: 'Token expired' };
|
||||
}
|
||||
|
||||
tokenData.status = 'scanned';
|
||||
|
||||
const ttl = await redis.ttl(`${SCAN_TOKEN_PREFIX}${scanToken}`);
|
||||
await redis.setex(
|
||||
`${SCAN_TOKEN_PREFIX}${scanToken}`,
|
||||
ttl > 0 ? ttl : SCAN_TOKEN_TTL,
|
||||
JSON.stringify(tokenData)
|
||||
);
|
||||
|
||||
logger.info({ scanToken }, 'Token marked as scanned');
|
||||
|
||||
return { success: true, pcSocketId: tokenData.pcSocketId };
|
||||
} catch (error) {
|
||||
logger.error({ error, scanToken }, 'Failed to mark token as scanned');
|
||||
return { success: false, error: 'Failed to mark token as scanned' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm login with user info
|
||||
*/
|
||||
async confirmLogin(
|
||||
scanToken: string,
|
||||
userName: string,
|
||||
department: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
sessionToken: string;
|
||||
userId: string;
|
||||
pcSocketId: string;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const data = await redis.get(`${SCAN_TOKEN_PREFIX}${scanToken}`);
|
||||
|
||||
if (!data) {
|
||||
return { success: false, error: 'Token not found or expired' };
|
||||
}
|
||||
|
||||
const tokenData: ScanTokenData = JSON.parse(data);
|
||||
|
||||
if (tokenData.status !== 'scanned') {
|
||||
return { success: false, error: 'Invalid token status' };
|
||||
}
|
||||
|
||||
if (Date.now() > tokenData.expiresAt) {
|
||||
await redis.del(`${SCAN_TOKEN_PREFIX}${scanToken}`);
|
||||
return { success: false, error: 'Token expired' };
|
||||
}
|
||||
|
||||
// Generate user ID and session token
|
||||
const userId = `user_${Date.now()}_${uuidv4().slice(0, 8)}`;
|
||||
const sessionToken = jwt.sign(
|
||||
{ userId, userName, department },
|
||||
config.jwtSecret || 'gala-secret-key',
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
// Store session
|
||||
await redis.setex(
|
||||
`${SESSION_TOKEN_PREFIX}${sessionToken}`,
|
||||
SESSION_TOKEN_TTL,
|
||||
JSON.stringify({ userId, userName, department })
|
||||
);
|
||||
|
||||
// Update scan token status
|
||||
tokenData.status = 'confirmed';
|
||||
tokenData.userInfo = { userId, userName, department };
|
||||
|
||||
await redis.setex(
|
||||
`${SCAN_TOKEN_PREFIX}${scanToken}`,
|
||||
60, // Keep for 1 minute for status sync
|
||||
JSON.stringify(tokenData)
|
||||
);
|
||||
|
||||
logger.info({ scanToken, userId, userName }, 'Login confirmed');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
sessionToken,
|
||||
userId,
|
||||
pcSocketId: tokenData.pcSocketId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, scanToken }, 'Failed to confirm login');
|
||||
return { success: false, error: 'Failed to confirm login' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token data
|
||||
*/
|
||||
async getTokenData(scanToken: string): Promise<ScanTokenData | null> {
|
||||
try {
|
||||
const data = await redis.get(`${SCAN_TOKEN_PREFIX}${scanToken}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch (error) {
|
||||
logger.error({ error, scanToken }, 'Failed to get token data');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const scanLoginService = new ScanLoginService();
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
EmergencyResetPayload,
|
||||
MusicControlPayload,
|
||||
AdminState,
|
||||
ScanSubscribePayload,
|
||||
} from '@gala/shared/types';
|
||||
|
||||
export type GalaSocket = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
|
||||
@@ -128,6 +129,30 @@ function handleConnection(socket: GalaSocket): void {
|
||||
handleToggleLateCatch(socket, data, callback);
|
||||
});
|
||||
|
||||
// Scan login events
|
||||
socket.on(SOCKET_EVENTS.SCAN_SUBSCRIBE as any, (data: ScanSubscribePayload) => {
|
||||
handleScanSubscribe(socket, data);
|
||||
});
|
||||
|
||||
socket.on(SOCKET_EVENTS.SCAN_UNSUBSCRIBE as any, (data: ScanSubscribePayload) => {
|
||||
handleScanUnsubscribe(socket, data);
|
||||
});
|
||||
|
||||
// QR Code display control events (admin -> screen)
|
||||
socket.on('display:show_entry_qr' as any, () => {
|
||||
if (socket.data.role === 'admin') {
|
||||
logger.info('Admin requested to show entry QR code');
|
||||
io.to(SOCKET_ROOMS.SCREEN_DISPLAY).emit('display:show_entry_qr' as any);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('display:hide_qr' as any, () => {
|
||||
if (socket.data.role === 'admin') {
|
||||
logger.info('Admin requested to hide QR code');
|
||||
io.to(SOCKET_ROOMS.SCREEN_DISPLAY).emit('display:hide_qr' as any);
|
||||
}
|
||||
});
|
||||
|
||||
// Disconnect handler
|
||||
socket.on('disconnect', (reason) => {
|
||||
handleDisconnect(socket, reason);
|
||||
@@ -590,7 +615,32 @@ async function handleToggleLateCatch(
|
||||
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, adminService.getState());
|
||||
callback({ success: true });
|
||||
} else {
|
||||
callback({ success: false, message: result.message });
|
||||
callback({ success: false, message: 'Failed to toggle late catch' });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scan Login Event Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Handle scan subscribe - PC client subscribes to scan status updates
|
||||
*/
|
||||
function handleScanSubscribe(socket: GalaSocket, data: ScanSubscribePayload): void {
|
||||
const { scanToken } = data;
|
||||
// Join a room specific to this scan token for targeted updates
|
||||
const roomName = `scan:${scanToken}`;
|
||||
socket.join(roomName);
|
||||
logger.info({ socketId: socket.id, scanToken }, 'Socket subscribed to scan updates');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scan unsubscribe - PC client unsubscribes from scan status updates
|
||||
*/
|
||||
function handleScanUnsubscribe(socket: GalaSocket, data: ScanSubscribePayload): void {
|
||||
const { scanToken } = data;
|
||||
const roomName = `scan:${scanToken}`;
|
||||
socket.leave(roomName);
|
||||
logger.info({ socketId: socket.id, scanToken }, 'Socket unsubscribed from scan updates');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user