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:
empty
2026-01-25 21:44:52 +08:00
parent 75570af8bc
commit f4736b6ebd
26 changed files with 1925 additions and 119 deletions

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -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

View 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;

View 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();

View File

@@ -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');
}