From f4736b6ebd07bc6ccf2a77f4cc01fab63c1cf542 Mon Sep 17 00:00:00 2001 From: empty Date: Sun, 25 Jan 2026 21:44:52 +0800 Subject: [PATCH] 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 --- packages/client-mobile/src/components.d.ts | 1 + packages/client-mobile/src/router/index.ts | 5 + .../client-mobile/src/stores/connection.ts | 62 ++- packages/client-mobile/src/views/HomeView.vue | 10 +- .../client-mobile/src/views/ScanLoginView.vue | 269 ++++++++++++ packages/client-mobile/src/views/VoteView.vue | 170 +++++--- packages/client-mobile/vite.config.ts | 3 +- packages/client-screen/package.json | 18 +- .../src/components/EntryQRCode.vue | 219 ++++++++++ .../src/components/QRCodeLogin.vue | 411 ++++++++++++++++++ .../client-screen/src/pixi/LotteryMachine.ts | 79 ++-- packages/client-screen/src/stores/display.ts | 15 + .../client-screen/src/views/AdminControl.vue | 28 ++ .../client-screen/src/views/MainDisplay.vue | 33 ++ packages/client-screen/vite.config.ts | 3 +- packages/server/package.json | 2 + packages/server/src/app.ts | 29 +- packages/server/src/config/index.ts | 10 +- packages/server/src/index.ts | 5 +- packages/server/src/routes/scan.routes.ts | 151 +++++++ .../server/src/services/scan-login.service.ts | 232 ++++++++++ packages/server/src/socket/index.ts | 52 ++- packages/shared/src/constants/events.ts | 9 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/scan-login.types.ts | 66 +++ pnpm-lock.yaml | 161 +++++++ 26 files changed, 1925 insertions(+), 119 deletions(-) create mode 100644 packages/client-mobile/src/views/ScanLoginView.vue create mode 100644 packages/client-screen/src/components/EntryQRCode.vue create mode 100644 packages/client-screen/src/components/QRCodeLogin.vue create mode 100644 packages/server/src/routes/scan.routes.ts create mode 100644 packages/server/src/services/scan-login.service.ts create mode 100644 packages/shared/src/types/scan-login.types.ts diff --git a/packages/client-mobile/src/components.d.ts b/packages/client-mobile/src/components.d.ts index 74eccf3..76e0104 100644 --- a/packages/client-mobile/src/components.d.ts +++ b/packages/client-mobile/src/components.d.ts @@ -19,6 +19,7 @@ declare module 'vue' { VanEmpty: typeof import('vant/es')['Empty'] VanField: typeof import('vant/es')['Field'] VanIcon: typeof import('vant/es')['Icon'] + VanLoading: typeof import('vant/es')['Loading'] VanNavBar: typeof import('vant/es')['NavBar'] VotingDock: typeof import('./components/VotingDock.vue')['default'] } diff --git a/packages/client-mobile/src/router/index.ts b/packages/client-mobile/src/router/index.ts index d743f48..97cbf0e 100644 --- a/packages/client-mobile/src/router/index.ts +++ b/packages/client-mobile/src/router/index.ts @@ -23,6 +23,11 @@ const router = createRouter({ name: 'profile', component: () => import('../views/ProfileView.vue'), }, + { + path: '/scan-login', + name: 'scan-login', + component: () => import('../views/ScanLoginView.vue'), + }, ], }); diff --git a/packages/client-mobile/src/stores/connection.ts b/packages/client-mobile/src/stores/connection.ts index 4912db9..713680d 100644 --- a/packages/client-mobile/src/stores/connection.ts +++ b/packages/client-mobile/src/stores/connection.ts @@ -14,6 +14,31 @@ import { CONFIG } from '@gala/shared/constants'; type GalaSocket = Socket; +// LocalStorage keys +const STORAGE_KEYS = { + USER_ID: 'gala_user_id', + USER_NAME: 'gala_user_name', + DEPARTMENT: 'gala_department', +}; + +// Helper functions for localStorage +function loadFromStorage(key: string, defaultValue: T): T { + try { + const stored = localStorage.getItem(key); + return stored ? JSON.parse(stored) : defaultValue; + } catch { + return defaultValue; + } +} + +function saveToStorage(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + console.warn('[Storage] Failed to save:', e); + } +} + export const useConnectionStore = defineStore('connection', () => { // State - use shallowRef for socket to avoid deep reactivity issues const socket = shallowRef(null); @@ -23,9 +48,9 @@ export const useConnectionStore = defineStore('connection', () => { const lastPingTime = ref(0); const latency = ref(0); const reconnectAttempts = ref(0); - const userId = ref(null); - const userName = ref(null); - const department = ref(null); + const userId = ref(loadFromStorage(STORAGE_KEYS.USER_ID, null)); + const userName = ref(loadFromStorage(STORAGE_KEYS.USER_NAME, null)); + const department = ref(loadFromStorage(STORAGE_KEYS.DEPARTMENT, null)); const votedCategories = ref([]); // Computed @@ -69,10 +94,12 @@ export const useConnectionStore = defineStore('connection', () => { isConnecting.value = false; reconnectAttempts.value = 0; - // Auto-generate userId if not set + // Auto-generate userId if not set (and save to localStorage) if (!userId.value) { userId.value = `user_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; userName.value = '访客'; + saveToStorage(STORAGE_KEYS.USER_ID, userId.value); + saveToStorage(STORAGE_KEYS.USER_NAME, userName.value); } // Join with user info @@ -217,19 +244,43 @@ export const useConnectionStore = defineStore('connection', () => { } /** - * Set user info + * Set user info (and persist to localStorage) */ function setUser(id: string, name: string, dept: string) { userId.value = id; userName.value = name; department.value = dept; + // Persist to localStorage + saveToStorage(STORAGE_KEYS.USER_ID, id); + saveToStorage(STORAGE_KEYS.USER_NAME, name); + saveToStorage(STORAGE_KEYS.DEPARTMENT, dept); + // Rejoin if already connected if (socket.value?.connected) { joinRoom(); } } + /** + * Logout and clear stored user info + */ + function logout() { + // Clear state + userId.value = null; + userName.value = null; + department.value = null; + votedCategories.value = []; + + // Clear localStorage + localStorage.removeItem(STORAGE_KEYS.USER_ID); + localStorage.removeItem(STORAGE_KEYS.USER_NAME); + localStorage.removeItem(STORAGE_KEYS.DEPARTMENT); + + // Disconnect socket + disconnect(); + } + /** * Add voted category */ @@ -274,6 +325,7 @@ export const useConnectionStore = defineStore('connection', () => { // Actions connect, disconnect, + logout, setUser, addVotedCategory, requestSync, diff --git a/packages/client-mobile/src/views/HomeView.vue b/packages/client-mobile/src/views/HomeView.vue index 1734f6b..753a87c 100644 --- a/packages/client-mobile/src/views/HomeView.vue +++ b/packages/client-mobile/src/views/HomeView.vue @@ -1,5 +1,5 @@ + + + + diff --git a/packages/client-mobile/src/views/VoteView.vue b/packages/client-mobile/src/views/VoteView.vue index d725bd2..f6d25ec 100644 --- a/packages/client-mobile/src/views/VoteView.vue +++ b/packages/client-mobile/src/views/VoteView.vue @@ -1,10 +1,13 @@ + + + + diff --git a/packages/client-screen/src/components/QRCodeLogin.vue b/packages/client-screen/src/components/QRCodeLogin.vue new file mode 100644 index 0000000..1f4a469 --- /dev/null +++ b/packages/client-screen/src/components/QRCodeLogin.vue @@ -0,0 +1,411 @@ + + + + + diff --git a/packages/client-screen/src/pixi/LotteryMachine.ts b/packages/client-screen/src/pixi/LotteryMachine.ts index da4020e..b984111 100644 --- a/packages/client-screen/src/pixi/LotteryMachine.ts +++ b/packages/client-screen/src/pixi/LotteryMachine.ts @@ -443,16 +443,10 @@ export class LotteryMachine { p.text.y = centerY + y1 * scale; p.text.scale.set(scale * 0.8); - // Depth-based alpha and color + // Depth-based alpha - all participants show gold color const depthAlpha = (z2 + this.sphereRadius) / (this.sphereRadius * 2); - p.text.alpha = p.isEligible ? 0.3 + depthAlpha * 0.7 : 0.15; - - // Dim ineligible names - if (!p.isEligible) { - p.text.style.fill = 0x666666; - } else { - p.text.style.fill = COLORS.gold; - } + p.text.alpha = 0.3 + depthAlpha * 0.7; + p.text.style.fill = COLORS.gold; // Z-sorting p.text.zIndex = Math.floor(z2); @@ -462,46 +456,65 @@ export class LotteryMachine { } // ============================================================================ - // Storm Phase (Tornado) + // Storm Phase (Fast Spinning Sphere) // ============================================================================ private updateStorm(deltaMs: number): void { const centerX = this.app.screen.width / 2; const centerY = this.app.screen.height / 2; - // Ramp up storm intensity - this.stormIntensity = Math.min(1, this.stormIntensity + deltaMs * 0.001); - this.stormAngle += deltaMs * 0.01 * this.stormIntensity; + // Ramp up rotation speed + this.stormIntensity = Math.min(1, this.stormIntensity + deltaMs * 0.002); - // Apply motion blur based on intensity - this.blurFilter.strength = this.stormIntensity * 8; + // Fast sphere rotation - much faster than galaxy phase + this.sphereRotationY += deltaMs * 0.008 * (1 + this.stormIntensity * 3); + this.sphereRotationX += deltaMs * 0.003 * this.stormIntensity; + + // Light motion blur for speed effect + this.blurFilter.strength = this.stormIntensity * 2; this.nameParticles.forEach((p, index) => { if (!p.text) return; - // Tornado vortex motion - const baseAngle = this.stormAngle + (index / this.nameParticles.length) * Math.PI * 2; - const verticalPos = ((this.time * 0.001 + index * 0.1) % 2) - 1; // -1 to 1 - const radius = 100 + Math.abs(verticalPos) * 200 * this.stormIntensity; + // Get original sphere position from fibonacci distribution + const phi = Math.acos(1 - 2 * (index + 0.5) / this.nameParticles.length); + const theta = Math.PI * (1 + Math.sqrt(5)) * index; - const targetX = centerX + Math.cos(baseAngle) * radius; - const targetY = centerY + verticalPos * 300; + const sphereX = this.sphereRadius * Math.sin(phi) * Math.cos(theta); + const sphereY = this.sphereRadius * Math.sin(phi) * Math.sin(theta); + const sphereZ = this.sphereRadius * Math.cos(phi); - // Smooth interpolation - p.x += (targetX - p.x) * 0.1; - p.y += (targetY - p.y) * 0.1; + // Apply 3D rotation (Y axis then X axis) + const cosY = Math.cos(this.sphereRotationY); + const sinY = Math.sin(this.sphereRotationY); + const cosX = Math.cos(this.sphereRotationX); + const sinX = Math.sin(this.sphereRotationX); - p.text.x = p.x; - p.text.y = p.y; - p.text.rotation += p.rotationSpeed * this.stormIntensity * 3; - p.text.alpha = p.isEligible ? 0.8 : 0.2; + // Rotate around Y axis + const x1 = sphereX * cosY - sphereZ * sinY; + const z1 = sphereX * sinY + sphereZ * cosY; - // Scale based on position - const distFromCenter = Math.sqrt( - Math.pow(p.x - centerX, 2) + Math.pow(p.y - centerY, 2) - ); - p.text.scale.set(0.5 + (1 - distFromCenter / 400) * 0.5); + // Rotate around X axis + const y1 = sphereY * cosX - z1 * sinX; + const z2 = sphereY * sinX + z1 * cosX; + + // Project to 2D with perspective + const perspective = 800; + const scale = perspective / (perspective + z2); + + p.text.x = centerX + x1 * scale; + p.text.y = centerY + y1 * scale; + p.text.scale.set(scale * 0.9); + + // Depth-based alpha - all participants show same + const depthAlpha = (z2 + this.sphereRadius) / (this.sphereRadius * 2); + p.text.alpha = 0.4 + depthAlpha * 0.6; + + // Z-sorting + p.text.zIndex = Math.floor(z2); }); + + this.galaxyContainer.sortChildren(); } // ============================================================================ diff --git a/packages/client-screen/src/stores/display.ts b/packages/client-screen/src/stores/display.ts index 488c12d..ce620cc 100644 --- a/packages/client-screen/src/stores/display.ts +++ b/packages/client-screen/src/stores/display.ts @@ -32,6 +32,9 @@ export const useDisplayStore = defineStore('display', () => { avatar?: string; } | null>(null); + // QR Code display state (controlled by admin) + const showEntryQR = ref(false); + // Computed const connectionStatus = computed(() => { if (isConnected.value) return 'connected'; @@ -131,6 +134,17 @@ export const useDisplayStore = defineStore('display', () => { } }); + // QR Code display control events + socketInstance.on('display:show_entry_qr' as any, () => { + console.log('[Screen] Show entry QR code'); + showEntryQR.value = true; + }); + + socketInstance.on('display:hide_qr' as any, () => { + console.log('[Screen] Hide QR code'); + showEntryQR.value = false; + }); + socket.value = socketInstance as GalaSocket; } @@ -169,6 +183,7 @@ export const useDisplayStore = defineStore('display', () => { isDrawing, currentPrize, currentWinner, + showEntryQR, // Computed connectionStatus, diff --git a/packages/client-screen/src/views/AdminControl.vue b/packages/client-screen/src/views/AdminControl.vue index 2d92b8d..bdde559 100644 --- a/packages/client-screen/src/views/AdminControl.vue +++ b/packages/client-screen/src/views/AdminControl.vue @@ -243,6 +243,21 @@ function playFanfare() { admin.controlMusic('play', 'fanfare'); } +// QR Code display control +function showEntryQR() { + const socket = admin.getSocket(); + if (socket) { + socket.emit('display:show_entry_qr' as any, {}); + } +} + +function hideQR() { + const socket = admin.getSocket(); + if (socket) { + socket.emit('display:hide_qr' as any, ); + } +} + // Computed helpers const currentPrizeConfig = computed(() => { // 优先使用从服务器加载的配置 @@ -666,6 +681,19 @@ onMounted(() => { + +
+

大屏二维码

+
+ + +
+
+

音乐控制

diff --git a/packages/client-screen/src/views/MainDisplay.vue b/packages/client-screen/src/views/MainDisplay.vue index e2cb80e..0b0fcf9 100644 --- a/packages/client-screen/src/views/MainDisplay.vue +++ b/packages/client-screen/src/views/MainDisplay.vue @@ -2,9 +2,27 @@ import { ref, onMounted, onUnmounted } from 'vue'; import { useDisplayStore } from '../stores/display'; import { BackgroundEffect } from '../pixi/BackgroundEffect'; +import QRCodeLogin from '../components/QRCodeLogin.vue'; +import EntryQRCode from '../components/EntryQRCode.vue'; const displayStore = useDisplayStore(); +// QR Code Login (legacy - kept for direct access) +const showQRLogin = ref(false); + +// Mobile URL for entry QR code +const mobileUrl = 'http://192.168.1.5:5174'; + +function handleLoginSuccess(data: { userId: string; userName: string; department: string; sessionToken: string }) { + console.log('Login success:', data); + showQRLogin.value = false; + // 可以在这里处理登录成功后的逻辑 +} + +function handleLoginCancel() { + showQRLogin.value = false; +} + // Pixi.js background const canvasRef = ref(null); let backgroundEffect: BackgroundEffect | null = null; @@ -44,6 +62,21 @@ onUnmounted(() => { + + + + + +
diff --git a/packages/client-screen/vite.config.ts b/packages/client-screen/vite.config.ts index 4dd6fef..bfa07e2 100644 --- a/packages/client-screen/vite.config.ts +++ b/packages/client-screen/vite.config.ts @@ -10,7 +10,8 @@ export default defineConfig({ }, }, server: { - port: 5174, + host: '0.0.0.0', + port: 5173, proxy: { '/api': { target: 'http://localhost:3000', diff --git a/packages/server/package.json b/packages/server/package.json index ce1740c..d3764b6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -29,6 +29,7 @@ "express": "^4.21.2", "helmet": "^8.0.0", "ioredis": "^5.4.2", + "jsonwebtoken": "^9.0.3", "multer": "^2.0.2", "nanoid": "^5.0.9", "pino": "^9.6.0", @@ -42,6 +43,7 @@ "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.0.0", "@types/uuid": "^11.0.0", "artillery": "^2.0.28", diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index cca8fe6..64d0b3c 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -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) => { diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index aa461b9..ab5c438 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -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, diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7c41cd2..e16e695 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -34,10 +34,11 @@ async function main(): Promise { 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 diff --git a/packages/server/src/routes/scan.routes.ts b/packages/server/src/routes/scan.routes.ts new file mode 100644 index 0000000..3682081 --- /dev/null +++ b/packages/server/src/routes/scan.routes.ts @@ -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; diff --git a/packages/server/src/services/scan-login.service.ts b/packages/server/src/services/scan-login.service.ts new file mode 100644 index 0000000..73e12e7 --- /dev/null +++ b/packages/server/src/services/scan-login.service.ts @@ -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 { + 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(); diff --git a/packages/server/src/socket/index.ts b/packages/server/src/socket/index.ts index 36cee1a..3a7879b 100644 --- a/packages/server/src/socket/index.ts +++ b/packages/server/src/socket/index.ts @@ -24,6 +24,7 @@ import type { EmergencyResetPayload, MusicControlPayload, AdminState, + ScanSubscribePayload, } from '@gala/shared/types'; export type GalaSocket = Socket; @@ -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'); +} + diff --git a/packages/shared/src/constants/events.ts b/packages/shared/src/constants/events.ts index 967b922..12af1da 100644 --- a/packages/shared/src/constants/events.ts +++ b/packages/shared/src/constants/events.ts @@ -34,6 +34,15 @@ export const SOCKET_EVENTS = { ADMIN_EMERGENCY_RESET: 'admin:emergency_reset', ADMIN_STATE_SYNC: 'admin:state_sync', ADMIN_MUSIC_CONTROL: 'admin:music_control', + + // Scan login events + SCAN_SUBSCRIBE: 'scan:subscribe', + SCAN_UNSUBSCRIBE: 'scan:unsubscribe', + SCAN_STATUS_UPDATE: 'scan:status_update', + + // QR code display control events + DISPLAY_SHOW_ENTRY_QR: 'display:show_entry_qr', + DISPLAY_HIDE_QR: 'display:hide_qr', } as const; export const SOCKET_ROOMS = { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 3a2ceec..a043ec0 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -3,3 +3,4 @@ export * from './socket.types'; export * from './vote.types'; export * from './draw.types'; export * from './admin.types'; +export * from './scan-login.types'; diff --git a/packages/shared/src/types/scan-login.types.ts b/packages/shared/src/types/scan-login.types.ts new file mode 100644 index 0000000..4c9cfa6 --- /dev/null +++ b/packages/shared/src/types/scan-login.types.ts @@ -0,0 +1,66 @@ +// Scan login types + +export type ScanLoginStatus = 'pending' | 'scanned' | 'confirmed' | 'expired'; + +export interface ScanTokenData { + scanToken: string; + pcSocketId: string; + status: ScanLoginStatus; + createdAt: number; + expiresAt: number; + userInfo?: { + userId: string; + userName: string; + department: string; + }; +} + +export interface GenerateScanTokenResponse { + success: boolean; + data?: { + scanToken: string; + qrCodeUrl: string; + expiresAt: number; + }; + error?: string; +} + +export interface ValidateTokenResponse { + success: boolean; + data?: { + valid: boolean; + status: ScanLoginStatus; + expiresAt: number; + }; + error?: string; +} + +export interface ScanConfirmPayload { + scanToken: string; + userName: string; + department: string; +} + +export interface ScanConfirmResponse { + success: boolean; + data?: { + sessionToken: string; + userId: string; + }; + error?: string; +} + +export interface ScanStatusUpdatePayload { + scanToken: string; + status: ScanLoginStatus; + userInfo?: { + userId: string; + userName: string; + department: string; + sessionToken: string; + }; +} + +export interface ScanSubscribePayload { + scanToken: string; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ef5680..858458f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: pixi.js: specifier: ^8.6.6 version: 8.15.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 socket.io-client: specifier: ^4.8.1 version: 4.8.3 @@ -121,6 +124,9 @@ importers: '@types/howler': specifier: ^2.2.12 version: 2.2.12 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@vitejs/plugin-vue': specifier: ^5.2.1 version: 5.2.4(vite@6.4.1(@types/node@25.0.8)(jiti@2.6.1)(sass@1.97.2)(terser@5.44.1)(tsx@4.21.0))(vue@3.5.26(typescript@5.9.3)) @@ -166,6 +172,9 @@ importers: ioredis: specifier: ^5.4.2 version: 5.9.1 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 multer: specifier: ^2.0.2 version: 2.0.2 @@ -200,6 +209,9 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.6 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/multer': specifier: ^2.0.0 version: 2.0.0 @@ -2760,9 +2772,15 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@2.0.0': resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} @@ -2772,6 +2790,9 @@ packages: '@types/pako@1.0.7': resolution: {integrity: sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} @@ -3258,6 +3279,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001764: resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==} @@ -3330,6 +3355,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -3527,6 +3555,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -3654,6 +3686,9 @@ packages: peerDependencies: typescript: ^5.4.4 + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dogapi@2.8.4: resolution: {integrity: sha512-065fsvu5dB0o4+ENtLjZILvXMClDNH/yA9H6L8nsdcNiz9l0Hzpn7aQaCOPYXxqyzq4CRPOdwkFXUjDOXfRGbg==} hasBin: true @@ -4028,6 +4063,10 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -4646,6 +4685,10 @@ packages: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -4977,14 +5020,26 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -5123,6 +5178,10 @@ packages: engines: {node: '>=18'} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5225,6 +5284,11 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.14.1: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} @@ -5321,6 +5385,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requirejs-config-file@4.0.0: resolution: {integrity: sha512-jnIre8cbWOyvr8a5F2KuqBnY+SDA4NXr/hzEZJG79Mxm2WiFQz2dzhC8ibtPJS7zkmBEl1mxSwp5HhC1W4qpxw==} engines: {node: '>=10.13.0'} @@ -5442,6 +5509,9 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -6184,6 +6254,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} @@ -6326,6 +6399,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6337,10 +6413,18 @@ packages: resolution: {integrity: sha512-LjoIFHCtSfkGzPsmYmfDsW+TShtQBcY7lwH1yLQ5brJHXRhjteUnVE2ThCbz1madq8JUZIAjFiavfnIFeTO8CQ==} engines: {node: '>=8'} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -9980,10 +10064,17 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 25.0.8 + '@types/keyv@3.1.4': dependencies: '@types/node': 25.0.8 + '@types/ms@2.1.0': {} + '@types/multer@2.0.0': dependencies: '@types/express': 5.0.6 @@ -9994,6 +10085,10 @@ snapshots: '@types/pako@1.0.7': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 25.0.8 + '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} @@ -10689,6 +10784,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001764: {} cfb@1.2.2: @@ -10787,6 +10884,12 @@ snapshots: cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -10968,6 +11071,8 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + decamelize@1.2.0: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -11093,6 +11198,8 @@ snapshots: transitivePeerDependencies: - supports-color + dijkstrajs@1.0.3: {} + dogapi@2.8.4: dependencies: extend: 3.0.2 @@ -11654,6 +11761,11 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -12283,6 +12395,10 @@ snapshots: pkg-types: 2.3.0 quansync: 0.2.11 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -12571,14 +12687,24 @@ snapshots: p-cancelable@2.1.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} pako@2.1.0: {} @@ -12727,6 +12853,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0): @@ -12833,6 +12961,12 @@ snapshots: pure-rand@6.1.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -12937,6 +13071,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + requirejs-config-file@4.0.0: dependencies: esprima: 4.0.1 @@ -13091,6 +13227,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -13887,6 +14025,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.19: dependencies: available-typed-arrays: 1.0.7 @@ -14075,14 +14215,35 @@ snapshots: xtend@4.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} yaml-js@0.3.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1