From 3c19455bddf46bb04a4ff5bb1b9fc7b39289bb0d Mon Sep 17 00:00:00 2001 From: Wil Date: Thu, 29 Oct 2026 12:44:33 -0500 Subject: [PATCH] feat: add LiveKit join flow with waiting room and host admission Co-Authored-By: Claude Sonnet 5 --- bun.lock | 50 ++++ package.json | 4 + .../[meetingUuid]/join-request/route.ts | 137 +++++++++++ .../[participantId]/admit/route.ts | 54 +++++ .../[participantId]/deny/route.ts | 54 +++++ .../[meetingUuid]/participants/me/route.ts | 46 ++++ .../[meetingUuid]/participants/route.ts | 56 +++++ src/app/join/[meetingUuid]/page.tsx | 217 ++++++++++++++++++ src/app/room/[meetingUuid]/page.tsx | 118 ++++++++++ src/components/join/join-flow.tsx | 207 +++++++++++++++++ src/components/room/admission-panel.tsx | 153 ++++++++++++ src/components/room/room-client.tsx | 107 +++++++++ src/lib/livekit.ts | 61 +++++ src/lib/meeting-auth.ts | 34 +++ src/lib/participant-identity.ts | 58 +++++ src/lib/validations/join.ts | 11 + 16 files changed, 1367 insertions(+) create mode 100644 src/app/api/meetings/[meetingUuid]/join-request/route.ts create mode 100644 src/app/api/meetings/[meetingUuid]/participants/[participantId]/admit/route.ts create mode 100644 src/app/api/meetings/[meetingUuid]/participants/[participantId]/deny/route.ts create mode 100644 src/app/api/meetings/[meetingUuid]/participants/me/route.ts create mode 100644 src/app/api/meetings/[meetingUuid]/participants/route.ts create mode 100644 src/app/join/[meetingUuid]/page.tsx create mode 100644 src/app/room/[meetingUuid]/page.tsx create mode 100644 src/components/join/join-flow.tsx create mode 100644 src/components/room/admission-panel.tsx create mode 100644 src/components/room/room-client.tsx create mode 100644 src/lib/livekit.ts create mode 100644 src/lib/meeting-auth.ts create mode 100644 src/lib/participant-identity.ts create mode 100644 src/lib/validations/join.ts diff --git a/bun.lock b/bun.lock index de1d01b..bf87bac 100644 --- a/bun.lock +++ b/bun.lock @@ -7,12 +7,16 @@ "dependencies": { "@base-ui/react": "^1.9.0", "@hookform/resolvers": "^6.0.2", + "@livekit/components-react": "^2.9.24", + "@livekit/components-styles": "^1.2.0", "@prisma/adapter-mariadb": "^7.10.1", "@prisma/client": "^7.10.1", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "cn": "^0.5.0", "jose": "^6.2.14", + "livekit-client": "^2.23.0", + "livekit-server-sdk": "^2.19.2", "lucide-react": "^1.64.0", "next": "16.4.3", "next-auth": "^5.0.0-beta.32", @@ -110,6 +114,8 @@ "@base-ui/utils": ["@base-ui/utils@0.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.7 || ^8.0.0", "@floating-ui/utils": "^0.2.12", "reselect": "^5.3.0", "use-sync-external-store": "^1.7.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-dYGGcEttjkYiMoa5nHm8vLaJ2UcuTWxo3BFAwOYXeRg/q7UVVvA6cacEIXEs3FgBfK1CGtW1ptElqs7wsx+t9A=="], + "@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.1", "", {}, "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="], "@dotenvx/primitives": ["@dotenvx/primitives@0.8.0", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="], @@ -228,6 +234,16 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@livekit/components-core": ["@livekit/components-core@0.12.15", "", { "dependencies": { "@floating-ui/dom": "1.7.6", "loglevel": "1.9.1", "rxjs": "7.8.2" }, "peerDependencies": { "livekit-client": "^2.20.1", "tslib": "^2.6.2" } }, "sha512-bDkK+jkPMgyT4a5lZmdcuc/hZ8fZUrXW+qQPNHVeSpXCBMzk5q9QWzx+HPtTsPJ8hnncWGfoT81RnXHBsy+Z8g=="], + + "@livekit/components-react": ["@livekit/components-react@2.9.24", "", { "dependencies": { "@livekit/components-core": "0.12.15", "clsx": "2.1.1", "events": "^3.3.0", "jose": "^6.0.12", "usehooks-ts": "3.1.1" }, "peerDependencies": { "@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0", "livekit-client": "^2.20.1", "react": ">=18", "react-dom": ">=18", "tslib": "^2.6.2" }, "optionalPeers": ["@livekit/krisp-noise-filter"] }, "sha512-qw5Oy1EfPg1f/xrKYrFielSrmtxY8BZ9mDt6299G5aikotQIztmldBfTNosrVS7VSNit7qeYfOFSyKaML20W6Q=="], + + "@livekit/components-styles": ["@livekit/components-styles@1.2.0", "", {}, "sha512-74/rt0lDh6aHmOPmWAeDE9C4OrNW9RIdmhX/YRbovQBVNGNVWojRjl3FgQZ5LPFXO6l1maKB4JhXcBFENVxVvw=="], + + "@livekit/mutex": ["@livekit/mutex@1.1.1", "", {}, "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw=="], + + "@livekit/protocol": ["@livekit/protocol@1.52.1", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.0" } }, "sha512-X8DxKlNbzk6Lh+v630+mjdFHSotdAFwIyzxSRSUql2DbTxCnlpa7JsvFGc6Na3mXiex2OFh0iiQtWwOBP0rIYA=="], + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.4.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q=="], "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.4.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.4.0", "zod": "^4.2.0" } }, "sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q=="], @@ -378,6 +394,8 @@ "@types/d3-time-format": ["@types/d3-time-format@2.1.0", "", {}, "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA=="], + "@types/dom-mediacapture-record": ["@types/dom-mediacapture-record@1.0.22", "", {}, "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw=="], + "@types/estree": ["@types/estree@1.0.10", "", {}, "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g=="], "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], @@ -744,6 +762,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], "exsolve": ["exsolve@1.2.0", "", {}, "sha512-h1pQPR9XoeSMdntRNCjyTznaume4UpfXrULBnlkZ2CUM/G5eSa+pBByGldO5uR8dxbPC+OuJXsPPNf+JZ81ADg=="], @@ -1008,14 +1028,22 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "livekit-client": ["livekit-client@2.23.0", "", { "dependencies": { "@livekit/mutex": "1.1.1", "@livekit/protocol": "1.52.1", "events": "^3.3.0", "jose": "^6.1.0", "loglevel": "^1.9.2", "machina": "^7.0.0", "sdp-transform": "^2.15.0", "tslib": "2.8.1", "typed-emitter": "^2.1.0", "webrtc-adapter": "9.0.6" }, "peerDependencies": { "@types/dom-mediacapture-record": "^1" } }, "sha512-j9Kg86nJjBjNFUD+ukp2jwMCGa0k+YD5gU//RzAM+ZY3pbG631QCte7fQjwqWMmtBzhDJL9nKYuOLMv/9IbAxg=="], + + "livekit-server-sdk": ["livekit-server-sdk@2.19.2", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.1", "@livekit/protocol": "1.51.0", "jose": "^5.1.2" } }, "sha512-aNb7iZOdMYWjg3X227GO9i/jWsqR6uiUa2le3T0JOaK+FwE3bMx+bEhHVoSioMFL4LQ4xk4ztz1H8TKxIPjq1w=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], + "loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -1026,6 +1054,8 @@ "lucide-react": ["lucide-react@1.64.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-v626F/bcLNvFom6vzztHILtG9tUgXXbgA5AGkd82YkV401+Ko/NHyrWafbPwGofa+6p/EgDJcvEkIwsMH9098w=="], + "machina": ["machina@7.0.1", "", {}, "sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.5", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA=="], @@ -1212,6 +1242,8 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], @@ -1224,6 +1256,10 @@ "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + "sdp": ["sdp@3.2.2", "", {}, "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA=="], + + "sdp-transform": ["sdp-transform@2.15.0", "", { "bin": { "sdp-verify": "checker.js" } }, "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], @@ -1338,6 +1374,8 @@ "typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="], + "typed-emitter": ["typed-emitter@2.1.0", "", { "optionalDependencies": { "rxjs": "*" } }, "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript-eslint": ["typescript-eslint@8.72.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.72.1", "@typescript-eslint/parser": "8.72.1", "@typescript-eslint/typescript-estree": "8.72.1", "@typescript-eslint/utils": "8.72.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw=="], @@ -1360,12 +1398,16 @@ "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], + "usehooks-ts": ["usehooks-ts@3.1.1", "", { "dependencies": { "lodash.debounce": "^4.0.8" }, "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + "webrtc-adapter": ["webrtc-adapter@9.0.6", "", { "dependencies": { "sdp": "^3.2.0" } }, "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -1410,6 +1452,10 @@ "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@livekit/components-core/@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@livekit/components-core/loglevel": ["loglevel@1.9.1", "", {}, "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg=="], + "@next/eslint-plugin-next/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], @@ -1480,6 +1526,10 @@ "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "livekit-server-sdk/@livekit/protocol": ["@livekit/protocol@1.51.0", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.0" } }, "sha512-kEDEDaFvIcp5VlYBromdSKMJMEJKfPvXPfEaJPdEz1Qgo84gZAoLF2g58CcMlVYGsjrwE5XYgQxRS5B7KuEj3w=="], + + "livekit-server-sdk/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], diff --git a/package.json b/package.json index e4c725b..d3a7825 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,16 @@ "dependencies": { "@base-ui/react": "^1.9.0", "@hookform/resolvers": "^6.0.2", + "@livekit/components-react": "^2.9.24", + "@livekit/components-styles": "^1.2.0", "@prisma/adapter-mariadb": "^7.10.1", "@prisma/client": "^7.10.1", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "cn": "^0.5.0", "jose": "^6.2.14", + "livekit-client": "^2.23.0", + "livekit-server-sdk": "^2.19.2", "lucide-react": "^1.64.0", "next": "16.4.3", "next-auth": "^5.0.0-beta.32", diff --git a/src/app/api/meetings/[meetingUuid]/join-request/route.ts b/src/app/api/meetings/[meetingUuid]/join-request/route.ts new file mode 100644 index 0000000..066d6d3 --- /dev/null +++ b/src/app/api/meetings/[meetingUuid]/join-request/route.ts @@ -0,0 +1,137 @@ +import crypto from "node:crypto" + +import { NextResponse } from "next/server" + +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { verifyJoinLinkToken } from "@/lib/join-link" +import { guestNameSchema } from "@/lib/validations/join" +import { + guestIdentityCookieName, + GUEST_IDENTITY_COOKIE_MAX_AGE_SECONDS, +} from "@/lib/participant-identity" + +// Creates (or reuses) the current viewer's MeetingParticipant row, landing +// them in `pending` status unless they're the host. Called from the +// /join/[meetingUuid] name-entry / confirm step, not automatically — so a +// participant row is only created once the visitor explicitly asks to +// join. +export async function POST( + request: Request, + { params }: { params: Promise<{ meetingUuid: string }> } +) { + const { meetingUuid } = await params + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json( + { error: "Cuerpo de solicitud inválido" }, + { status: 400 } + ) + } + + const { token, guestName } = (body ?? {}) as { + token?: string + guestName?: string + } + + if (typeof token !== "string" || !token) { + return NextResponse.json( + { error: "Falta el token de invitación" }, + { status: 400 } + ) + } + + const verified = await verifyJoinLinkToken(token) + if (!verified || verified.meetingUuid !== meetingUuid) { + return NextResponse.json( + { error: "El enlace de invitación no es válido o ha expirado" }, + { status: 400 } + ) + } + + const meeting = await prisma.meeting.findUnique({ + where: { uuid: meetingUuid }, + }) + + if (!meeting) { + return NextResponse.json( + { error: "Reunión no encontrada" }, + { status: 404 } + ) + } + + if (meeting.status === "ended") { + return NextResponse.json( + { error: "Esta reunión ha finalizado" }, + { status: 409 } + ) + } + + const session = await auth() + + if (session?.user?.id) { + const userId = Number(session.user.id) + const identity = `user-${userId}` + const isHost = meeting.hostUserId === userId + + const participant = await prisma.meetingParticipant.upsert({ + where: { meetingId_identity: { meetingId: meeting.id, identity } }, + update: {}, + create: { + meetingId: meeting.id, + userId, + identity, + role: isHost ? "host" : "participant", + status: isHost ? "admitted" : "pending", + admittedAt: isHost ? new Date() : null, + }, + }) + + return NextResponse.json({ + participantId: participant.id, + status: participant.status, + }) + } + + // Anonymous guest: validate the display name and mint a fresh identity. + const parsed = guestNameSchema.safeParse({ guestName }) + if (!parsed.success) { + return NextResponse.json( + { + error: "Datos inválidos", + issues: parsed.error.flatten().fieldErrors, + }, + { status: 400 } + ) + } + + const identity = `guest-${crypto.randomUUID()}` + + const participant = await prisma.meetingParticipant.create({ + data: { + meetingId: meeting.id, + guestName: parsed.data.guestName, + identity, + role: "participant", + status: "pending", + }, + }) + + const response = NextResponse.json({ + participantId: participant.id, + status: participant.status, + }) + + response.cookies.set(guestIdentityCookieName(meetingUuid), identity, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: GUEST_IDENTITY_COOKIE_MAX_AGE_SECONDS, + path: "/", + }) + + return response +} diff --git a/src/app/api/meetings/[meetingUuid]/participants/[participantId]/admit/route.ts b/src/app/api/meetings/[meetingUuid]/participants/[participantId]/admit/route.ts new file mode 100644 index 0000000..d82ecc7 --- /dev/null +++ b/src/app/api/meetings/[meetingUuid]/participants/[participantId]/admit/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server" + +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { isHostOrCoHost } from "@/lib/meeting-auth" + +export async function POST( + _request: Request, + { params }: { params: Promise<{ meetingUuid: string; participantId: string }> } +) { + const { meetingUuid, participantId } = await params + + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: "No autenticado" }, { status: 401 }) + } + + const meeting = await prisma.meeting.findUnique({ + where: { uuid: meetingUuid }, + }) + + if (!meeting) { + return NextResponse.json( + { error: "Reunión no encontrada" }, + { status: 404 } + ) + } + + const userId = Number(session.user.id) + if (!(await isHostOrCoHost(meeting, userId))) { + return NextResponse.json( + { error: "No tienes permiso para admitir participantes" }, + { status: 403 } + ) + } + + const participant = await prisma.meetingParticipant.findUnique({ + where: { id: Number(participantId) }, + }) + + if (!participant || participant.meetingId !== meeting.id) { + return NextResponse.json( + { error: "Participante no encontrado" }, + { status: 404 } + ) + } + + const updated = await prisma.meetingParticipant.update({ + where: { id: participant.id }, + data: { status: "admitted", admittedAt: new Date() }, + }) + + return NextResponse.json({ id: updated.id, status: updated.status }) +} diff --git a/src/app/api/meetings/[meetingUuid]/participants/[participantId]/deny/route.ts b/src/app/api/meetings/[meetingUuid]/participants/[participantId]/deny/route.ts new file mode 100644 index 0000000..2372ae5 --- /dev/null +++ b/src/app/api/meetings/[meetingUuid]/participants/[participantId]/deny/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server" + +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { isHostOrCoHost } from "@/lib/meeting-auth" + +export async function POST( + _request: Request, + { params }: { params: Promise<{ meetingUuid: string; participantId: string }> } +) { + const { meetingUuid, participantId } = await params + + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: "No autenticado" }, { status: 401 }) + } + + const meeting = await prisma.meeting.findUnique({ + where: { uuid: meetingUuid }, + }) + + if (!meeting) { + return NextResponse.json( + { error: "Reunión no encontrada" }, + { status: 404 } + ) + } + + const userId = Number(session.user.id) + if (!(await isHostOrCoHost(meeting, userId))) { + return NextResponse.json( + { error: "No tienes permiso para denegar participantes" }, + { status: 403 } + ) + } + + const participant = await prisma.meetingParticipant.findUnique({ + where: { id: Number(participantId) }, + }) + + if (!participant || participant.meetingId !== meeting.id) { + return NextResponse.json( + { error: "Participante no encontrado" }, + { status: 404 } + ) + } + + const updated = await prisma.meetingParticipant.update({ + where: { id: participant.id }, + data: { status: "denied" }, + }) + + return NextResponse.json({ id: updated.id, status: updated.status }) +} diff --git a/src/app/api/meetings/[meetingUuid]/participants/me/route.ts b/src/app/api/meetings/[meetingUuid]/participants/me/route.ts new file mode 100644 index 0000000..ac30e01 --- /dev/null +++ b/src/app/api/meetings/[meetingUuid]/participants/me/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server" + +import { prisma } from "@/lib/prisma" +import { resolveViewerIdentity } from "@/lib/participant-identity" + +// Polled by the waiting-room screen every ~3s until status flips to +// `admitted` or `denied`. Identity is resolved server-side from the +// session or guest cookie, never from a client-supplied value. +export async function GET( + _request: Request, + { params }: { params: Promise<{ meetingUuid: string }> } +) { + const { meetingUuid } = await params + + const meeting = await prisma.meeting.findUnique({ + where: { uuid: meetingUuid }, + }) + + if (!meeting) { + return NextResponse.json( + { error: "Reunión no encontrada" }, + { status: 404 } + ) + } + + const viewer = await resolveViewerIdentity(meetingUuid) + + if (!viewer) { + return NextResponse.json({ status: null }, { status: 200 }) + } + + const participant = await prisma.meetingParticipant.findUnique({ + where: { + meetingId_identity: { meetingId: meeting.id, identity: viewer.identity }, + }, + }) + + if (!participant) { + return NextResponse.json({ status: null }, { status: 200 }) + } + + return NextResponse.json({ + status: participant.status, + role: participant.role, + }) +} diff --git a/src/app/api/meetings/[meetingUuid]/participants/route.ts b/src/app/api/meetings/[meetingUuid]/participants/route.ts new file mode 100644 index 0000000..411514e --- /dev/null +++ b/src/app/api/meetings/[meetingUuid]/participants/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server" + +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { isHostOrCoHost } from "@/lib/meeting-auth" + +// Lists participants for the host/co-host admission panel — polled from +// /room/[meetingUuid] to surface pending join requests. +export async function GET( + _request: Request, + { params }: { params: Promise<{ meetingUuid: string }> } +) { + const { meetingUuid } = await params + + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: "No autenticado" }, { status: 401 }) + } + + const meeting = await prisma.meeting.findUnique({ + where: { uuid: meetingUuid }, + }) + + if (!meeting) { + return NextResponse.json( + { error: "Reunión no encontrada" }, + { status: 404 } + ) + } + + const userId = Number(session.user.id) + if (!(await isHostOrCoHost(meeting, userId))) { + return NextResponse.json( + { error: "No tienes permiso para ver esta lista" }, + { status: 403 } + ) + } + + const participants = await prisma.meetingParticipant.findMany({ + where: { meetingId: meeting.id }, + orderBy: { requestedAt: "asc" }, + include: { user: { select: { displayName: true, email: true } } }, + }) + + return NextResponse.json({ + participants: participants.map((participant) => ({ + id: participant.id, + identity: participant.identity, + displayName: + participant.user?.displayName ?? participant.guestName ?? "Invitado", + role: participant.role, + status: participant.status, + requestedAt: participant.requestedAt.toISOString(), + })), + }) +} diff --git a/src/app/join/[meetingUuid]/page.tsx b/src/app/join/[meetingUuid]/page.tsx new file mode 100644 index 0000000..e559540 --- /dev/null +++ b/src/app/join/[meetingUuid]/page.tsx @@ -0,0 +1,217 @@ +import { cookies } from "next/headers" +import { redirect } from "next/navigation" + +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { verifyJoinLinkToken } from "@/lib/join-link" +import { guestIdentityCookieName } from "@/lib/participant-identity" +import { AppShell } from "@/components/shell/app-shell" +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { JoinFlow } from "@/components/join/join-flow" + +// Reads the session/cookies and queries the DB on every request to +// resolve "who is this visitor and what's their access state" — same +// reasoning as the dashboard page (see its own comment) for opting out +// of Cache Components' static-shell validation. +export const instant = false + +function JoinShell({ + title, + children, +}: { + title?: string + children: React.ReactNode +}) { + return ( + +
+ {title ? ( +

+ {title} +

+ ) : null} + {children} +
+
+ ) +} + +function JoinMessage({ + title, + description, +}: { + title: string + description: string +}) { + return ( + + + + {title} + {description} + + + + ) +} + +export default async function JoinPage({ + params, + searchParams, +}: { + params: Promise<{ meetingUuid: string }> + searchParams: Promise<{ token?: string }> +}) { + const { meetingUuid } = await params + const { token } = await searchParams + + if (!token) { + return ( + + ) + } + + const verified = await verifyJoinLinkToken(token) + if (!verified || verified.meetingUuid !== meetingUuid) { + return ( + + ) + } + + const meeting = await prisma.meeting.findUnique({ + where: { uuid: meetingUuid }, + }) + + if (!meeting) { + return ( + + ) + } + + if (meeting.status === "ended") { + return ( + + ) + } + + const session = await auth() + + if (session?.user?.id) { + const userId = Number(session.user.id) + const identity = `user-${userId}` + + if (meeting.hostUserId === userId) { + await prisma.meetingParticipant.upsert({ + where: { + meetingId_identity: { meetingId: meeting.id, identity }, + }, + update: {}, + create: { + meetingId: meeting.id, + userId, + identity, + role: "host", + status: "admitted", + admittedAt: new Date(), + }, + }) + + redirect(`/room/${meetingUuid}`) + } + + const participant = await prisma.meetingParticipant.findUnique({ + where: { + meetingId_identity: { meetingId: meeting.id, identity }, + }, + }) + + if (participant) { + if (participant.status === "admitted") { + redirect(`/room/${meetingUuid}`) + } + + if (participant.status === "denied") { + return ( + + + + ) + } + + return ( + + + + ) + } + + return ( + + + + ) + } + + // Anonymous visitor: recognize a returning guest via cookie before + // falling back to the name-entry form. + const cookieStore = await cookies() + const guestIdentity = cookieStore.get( + guestIdentityCookieName(meetingUuid) + )?.value + + if (guestIdentity) { + const participant = await prisma.meetingParticipant.findUnique({ + where: { + meetingId_identity: { meetingId: meeting.id, identity: guestIdentity }, + }, + }) + + if (participant) { + if (participant.status === "admitted") { + redirect(`/room/${meetingUuid}`) + } + + if (participant.status === "denied") { + return ( + + + + ) + } + + return ( + + + + ) + } + } + + return ( + + + + ) +} diff --git a/src/app/room/[meetingUuid]/page.tsx b/src/app/room/[meetingUuid]/page.tsx new file mode 100644 index 0000000..9a861e2 --- /dev/null +++ b/src/app/room/[meetingUuid]/page.tsx @@ -0,0 +1,118 @@ +import Link from "next/link" + +import { prisma } from "@/lib/prisma" +import { resolveViewerIdentity } from "@/lib/participant-identity" +import { mintAccessToken, LIVEKIT_URL } from "@/lib/livekit" +import { AppShell } from "@/components/shell/app-shell" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { RoomClient } from "@/components/room/room-client" + +// Reads the session/guest cookie and queries the DB on every request to +// resolve admission state and mint a fresh LiveKit token — see the +// dashboard/join pages' own comments for why this opts out of Cache +// Components' static-shell validation. +export const instant = false + +function NoAccess() { + return ( + + + + No tienes acceso a esta sala + + Usa el enlace de invitación que te compartió el anfitrión para + unirte a esta reunión. + + + + + + + + ) +} + +export default async function RoomPage({ + params, +}: { + params: Promise<{ meetingUuid: string }> +}) { + const { meetingUuid } = await params + + const meeting = await prisma.meeting.findUnique({ + where: { uuid: meetingUuid }, + }) + + if (!meeting) { + return + } + + if (meeting.status === "ended") { + return ( + + + + Esta reunión ha finalizado + + El anfitrión ya terminó esta reunión. + + + + + + + + ) + } + + const viewer = await resolveViewerIdentity(meetingUuid) + if (!viewer) { + return + } + + const participant = await prisma.meetingParticipant.findUnique({ + where: { + meetingId_identity: { meetingId: meeting.id, identity: viewer.identity }, + }, + include: { user: { select: { displayName: true } } }, + }) + + if (!participant || participant.status !== "admitted") { + return + } + + const isHost = participant.role === "host" || participant.role === "co_host" + const displayName = + participant.user?.displayName ?? participant.guestName ?? "Invitado" + + const token = await mintAccessToken({ + roomName: meeting.uuid, + identity: viewer.identity, + displayName, + canPublish: true, + canSubscribe: true, + isHost, + }) + + return ( + + ) +} diff --git a/src/components/join/join-flow.tsx b/src/components/join/join-flow.tsx new file mode 100644 index 0000000..d31f40b --- /dev/null +++ b/src/components/join/join-flow.tsx @@ -0,0 +1,207 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { useRouter } from "next/navigation" +import { Loader2 } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +type Step = "name-entry" | "confirm" | "waiting" | "denied" + +const POLL_INTERVAL_MS = 3000 + +/** + * Client-side driver for the /join/[meetingUuid] page's non-host paths: + * anonymous name entry, authenticated-non-host confirmation, and the + * waiting-room polling loop. The server component decides which `mode` + * to start in based on session/cookie/participant-row state; this + * component only handles what happens next in the browser. + */ +export function JoinFlow({ + meetingUuid, + token, + mode, + displayName, +}: { + meetingUuid: string + token: string + mode: Step + displayName?: string +}) { + const router = useRouter() + const [step, setStep] = useState(mode) + const [guestName, setGuestName] = useState("") + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + const pollRef = useRef | null>(null) + + const stopPolling = useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + }, []) + + useEffect(() => { + if (step !== "waiting") { + return + } + + async function poll() { + try { + const response = await fetch( + `/api/meetings/${meetingUuid}/participants/me` + ) + if (!response.ok) return + const data = (await response.json()) as { status: string | null } + + if (data.status === "admitted") { + stopPolling() + router.push(`/room/${meetingUuid}`) + } else if (data.status === "denied") { + stopPolling() + setStep("denied") + } + } catch { + // Transient network error: next poll tick will retry. + } + } + + poll() + pollRef.current = setInterval(poll, POLL_INTERVAL_MS) + return stopPolling + }, [step, meetingUuid, router, stopPolling]) + + async function submitJoinRequest(body: { guestName?: string }) { + setSubmitting(true) + setError(null) + try { + const response = await fetch( + `/api/meetings/${meetingUuid}/join-request`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, ...body }), + } + ) + const data = await response.json() + + if (!response.ok) { + setError(data?.error ?? "No se pudo enviar la solicitud") + return + } + + if (data.status === "admitted") { + router.push(`/room/${meetingUuid}`) + return + } + + if (data.status === "denied") { + setStep("denied") + return + } + + setStep("waiting") + } catch { + setError("No se pudo conectar con el servidor") + } finally { + setSubmitting(false) + } + } + + if (step === "name-entry") { + return ( + + + Unirse a la reunión + + Ingresa tu nombre para que el anfitrión pueda identificarte. + + + + + setGuestName(event.target.value)} + placeholder="Ej. Ana Pérez" + maxLength={80} + disabled={submitting} + /> + {error ?

{error}

: null} +
+ + + +
+ ) + } + + if (step === "confirm") { + return ( + + + Unirse a la reunión + + Vas a unirte como {displayName}. + + + + {error ?

{error}

: null} +
+ + + +
+ ) + } + + if (step === "denied") { + return ( + + + Solicitud rechazada + + El anfitrión rechazó tu solicitud para unirte a la reunión. + + + + ) + } + + // step === "waiting" + return ( + + + + + Sala de espera + + + Esperando a que el anfitrión te admita... + + + + ) +} diff --git a/src/components/room/admission-panel.tsx b/src/components/room/admission-panel.tsx new file mode 100644 index 0000000..9e17914 --- /dev/null +++ b/src/components/room/admission-panel.tsx @@ -0,0 +1,153 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { Loader2 } from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +type ParticipantRow = { + id: number + displayName: string + role: "host" | "co_host" | "participant" + status: "pending" | "admitted" | "denied" | "left" + requestedAt: string +} + +const POLL_INTERVAL_MS = 3000 + +const STATUS_BADGE: Record = { + pending: "bg-amber-100 text-amber-700", + admitted: "bg-(--meet-status-active-bg) text-(--meet-status-active-fg)", + denied: "bg-destructive/10 text-destructive", + left: "bg-muted text-muted-foreground", +} + +/** + * Host/co-host admission panel: polls the participants list and shows + * Admit/Deny buttons on pending requests. Minimal but functional, per + * this task's scope — a polished version lands with the full in-call UI. + */ +export function AdmissionPanel({ meetingUuid }: { meetingUuid: string }) { + const [participants, setParticipants] = useState([]) + const [loading, setLoading] = useState(true) + const [actioningId, setActioningId] = useState(null) + + const refresh = useCallback(async () => { + try { + const response = await fetch( + `/api/meetings/${meetingUuid}/participants` + ) + if (!response.ok) return + const data = (await response.json()) as { + participants: ParticipantRow[] + } + setParticipants(data.participants) + } finally { + setLoading(false) + } + }, [meetingUuid]) + + useEffect(() => { + refresh() + const interval = setInterval(refresh, POLL_INTERVAL_MS) + return () => clearInterval(interval) + }, [refresh]) + + async function act(participantId: number, action: "admit" | "deny") { + setActioningId(participantId) + try { + await fetch( + `/api/meetings/${meetingUuid}/participants/${participantId}/${action}`, + { method: "POST" } + ) + await refresh() + } finally { + setActioningId(null) + } + } + + const pending = participants.filter((p) => p.status === "pending") + const others = participants.filter((p) => p.status !== "pending") + + return ( +
+ + + Solicitudes de ingreso + + {loading + ? "Cargando..." + : pending.length === 0 + ? "No hay solicitudes pendientes" + : `${pending.length} esperando admisión`} + + + {pending.length > 0 ? ( + + {pending.map((participant) => ( +
+ + {participant.displayName} + +
+ + +
+
+ ))} +
+ ) : null} +
+ + {others.length > 0 ? ( + + + Participantes + + + {others.map((participant) => ( +
+ + {participant.displayName} + + + {participant.status} + +
+ ))} +
+
+ ) : null} +
+ ) +} diff --git a/src/components/room/room-client.tsx b/src/components/room/room-client.tsx new file mode 100644 index 0000000..90451d4 --- /dev/null +++ b/src/components/room/room-client.tsx @@ -0,0 +1,107 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { + LiveKitRoom, + useConnectionState, +} from "@livekit/components-react" +import "@livekit/components-styles" + +import { CallShell } from "@/components/shell/call-shell" +import { Button } from "@/components/ui/button" +import { AdmissionPanel } from "@/components/room/admission-panel" + +/** + * Placeholder room screen: proves a minted LiveKit access token actually + * connects (or reports why it didn't), plus a minimal host admission + * panel. The real video-grid/chat/notes/recording UI is a separate, + * already-planned follow-up task — this intentionally stays simple. + */ +export function RoomClient({ + serverUrl, + token, + identity, + roomName, + meetingUuid, + isHost, +}: { + serverUrl: string + token: string + identity: string + roomName: string + meetingUuid: string + isHost: boolean +}) { + const router = useRouter() + const [sidePanelOpen, setSidePanelOpen] = useState(isHost) + const [connectError, setConnectError] = useState(null) + + return ( + setConnectError(error.message)} + onDisconnected={() => router.push("/dashboard")} + > + setSidePanelOpen(false)} + sidePanel={ + isHost ? : null + } + controlBar={ + <> + + {isHost ? ( + + ) : null} + + } + > + + + + ) +} + +function RoomStatus({ + identity, + roomName, + connectError, +}: { + identity: string + roomName: string + connectError: string | null +}) { + const connectionState = useConnectionState() + + return ( +
+

Conectado como {identity}

+

Sala: {roomName}

+

+ Estado de conexión: {connectionState} +

+ {connectError ? ( +

+ Error de conexión: {connectError} +

+ ) : null} +
+ ) +} diff --git a/src/lib/livekit.ts b/src/lib/livekit.ts new file mode 100644 index 0000000..4f55e72 --- /dev/null +++ b/src/lib/livekit.ts @@ -0,0 +1,61 @@ +import "server-only" + +import { AccessToken } from "livekit-server-sdk" + +// Server-side LiveKit integration: minting access tokens for participants +// to connect to the self-hosted LiveKit SFU (see LIVEKIT_URL/API_KEY/SECRET +// in .env). Room name convention: the meeting's `uuid` is used directly as +// the LiveKit room name (stable, unique, matches the original Laravel +// version's approach — no separate mapping table needed). + +const apiKey = process.env.LIVEKIT_API_KEY +const apiSecret = process.env.LIVEKIT_API_SECRET +const url = process.env.LIVEKIT_URL + +if (!apiKey || !apiSecret || !url) { + throw new Error( + "LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET environment variables are not set. Add them to .env (see .env.example)." + ) +} + +export const LIVEKIT_URL = url + +interface MintAccessTokenOptions { + /** LiveKit room name — use the meeting's `uuid`. */ + roomName: string + /** Stable per-participant identity, e.g. `user-{id}` or `guest-{uuid}`. */ + identity: string + /** Shown to other participants as `Participant.name`. */ + displayName: string + canPublish?: boolean + canSubscribe?: boolean + /** Host/co-host get room-admin grants; everyone else gets standard publish/subscribe. */ + isHost?: boolean +} + +/** + * Mints a signed LiveKit access token (JWT) authorizing a participant to + * join a room. This only requires the token's signing key/secret to match + * the LiveKit server's configuration — it does not itself open a network + * connection, so minting succeeds even if the actual WebSocket handshake + * against the server would fail due to mismatched credentials. + */ +export async function mintAccessToken( + options: MintAccessTokenOptions +): Promise { + const at = new AccessToken(apiKey, apiSecret, { + identity: options.identity, + name: options.displayName, + ttl: "6h", + }) + + at.addGrant({ + room: options.roomName, + roomJoin: true, + roomAdmin: options.isHost ?? false, + canPublish: options.canPublish ?? true, + canSubscribe: options.canSubscribe ?? true, + }) + + return at.toJwt() +} diff --git a/src/lib/meeting-auth.ts b/src/lib/meeting-auth.ts new file mode 100644 index 0000000..b70992a --- /dev/null +++ b/src/lib/meeting-auth.ts @@ -0,0 +1,34 @@ +import "server-only" + +import type { Meeting } from "@prisma/client" + +import { prisma } from "@/lib/prisma" + +/** + * True if `userId` is the meeting's host, or an admitted co-host of it. + * Used to gate host-only actions (listing/admitting/denying participants) + * server-side — never trust a client-side role check alone. + */ +export async function isHostOrCoHost( + meeting: Meeting, + userId: number +): Promise { + if (meeting.hostUserId === userId) { + return true + } + + const participant = await prisma.meetingParticipant.findUnique({ + where: { + meetingId_identity: { + meetingId: meeting.id, + identity: `user-${userId}`, + }, + }, + }) + + return ( + !!participant && + participant.status === "admitted" && + (participant.role === "host" || participant.role === "co_host") + ) +} diff --git a/src/lib/participant-identity.ts b/src/lib/participant-identity.ts new file mode 100644 index 0000000..7376816 --- /dev/null +++ b/src/lib/participant-identity.ts @@ -0,0 +1,58 @@ +import "server-only" + +import { cookies } from "next/headers" + +import { auth } from "@/auth" + +// Resolves "who is looking at this meeting" without trusting anything the +// client sends directly: an authenticated user's identity is derived from +// their session, and an anonymous guest's identity is derived from an +// httpOnly cookie set server-side after they submit the name-entry form +// (see /api/meetings/[meetingUuid]/join-request). Neither path lets the +// caller spoof another participant's identity. + +export function guestIdentityCookieName(meetingUuid: string): string { + return `meet-guest-${meetingUuid}` +} + +// Matches the join-link token grace period (src/lib/join-link.ts) so a +// guest's identity cookie doesn't outlive the link that let them in. +export const GUEST_IDENTITY_COOKIE_MAX_AGE_SECONDS = 6 * 60 * 60 + +export type ViewerIdentity = + | { + kind: "user" + identity: string + userId: number + displayName: string + } + | { + kind: "guest" + identity: string + } + +export async function resolveViewerIdentity( + meetingUuid: string +): Promise { + const session = await auth() + + if (session?.user?.id) { + return { + kind: "user", + identity: `user-${session.user.id}`, + userId: Number(session.user.id), + displayName: session.user.displayName ?? session.user.email ?? "Usuario", + } + } + + const cookieStore = await cookies() + const guestIdentity = cookieStore.get( + guestIdentityCookieName(meetingUuid) + )?.value + + if (guestIdentity) { + return { kind: "guest", identity: guestIdentity } + } + + return null +} diff --git a/src/lib/validations/join.ts b/src/lib/validations/join.ts new file mode 100644 index 0000000..c288db0 --- /dev/null +++ b/src/lib/validations/join.ts @@ -0,0 +1,11 @@ +import { z } from "zod" + +export const guestNameSchema = z.object({ + guestName: z + .string() + .trim() + .min(1, "El nombre es obligatorio") + .max(80, "El nombre es demasiado largo"), +}) + +export type GuestNameInput = z.infer