diff --git a/.env.example b/.env.example index 4111b22..736a98a 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,16 @@ SUPABASE_SERVICE_ROLE_KEY= # SUPABASE_SERVICE_KEY= +# --- Entry passes (QR check-in at the venue) -------------------------------------------------------------- +# Required for entry passes. SECRET, server-only: 32 random bytes, base64. Encrypts each participant's QR token at rest +# so POST /api/pass can show the owner the same QR on any device (the database only stores the token's SHA-256 plus +# this ciphertext). Generate with: +# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +# Set it once and keep it: changing it makes every QR already shown undecryptable (admins would have to reissue them). +# Use a different value per environment (production, preview, local/test). +ENTRY_PASS_ENCRYPTION_KEY= + + # --- Site identity and search engines ------------------------------------------------------------------- # Optional (public). The ONE canonical origin of the site (src/config/site.ts), no trailing slash. Used for canonical diff --git a/next.config.ts b/next.config.ts index 83c5ac8..1a701e5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -59,6 +59,14 @@ const nextConfig: NextConfig = { { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, ], }, + { + // The check-in scanner needs the camera. Must stay AFTER the global "/:path*" entry: when two entries set the + // same header for a path, the last one wins (Next.js headers docs, "Header Overriding Behavior"). + source: "/admin/scan", + headers: [ + { key: "Permissions-Policy", value: "camera=(self), microphone=(), geolocation=(), payment=(), usb=()" }, + ], + }, { // API responses carry personal data and tokens: never let a browser or CDN cache them. The two public, // anonymous endpoints (events, gallery) are excluded and set their own cache headers. diff --git a/package-lock.json b/package-lock.json index a6b8216..b151427 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,13 @@ "dependencies": { "@supabase/supabase-js": "^2.117.2", "@vercel/analytics": "^2.0.1", + "@zxing/browser": "^0.2.1", + "@zxing/library": "^0.23.0", "gsap": "^3.15.0", "imagekit": "^6.0.0", + "jspdf": "^4.2.1", "next": "16.3.8", + "qrcode": "^1.5.4", "react": "19.2.8", "react-dom": "19.2.8" }, @@ -20,6 +24,7 @@ "@electric-sql/pglite": "^0.5.8", "@playwright/test": "^1.63.0", "@types/node": "^20", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -221,6 +226,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -1483,6 +1497,29 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", @@ -1503,6 +1540,13 @@ "@types/react": "^19.3.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.71.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", @@ -2164,6 +2208,40 @@ } } }, + "node_modules/@zxing/browser": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.1.tgz", + "integrity": "sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==", + "license": "MIT", + "optionalDependencies": { + "@zxing/text-encoding": "^0.9.0" + }, + "peerDependencies": { + "@zxing/library": "^0.23.0" + } + }, + "node_modules/@zxing/library": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.23.0.tgz", + "integrity": "sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==", + "license": "Apache-2.0", + "dependencies": { + "ts-custom-error": "^3.3.1" + }, + "engines": { + "node": ">= 24.0.0" + }, + "optionalDependencies": { + "@zxing/text-encoding": "~0.9.0" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -2249,7 +2327,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2516,6 +2593,16 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.27", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", @@ -2645,6 +2732,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001814", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", @@ -2665,6 +2761,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2721,11 +2837,85 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2738,7 +2928,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colorette": { @@ -2784,6 +2973,21 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2799,6 +3003,16 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2884,6 +3098,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2946,6 +3169,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -2959,6 +3188,16 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3683,6 +3922,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fastq": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", @@ -3693,6 +3943,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3872,6 +4128,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", @@ -4128,6 +4393,20 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -4236,6 +4515,12 @@ "node": ">= 0.4" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4804,6 +5089,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -5476,6 +5778,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5493,7 +5820,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5516,6 +5842,13 @@ "dev": true, "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5564,6 +5897,15 @@ "node": ">=20" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5615,6 +5957,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5636,6 +5995,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -5687,6 +6056,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -5708,6 +6084,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -5787,6 +6178,16 @@ "dev": true, "license": "MIT" }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5882,6 +6283,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6152,6 +6559,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6395,6 +6812,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tinyexec": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", @@ -6479,6 +6916,15 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -6744,6 +7190,16 @@ "punycode": "^2.1.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", @@ -6840,6 +7296,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.24", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", @@ -6928,6 +7390,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6951,6 +7419,143 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 930cc5d..54558f0 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,13 @@ "dependencies": { "@supabase/supabase-js": "^2.117.2", "@vercel/analytics": "^2.0.1", + "@zxing/browser": "^0.2.1", + "@zxing/library": "^0.23.0", "gsap": "^3.15.0", "imagekit": "^6.0.0", + "jspdf": "^4.2.1", "next": "16.3.8", + "qrcode": "^1.5.4", "react": "19.2.8", "react-dom": "19.2.8" }, @@ -24,6 +28,7 @@ "@electric-sql/pglite": "^0.5.8", "@playwright/test": "^1.63.0", "@types/node": "^20", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/src/app/admin/scan/page.tsx b/src/app/admin/scan/page.tsx new file mode 100644 index 0000000..46fe4b8 --- /dev/null +++ b/src/app/admin/scan/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from 'next'; +import { cookies, headers } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { verifyRegistrationStaff } from '@/lib/auth-server'; +import EntryScanner from '@/components/entry/EntryScanner'; +import SessionRestore from '@/components/entry/SessionRestore'; + +export const metadata: Metadata = { + title: 'Check-in scanner', + robots: { index: false, follow: false }, +}; + +/** + * /admin/scan: the venue check-in scanner, for the registration team and admins only. + * The role is checked here from the database (never from anything the client sends); every scan and check-in is + * authorized again by /api/admin/entry/* and by the database functions behind them. + */ +export default async function ScanPage() { + const source = { headers: await headers(), cookies: await cookies() }; + const auth = await verifyRegistrationStaff(source); + + if ('error' in auth) { + // Expired access token but a refresh token is present: refresh once in the browser instead of bouncing to login. + if (auth.status === 401 && source.cookies.get('inn_refresh_token')?.value) return ; + redirect(auth.status === 401 ? '/?auth=login&required=scan' : '/'); + } + + const name = auth.user.user_metadata?.full_name || auth.user.user_metadata?.name; + const staffLabel = `${typeof name === 'string' && name ? name : auth.user.email ?? 'staff'} (${auth.role})`; + return ; +} diff --git a/src/app/api/admin/entry/check-in/route.ts b/src/app/api/admin/entry/check-in/route.ts new file mode 100644 index 0000000..2462cb3 --- /dev/null +++ b/src/app/api/admin/entry/check-in/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyRegistrationStaff } from '@/lib/auth-server'; +import { getSupabaseAsUser } from '@/lib/supabase'; +import { createRateLimiter, isUuid, readJsonObject } from '@/lib/security'; +import { parseDesk, parseScannedValue } from '@/lib/entry-pass'; +import { badRequest, entryAuthFailure, entryRateLimited, entryRpcFailure } from '@/lib/entry-route'; + +export const runtime = 'nodejs'; + +const checkInLimiter = createRateLimiter({ limit: 60, windowMs: 60 * 1000 }); + +/** + * POST /api/admin/entry/check-in { token | registration, scanId, desk } + * Registration team / admin: the scanner's "Approve". Checks the registration in, once ever. The database + * re-validates everything (never trusting the earlier lookup), refuses self check-in, and its unique index makes + * simultaneous Approves produce exactly one check-in. Retrying with the same scanId answers CHECKED_IN again. + * `registration` (a registration uuid from a manual lookup) replaces `token` for damaged or missing QRs. + */ +export async function POST(req: NextRequest) { + try { + const auth = await verifyRegistrationStaff(req); + if ('error' in auth) { + return entryAuthFailure(auth); + } + const limited = entryRateLimited(checkInLimiter, auth.user.id); + if (limited) return limited; + const db = getSupabaseAsUser(auth.token); + + const parsed = await readJsonObject(req); + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status }); + const { token: rawToken, registration, scanId, desk: rawDesk } = parsed.value; + + if (!isUuid(scanId)) return badRequest('Invalid scanId.'); + const desk = parseDesk(rawDesk); + if (!desk) return badRequest('Choose a desk name (letters, digits, up to 40 characters).'); + const token = rawToken === undefined ? null : parseScannedValue(rawToken); + if ((token === null) === (registration === undefined)) return badRequest('Send either a scanned token or a registration.'); + if (registration !== undefined && !isUuid(registration)) return badRequest('Invalid registration.'); + + const { data, error } = token + ? await db.rpc('check_in_entry_pass', { p_token: token, p_scan_id: scanId, p_desk: desk }) + : await db.rpc('check_in_entry_registration', { p_registration: registration, p_scan_id: scanId, p_desk: desk }); + if (error) return entryRpcFailure(error, '/api/admin/entry/check-in'); + return NextResponse.json({ success: true, result: data }); + } catch (err: unknown) { + console.error('/api/admin/entry/check-in error:', err); + return NextResponse.json({ error: 'Check-in failed.' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/entry/lookup/route.ts b/src/app/api/admin/entry/lookup/route.ts new file mode 100644 index 0000000..48f1b6e --- /dev/null +++ b/src/app/api/admin/entry/lookup/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyRegistrationStaff } from '@/lib/auth-server'; +import { getSupabaseAsUser } from '@/lib/supabase'; +import { createRateLimiter, readJsonObject } from '@/lib/security'; +import { parseScannedValue } from '@/lib/entry-pass'; +import { badRequest, entryAuthFailure, entryRateLimited, entryRpcFailure } from '@/lib/entry-route'; + +export const runtime = 'nodejs'; + +// Per user: a volunteer scanning steadily makes well under one lookup a second. +const lookupLimiter = createRateLimiter({ limit: 120, windowMs: 60 * 1000 }); + +/** + * POST /api/admin/entry/lookup { token } + * Registration team / admin. What a scanned QR belongs to: VALID (details to compare with the college ID), + * ALREADY_CHECKED_IN, or INVALID with no personal data. Changes nothing except the audit log. + */ +export async function POST(req: NextRequest) { + try { + const auth = await verifyRegistrationStaff(req); + if ('error' in auth) { + return entryAuthFailure(auth); + } + const limited = entryRateLimited(lookupLimiter, auth.user.id); + if (limited) return limited; + const db = getSupabaseAsUser(auth.token); + + const parsed = await readJsonObject(req); + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status }); + const token = parseScannedValue(parsed.value.token); + if (!token) return badRequest('Missing scanned code.'); + + const { data, error } = await db.rpc('lookup_entry_pass', { p_token: token }); + if (error) return entryRpcFailure(error, '/api/admin/entry/lookup'); + return NextResponse.json({ success: true, result: data }); + } catch (err: unknown) { + console.error('/api/admin/entry/lookup error:', err); + return NextResponse.json({ error: 'Lookup failed.' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/entry/manual-lookup/route.ts b/src/app/api/admin/entry/manual-lookup/route.ts new file mode 100644 index 0000000..48a9f5f --- /dev/null +++ b/src/app/api/admin/entry/manual-lookup/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyRegistrationStaff } from '@/lib/auth-server'; +import { getSupabaseAsUser } from '@/lib/supabase'; +import { createRateLimiter, readJsonObject } from '@/lib/security'; +import { parseManualQuery } from '@/lib/entry-pass'; +import { badRequest, entryAuthFailure, entryRateLimited, entryRpcFailure } from '@/lib/entry-route'; + +export const runtime = 'nodejs'; + +const manualLookupLimiter = createRateLimiter({ limit: 60, windowMs: 60 * 1000 }); + +/** + * POST /api/admin/entry/manual-lookup { query } + * Registration team / admin, for a damaged or missing QR: exact registration ID (IV26-NNNN) or roll number. + * Same answer shape as a scan (plus AMBIGUOUS when a roll number matches several registrations). Logged. + */ +export async function POST(req: NextRequest) { + try { + const auth = await verifyRegistrationStaff(req); + if ('error' in auth) { + return entryAuthFailure(auth); + } + const limited = entryRateLimited(manualLookupLimiter, auth.user.id); + if (limited) return limited; + const db = getSupabaseAsUser(auth.token); + + const parsed = await readJsonObject(req); + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status }); + const query = parseManualQuery(parsed.value.query); + if (!query) return badRequest('Enter a registration ID (IV26-1234) or a roll number.'); + + const { data, error } = await db.rpc('lookup_entry_manual', { p_query: query }); + if (error) return entryRpcFailure(error, '/api/admin/entry/manual-lookup'); + return NextResponse.json({ success: true, result: data }); + } catch (err: unknown) { + console.error('/api/admin/entry/manual-lookup error:', err); + return NextResponse.json({ error: 'Lookup failed.' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/entry/overview/route.ts b/src/app/api/admin/entry/overview/route.ts new file mode 100644 index 0000000..4b7eb5f --- /dev/null +++ b/src/app/api/admin/entry/overview/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyAdmin } from '@/lib/auth-server'; +import { getSupabaseAsUser } from '@/lib/supabase'; +import { createRateLimiter, sanitizeFilterValue } from '@/lib/security'; +import { parsePage } from '@/lib/entry-pass'; +import { badRequest, entryAuthFailure, entryRateLimited, entryRpcFailure } from '@/lib/entry-route'; + +export const runtime = 'nodejs'; + +// The dashboard polls every ~12 s; this leaves room for searching and paging on top. +const overviewLimiter = createRateLimiter({ limit: 60, windowMs: 60 * 1000 }); + +const PAGE_SIZE = 30; +const LISTS = ['registrations', 'checkins', 'rejections', 'audit'] as const; +type List = (typeof LISTS)[number]; +const FILTERS = ['all', 'checked_in', 'waiting'] as const; +const AUDIT_ACTIONS = ['lookup', 'manual_lookup', 'check_in', 'reject', 'void', 'reissue'] as const; + +/** + * GET /api/admin/entry/overview?list=registrations|checkins|rejections|audit&q=&page=&filter=&action= + * Admin only, read-only (no GET writes anything). Counts per registration plus one page of the chosen list. + * Reads run as the admin (getSupabaseAsUser), so the admin-only RLS on the entry tables applies. + */ +export async function GET(req: NextRequest) { + try { + const auth = await verifyAdmin(req); + if ('error' in auth) { + return entryAuthFailure(auth); + } + const limited = entryRateLimited(overviewLimiter, auth.user.id); + if (limited) return limited; + const db = getSupabaseAsUser(auth.token); + + const params = req.nextUrl.searchParams; + const list = (params.get('list') || 'registrations') as List; + if (!LISTS.includes(list)) return badRequest('Invalid list.'); + const filter = params.get('filter') || 'all'; + if (!(FILTERS as readonly string[]).includes(filter)) return badRequest('Invalid filter.'); + const action = params.get('action') || ''; + if (action && !(AUDIT_ACTIONS as readonly string[]).includes(action)) return badRequest('Invalid action.'); + // Stripped of PostgREST filter syntax so a search term can't inject extra `.or()` conditions. + const q = sanitizeFilterValue(params.get('q'), 60); + const page = parsePage(params.get('page')); + const from = (page - 1) * PAGE_SIZE; + + const counts = await db.rpc('entry_overview_counts'); + if (counts.error) return entryRpcFailure(counts.error, '/api/admin/entry/overview'); + + let query; + if (list === 'registrations') { + query = db.from('entry_overview').select('*'); + if (filter === 'checked_in') query = query.not('checkin_id', 'is', null); + if (filter === 'waiting') query = query.is('checkin_id', null); + if (q) query = query.or(`registration_id.ilike.%${q}%,enrollment_no.ilike.%${q}%,name.ilike.%${q}%`); + query = query.order('registration_id', { ascending: true }); + } else if (list === 'checkins') { + query = db.from('entry_checkin_feed').select('*'); + if (q) query = query.or(`registration_id.ilike.%${q}%,enrollment_no.ilike.%${q}%,name.ilike.%${q}%`); + query = query.order('created_at', { ascending: false }); + } else { + query = db.from('entry_audit_feed').select('*'); + if (list === 'rejections') query = query.eq('action', 'reject'); + else if (action) query = query.eq('action', action); + if (q) query = query.or(`registration_id.ilike.%${q}%,name.ilike.%${q}%,actor_name.ilike.%${q}%`); + query = query.order('created_at', { ascending: false }); + } + + // One row more than a page tells whether a next page exists, without counting the whole table. + const { data, error } = await query.range(from, from + PAGE_SIZE); + if (error) return entryRpcFailure(error, '/api/admin/entry/overview'); + const rows = data ?? []; + + return NextResponse.json({ + success: true, + counts: counts.data, + list, + page, + pageSize: PAGE_SIZE, + hasMore: rows.length > PAGE_SIZE, + rows: rows.slice(0, PAGE_SIZE), + }); + } catch (err: unknown) { + console.error('/api/admin/entry/overview error:', err); + return NextResponse.json({ error: 'Failed to load entry data.' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/entry/reissue/route.ts b/src/app/api/admin/entry/reissue/route.ts new file mode 100644 index 0000000..1be6c37 --- /dev/null +++ b/src/app/api/admin/entry/reissue/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyAdmin } from '@/lib/auth-server'; +import { getSupabaseAsUser } from '@/lib/supabase'; +import { createRateLimiter, isUuid, readJsonObject } from '@/lib/security'; +import { badRequest, entryAuthFailure, entryRateLimited, entryRpcFailure } from '@/lib/entry-route'; + +export const runtime = 'nodejs'; + +const reissueLimiter = createRateLimiter({ limit: 30, windowMs: 60 * 1000 }); + +/** + * POST /api/admin/entry/reissue { registration } + * Admin only: a new QR for a lost or leaked pass. The old QR stops working immediately; the owner's profile shows the + * new one. Refused (409) once the registration is checked in. + */ +export async function POST(req: NextRequest) { + try { + const auth = await verifyAdmin(req); + if ('error' in auth) { + return entryAuthFailure(auth); + } + const limited = entryRateLimited(reissueLimiter, auth.user.id); + if (limited) return limited; + const db = getSupabaseAsUser(auth.token); + + const parsed = await readJsonObject(req); + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status }); + const { registration } = parsed.value; + if (!isUuid(registration)) return badRequest('Invalid registration.'); + + const { data, error } = await db.rpc('reissue_entry_pass', { p_registration: registration }); + if (error) return entryRpcFailure(error, '/api/admin/entry/reissue'); + return NextResponse.json({ success: true, result: data }); + } catch (err: unknown) { + console.error('/api/admin/entry/reissue error:', err); + return NextResponse.json({ error: 'Could not reissue the pass.' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/entry/reject/route.ts b/src/app/api/admin/entry/reject/route.ts new file mode 100644 index 0000000..3c6f311 --- /dev/null +++ b/src/app/api/admin/entry/reject/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyRegistrationStaff } from '@/lib/auth-server'; +import { getSupabaseAsUser } from '@/lib/supabase'; +import { createRateLimiter, isUuid, readJsonObject } from '@/lib/security'; +import { parseRejectReason, parseScannedValue } from '@/lib/entry-pass'; +import { badRequest, entryAuthFailure, entryRateLimited, entryRpcFailure } from '@/lib/entry-route'; + +export const runtime = 'nodejs'; + +const rejectLimiter = createRateLimiter({ limit: 60, windowMs: 60 * 1000 }); + +/** + * POST /api/admin/entry/reject { token | registration, reason: 'id_mismatch' | 'no_id' | 'other', note? } + * Registration team / admin: the scanner's "Reject". Changes nothing on the pass (it stays usable so the case can be + * resolved later); only logs the attempt with who, when and why. + */ +export async function POST(req: NextRequest) { + try { + const auth = await verifyRegistrationStaff(req); + if ('error' in auth) { + return entryAuthFailure(auth); + } + const limited = entryRateLimited(rejectLimiter, auth.user.id); + if (limited) return limited; + const db = getSupabaseAsUser(auth.token); + + const parsed = await readJsonObject(req); + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status }); + const { token: rawToken, registration } = parsed.value; + + const reason = parseRejectReason(parsed.value.reason, parsed.value.note); + if (!reason.ok) return badRequest(reason.error); + const token = rawToken === undefined ? null : parseScannedValue(rawToken); + if ((token === null) === (registration === undefined)) return badRequest('Send either a scanned token or a registration.'); + if (registration !== undefined && !isUuid(registration)) return badRequest('Invalid registration.'); + + const { data, error } = await db.rpc('reject_entry_attempt', { + p_token: token, + p_registration: token ? null : registration, + p_reason: reason.reason, + p_note: reason.note, + }); + if (error) return entryRpcFailure(error, '/api/admin/entry/reject'); + return NextResponse.json({ success: true, result: data }); + } catch (err: unknown) { + console.error('/api/admin/entry/reject error:', err); + return NextResponse.json({ error: 'Could not log the rejection.' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/entry/void/route.ts b/src/app/api/admin/entry/void/route.ts new file mode 100644 index 0000000..9e7fc8f --- /dev/null +++ b/src/app/api/admin/entry/void/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyAdmin } from '@/lib/auth-server'; +import { getSupabaseAsUser } from '@/lib/supabase'; +import { createRateLimiter, isUuid, readJsonObject } from '@/lib/security'; +import { parseVoidReason } from '@/lib/entry-pass'; +import { badRequest, entryAuthFailure, entryRateLimited, entryRpcFailure } from '@/lib/entry-route'; + +export const runtime = 'nodejs'; + +const voidLimiter = createRateLimiter({ limit: 30, windowMs: 60 * 1000 }); + +/** + * POST /api/admin/entry/void { checkinId, reason } + * Admin only: marks a wrong check-in as voided (never deletes it), so the registration can be checked in again. + */ +export async function POST(req: NextRequest) { + try { + const auth = await verifyAdmin(req); + if ('error' in auth) { + return entryAuthFailure(auth); + } + const limited = entryRateLimited(voidLimiter, auth.user.id); + if (limited) return limited; + const db = getSupabaseAsUser(auth.token); + + const parsed = await readJsonObject(req); + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status }); + const { checkinId } = parsed.value; + if (!isUuid(checkinId)) return badRequest('Invalid checkinId.'); + const reason = parseVoidReason(parsed.value.reason); + if (!reason) return badRequest('A reason (up to 200 characters) is required.'); + + const { data, error } = await db.rpc('void_entry_checkin', { p_checkin_id: checkinId, p_reason: reason }); + if (error) return entryRpcFailure(error, '/api/admin/entry/void'); + return NextResponse.json({ success: true, result: data }); + } catch (err: unknown) { + console.error('/api/admin/entry/void error:', err); + return NextResponse.json({ error: 'Could not void the check-in.' }, { status: 500 }); + } +} diff --git a/src/app/api/pass/route.ts b/src/app/api/pass/route.ts new file mode 100644 index 0000000..9d343ea --- /dev/null +++ b/src/app/api/pass/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUser } from '@/lib/auth-server'; +import { getSupabaseAsUser } from '@/lib/supabase'; +import { createRateLimiter } from '@/lib/security'; +import { decryptEntryToken, encryptEntryToken, entryPassKey, mintEntryToken } from '@/lib/entry-pass'; + +export const runtime = 'nodejs'; + +const passLimiter = createRateLimiter({ limit: 20, windowMs: 60 * 1000 }); + +interface MyEntryPass { + registration_status: 'none' | 'pending' | 'confirmed' | 'rejected'; + registration_id?: string; + name?: string; + pass?: string | null; + pass_status: 'none' | 'active' | 'revoked'; + token_ciphertext?: string | null; + checked_in?: boolean; +} + +/** + * POST /api/pass + * The CALLER's own entry pass. The QR token is returned only to the owner, only while the registration is confirmed + * and the pass active. The first call after approval (or after a reissue) mints the token. POST, not GET, because + * that first call writes. + */ +export async function POST(req: NextRequest) { + try { + const auth = await getAuthenticatedUser(req); + if (!auth) { + return NextResponse.json({ error: 'Your session has expired. Please log in again.' }, { status: 401 }); + } + if (!passLimiter.hit(auth.user.id)) { + return NextResponse.json( + { error: 'Too many requests. Please wait a moment.' }, + { status: 429, headers: { 'Retry-After': String(passLimiter.retryAfterSeconds(auth.user.id)) } } + ); + } + + const db = getSupabaseAsUser(auth.token); + const { data, error } = await db.rpc('my_entry_pass'); + if (error || !data) { + console.error('/api/pass database error:', error); + return NextResponse.json({ error: 'Could not load your pass.' }, { status: 500 }); + } + const mine = data as MyEntryPass; + + const pass = { + registrationStatus: mine.registration_status, + passStatus: mine.pass_status, + registrationId: mine.registration_id ?? null, + name: mine.name ?? null, + checkedIn: !!mine.checked_in, + token: null as string | null, + }; + if (mine.registration_status !== 'confirmed' || mine.pass_status !== 'active' || !mine.pass) { + return NextResponse.json({ success: true, pass }); + } + + const key = entryPassKey(); + if (!key) { + console.error('/api/pass: ENTRY_PASS_ENCRYPTION_KEY is missing or not 32 bytes'); + return NextResponse.json({ error: 'Entry passes are not available yet. Please try again later.' }, { status: 503 }); + } + + let ciphertext = mine.token_ciphertext ?? null; + if (!ciphertext) { + const { token, hash } = mintEntryToken(); + const claimed = await db.rpc('claim_entry_pass_token', { + p_pass: mine.pass, + p_hash: hash, + p_ciphertext: encryptEntryToken(token, mine.pass, key), + }); + if (claimed.error || typeof claimed.data !== 'string') { + console.error('/api/pass claim error:', claimed.error); + return NextResponse.json({ error: 'Could not create your pass. Please try again.' }, { status: 500 }); + } + ciphertext = claimed.data; // ours, or the one a parallel request stored first + } + + pass.token = decryptEntryToken(ciphertext, mine.pass, key); + if (!pass.token) { + console.error('/api/pass: stored token does not decrypt (wrong ENTRY_PASS_ENCRYPTION_KEY?)'); + return NextResponse.json({ error: 'Your pass could not be loaded. Please contact the help desk.' }, { status: 500 }); + } + return NextResponse.json({ success: true, pass }); + } catch (err: unknown) { + console.error('/api/pass error:', err); + return NextResponse.json({ error: 'Could not load your pass.' }, { status: 500 }); + } +} diff --git a/src/components/entry/EntryScanner.module.css b/src/components/entry/EntryScanner.module.css new file mode 100644 index 0000000..819c965 --- /dev/null +++ b/src/components/entry/EntryScanner.module.css @@ -0,0 +1,65 @@ +/* Check-in scanner (/admin/scan). Phone-first, used outdoors: big type, flat high-contrast result colours. + html/body don't scroll (globals.css), so the page is its own scroller. */ + +/* The site-wide paper-noise overlay would dull the result colours in sunlight. */ +:global(body):has(.root)::after{display:none} + +.root{position:fixed;inset:0;overflow-y:auto;display:flex;flex-direction:column;background:#141312;color:#ECE8DF;font-family:var(--font-sans);-webkit-tap-highlight-color:transparent} + +.bar{position:sticky;top:0;z-index:5;display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:calc(10px + env(safe-area-inset-top)) 16px 10px;background:rgba(20,19,18,.92);border-bottom:1px solid rgba(236,232,223,.14)} +.brand{margin:0;font-family:var(--font-display);font-weight:400;font-size:20px;line-height:1} +.desk{margin-left:auto;display:flex;align-items:center;gap:8px;font-size:13px;font-weight:700;letter-spacing:.06em} +.linkBtn{appearance:none;border:0;background:none;color:oklch(0.8 0.12 85);font:inherit;font-weight:700;text-decoration:underline;text-underline-offset:3px;padding:6px 2px;cursor:pointer;min-height:36px} + +.main{flex:1;display:flex;flex-direction:column;padding:16px;gap:16px} + +/* Camera */ +.cameraWrap{position:relative;width:100%;max-width:560px;margin:0 auto;aspect-ratio:3/4;max-height:62vh;background:#000;overflow:hidden;border-radius:4px} +.video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover} +.frame{position:absolute;inset:16%;border:3px solid rgba(236,232,223,.85);border-radius:10px;box-shadow:0 0 0 999px rgba(0,0,0,.28);pointer-events:none} +.hint{margin:0;text-align:center;font-size:16px;line-height:1.45;color:rgba(236,232,223,.82)} +.cameraMsg{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:20px;text-align:center;background:#1f1d1b;font-size:15px;line-height:1.5} +.cameraMsg ol{margin:0;padding-left:20px;text-align:left} + +/* Full-screen result states */ +.screen{position:fixed;inset:0;z-index:20;overflow-y:auto;display:flex;flex-direction:column;gap:18px;padding:calc(24px + env(safe-area-inset-top)) 20px calc(24px + env(safe-area-inset-bottom))} +.green{background:#0b6b34;color:#fff} +.amber{background:#f2b705;color:#141312} +.red{background:#a4161a;color:#fff} +.grey{background:#4b4b4b;color:#fff} +.dark{background:#141312;color:#ECE8DF} + +.state{margin:0;font-weight:800;font-size:clamp(34px,10vw,56px);line-height:1;letter-spacing:.01em} +.sub{margin:0;font-size:clamp(18px,5vw,24px);line-height:1.35;font-weight:600} +.details{margin:0;display:grid;grid-template-columns:auto 1fr;gap:10px 14px;font-size:clamp(17px,4.8vw,22px);line-height:1.3} +.details dt{font-size:12px;font-weight:800;letter-spacing:.14em;text-transform:uppercase;opacity:.8;align-self:center} +.details dd{margin:0;font-weight:700;overflow-wrap:anywhere} +.big{font-size:clamp(24px,7vw,34px)} + +.actions{margin-top:auto;display:flex;flex-direction:column;gap:12px} +.btn{appearance:none;border:0;cursor:pointer;min-height:64px;padding:0 24px;font:inherit;font-size:20px;font-weight:800;letter-spacing:.06em;border-radius:6px;touch-action:manipulation} +.btn:disabled{opacity:.55;cursor:default} +.btnApprove{background:#fff;color:#0b6b34;min-height:84px;font-size:28px} +.btnReject{background:transparent;color:inherit;border:3px solid currentColor} +.btnNeutral{background:#ECE8DF;color:#141312} +.btnGhost{background:transparent;color:inherit;border:2px solid currentColor;min-height:54px;font-size:17px} + +.reasons{display:grid;grid-template-columns:1fr;gap:10px} +.reason{appearance:none;cursor:pointer;min-height:58px;padding:0 18px;border:2px solid rgba(255,255,255,.7);border-radius:6px;background:transparent;color:inherit;font:inherit;font-size:19px;font-weight:700;text-align:left} +.reasonOn{background:#fff;color:#a4161a;border-color:#fff} + +.field{display:flex;flex-direction:column;gap:8px;font-size:14px;font-weight:700;letter-spacing:.06em} +.input{min-height:54px;padding:0 14px;border:2px solid rgba(236,232,223,.4);border-radius:6px;background:#1f1d1b;color:#ECE8DF;font:inherit;font-size:19px;font-weight:600;letter-spacing:0} +.screen .input{background:rgba(0,0,0,.25);color:inherit;border-color:rgba(255,255,255,.6)} +.input:focus{outline:3px solid oklch(0.8 0.12 85);outline-offset:1px} +.error{margin:0;font-size:15px;font-weight:700;color:#ffb4ab} +.chips{display:flex;flex-wrap:wrap;gap:8px} +.chip{appearance:none;cursor:pointer;min-height:44px;padding:0 16px;border:1.5px solid rgba(236,232,223,.45);border-radius:22px;background:transparent;color:#ECE8DF;font:inherit;font-size:15px;font-weight:700} + +.panel{width:100%;max-width:560px;margin:0 auto;display:flex;flex-direction:column;gap:14px} +.title{margin:0;font-family:var(--font-display);font-weight:400;font-size:clamp(30px,8vw,44px);line-height:1.05} +.muted{margin:0;font-size:15px;line-height:1.5;color:rgba(236,232,223,.75)} +.spinner{width:56px;height:56px;border:6px solid rgba(236,232,223,.25);border-top-color:#ECE8DF;border-radius:50%;animation:spin .8s linear infinite} +@keyframes spin{to{transform:rotate(360deg)}} +@media (prefers-reduced-motion:reduce){.spinner{animation-duration:2.4s}} +.center{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;text-align:center} diff --git a/src/components/entry/EntryScanner.tsx b/src/components/entry/EntryScanner.tsx new file mode 100644 index 0000000..9c6969f --- /dev/null +++ b/src/components/entry/EntryScanner.tsx @@ -0,0 +1,796 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { fetchSessionFromDatabase, getSupabase } from '@/lib/supabase'; +import { DESK_RE, REJECT_REASONS, REJECT_REASON_LABELS, type RejectReason } from '@/lib/entry-shared'; +import styles from './EntryScanner.module.css'; + +/* + * Check-in scanner for the registration team. Every decision is the server's: this page only shows what + * /api/admin/entry/* answered. It never shows green without a server-confirmed answer: no answer, a timeout or a + * server error is UNVERIFIED (grey, with Retry), and an expired session is its own screen. + * UI says "Approve"; in the code and the database this is a check-in (registration approval is something else). + */ + +interface Details { + registration: string; + registration_id: string; + name: string; + enrollment_no: string | null; + college: string; + phone: string; + utr: string | null; +} +interface CheckInInfo { + registration_id?: string; + name?: string; + checked_in_at: string; + checked_in_by?: string; + desk: string; + method?: string; +} +type EntryResult = + | ({ state: 'VALID' } & Details) + | ({ state: 'CHECKED_IN' } & CheckInInfo) + | ({ state: 'ALREADY_CHECKED_IN' } & CheckInInfo) + | { state: 'INVALID'; reason: string } + | { state: 'REJECTED'; registration_id: string; reason: string } + | { state: 'AMBIGUOUS'; count: number }; + +type Source = { token: string } | { registration: string }; + +type View = + | { kind: 'scanning' } + | { kind: 'busy'; label: string } + | { kind: 'valid'; details: Details; source: Source; scanId: string } + | { kind: 'reject'; details: Details; source: Source; scanId: string } + | { kind: 'checked_in'; info: CheckInInfo } + | { kind: 'already'; info: CheckInInfo } + | { kind: 'invalid'; reason: string } + | { kind: 'rejected'; registrationId: string; reason: string } + | { kind: 'ambiguous'; count: number } + | { kind: 'refused'; message: string } + | { kind: 'unverified'; message: string; retry: () => void } + | { kind: 'session' } + | { kind: 'manual' }; + +type CallResult = + | { kind: 'ok'; result: EntryResult } + | { kind: 'refused'; message: string } + | { kind: 'unverified'; message: string } + | { kind: 'session' }; + +type CameraState = + | { status: 'starting' } + | { status: 'on' } + | { status: 'denied' } + | { status: 'missing' } + | { status: 'insecure' } + | { status: 'error'; message: string }; + +const DESK_KEY = 'inv_entry_desk'; +const REQUEST_TIMEOUT_MS = 8000; +const SAME_CODE_IGNORE_MS = 3000; +const DESK_SUGGESTIONS = ['Gate 1', 'Gate 2', 'Main Gate', 'Help Desk']; +const LOGIN_URL = '/?auth=login&required=scan'; // the home page returns to /admin/scan after login + +const INVALID_REASONS: Record = { + unknown: 'This QR is not an Innovision entry pass.', + revoked: 'This pass was replaced or cancelled.', + not_confirmed: 'This registration is not confirmed.', + not_found: 'No confirmed registration matches.', +}; + +// ---- session and requests ------------------------------------------------------------------------------------ + +/** A current access token. supabase-js refreshes an expired one before returning it. */ +async function currentToken(): Promise { + try { + const { data } = await getSupabase().auth.getSession(); + if (data.session?.access_token) return data.session.access_token; + } catch {} + return (await fetchSessionFromDatabase()).tokens?.access_token ?? null; +} + +/** After a 401: force a refresh (the auto-refresh timer is throttled on phones), else the server-side refresh. */ +async function refreshedToken(): Promise { + try { + const { data, error } = await getSupabase().auth.refreshSession(); + if (!error && data.session?.access_token) return data.session.access_token; + } catch {} + return (await fetchSessionFromDatabase()).tokens?.access_token ?? null; +} + +const offline = () => typeof navigator !== 'undefined' && navigator.onLine === false; + +async function send(path: string, body: unknown, token: string): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS); + try { + return await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + cache: 'no-store', + signal: ctrl.signal, + }); + } finally { + clearTimeout(timer); + } +} + +async function post(path: string, body: unknown): Promise { + const noAnswer = (message: string): CallResult => ({ kind: 'unverified', message }); + try { + let token = await currentToken(); + if (!token) return offline() ? noAnswer('This phone is offline.') : { kind: 'session' }; + let res = await send(path, body, token); + if (res.status === 401) { + token = await refreshedToken(); + if (!token) return offline() ? noAnswer('This phone is offline.') : { kind: 'session' }; + res = await send(path, body, token); + if (res.status === 401) return { kind: 'session' }; + } + const data = await res.json().catch(() => null); + if (res.ok && data?.result) return { kind: 'ok', result: data.result as EntryResult }; + if (res.status === 429) return noAnswer(data?.error || 'Too many requests. Wait a few seconds, then retry.'); + if (res.status >= 500 || !data) return noAnswer(`The server did not confirm (HTTP ${res.status}).`); + return { kind: 'refused', message: data.error || `Request refused (HTTP ${res.status}).` }; + } catch (err) { + const timedOut = err instanceof DOMException && err.name === 'AbortError'; + return noAnswer(timedOut ? 'No answer from the server within 8 seconds.' : 'Network error. Check the connection.'); + } +} + +function newScanId(): string { + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); + const b = crypto.getRandomValues(new Uint8Array(16)); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; +} + +const timeOf = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? iso + : d.toLocaleString('en-IN', { timeZone: 'Asia/Kolkata', day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', second: '2-digit' }); +}; + +const noSubscription = () => () => {}; + +function readDesk(): string { + try { + const d = localStorage.getItem(DESK_KEY) || ''; + return DESK_RE.test(d) ? d : ''; + } catch { + return ''; + } +} + +// ---- feedback ---------------------------------------------------------------------------------------------------- + +type Tone = 'ok' | 'warn' | 'bad'; + +// Native BarcodeDetector is not in TypeScript's DOM lib yet. +interface DetectedCode { rawValue: string } +interface NativeBarcodeDetector { detect(source: HTMLVideoElement): Promise } +interface NativeBarcodeDetectorClass { + new (opts: { formats: string[] }): NativeBarcodeDetector; + getSupportedFormats?: () => Promise; +} + +export default function EntryScanner({ staffLabel }: { staffLabel: string }) { + // The desk chosen earlier on this phone (localStorage; per device, never shared). null while server rendering. + const storedDesk = useSyncExternalStore(noSubscription, readDesk, () => null); + const [chosenDesk, setChosenDesk] = useState(null); + const desk = chosenDesk ?? storedDesk ?? ''; + const deskReady = storedDesk !== null; + const [editingDesk, setEditingDesk] = useState(false); + const showDeskForm = editingDesk || !desk; + const [deskDraft, setDeskDraft] = useState(''); + const [view, setView] = useState({ kind: 'scanning' }); + const [camera, setCamera] = useState({ status: 'starting' }); + const [cameraAttempt, setCameraAttempt] = useState(0); + const [manualQuery, setManualQuery] = useState(''); + const [rejectReason, setRejectReason] = useState(null); + const [rejectNote, setRejectNote] = useState(''); + const [formError, setFormError] = useState(''); + + const videoRef = useRef(null); + const viewRef = useRef(view); + const lastCodeRef = useRef<{ value: string; at: number } | null>(null); + const audioRef = useRef(null); + const onCodeRef = useRef<(value: string) => void>(() => {}); + + useEffect(() => { + viewRef.current = view; + }, [view]); + + // The page loaded with a valid session: allow SessionRestore to try again some other time. + useEffect(() => { + try { sessionStorage.removeItem('inv_scan_restore'); } catch {} + }, []); + + // Audio must be unlocked by a tap; every button calls this. + const unlockAudio = () => { + try { + if (!audioRef.current) { + const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (Ctx) audioRef.current = new Ctx(); + } + void audioRef.current?.resume(); + } catch {} + }; + + const feedback = useCallback((tone: Tone) => { + try { + navigator.vibrate?.(tone === 'ok' ? [90] : tone === 'warn' ? [70, 60, 70] : [300]); + } catch {} + const ctx = audioRef.current; + if (!ctx || ctx.state !== 'running') return; + try { + const notes = tone === 'ok' ? [880, 1320] : tone === 'warn' ? [520, 520] : [220]; + notes.forEach((freq, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + const start = ctx.currentTime + i * 0.16; + osc.frequency.value = freq; + osc.type = tone === 'bad' ? 'square' : 'sine'; + gain.gain.setValueAtTime(0.25, start); + gain.gain.exponentialRampToValueAtTime(0.001, start + (tone === 'bad' ? 0.45 : 0.14)); + osc.connect(gain).connect(ctx.destination); + osc.start(start); + osc.stop(start + 0.5); + }); + } catch {} + }, []); + + const showResult = useCallback((result: EntryResult, source: Source | null, scanId: string) => { + switch (result.state) { + case 'VALID': + if (!source) source = { registration: result.registration }; + feedback('ok'); + setRejectReason(null); + setRejectNote(''); + setFormError(''); + setView({ kind: 'valid', details: result, source, scanId }); + return; + case 'CHECKED_IN': + feedback('ok'); + setView({ kind: 'checked_in', info: result }); + return; + case 'ALREADY_CHECKED_IN': + feedback('warn'); + setView({ kind: 'already', info: result }); + return; + case 'REJECTED': + feedback('warn'); + setView({ kind: 'rejected', registrationId: result.registration_id, reason: result.reason }); + return; + case 'AMBIGUOUS': + feedback('warn'); + setView({ kind: 'ambiguous', count: result.count }); + return; + default: + feedback('bad'); + setView({ kind: 'invalid', reason: result.state === 'INVALID' ? result.reason : 'unknown' }); + } + }, [feedback]); + + const handle = useCallback((r: CallResult, source: Source | null, scanId: string, retry: () => void) => { + if (r.kind === 'ok') return showResult(r.result, source, scanId); + if (r.kind === 'session') return setView({ kind: 'session' }); + feedback('bad'); + if (r.kind === 'refused') return setView({ kind: 'refused', message: r.message }); + setView({ kind: 'unverified', message: r.message, retry }); + }, [feedback, showResult]); + + const lookupToken = useCallback(async (token: string) => { + const scanId = newScanId(); + const run = async () => { + setView({ kind: 'busy', label: 'Checking pass…' }); + handle(await post('/api/admin/entry/lookup', { token }), { token }, scanId, run); + }; + await run(); + }, [handle]); + + const lookupManual = async (query: string) => { + const scanId = newScanId(); + const run = async () => { + setView({ kind: 'busy', label: 'Looking up…' }); + handle(await post('/api/admin/entry/manual-lookup', { query }), null, scanId, run); + }; + await run(); + }; + + // Approve = check-in. The same scanId on every retry, so a retry after a lost answer is recognised as the same tap. + const approve = async (source: Source, scanId: string) => { + unlockAudio(); + const run = async () => { + setView({ kind: 'busy', label: 'Checking in…' }); + handle(await post('/api/admin/entry/check-in', { ...source, scanId, desk }), source, scanId, run); + }; + await run(); + }; + + const submitReject = async (details: Details, source: Source, scanId: string) => { + unlockAudio(); + if (!rejectReason) return setFormError('Choose a reason.'); + const note = rejectNote.replace(/\s+/g, ' ').trim(); + if (rejectReason === 'other' && !note) return setFormError('Describe the reason.'); + const body = { ...source, reason: rejectReason, note: note || undefined }; + const run = async () => { + setView({ kind: 'busy', label: 'Logging rejection…' }); + const r = await post('/api/admin/entry/reject', body); + if (r.kind === 'refused') { + // A validation message: stay on the form. + feedback('bad'); + setFormError(r.message); + setView({ kind: 'reject', details, source, scanId }); + return; + } + handle(r, source, scanId, run); + }; + await run(); + }; + + // Every sighting of a code refreshes its timestamp, so a QR held in front of the camera is looked up once, and + // again only after it has been out of view for SAME_CODE_IGNORE_MS. + useEffect(() => { + onCodeRef.current = (raw: string) => { + const value = raw.trim(); + if (!value) return; + const now = Date.now(); + const last = lastCodeRef.current; + const repeat = last && last.value === value && now - last.at < SAME_CODE_IGNORE_MS; + lastCodeRef.current = { value, at: now }; + if (repeat || viewRef.current.kind !== 'scanning') return; + viewRef.current = { kind: 'busy', label: '' }; // before the re-render, so the next frame can't start a second lookup + void lookupToken(value); + }; + }, [lookupToken]); + + // Camera: native BarcodeDetector where available, else ZXing (iOS Safari). Restarted when the page comes back to + // the foreground (mobile browsers suspend the camera in the background). + useEffect(() => { + if (!deskReady || showDeskForm) return; + let stopped = false; + let stream: MediaStream | null = null; + let timer: ReturnType | undefined; + let zxing: { stop(): void } | null = null; + + (async () => { + setCamera({ status: 'starting' }); + if (!window.isSecureContext) return setCamera({ status: 'insecure' }); + if (!navigator.mediaDevices?.getUserMedia) return setCamera({ status: 'error', message: 'This browser cannot open the camera.' }); + try { + stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false }); + } catch (err) { + const name = err instanceof DOMException ? err.name : ''; + if (name === 'NotAllowedError' || name === 'SecurityError') return setCamera({ status: 'denied' }); + if (name === 'NotFoundError' || name === 'OverconstrainedError') return setCamera({ status: 'missing' }); + return setCamera({ status: 'error', message: 'The camera could not be started. Close other apps using it and retry.' }); + } + if (stopped || !videoRef.current) { + stream.getTracks().forEach((t) => t.stop()); + return; + } + const video = videoRef.current; + video.srcObject = stream; + await video.play().catch(() => {}); + setCamera({ status: 'on' }); + + const Native = (window as unknown as { BarcodeDetector?: NativeBarcodeDetectorClass }).BarcodeDetector; + let native = false; + if (Native) { + try { + const formats = await Native.getSupportedFormats?.(); + native = !formats || formats.includes('qr_code'); + } catch {} + } + if (native && Native) { + const detector = new Native({ formats: ['qr_code'] }); + const tick = async () => { + if (stopped) return; + if (video.readyState >= 2) { + try { + const codes = await detector.detect(video); + if (codes[0]?.rawValue) onCodeRef.current(codes[0].rawValue); + } catch {} + } + timer = setTimeout(tick, 150); + }; + void tick(); + } else { + const { BrowserQRCodeReader } = await import('@zxing/browser'); + if (stopped) return; + const reader = new BrowserQRCodeReader(undefined, { delayBetweenScanAttempts: 150 }); + zxing = await reader.decodeFromStream(stream, video, (result) => { + if (result) onCodeRef.current(result.getText()); + }); + if (stopped) zxing.stop(); + } + })(); + + return () => { + stopped = true; + clearTimeout(timer); + zxing?.stop(); + stream?.getTracks().forEach((t) => t.stop()); + }; + }, [desk, deskReady, showDeskForm, cameraAttempt]); + + // Back in the foreground: refresh the session early and restart the camera. + useEffect(() => { + const onVisible = () => { + if (document.visibilityState !== 'visible') return; + void getSupabase().auth.getSession().catch(() => {}); + setCameraAttempt((n) => n + 1); + }; + document.addEventListener('visibilitychange', onVisible); + return () => document.removeEventListener('visibilitychange', onVisible); + }, []); + + const saveDesk = () => { + unlockAudio(); + const value = deskDraft.replace(/\s+/g, ' ').trim(); + if (!DESK_RE.test(value)) return setFormError('Use letters and digits, up to 40 characters (e.g. Gate 1).'); + try { localStorage.setItem(DESK_KEY, value); } catch {} + setFormError(''); + setChosenDesk(value); + setEditingDesk(false); + }; + + const backToScanning = () => { + unlockAudio(); + setFormError(''); + setView({ kind: 'scanning' }); + }; + + // ---- render ------------------------------------------------------------------------------------------------------ + + if (!deskReady) return
; + + if (showDeskForm) { + return ( +
+
+
+

Which desk is this?

+

Every check-in records the desk. This phone remembers it until you change it.

+
+ {DESK_SUGGESTIONS.map((d) => ( + + ))} +
+ + {formError &&

{formError}

} + + {desk && ( + + )} +

Signed in as {staffLabel}

+
+
+
+ ); + } + + return ( +
+
+

Innovision check-in

+
+ {desk} + +
+
+ +
+
+
+ + {view.kind !== 'scanning' && ( + { setRejectReason(r); setFormError(''); }} + rejectNote={rejectNote} + setRejectNote={setRejectNote} + formError={formError} + setFormError={setFormError} + onApprove={approve} + onStartReject={(v) => { unlockAudio(); setFormError(''); setView({ kind: 'reject', details: v.details, source: v.source, scanId: v.scanId }); }} + onSubmitReject={submitReject} + onManualLookup={(q) => { unlockAudio(); void lookupManual(q); }} + onDone={backToScanning} + /> + )} +
+ ); +} + +function CameraMessage({ camera, onRetry }: { camera: CameraState; onRetry: () => void }) { + if (camera.status === 'starting') return
Starting camera…
; + if (camera.status === 'denied') { + return ( +
+ Camera access is blocked +
    +
  1. Tap the lock or "aA" icon next to the address bar.
  2. +
  3. Set Camera to Allow for this site.
  4. +
  5. On iPhone: Settings → Safari → Camera → Allow.
  6. +
  7. Then tap Retry (or reload the page).
  8. +
+ +
+ ); + } + if (camera.status === 'insecure') { + return
The camera only works over HTTPS. Open the site's https:// address.
; + } + const message = camera.status === 'missing' ? 'No camera was found on this device.' : camera.status === 'error' ? camera.message : ''; + return ( +
+ {message} + +
+ ); +} + +interface ResultScreenProps { + view: Exclude; + desk: string; + manualQuery: string; + setManualQuery: (q: string) => void; + rejectReason: RejectReason | null; + setRejectReason: (r: RejectReason) => void; + rejectNote: string; + setRejectNote: (n: string) => void; + formError: string; + setFormError: (e: string) => void; + onApprove: (source: Source, scanId: string) => void; + onStartReject: (v: { details: Details; source: Source; scanId: string }) => void; + onSubmitReject: (details: Details, source: Source, scanId: string) => void; + onManualLookup: (query: string) => void; + onDone: () => void; +} + +function ResultScreen(p: ResultScreenProps) { + const { view } = p; + const done = (label = 'Scan next') => ( +
+ +
+ ); + + switch (view.kind) { + case 'busy': + return ( +
+
+ +
+ ); + + case 'valid': + return ( +
+

VALID PASS

+

Compare with the college ID card.

+ +
+ + + +
+
+ ); + + case 'reject': + return ( +
+

REJECT

+

{view.details.name} · {view.details.registration_id}

+

Nothing changes on the pass; only this attempt is logged.

+
+ {REJECT_REASONS.map((r) => ( + + ))} +
+ + {p.formError &&

{p.formError}

} +
+ + +
+
+ ); + + case 'checked_in': + return ( +
+

CHECKED IN

+ {view.info.name &&

{view.info.name}

} +

{view.info.registration_id} · {timeOf(view.info.checked_in_at)} · {view.info.desk}

+

Hand over the Innovision ID card.

+ {done()} +
+ ); + + case 'already': + return ( +
+

ALREADY CHECKED IN

+ {view.info.name &&

{view.info.name} · {view.info.registration_id}

} +

+ Already checked in at {timeOf(view.info.checked_in_at)} by {view.info.checked_in_by || 'staff'}, {view.info.desk}. +

+ {done()} +
+ ); + + case 'invalid': + return ( +
+

INVALID PASS

+

{INVALID_REASONS[view.reason] ?? 'This pass cannot be used.'}

+

Send the person to the help desk.

+ {done()} +
+ ); + + case 'rejected': + return ( +
+

REJECTION LOGGED

+

{view.registrationId} · {REJECT_REASON_LABELS[view.reason] ?? view.reason}

+

The pass is unchanged and can be checked in later.

+ {done()} +
+ ); + + case 'ambiguous': + return ( +
+

SEVERAL MATCHES

+

{view.count} registrations share this roll number. Search by registration ID (IV26-…) instead.

+ {done('Back')} +
+ ); + + case 'refused': + return ( +
+

NOT ALLOWED

+

{view.message}

+ {done('Back')} +
+ ); + + case 'unverified': + return ( +
+

UNVERIFIED

+

{view.message}

+

Nothing is confirmed. Do not let the person in on this screen.

+
+ + +
+
+ ); + + case 'session': + return ( +
+

SESSION EXPIRED

+

Log in again to keep scanning. Nothing from the last scan was confirmed.

+ +
+ ); + + case 'manual': + return ( +
+

Manual lookup

+

For a damaged or missing QR. Enter the exact registration ID or roll number.

+
{ + e.preventDefault(); + const q = p.manualQuery.trim(); + if (!/^[A-Za-z0-9/_ -]{1,50}$/.test(q)) return p.setFormError('Enter a registration ID (IV26-1234) or a roll number.'); + p.setFormError(''); + p.onManualLookup(q); + }} + > + + {p.formError &&

{p.formError}

} + + +
+
+ ); + } +} + +function DetailsList({ details }: { details: Details }) { + return ( +
+
Name
+
{details.name}
+
Roll no.
+
{details.enrollment_no || '—'}
+
College
+
{details.college}
+
Phone
+
{details.phone}
+
UTR
+
{details.utr || '—'}
+
Reg. ID
+
{details.registration_id}
+
+ ); +} diff --git a/src/components/entry/SessionRestore.tsx b/src/components/entry/SessionRestore.tsx new file mode 100644 index 0000000..468ec0b --- /dev/null +++ b/src/components/entry/SessionRestore.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useEffect } from 'react'; +import { fetchSessionFromDatabase } from '@/lib/supabase'; +import styles from './EntryScanner.module.css'; + +const LOGIN_URL = '/?auth=login&required=scan'; // the home page returns to /admin/scan after login +const FLAG = 'inv_scan_restore'; + +/** + * Shown by /admin/scan when the access-token cookie has expired but a refresh token is still there (a phone left + * idle for over an hour). Refreshes the session once through /api/auth/session, which re-issues the cookies, then + * reloads; if that doesn't work, goes to the login screen. The flag stops a reload loop. + */ +export default function SessionRestore() { + useEffect(() => { + let alreadyTried = false; + try { + alreadyTried = sessionStorage.getItem(FLAG) === '1'; + sessionStorage.setItem(FLAG, '1'); + } catch {} + if (alreadyTried) { + window.location.replace(LOGIN_URL); + return; + } + fetchSessionFromDatabase() + .then((s) => { + if (s.tokens?.access_token) window.location.reload(); + else window.location.replace(LOGIN_URL); + }) + .catch(() => window.location.replace(LOGIN_URL)); + }, []); + + return ( +
+
+ +
+ ); +} diff --git a/src/components/innovision/AdminDashboard.tsx b/src/components/innovision/AdminDashboard.tsx index 78e2007..92a8b0d 100644 --- a/src/components/innovision/AdminDashboard.tsx +++ b/src/components/innovision/AdminDashboard.tsx @@ -8,6 +8,7 @@ import { csvCell, GENDER_OPTIONS, safeHttpUrl } from '@/lib/validation'; const genderLabel = (value: string) => GENDER_OPTIONS.find((g) => g.value === value)?.label ?? value; import EventsManager from './admin/EventsManager'; import GalleryManager from './admin/GalleryManager'; +import EntryManager from './admin/EntryManager'; import { httpErrorMessage } from '@/lib/api-errors'; interface AdminDashboardProps { @@ -30,7 +31,7 @@ export default function AdminDashboard({ const canReviewRegistrations = isAdmin || isRegTeam; const defaultTab = canReviewRegistrations ? 'registrations' : 'events'; - const [tab, setTab] = useState<'registrations' | 'events' | 'gallery' | 'users'>(defaultTab); + const [tab, setTab] = useState<'registrations' | 'events' | 'gallery' | 'users' | 'entry'>(defaultTab); const [registrations, setRegistrations] = useState([]); const [users, setUsers] = useState([]); const [loadingRegs, setLoadingRegs] = useState(false); @@ -201,6 +202,7 @@ export default function AdminDashboard({ // Active tab: 'users' is strictly admin-only; staff have their own tabs let activeTab = tab; if (tab === 'users' && !isAdmin) activeTab = defaultTab; + if (tab === 'entry' && !isAdmin) activeTab = defaultTab; if (tab === 'registrations' && !canReviewRegistrations) activeTab = defaultTab; if ((tab === 'events' || tab === 'gallery') && !isAdmin && !isItTeam) activeTab = defaultTab; @@ -871,6 +873,49 @@ export default function AdminDashboard({ USERS & ROLES ({users.length}) )} + + {isAdmin && ( + + )} + + {/* Venue check-in scanner (registration team and admins; the page and its API check the role again). */} + {canReviewRegistrations && ( + + OPEN SCANNER ↗ + + )}
@@ -1375,6 +1420,11 @@ export default function AdminDashboard({ /> )} + {/* TAB 5: VENUE ENTRY / CHECK-IN (Admin Only) */} + {activeTab === 'entry' && isAdmin && ( + + )} + {/* TAB 4: USERS DIRECTORY & ROLE MANAGEMENT (Admin Only) */} {activeTab === 'users' && isAdmin && (
diff --git a/src/components/innovision/EntryPassCard.tsx b/src/components/innovision/EntryPassCard.tsx new file mode 100644 index 0000000..6a19e82 --- /dev/null +++ b/src/components/innovision/EntryPassCard.tsx @@ -0,0 +1,220 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { getSupabase } from '@/lib/supabase'; +import { httpErrorMessage } from '@/lib/api-errors'; + +/* + * The participant's QR entry pass (profile overlay). The QR holds only an opaque random token, fetched from + * POST /api/pass for the signed-in owner and kept in memory only (never in localStorage). Shown only while the + * registration is confirmed and the pass active; otherwise the status is explained instead. + */ + +interface PassInfo { + registrationStatus: 'none' | 'pending' | 'confirmed' | 'rejected'; + passStatus: 'none' | 'active' | 'revoked'; + registrationId: string | null; + name: string | null; + checkedIn: boolean; + token: string | null; +} + +type Load = + | { state: 'loading' } + | { state: 'error'; message: string } + | { state: 'ready'; pass: PassInfo; qr: string | null }; + +const GOLD = 'oklch(0.8 0.12 85)'; +const box: React.CSSProperties = { + marginBottom: '18px', + padding: '16px', + background: 'rgba(236,232,223,0.06)', + border: '1px solid rgba(236,232,223,0.12)', +}; +const label: React.CSSProperties = { margin: 0, fontSize: '12px', fontWeight: 700, letterSpacing: '.18em', color: GOLD }; +const text: React.CSSProperties = { margin: 0, fontSize: '14px', lineHeight: 1.5, color: 'rgba(236,232,223,0.8)' }; + +async function fetchPass(): Promise { + let token: string | undefined; + try { + token = (await getSupabase().auth.getSession()).data.session?.access_token; + } catch {} + const res = await fetch('/api/pass', { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + cache: 'no-store', + }); + const body = await res.json().catch(() => ({})); + if (!res.ok || !body.pass) throw new Error(httpErrorMessage(res) || body.error || 'Could not load your entry pass.'); + return body.pass as PassInfo; +} + +async function downloadPdf(pass: PassInfo, qr: string) { + const { jsPDF } = await import('jspdf'); + const doc = new jsPDF({ unit: 'mm', format: 'a5' }); // 148 x 210 + const W = 148; + + doc.setFillColor(20, 19, 18); + doc.rect(0, 0, W, 210, 'F'); + doc.setTextColor(220, 183, 106); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(10); + doc.text('NIT ROURKELA · TECHNO-MANAGEMENT FEST', W / 2, 16, { align: 'center' }); + doc.setTextColor(236, 232, 223); + doc.setFont('times', 'normal'); + doc.setFontSize(30); + doc.text('Innovision 2026', W / 2, 30, { align: 'center' }); + doc.setFont('times', 'italic'); + doc.setFontSize(13); + doc.text('The Celestial Odyssey', W / 2, 38, { align: 'center' }); + + doc.setFillColor(236, 232, 223); + doc.rect(29, 48, 90, 90, 'F'); + doc.addImage(qr, 'PNG', 32, 51, 84, 84); + + doc.setTextColor(220, 183, 106); + doc.setFont('helvetica', 'bold'); + doc.setFontSize(9); + doc.text('ENTRY PASS', W / 2, 150, { align: 'center' }); + doc.setTextColor(236, 232, 223); + doc.setFontSize(18); + doc.text(doc.splitTextToSize(pass.name || '', 120), W / 2, 160, { align: 'center' }); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(12); + doc.text(`Registration ID ${pass.registrationId ?? ''}`, W / 2, 174, { align: 'center' }); + + doc.setFontSize(9); + doc.setTextColor(200, 196, 188); + doc.text( + doc.splitTextToSize('Show this pass together with your college ID card at the registration desk. Valid for one check-in. Do not share this QR.', 118), + W / 2, + 188, + { align: 'center' } + ); + doc.save(`Innovision-2026-entry-pass-${pass.registrationId ?? 'pass'}.pdf`); +} + +export default function EntryPassCard() { + const [load, setLoad] = useState({ state: 'loading' }); + const [attempt, setAttempt] = useState(0); + const [downloading, setDownloading] = useState(false); + const [downloadError, setDownloadError] = useState(''); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const pass = await fetchPass(); + let qr: string | null = null; + if (pass.token) { + const QRCode = (await import('qrcode')).default; + qr = await QRCode.toDataURL(pass.token, { errorCorrectionLevel: 'M', margin: 2, width: 512 }); + } + if (!cancelled) setLoad({ state: 'ready', pass, qr }); + } catch (err) { + if (!cancelled) setLoad({ state: 'error', message: err instanceof Error ? err.message : 'Could not load your entry pass.' }); + } + })(); + return () => { + cancelled = true; + }; + }, [attempt]); + + if (load.state === 'loading') { + return ( +
+

ENTRY PASS

+

Loading your entry pass…

+
+ ); + } + + if (load.state === 'error') { + return ( +
+

ENTRY PASS

+

{load.message}

+ +
+ ); + } + + const { pass, qr } = load; + if (pass.registrationStatus !== 'confirmed' || pass.passStatus !== 'active' || !qr) { + const message = + pass.registrationStatus === 'pending' + ? 'Your entry QR appears here once the Registration Team confirms your registration.' + : pass.registrationStatus === 'rejected' + ? 'There is no entry pass for a declined registration. Please contact the help desk.' + : pass.passStatus === 'revoked' + ? 'This entry pass was cancelled. Please contact the help desk.' + : 'Your entry pass is not available yet. Please check again later.'; + return ( +
+

ENTRY PASS

+

{message}

+
+ ); + } + + return ( +
+
+

ENTRY PASS

+ {pass.checkedIn && ( + + CHECKED IN + + )} +
+ {/* eslint-disable-next-line @next/next/no-img-element -- a locally generated data: URL, nothing to optimise */} + {`Entry +

+ Show this QR together with your college ID card at the registration desk. Do not share it. +

+ + {downloadError &&

{downloadError}

} +
+ ); +} diff --git a/src/components/innovision/Innovision.tsx b/src/components/innovision/Innovision.tsx index 4ea4637..ffdb40a 100644 --- a/src/components/innovision/Innovision.tsx +++ b/src/components/innovision/Innovision.tsx @@ -366,11 +366,13 @@ export class Innovision extends Component { if (!REGISTRATIONS_OPEN && (isOpenRegister || search.get('required') === 'register')) { this.showRegistrationsSoon(true); } else if (isAuthRequired) { - // Remembered across the Google sign-in round trip, so the student lands on the form once signed in. - if (search.get('required') === 'register') sessionStorage.setItem('inv_pending_action', 'register'); + // Remembered across the Google sign-in round trip, so the student lands on the form once signed in, and a + // volunteer sent here by the check-in scanner ("Log in again") goes straight back to /admin/scan. + const required = search.get('required'); + if (required === 'register' || required === 'scan') sessionStorage.setItem('inv_pending_action', required); setTimeout(() => { this.openAuth('login'); - this.toast('Please log in to register.'); + this.toast(required === 'scan' ? 'Please log in again to continue scanning.' : 'Please log in to register.'); }, 1200); } else if (isOpenRegister) { // Waits for the session check, so a signed-in student isn't sent to log in first. @@ -1754,6 +1756,13 @@ export class Innovision extends Component { }); const pendingAction = typeof window !== 'undefined' ? sessionStorage.getItem('inv_pending_action') : null; + if (pendingAction === 'scan') { + // Back to the check-in scanner (a fixed path, never one taken from the URL). Removed first, so a signed-in user + // who isn't staff (bounced from /admin/scan to /) can't loop. + sessionStorage.removeItem('inv_pending_action'); + this.leaveTo('/admin/scan', true); + return; + } if (pendingAction === 'register') { if (typeof window !== 'undefined') sessionStorage.removeItem('inv_pending_action'); if (registration) { diff --git a/src/components/innovision/ProfileOverlay.tsx b/src/components/innovision/ProfileOverlay.tsx index 36f9195..609235d 100644 --- a/src/components/innovision/ProfileOverlay.tsx +++ b/src/components/innovision/ProfileOverlay.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import type { UserProfile, Registration } from '@/lib/supabase'; +import EntryPassCard from './EntryPassCard'; interface ProfileOverlayProps { isOpen: boolean; @@ -424,6 +425,8 @@ export default function ProfileOverlay({
+ + + ))} +
+ +
+ setSearch(e.target.value)} + placeholder={list === 'audit' || list === 'rejections' ? 'Search registration ID, name or staff' : 'Search registration ID, roll number or name'} + aria-label="Search" + style={{ ...input, flex: '1 1 260px' }} + /> + {list === 'registrations' && ( + + )} + {list === 'audit' && ( + + )} +
+ + {error && ( +
+ {error} +
+ )} + +
+ + {headerFor(list)} + + {rows.map((row) => ( + { setVoidReason(''); setVoiding({ id, label }); }} + onReissue={askReissue} + /> + ))} + {!rows.length && ( + + + + )} + +
+ {loading ? 'Loading…' : 'Nothing here yet.'} +
+
+ +
+ + Page {page} + +
+ + {voiding && ( +
+
+

Void check-in for {voiding.label}?

+

+ The record is kept and marked voided; the registration can then be checked in again. A reason is required. +

+ setVoidReason(e.target.value)} + placeholder="e.g. Scanned the wrong person" + aria-label="Reason" + style={input} + /> +
+ + +
+
+
+ )} +
+ ); +} + +function headerFor(list: List) { + const cols = + list === 'registrations' ? ['REG. ID', 'NAME', 'ROLL NO.', 'COLLEGE', 'CHECKED IN', 'BY / DESK', ''] + : list === 'checkins' ? ['TIME', 'REG. ID', 'NAME', 'ROLL NO.', 'BY', 'DESK', 'STATUS', ''] + : list === 'rejections' ? ['TIME', 'REG. ID', 'NAME', 'REASON', 'NOTE', 'BY'] + : ['TIME', 'ACTION', 'REG. ID', 'NAME', 'BY', 'DETAIL']; + return {cols.map((c, i) => {c})}; +} + +function EntryRow({ list, row, ...actions }: { + list: List; + row: Row; + onVoid: (id: string, label: string) => void; + onReissue: (registration: string, label: string) => void; +}) { + const label = `${s(row.registration_id)} (${s(row.name)})`; + if (list === 'registrations') { + const checked = typeof row.checkin_id === 'string'; + return ( + + {s(row.registration_id)} + {s(row.name)} + {s(row.enrollment_no)} + {s(row.college)} + {checked ? time(row.checked_in_at) : 'Not yet'} + {checked ? `${s(row.checked_in_by)} · ${s(row.desk)}${row.method === 'manual' ? ' (manual)' : ''}` : '—'} + + {checked ? ( + + ) : row.pass_id ? ( + + ) : null} + + + ); + } + if (list === 'checkins') { + const voided = typeof row.voided_at === 'string'; + return ( + + {time(row.created_at)} + {s(row.registration_id)} + {s(row.name)} + {s(row.enrollment_no)} + {s(row.checked_in_by)} + {s(row.desk)}{row.method === 'manual' ? ' (manual)' : ''} + {voided ? `Voided by ${s(row.voided_by)}: ${s(row.void_reason)}` : 'Valid'} + + {!voided && } + + + ); + } + const d = detailOf(row); + if (list === 'rejections') { + return ( + + {time(row.created_at)} + {s(row.registration_id)} + {s(row.name)} + {REJECT_REASON_LABELS[String(d.reason)] ?? s(d.reason)} + {s(d.note)} + {s(row.actor_name ?? row.actor_email)} + + ); + } + const detail = [ + d.result ? `result: ${d.result}` : '', + d.desk ? `desk: ${d.desk}` : '', + d.method ? `via ${d.method}` : '', + d.query ? `query: ${d.query}` : '', + d.reason ? `reason: ${REJECT_REASON_LABELS[String(d.reason)] ?? d.reason}` : '', + d.note ? `note: ${d.note}` : '', + ].filter(Boolean).join(' · '); + return ( + + {time(row.created_at)} + {s(row.action).replace('_', ' ')} + {s(row.registration_id)} + {s(row.name)} + {s(row.actor_name ?? row.actor_email)} + {detail || '—'} + + ); +} diff --git a/src/lib/auth-server.ts b/src/lib/auth-server.ts index c6b3810..6d3459a 100644 --- a/src/lib/auth-server.ts +++ b/src/lib/auth-server.ts @@ -1,4 +1,3 @@ -import type { NextRequest } from 'next/server'; import { getSupabaseAdmin } from '@/lib/supabase'; import type { SupabaseClient, User } from '@supabase/supabase-js'; @@ -6,6 +5,8 @@ export interface StaffAuthSuccess { user: User; role: 'admin' | 'it-team' | 'registration-team'; supabase: SupabaseClient; + /** The caller's validated access token, for calls that must run as the caller (getSupabaseAsUser). */ + token: string; } export interface StaffAuthFailure { @@ -15,6 +16,18 @@ export interface StaffAuthFailure { export type StaffAuthResult = StaffAuthSuccess | StaffAuthFailure; +/** + * Where a caller's tokens are read from: a route handler's NextRequest, or `{ headers: await headers(), cookies: + * await cookies() }` in a server component (next/headers). + */ +export interface AuthSource { + headers: { get(name: string): string | null }; + cookies: { + get(name: string): { value: string } | undefined; + getAll(): { name: string; value: string }[]; + }; +} + const isUsableToken = (t: string | null | undefined): t is string => !!t && t !== 'null' && t !== 'undefined' && t.length < 8192; @@ -36,7 +49,7 @@ function tokenFromSupabaseCookie(value: string): string | null { * Candidate access tokens for a request, in priority order: `Authorization: Bearer`, the `inn_access_token` * cookie, then Supabase's own auth cookie. None of these are trusted until Supabase Auth validates them. */ -export function extractAccessTokens(req: NextRequest): string[] { +export function extractAccessTokens(req: AuthSource): string[] { const tokens: string[] = []; const authHeader = req.headers.get('authorization'); const bearer = authHeader?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim(); @@ -64,7 +77,7 @@ export interface AuthenticatedUser { * Never spends refresh tokens: rotating them here without re-issuing cookies would revoke the user's real session * (Supabase refresh-token reuse detection). Clients refresh via the Supabase SDK or GET /api/auth/session. */ -export async function getAuthenticatedUser(req: NextRequest): Promise { +export async function getAuthenticatedUser(req: AuthSource): Promise { const verifier = getSupabaseAdmin(); for (const token of extractAccessTokens(req)) { @@ -79,7 +92,7 @@ export async function getAuthenticatedUser(req: NextRequest): Promise { +export async function verifyEventStaff(req: AuthSource): Promise { const auth = await getAuthenticatedUser(req); if (!auth) { return { error: 'Unauthorized: Session missing or expired. Please sign in again.', status: 401 }; @@ -96,14 +109,14 @@ export async function verifyEventStaff(req: NextRequest): Promise { +export async function verifyRegistrationStaff(req: AuthSource): Promise { const auth = await getAuthenticatedUser(req); if (!auth) { return { error: 'Unauthorized: Session missing or expired. Please sign in again.', status: 401 }; @@ -120,11 +133,11 @@ export async function verifyRegistrationStaff(req: NextRequest): Promise { +export async function verifyAdmin(req: AuthSource): Promise { const auth = await getAuthenticatedUser(req); if (!auth) { return { error: 'Unauthorized: Session missing or expired. Please sign in again.', status: 401 }; @@ -141,7 +154,7 @@ export async function verifyAdmin(req: NextRequest): Promise { return { error: 'Forbidden: Superadmin access required to modify user roles', status: 403 }; } - return { user: auth.user, role: profile.role as 'admin' | 'it-team' | 'registration-team', supabase }; + return { user: auth.user, role: profile.role as 'admin' | 'it-team' | 'registration-team', supabase, token: auth.token }; } export { isValidGoogleDriveUrl } from '@/lib/validation'; diff --git a/src/lib/entry-pass.ts b/src/lib/entry-pass.ts new file mode 100644 index 0000000..9477ddf --- /dev/null +++ b/src/lib/entry-pass.ts @@ -0,0 +1,112 @@ +/** + * Entry pass (QR) tokens and the input rules of the check-in endpoints. + * + * Server only (node:crypto). Kept free of `@/` imports and framework APIs so it can be unit tested with `node --test`; + * the rules the browser also needs live in ./entry-shared. + * + * The QR encodes only an opaque token: 16 bytes from the OS CSPRNG (128 bits), base64url, 22 characters. The + * database keeps its SHA-256 (what a scan is matched against) and an AES-256-GCM ciphertext so the owner sees the + * same QR on every device. The key (ENTRY_PASS_ENCRYPTION_KEY) never leaves the server, and the pass id is bound in + * as associated data, so a ciphertext copied onto another pass row does not decrypt. + */ + +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; +import { DESK_RE, REJECT_REASONS, type RejectReason } from './entry-shared'; + +export const ENTRY_TOKEN_BYTES = 16; +export const ENTRY_TOKEN_RE = /^[A-Za-z0-9_-]{22}$/; + +export function mintEntryToken(): { token: string; hash: string } { + const token = randomBytes(ENTRY_TOKEN_BYTES).toString('base64url'); + return { token, hash: hashEntryToken(token) }; +} + +/** Hex SHA-256, exactly what supabase/schema.sql computes: encode(sha256(convert_to(token, 'UTF8')), 'hex'). */ +export function hashEntryToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); +} + +/** The 32-byte key from ENTRY_PASS_ENCRYPTION_KEY (base64 or base64url), or null when it is missing or malformed. */ +export function entryPassKey(raw: string | undefined = process.env.ENTRY_PASS_ENCRYPTION_KEY): Buffer | null { + if (!raw) return null; + const key = Buffer.from(raw.trim(), 'base64'); // Node's base64 decoder also accepts the base64url alphabet + return key.length === 32 ? key : null; +} + +const CIPHER_PREFIX = 'v1'; + +/** `v1...` (base64url parts). */ +export function encryptEntryToken(token: string, passId: string, key: Buffer): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + cipher.setAAD(Buffer.from(passId, 'utf8')); + const body = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]); + return [CIPHER_PREFIX, iv.toString('base64url'), body.toString('base64url'), cipher.getAuthTag().toString('base64url')].join('.'); +} + +/** The token, or null if the ciphertext is malformed, was made for another pass, or under another key. */ +export function decryptEntryToken(ciphertext: string, passId: string, key: Buffer): string | null { + const parts = typeof ciphertext === 'string' ? ciphertext.split('.') : []; + if (parts.length !== 4 || parts[0] !== CIPHER_PREFIX) return null; + try { + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(parts[1], 'base64url')); + decipher.setAAD(Buffer.from(passId, 'utf8')); + decipher.setAuthTag(Buffer.from(parts[3], 'base64url')); + const token = Buffer.concat([decipher.update(Buffer.from(parts[2], 'base64url')), decipher.final()]).toString('utf8'); + return ENTRY_TOKEN_RE.test(token) ? token : null; + } catch { + return null; + } +} + +/* --------------------------------------------------------------------------------------------------------------- + * Input rules (the database functions re-check every one of them) + * ------------------------------------------------------------------------------------------------------------- */ + +/** + * Whatever the camera read, as sent to the database (which answers INVALID for anything that isn't a live token, and + * logs the scan either way). Only the type and size are checked here. + */ +export function parseScannedValue(value: unknown): string | null { + if (typeof value !== 'string') return null; + const text = value.trim(); + return text.length >= 1 && text.length <= 512 ? text : null; +} + +export function parseDesk(value: unknown): string | null { + if (typeof value !== 'string') return null; + const desk = value.replace(/\s+/g, ' ').trim(); + return DESK_RE.test(desk) ? desk : null; +} + +export function parseRejectReason( + reason: unknown, + note: unknown +): { ok: true; reason: RejectReason; note: string | null } | { ok: false; error: string } { + if (!REJECT_REASONS.some((r) => r.value === reason)) return { ok: false, error: 'Choose a reason.' }; + if (note !== undefined && note !== null && typeof note !== 'string') return { ok: false, error: 'Invalid note.' }; + const text = typeof note === 'string' ? note.replace(/\s+/g, ' ').trim() : ''; + if (text.length > 200) return { ok: false, error: 'Keep the note under 200 characters.' }; + if (reason === 'other' && !text) return { ok: false, error: 'Describe the reason.' }; + return { ok: true, reason: reason as RejectReason, note: text || null }; +} + +/** Free-text reason for voiding a check-in: required, single line, at most 200 characters. */ +export function parseVoidReason(value: unknown): string | null { + if (typeof value !== 'string') return null; + const text = value.replace(/\s+/g, ' ').trim(); + return text.length >= 1 && text.length <= 200 ? text : null; +} + +/** Manual lookup: a registration ID (IV26-NNNN) or a roll number, same characters registration accepts. */ +export function parseManualQuery(value: unknown): string | null { + if (typeof value !== 'string') return null; + const q = value.trim(); + return q.length >= 1 && q.length <= 50 && /^[A-Za-z0-9/_ -]+$/.test(q) ? q : null; +} + +/** 1-based page number from a query string value; 1 when absent or invalid, capped at 10,000. */ +export function parsePage(value: string | null): number { + const n = Number(value); + return Number.isInteger(n) && n >= 1 ? Math.min(n, 10_000) : 1; +} diff --git a/src/lib/entry-route.ts b/src/lib/entry-route.ts new file mode 100644 index 0000000..e5c3bf1 --- /dev/null +++ b/src/lib/entry-route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server'; +import type { StaffAuthFailure } from '@/lib/auth-server'; +import type { RateLimiter } from '@/lib/security'; + +/** + * Shared plumbing for the /api/admin/entry/* route handlers. + * + * Each handler first awaits verifyRegistrationStaff / verifyAdmin (fail fast, same convention as every admin route), + * then calls the database as the caller (getSupabaseAsUser): the entry functions authorize auth.uid() themselves. + */ + +export function entryAuthFailure(auth: StaffAuthFailure): NextResponse { + // 401 is what the scanner turns into "Session expired, log in again". + const error = auth.status === 401 ? 'Your session has expired. Please log in again.' : auth.error; + return NextResponse.json({ error }, { status: auth.status }); +} + +/** Per user, not per IP: several scanner phones share one campus address (see src/proxy.ts). */ +export function entryRateLimited(limiter: RateLimiter, userId: string): NextResponse | null { + if (limiter.hit(userId)) return null; + return NextResponse.json( + { error: 'Too many requests. Wait a moment and try again.' }, + { status: 429, headers: { 'Retry-After': String(limiter.retryAfterSeconds(userId)) } } + ); +} + +/** Maps an error raised by an entry database function to an HTTP response. Messages are written for staff. */ +export function entryRpcFailure( + error: { code?: string; message?: string } | null, + route: string +): NextResponse { + const code = error?.code ?? ''; + const message = error?.message || 'Request failed.'; + if (code === '42501') return NextResponse.json({ error: message }, { status: 403 }); + if (code === '22023' || code === '22P02') return NextResponse.json({ error: message }, { status: 400 }); + if (code === 'P0002') return NextResponse.json({ error: message }, { status: 404 }); + if (code === '55000') return NextResponse.json({ error: message }, { status: 409 }); + if (code === '40001') return NextResponse.json({ error: 'Busy, please retry.' }, { status: 503 }); + console.error(`${route} database error:`, error); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); +} + +export const badRequest = (error: string) => NextResponse.json({ error }, { status: 400 }); diff --git a/src/lib/entry-shared.ts b/src/lib/entry-shared.ts new file mode 100644 index 0000000..eb07544 --- /dev/null +++ b/src/lib/entry-shared.ts @@ -0,0 +1,19 @@ +/** + * Entry check-in rules shared by the scanner (browser) and the API routes (server). No imports: safe in both. + * supabase/schema.sql enforces the same rules again. + */ + +/** Desk / gate name, as entry_check_in() accepts it. */ +export const DESK_RE = /^[A-Za-z0-9][A-Za-z0-9 ._#/()-]{0,39}$/; + +/** Preset reasons for the scanner's "Reject" (values match reject_entry_attempt()). */ +export const REJECT_REASONS = [ + { value: 'id_mismatch', label: 'ID mismatch' }, + { value: 'no_id', label: 'No ID' }, + { value: 'other', label: 'Other' }, +] as const; +export type RejectReason = (typeof REJECT_REASONS)[number]['value']; + +export const REJECT_REASON_LABELS: Record = Object.fromEntries( + REJECT_REASONS.map((r) => [r.value, r.label]) +); diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index e1a9fbb..9f51b05 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -249,6 +249,23 @@ export function getSupabaseAdmin(token?: string): SupabaseClient { }); } +/** + * Server client that runs AS the signed-in caller (publishable key + their access token), never as service_role, + * even when the service key is configured. Use it for database functions that authorize the caller themselves + * through auth.uid() (the entry pass / check-in functions): with the service role, auth.uid() would be NULL. + */ +export function getSupabaseAsUser(token: string): SupabaseClient { + return createClient(supabaseUrl, supabaseKey, { + auth: { + persistSession: false, + autoRefreshToken: false, + }, + global: { + headers: { Authorization: `Bearer ${token}` }, + }, + }); +} + /** * Initiates Google OAuth with Supabase. * If internalOnly is true: diff --git a/src/proxy.ts b/src/proxy.ts index b390593..1e8f72a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -12,6 +12,16 @@ const MAX_API_BODY_BYTES = 3 * 1024 * 1024; const mutationLimiter = createRateLimiter({ limit: 300, windowMs: 60 * 1000 }); const authLimiter = createRateLimiter({ limit: 300, windowMs: 60 * 1000 }); +/** + * Venue routes left out of the per-IP write limit: the check-in scanners (/api/admin/entry/*) and participants opening + * their pass (/api/pass). At the gate they all share one campus Wi-Fi or carrier-NAT address, and a busy minute of + * participants could otherwise exhaust that address's allowance and lock the scanners out. Both require a signed-in + * user and are rate limited per user in the route instead. CSRF and body-size checks still apply. + */ +function isPerUserLimitedVenueRoute(pathname: string): boolean { + return pathname === '/api/pass' || pathname.startsWith('/api/admin/entry/'); +} + const tooMany = () => NextResponse.json( { error: 'Too Many Requests', message: 'Too many requests. Please slow down and try again shortly.' }, @@ -48,7 +58,13 @@ export function proxy(request: NextRequest) { const ip = clientIp(request.headers, { trustPlatformHeaders: process.env.VERCEL === '1' }); if (ip) { if (pathname.startsWith('/api/auth/') && !authLimiter.hit(ip)) return tooMany(); - if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method) && !mutationLimiter.hit(ip)) return tooMany(); + if ( + !['GET', 'HEAD', 'OPTIONS'].includes(request.method) && + !isPerUserLimitedVenueRoute(pathname) && + !mutationLimiter.hit(ip) + ) { + return tooMany(); + } } } @@ -102,9 +118,10 @@ export function proxy(request: NextRequest) { const redirectUrl = request.nextUrl.clone(); redirectUrl.pathname = '/'; redirectUrl.searchParams.set('auth', 'login'); + // 'scan': the home page sends the volunteer back to the check-in scanner once signed in. redirectUrl.searchParams.set( 'required', - isAdminPage ? 'admin' : 'register' + pathname === '/admin/scan' ? 'scan' : isAdminPage ? 'admin' : 'register' ); return NextResponse.redirect(redirectUrl); } diff --git a/supabase/manual/backfill_entry_passes.sql b/supabase/manual/backfill_entry_passes.sql new file mode 100644 index 0000000..edbb01b --- /dev/null +++ b/supabase/manual/backfill_entry_passes.sql @@ -0,0 +1,29 @@ +-- ============================================================================== +-- BACKFILL (run in the Supabase SQL editor): entry passes for registrations confirmed before section 13 of +-- supabase/schema.sql (entry passes and check-in) was applied. +-- +-- Order: +-- 1. Apply supabase/schema.sql (it creates the entry tables, functions and the approval trigger). From then on every +-- newly confirmed registration gets its pass automatically. +-- 2. Run this file. It issues one active pass per confirmed registration that has none. It is idempotent: running it +-- again issues 0, because a partial unique index allows only one active pass per registration. +-- 3. Run the checks below. Both "missing" counts must be 0. +-- Do it on the TEST project first, then on production. +-- +-- Passes are created without a QR token: each participant's token is minted the first time they open their profile +-- (POST /api/pass), so nothing secret is generated or stored here. Touches no other table. +-- ============================================================================== + +SELECT public.backfill_entry_passes() AS passes_issued; + +-- Checks +SELECT + (SELECT count(*) FROM public.registrations WHERE status = 'confirmed') AS confirmed_registrations, + (SELECT count(*) FROM public.entry_passes WHERE status = 'active') AS active_passes, + (SELECT count(*) FROM public.registrations r + WHERE r.status = 'confirmed' + AND NOT EXISTS (SELECT 1 FROM public.entry_passes p WHERE p.registration_id = r.id AND p.status = 'active') + ) AS confirmed_missing_a_pass, + (SELECT count(*) FROM public.entry_passes p JOIN public.registrations r ON r.id = p.registration_id + WHERE p.status = 'active' AND r.status <> 'confirmed' + ) AS active_pass_on_unconfirmed; diff --git a/supabase/schema.sql b/supabase/schema.sql index 0889cf3..aee701b 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -680,3 +680,623 @@ CREATE POLICY "Payment proofs: never outside the owner's folder" ON storage.obje ALL TO anon, authenticated USING (bucket_id <> 'payment-proofs' OR (storage.foldername(name))[1] = auth.uid()::text) WITH CHECK (bucket_id <> 'payment-proofs' OR (storage.foldername(name))[1] = auth.uid()::text); + + +-- 13. ENTRY PASSES AND CHECK-IN AT THE VENUE +-- Every confirmed registration has one QR entry pass. At the venue the registration team scans it, compares the +-- details with the person's college ID and checks them in: once per registration, ever. +-- Naming: "check-in" everywhere in the database and the code. The scanner's button reads "Approve", but approval +-- of a REGISTRATION (payment review) is a different thing and is untouched here. +-- +-- The QR holds only an opaque random token (16 CSPRNG bytes, base64url, minted by POST /api/pass). The database +-- stores its SHA-256 (token_hash) and an AES-256-GCM ciphertext (token_ciphertext) that only the server can decrypt +-- (ENTRY_PASS_ENCRYPTION_KEY), so the owner sees the same QR again on any device. +-- Clients never write these tables: every change goes through the SECURITY DEFINER functions below, which check +-- the caller's role themselves and log to entry_audit_log. + +CREATE TABLE IF NOT EXISTS public.entry_passes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + registration_id UUID NOT NULL REFERENCES public.registrations(id) ON DELETE CASCADE, + token_hash TEXT UNIQUE, -- hex SHA-256 of the QR token; NULL until the owner first opens the pass + token_ciphertext TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')), + issued_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ, + revoke_reason TEXT, + CONSTRAINT entry_passes_token_hash_check CHECK (token_hash IS NULL OR token_hash ~ '^[0-9a-f]{64}$'), + CONSTRAINT entry_passes_token_pair_check CHECK ((token_hash IS NULL) = (token_ciphertext IS NULL)), + CONSTRAINT entry_passes_token_ciphertext_check CHECK (token_ciphertext IS NULL OR char_length(token_ciphertext) <= 512), + CONSTRAINT entry_passes_revoked_check CHECK ((status = 'revoked') = (revoked_at IS NOT NULL)) +); + +-- Never more than one active pass per registration (approving twice or re-running the backfill can't add one). +CREATE UNIQUE INDEX IF NOT EXISTS uq_entry_passes_active_registration + ON public.entry_passes(registration_id) WHERE status = 'active'; +CREATE INDEX IF NOT EXISTS idx_entry_passes_registration ON public.entry_passes(registration_id); + +CREATE TABLE IF NOT EXISTS public.entry_checkins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + registration_id UUID NOT NULL REFERENCES public.registrations(id) ON DELETE CASCADE, + pass_id UUID REFERENCES public.entry_passes(id) ON DELETE SET NULL, + desk TEXT NOT NULL CHECK (char_length(desk) BETWEEN 1 AND 40), + checked_in_by UUID NOT NULL REFERENCES public.profiles(id), + scan_id UUID NOT NULL, -- chosen by the scanner per scan; a retry with the same scan_id is answered CHECKED_IN + method TEXT NOT NULL CHECK (method IN ('qr', 'manual')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + voided_at TIMESTAMPTZ, + voided_by UUID REFERENCES public.profiles(id), + void_reason TEXT CHECK (void_reason IS NULL OR char_length(void_reason) BETWEEN 1 AND 200), + CONSTRAINT entry_checkins_void_check + CHECK ((voided_at IS NULL) = (voided_by IS NULL) AND (voided_at IS NULL) = (void_reason IS NULL)) +); + +-- One check-in per registration, whichever QR (old or reissued) was shown. This index, not application code, is what +-- makes two simultaneous Approves produce exactly one row. A voided check-in no longer counts. +CREATE UNIQUE INDEX IF NOT EXISTS uq_entry_checkins_registration + ON public.entry_checkins(registration_id) WHERE voided_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_entry_checkins_created_at ON public.entry_checkins(created_at DESC); + +-- Append-only trail of every scan, manual lookup, check-in, rejected attempt, void and reissue. No foreign keys: +-- the log must outlive deleted accounts and registrations. +CREATE TABLE IF NOT EXISTS public.entry_audit_log ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + action TEXT NOT NULL CHECK (action IN ('lookup', 'manual_lookup', 'check_in', 'reject', 'void', 'reissue')), + actor UUID NOT NULL, + registration_id UUID, + pass_id UUID, + checkin_id UUID, + detail JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_entry_audit_log_created_at ON public.entry_audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_entry_audit_log_action ON public.entry_audit_log(action, created_at DESC); + +ALTER TABLE public.entry_passes ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.entry_checkins ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.entry_audit_log ENABLE ROW LEVEL SECURITY; + +-- Owners see their own pass; admins see all. No INSERT/UPDATE/DELETE policies on any of the three tables. +DROP POLICY IF EXISTS "Owners can view their entry pass, admins all" ON public.entry_passes; +CREATE POLICY "Owners can view their entry pass, admins all" ON public.entry_passes FOR +SELECT TO authenticated USING ( + public.is_admin(auth.uid()) + -- Qualified: registrations has its own registration_id (the text IV26-NNNN code). + OR EXISTS (SELECT 1 FROM public.registrations r WHERE r.id = entry_passes.registration_id AND r.user_id = auth.uid()) +); + +DROP POLICY IF EXISTS "Admins can view check-ins" ON public.entry_checkins; +CREATE POLICY "Admins can view check-ins" ON public.entry_checkins FOR +SELECT TO authenticated USING (public.is_admin(auth.uid())); + +DROP POLICY IF EXISTS "Admins can view the entry audit log" ON public.entry_audit_log; +CREATE POLICY "Admins can view the entry audit log" ON public.entry_audit_log FOR +SELECT TO authenticated USING (public.is_admin(auth.uid())); + +REVOKE ALL ON public.entry_passes, public.entry_checkins, public.entry_audit_log FROM anon; +REVOKE INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER + ON public.entry_passes, public.entry_checkins, public.entry_audit_log FROM authenticated; +GRANT SELECT ON public.entry_passes, public.entry_checkins, public.entry_audit_log TO authenticated; + + +-- Pass lifecycle: a registration becoming 'confirmed' issues its pass; leaving 'confirmed' revokes it. A trigger, so it +-- runs in the same transaction as the status change whoever makes it (/api/admin/registrations, the API, SQL). +CREATE OR REPLACE FUNCTION public.issue_entry_pass(p_registration UUID) RETURNS UUID AS $$ +DECLARE + v_id UUID; +BEGIN + INSERT INTO public.entry_passes (registration_id) + SELECT r.id FROM public.registrations r WHERE r.id = p_registration AND r.status = 'confirmed' + ON CONFLICT (registration_id) WHERE status = 'active' DO NOTHING + RETURNING id INTO v_id; + RETURN v_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +CREATE OR REPLACE FUNCTION public.sync_entry_pass() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.status = 'confirmed' THEN + PERFORM public.issue_entry_pass(NEW.id); + ELSE + UPDATE public.entry_passes + SET status = 'revoked', revoked_at = now(), revoke_reason = 'registration ' || COALESCE(NEW.status, 'unknown') + WHERE registration_id = NEW.id AND status = 'active'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +DROP TRIGGER IF EXISTS trg_entry_pass_lifecycle ON public.registrations; +CREATE TRIGGER trg_entry_pass_lifecycle + AFTER INSERT OR UPDATE OF status ON public.registrations + FOR EACH ROW EXECUTE FUNCTION public.sync_entry_pass(); + +-- Issues passes for registrations confirmed before this section existed. Idempotent: returns how many it issued, +-- 0 on a second run. Run from the SQL editor (supabase/manual/backfill_entry_passes.sql); no API role may call it. +CREATE OR REPLACE FUNCTION public.backfill_entry_passes() RETURNS INTEGER AS $$ +DECLARE + v_count INTEGER; +BEGIN + INSERT INTO public.entry_passes (registration_id) + SELECT r.id FROM public.registrations r WHERE r.status = 'confirmed' + ON CONFLICT (registration_id) WHERE status = 'active' DO NOTHING; + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + + +-- What the scanner shows for a registration: VALID (with the details staff compare against the college ID), +-- ALREADY_CHECKED_IN (when, by whom, at which desk), or INVALID (a reason code, never personal data). +CREATE OR REPLACE FUNCTION public.entry_state(p_registration UUID, p_pass UUID, p_invalid_reason TEXT) +RETURNS JSONB AS $$ +DECLARE + r public.registrations%ROWTYPE; + c RECORD; +BEGIN + IF p_invalid_reason IS NOT NULL THEN + RETURN jsonb_build_object('state', 'INVALID', 'reason', p_invalid_reason); + END IF; + + SELECT * INTO r FROM public.registrations WHERE id = p_registration; + IF NOT FOUND OR r.status <> 'confirmed' THEN + RETURN jsonb_build_object('state', 'INVALID', 'reason', 'not_confirmed'); + END IF; + + SELECT ch.id, ch.created_at, ch.desk, ch.method, pr.full_name AS staff_name INTO c + FROM public.entry_checkins ch LEFT JOIN public.profiles pr ON pr.id = ch.checked_in_by + WHERE ch.registration_id = r.id AND ch.voided_at IS NULL; + IF FOUND THEN + RETURN jsonb_build_object( + 'state', 'ALREADY_CHECKED_IN', + 'registration', r.id, 'registration_id', r.registration_id, 'name', r.name, + 'checkin_id', c.id, 'checked_in_at', c.created_at, 'checked_in_by', COALESCE(c.staff_name, 'Staff'), + 'desk', c.desk, 'method', c.method + ); + END IF; + + RETURN jsonb_build_object( + 'state', 'VALID', + 'registration', r.id, 'registration_id', r.registration_id, 'name', r.name, + 'enrollment_no', r.enrollment_no, 'college', r.college, 'phone', r.phone, 'utr', r.utr + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER STABLE SET search_path = public; + +-- Resolves a scanned token to its pass and registration, or an invalid reason. Malformed and unknown tokens are +-- both 'unknown'. +CREATE OR REPLACE FUNCTION public.entry_resolve_token( + p_token TEXT, OUT pass_id UUID, OUT registration_id UUID, OUT invalid_reason TEXT +) AS $$ +DECLARE + p public.entry_passes%ROWTYPE; +BEGIN + IF p_token IS NULL OR p_token !~ '^[A-Za-z0-9_-]{22}$' THEN + invalid_reason := 'unknown'; + RETURN; + END IF; + SELECT * INTO p FROM public.entry_passes WHERE token_hash = encode(sha256(convert_to(p_token, 'UTF8')), 'hex'); + IF NOT FOUND THEN + invalid_reason := 'unknown'; + RETURN; + END IF; + pass_id := p.id; + registration_id := p.registration_id; + IF p.status <> 'active' THEN + invalid_reason := 'revoked'; + END IF; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER STABLE SET search_path = public; + +CREATE OR REPLACE FUNCTION public.entry_require_registration_staff() RETURNS VOID AS $$ +BEGIN + IF auth.uid() IS NULL OR NOT public.is_registration_staff(auth.uid()) THEN + RAISE EXCEPTION 'Registration team or admin access required' USING ERRCODE = '42501'; + END IF; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER STABLE SET search_path = public; + +CREATE OR REPLACE FUNCTION public.entry_require_admin() RETURNS VOID AS $$ +BEGIN + IF auth.uid() IS NULL OR NOT public.is_admin(auth.uid()) THEN + RAISE EXCEPTION 'Admin access required' USING ERRCODE = '42501'; + END IF; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER STABLE SET search_path = public; + +-- The one place a check-in row is written (QR and manual paths alike). +CREATE OR REPLACE FUNCTION public.entry_check_in(p_pass UUID, p_scan_id UUID, p_desk TEXT, p_method TEXT) +RETURNS JSONB AS $$ +DECLARE + v_actor UUID := auth.uid(); + v_desk TEXT := btrim(COALESCE(p_desk, '')); + v_reg public.registrations%ROWTYPE; + v_id UUID; + v_at TIMESTAMPTZ; + v_existing RECORD; + v_result JSONB; +BEGIN + IF p_scan_id IS NULL THEN + RAISE EXCEPTION 'scan_id is required' USING ERRCODE = '22023'; + END IF; + IF v_desk !~ '^[A-Za-z0-9][A-Za-z0-9 ._#/()-]{0,39}$' THEN + RAISE EXCEPTION 'Invalid desk name' USING ERRCODE = '22023'; + END IF; + + -- Re-validate everything; never trust an earlier lookup. FOR SHARE: a concurrent reissue waits for this check-in. + SELECT r.* INTO v_reg + FROM public.entry_passes p JOIN public.registrations r ON r.id = p.registration_id + WHERE p.id = p_pass AND p.status = 'active' AND r.status = 'confirmed' + FOR SHARE OF p; + IF NOT FOUND THEN + INSERT INTO public.entry_audit_log (action, actor, pass_id, detail) + VALUES ('check_in', v_actor, p_pass, jsonb_build_object('result', 'INVALID', 'method', p_method, 'desk', v_desk)); + RETURN jsonb_build_object('state', 'INVALID', 'reason', 'revoked'); + END IF; + + IF v_reg.user_id = v_actor THEN + RAISE EXCEPTION 'You cannot check in your own registration' USING ERRCODE = '42501'; + END IF; + + INSERT INTO public.entry_checkins (registration_id, pass_id, desk, checked_in_by, scan_id, method) + VALUES (v_reg.id, p_pass, v_desk, v_actor, p_scan_id, p_method) + ON CONFLICT (registration_id) WHERE voided_at IS NULL DO NOTHING + RETURNING id, created_at INTO v_id, v_at; + + IF v_id IS NOT NULL THEN + INSERT INTO public.entry_audit_log (action, actor, registration_id, pass_id, checkin_id, detail) + VALUES ('check_in', v_actor, v_reg.id, p_pass, v_id, + jsonb_build_object('result', 'CHECKED_IN', 'method', p_method, 'desk', v_desk, 'scan_id', p_scan_id)); + RETURN jsonb_build_object( + 'state', 'CHECKED_IN', 'registration', v_reg.id, 'registration_id', v_reg.registration_id, 'name', v_reg.name, + 'checkin_id', v_id, 'checked_in_at', v_at, 'desk', v_desk + ); + END IF; + + -- Already checked in, or lost the race. The conflicting row is committed by now (ON CONFLICT waited for it). + SELECT ch.id, ch.created_at, ch.desk, ch.scan_id, ch.checked_in_by INTO v_existing + FROM public.entry_checkins ch WHERE ch.registration_id = v_reg.id AND ch.voided_at IS NULL; + IF NOT FOUND THEN + RAISE EXCEPTION 'Check-in changed concurrently, please retry' USING ERRCODE = '40001'; + END IF; + + IF v_existing.scan_id = p_scan_id AND v_existing.checked_in_by = v_actor THEN + -- The same Approve retried after a lost response: the earlier attempt did succeed. + RETURN jsonb_build_object( + 'state', 'CHECKED_IN', 'registration', v_reg.id, 'registration_id', v_reg.registration_id, 'name', v_reg.name, + 'checkin_id', v_existing.id, 'checked_in_at', v_existing.created_at, 'desk', v_existing.desk + ); + END IF; + + v_result := public.entry_state(v_reg.id, p_pass, NULL); + INSERT INTO public.entry_audit_log (action, actor, registration_id, pass_id, checkin_id, detail) + VALUES ('check_in', v_actor, v_reg.id, p_pass, v_existing.id, + jsonb_build_object('result', 'ALREADY_CHECKED_IN', 'method', p_method, 'desk', v_desk)); + RETURN v_result; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + + +-- ---- Called by the app (POST /api/admin/entry/*) with the caller's own JWT ---- + +-- Scan: what the registration team sees before deciding. Changes nothing except the audit log. +CREATE OR REPLACE FUNCTION public.lookup_entry_pass(p_token TEXT) RETURNS JSONB AS $$ +DECLARE + t RECORD; + v_result JSONB; +BEGIN + PERFORM public.entry_require_registration_staff(); + SELECT * INTO t FROM public.entry_resolve_token(p_token); + v_result := public.entry_state(t.registration_id, t.pass_id, t.invalid_reason); + INSERT INTO public.entry_audit_log (action, actor, registration_id, pass_id, detail) + VALUES ('lookup', auth.uid(), t.registration_id, t.pass_id, jsonb_build_object('result', v_result->>'state')); + RETURN v_result; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- Damaged or missing QR: exact registration ID (IV26-NNNN) or roll number (enrollment_no). +CREATE OR REPLACE FUNCTION public.lookup_entry_manual(p_query TEXT) RETURNS JSONB AS $$ +DECLARE + v_query TEXT := btrim(COALESCE(p_query, '')); + v_ids UUID[]; + v_pass UUID; + v_result JSONB; +BEGIN + PERFORM public.entry_require_registration_staff(); + IF char_length(v_query) NOT BETWEEN 1 AND 50 THEN + RAISE EXCEPTION 'Enter a registration ID or roll number' USING ERRCODE = '22023'; + END IF; + + IF upper(v_query) ~ '^IV26-[0-9]{4}$' THEN + SELECT array_agg(id) INTO v_ids FROM public.registrations WHERE registration_id = upper(v_query); + ELSE + SELECT array_agg(id) INTO v_ids FROM public.registrations + WHERE lower(btrim(enrollment_no)) = lower(v_query) AND status = 'confirmed'; + END IF; + + IF COALESCE(cardinality(v_ids), 0) = 0 THEN + v_result := jsonb_build_object('state', 'INVALID', 'reason', 'not_found'); + ELSIF cardinality(v_ids) > 1 THEN + v_result := jsonb_build_object('state', 'AMBIGUOUS', 'count', cardinality(v_ids)); + ELSE + SELECT id INTO v_pass FROM public.entry_passes WHERE registration_id = v_ids[1] AND status = 'active'; + v_result := public.entry_state(v_ids[1], v_pass, CASE WHEN v_pass IS NULL THEN 'not_confirmed' END); + END IF; + + INSERT INTO public.entry_audit_log (action, actor, registration_id, pass_id, detail) + VALUES ('manual_lookup', auth.uid(), CASE WHEN cardinality(v_ids) = 1 THEN v_ids[1] END, v_pass, + jsonb_build_object('query', v_query, 'result', v_result->>'state')); + RETURN v_result; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- "Approve" on a scanned QR. +CREATE OR REPLACE FUNCTION public.check_in_entry_pass(p_token TEXT, p_scan_id UUID, p_desk TEXT) RETURNS JSONB AS $$ +DECLARE + t RECORD; +BEGIN + PERFORM public.entry_require_registration_staff(); + SELECT * INTO t FROM public.entry_resolve_token(p_token); + IF t.invalid_reason IS NOT NULL THEN + INSERT INTO public.entry_audit_log (action, actor, registration_id, pass_id, detail) + VALUES ('check_in', auth.uid(), t.registration_id, t.pass_id, jsonb_build_object('result', 'INVALID', 'method', 'qr')); + RETURN jsonb_build_object('state', 'INVALID', 'reason', t.invalid_reason); + END IF; + RETURN public.entry_check_in(t.pass_id, p_scan_id, p_desk, 'qr'); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- "Approve" after a manual lookup: the registration's current active pass, through the same entry_check_in. +CREATE OR REPLACE FUNCTION public.check_in_entry_registration(p_registration UUID, p_scan_id UUID, p_desk TEXT) +RETURNS JSONB AS $$ +DECLARE + v_pass UUID; +BEGIN + PERFORM public.entry_require_registration_staff(); + SELECT id INTO v_pass FROM public.entry_passes WHERE registration_id = p_registration AND status = 'active'; + IF v_pass IS NULL THEN + INSERT INTO public.entry_audit_log (action, actor, registration_id, detail) + VALUES ('check_in', auth.uid(), p_registration, jsonb_build_object('result', 'INVALID', 'method', 'manual')); + RETURN jsonb_build_object('state', 'INVALID', 'reason', 'not_confirmed'); + END IF; + RETURN public.entry_check_in(v_pass, p_scan_id, p_desk, 'manual'); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- "Reject": the details didn't match the college ID, or there was none. Changes nothing on the pass, which stays +-- usable so the case can be resolved later; only the attempt is logged. +CREATE OR REPLACE FUNCTION public.reject_entry_attempt(p_token TEXT, p_registration UUID, p_reason TEXT, p_note TEXT) +RETURNS JSONB AS $$ +DECLARE + v_note TEXT := NULLIF(btrim(COALESCE(p_note, '')), ''); + v_reg UUID; + v_pass UUID; + v_invalid TEXT; + v_state JSONB; + t RECORD; +BEGIN + PERFORM public.entry_require_registration_staff(); + IF p_reason IS NULL OR p_reason NOT IN ('id_mismatch', 'no_id', 'other') THEN + RAISE EXCEPTION 'Invalid reason' USING ERRCODE = '22023'; + END IF; + IF p_reason = 'other' AND v_note IS NULL THEN + RAISE EXCEPTION 'Describe the reason' USING ERRCODE = '22023'; + END IF; + IF char_length(COALESCE(v_note, '')) > 200 THEN + RAISE EXCEPTION 'Note is too long' USING ERRCODE = '22023'; + END IF; + IF (p_token IS NULL) = (p_registration IS NULL) THEN + RAISE EXCEPTION 'Give either a token or a registration' USING ERRCODE = '22023'; + END IF; + + IF p_token IS NOT NULL THEN + SELECT * INTO t FROM public.entry_resolve_token(p_token); + v_reg := t.registration_id; + v_pass := t.pass_id; + v_invalid := t.invalid_reason; + ELSE + v_reg := p_registration; + SELECT id INTO v_pass FROM public.entry_passes WHERE registration_id = p_registration AND status = 'active'; + IF v_pass IS NULL THEN v_invalid := 'not_confirmed'; END IF; + END IF; + + v_state := public.entry_state(v_reg, v_pass, v_invalid); + IF v_state->>'state' <> 'VALID' THEN + RETURN v_state; -- nothing to reject: the scanner shows this state instead + END IF; + + INSERT INTO public.entry_audit_log (action, actor, registration_id, pass_id, detail) + VALUES ('reject', auth.uid(), v_reg, v_pass, jsonb_build_object( + 'reason', p_reason, 'note', v_note, 'method', CASE WHEN p_token IS NULL THEN 'manual' ELSE 'qr' END)); + RETURN jsonb_build_object('state', 'REJECTED', 'registration_id', v_state->>'registration_id', 'reason', p_reason); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- Admin: undo a wrong check-in. Never deletes; the registration can then be checked in again. +CREATE OR REPLACE FUNCTION public.void_entry_checkin(p_checkin_id UUID, p_reason TEXT) RETURNS JSONB AS $$ +DECLARE + v_reason TEXT := btrim(COALESCE(p_reason, '')); + v_reg UUID; + v_pass UUID; +BEGIN + PERFORM public.entry_require_admin(); + IF char_length(v_reason) NOT BETWEEN 1 AND 200 THEN + RAISE EXCEPTION 'A reason (up to 200 characters) is required' USING ERRCODE = '22023'; + END IF; + + UPDATE public.entry_checkins + SET voided_at = now(), voided_by = auth.uid(), void_reason = v_reason + WHERE id = p_checkin_id AND voided_at IS NULL + RETURNING registration_id, pass_id INTO v_reg, v_pass; + IF NOT FOUND THEN + RAISE EXCEPTION 'Check-in not found or already voided' USING ERRCODE = 'P0002'; + END IF; + + INSERT INTO public.entry_audit_log (action, actor, registration_id, pass_id, checkin_id, detail) + VALUES ('void', auth.uid(), v_reg, v_pass, p_checkin_id, jsonb_build_object('reason', v_reason)); + RETURN jsonb_build_object('state', 'VOIDED', 'checkin_id', p_checkin_id); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- Admin: new QR for a registration (lost or leaked pass); the old QR stops working at once. Blocked after check-in. +CREATE OR REPLACE FUNCTION public.reissue_entry_pass(p_registration UUID) RETURNS JSONB AS $$ +DECLARE + v_old UUID; + v_new UUID; +BEGIN + PERFORM public.entry_require_admin(); + IF NOT EXISTS (SELECT 1 FROM public.registrations WHERE id = p_registration AND status = 'confirmed') THEN + RAISE EXCEPTION 'Only a confirmed registration has a pass' USING ERRCODE = 'P0002'; + END IF; + + -- FOR UPDATE waits for a check-in in progress on this pass, which the next statement then sees. + SELECT id INTO v_old FROM public.entry_passes WHERE registration_id = p_registration AND status = 'active' FOR UPDATE; + IF EXISTS (SELECT 1 FROM public.entry_checkins WHERE registration_id = p_registration AND voided_at IS NULL) THEN + RAISE EXCEPTION 'Already checked in: the pass can no longer be reissued' USING ERRCODE = '55000'; + END IF; + + UPDATE public.entry_passes SET status = 'revoked', revoked_at = now(), revoke_reason = 'reissued' WHERE id = v_old; + v_new := public.issue_entry_pass(p_registration); + + INSERT INTO public.entry_audit_log (action, actor, registration_id, pass_id, detail) + VALUES ('reissue', auth.uid(), p_registration, v_new, jsonb_build_object('old_pass', v_old)); + RETURN jsonb_build_object('state', 'REISSUED', 'pass', v_new); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +-- ---- Called by POST /api/pass for the signed-in participant ---- + +-- The caller's own registration and pass. token_ciphertext is useless without the server's key. +CREATE OR REPLACE FUNCTION public.my_entry_pass() RETURNS JSONB AS $$ +DECLARE + r public.registrations%ROWTYPE; + p public.entry_passes%ROWTYPE; +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'Sign in required' USING ERRCODE = '42501'; + END IF; + SELECT * INTO r FROM public.registrations WHERE user_id = auth.uid() ORDER BY created_at DESC LIMIT 1; + IF NOT FOUND THEN + RETURN jsonb_build_object('registration_status', 'none', 'pass_status', 'none'); + END IF; + SELECT * INTO p FROM public.entry_passes WHERE registration_id = r.id + ORDER BY (status = 'active') DESC, issued_at DESC LIMIT 1; + RETURN jsonb_build_object( + 'registration_status', r.status, 'registration_id', r.registration_id, 'name', r.name, + 'pass', p.id, 'pass_status', COALESCE(p.status, 'none'), 'token_ciphertext', p.token_ciphertext, + 'checked_in', EXISTS (SELECT 1 FROM public.entry_checkins WHERE registration_id = r.id AND voided_at IS NULL) + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER STABLE SET search_path = public; + +-- Stores the token the server minted for the caller's own active pass, unless one is already stored (two tabs opening +-- the pass at once: the first wins and both get its ciphertext back). +CREATE OR REPLACE FUNCTION public.claim_entry_pass_token(p_pass UUID, p_hash TEXT, p_ciphertext TEXT) RETURNS TEXT AS $$ +DECLARE + v_cipher TEXT; +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'Sign in required' USING ERRCODE = '42501'; + END IF; + IF p_hash IS NULL OR p_hash !~ '^[0-9a-f]{64}$' OR p_ciphertext IS NULL OR char_length(p_ciphertext) > 512 THEN + RAISE EXCEPTION 'Invalid token data' USING ERRCODE = '22023'; + END IF; + + UPDATE public.entry_passes p SET token_hash = p_hash, token_ciphertext = p_ciphertext + FROM public.registrations r + WHERE p.id = p_pass AND p.status = 'active' AND p.token_hash IS NULL + AND r.id = p.registration_id AND r.user_id = auth.uid() AND r.status = 'confirmed'; + + SELECT p.token_ciphertext INTO v_cipher + FROM public.entry_passes p JOIN public.registrations r ON r.id = p.registration_id + WHERE p.id = p_pass AND p.status = 'active' AND r.user_id = auth.uid() AND r.status = 'confirmed'; + IF v_cipher IS NULL THEN + RAISE EXCEPTION 'No active pass' USING ERRCODE = 'P0002'; + END IF; + RETURN v_cipher; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + + +-- Admin dashboard views. security_invoker: they run with the caller's rights, so the admin-only RLS on the entry +-- tables applies. Counts are per registration, over active passes and non-voided check-ins only. +DROP VIEW IF EXISTS public.entry_overview; +CREATE VIEW public.entry_overview WITH (security_invoker = true) AS +SELECT + r.id AS registration, r.registration_id, r.name, r.enrollment_no, r.college, + p.id AS pass_id, p.issued_at AS pass_issued_at, (p.token_hash IS NOT NULL) AS pass_opened, + c.id AS checkin_id, c.created_at AS checked_in_at, c.desk, c.method, pr.full_name AS checked_in_by +FROM public.registrations r +LEFT JOIN public.entry_passes p ON p.registration_id = r.id AND p.status = 'active' +LEFT JOIN public.entry_checkins c ON c.registration_id = r.id AND c.voided_at IS NULL +LEFT JOIN public.profiles pr ON pr.id = c.checked_in_by +WHERE r.status = 'confirmed'; + +DROP VIEW IF EXISTS public.entry_checkin_feed; +CREATE VIEW public.entry_checkin_feed WITH (security_invoker = true) AS +SELECT + c.id, c.created_at, c.desk, c.method, c.voided_at, c.void_reason, + r.id AS registration, r.registration_id, r.name, r.enrollment_no, + pr.full_name AS checked_in_by, pv.full_name AS voided_by +FROM public.entry_checkins c +JOIN public.registrations r ON r.id = c.registration_id +LEFT JOIN public.profiles pr ON pr.id = c.checked_in_by +LEFT JOIN public.profiles pv ON pv.id = c.voided_by; + +DROP VIEW IF EXISTS public.entry_audit_feed; +CREATE VIEW public.entry_audit_feed WITH (security_invoker = true) AS +SELECT + a.id, a.created_at, a.action, a.detail, a.registration_id AS registration, r.registration_id, r.name, + pr.full_name AS actor_name, pr.email AS actor_email +FROM public.entry_audit_log a +LEFT JOIN public.registrations r ON r.id = a.registration_id +LEFT JOIN public.profiles pr ON pr.id = a.actor; + +-- Admin dashboard tiles, from the same view (so they always agree with the list). +CREATE OR REPLACE FUNCTION public.entry_overview_counts() RETURNS JSONB AS $$ +BEGIN + PERFORM public.entry_require_admin(); + RETURN ( + SELECT jsonb_build_object( + 'confirmed', count(*), 'checked_in', count(checkin_id), 'not_checked_in', count(*) - count(checkin_id) + ) + FROM public.entry_overview + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER STABLE SET search_path = public; + +REVOKE ALL ON public.entry_overview, public.entry_checkin_feed, public.entry_audit_feed FROM anon; +REVOKE INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER + ON public.entry_overview, public.entry_checkin_feed, public.entry_audit_feed FROM authenticated; +GRANT SELECT ON public.entry_overview, public.entry_checkin_feed, public.entry_audit_feed TO authenticated; + +-- Function privileges: the internal helpers can't be called through the API by anyone; the entry points only by +-- signed-in users, and each one checks the caller's role itself. +REVOKE EXECUTE ON FUNCTION public.issue_entry_pass(UUID) FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.sync_entry_pass() FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.backfill_entry_passes() FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.entry_state(UUID, UUID, TEXT) FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.entry_resolve_token(TEXT) FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.entry_require_registration_staff() FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.entry_require_admin() FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION public.entry_check_in(UUID, UUID, TEXT, TEXT) FROM PUBLIC, anon, authenticated; + +REVOKE EXECUTE ON FUNCTION public.lookup_entry_pass(TEXT) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.lookup_entry_manual(TEXT) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.check_in_entry_pass(TEXT, UUID, TEXT) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.check_in_entry_registration(UUID, UUID, TEXT) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.reject_entry_attempt(TEXT, UUID, TEXT, TEXT) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.void_entry_checkin(UUID, TEXT) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.reissue_entry_pass(UUID) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.my_entry_pass() FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.claim_entry_pass_token(UUID, TEXT, TEXT) FROM PUBLIC, anon; +REVOKE EXECUTE ON FUNCTION public.entry_overview_counts() FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.entry_overview_counts() TO authenticated; +GRANT EXECUTE ON FUNCTION public.lookup_entry_pass(TEXT) TO authenticated; +GRANT EXECUTE ON FUNCTION public.lookup_entry_manual(TEXT) TO authenticated; +GRANT EXECUTE ON FUNCTION public.check_in_entry_pass(TEXT, UUID, TEXT) TO authenticated; +GRANT EXECUTE ON FUNCTION public.check_in_entry_registration(UUID, UUID, TEXT) TO authenticated; +GRANT EXECUTE ON FUNCTION public.reject_entry_attempt(TEXT, UUID, TEXT, TEXT) TO authenticated; +GRANT EXECUTE ON FUNCTION public.void_entry_checkin(UUID, TEXT) TO authenticated; +GRANT EXECUTE ON FUNCTION public.reissue_entry_pass(UUID) TO authenticated; +GRANT EXECUTE ON FUNCTION public.my_entry_pass() TO authenticated; +GRANT EXECUTE ON FUNCTION public.claim_entry_pass_token(UUID, TEXT, TEXT) TO authenticated;