diff --git a/.env.example b/.env.example index 337155a6f..103e2725b 100644 --- a/.env.example +++ b/.env.example @@ -78,6 +78,10 @@ YOUTUBE_OUTPUT_DIR=./downloads # Default: true (authentication enabled) AUTH_ENABLED=true +# Versioned external API. This API always requires an external API key even +# when AUTH_ENABLED=false. It is disabled by default; set to true to enable it. +EXTERNAL_API_ENABLED=false + # Initial Admin Credentials (for headless/automated deployments) # If not set, you'll create credentials through the web UI on first access # And they will be saved to config/config.json (password hashed) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb7ce25f1..e100cb35b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,9 @@ jobs: - name: Install dependencies run: npm ci --ignore-scripts + - name: Validate external API fixture and checksum + run: npm run test:external-fixture + - name: Run backend tests with coverage run: npm run test:backend -- --coverage --coverageReporters=json-summary --coverageReporters=lcov --coverageReporters=text env: @@ -161,7 +164,7 @@ jobs: coverage/lcov.info test-external-api-database: - name: External API DB migrations (${{ matrix.engine }}) + name: External API DB runtime and migrations (${{ matrix.engine }}) runs-on: ubuntu-latest timeout-minutes: 10 strategy: @@ -197,7 +200,7 @@ jobs: run: npm install -g npm@11.15.0 --ignore-scripts - name: Install dependencies run: npm ci --ignore-scripts - - name: Run external API migration lifecycle + - name: Run external API runtime and migration lifecycle run: | npm run test:external-api-database -- --json --outputFile=external-api-database-results.json node -e "const result = require('./external-api-database-results.json'); if (result.numPassedTests < 1 || result.numPendingTestSuites !== 0 || result.numPendingTests !== 0 || result.numTodoTests !== 0) process.exit(1);" @@ -208,6 +211,19 @@ jobs: DB_PASSWORD: rootpass DB_ADMIN_USER: root DB_ADMIN_PASSWORD: rootpass + - name: Verify runtime query plans on synthetic data + run: npm run test:external-index-plans + env: + DB_HOST: 127.0.0.1 + DB_PORT: 3321 + DB_USER: root + DB_PASSWORD: rootpass + EXTERNAL_API_INDEX_REPORT: external-api-index-plans.json + - name: Upload runtime query plans + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: external-api-index-plans-${{ matrix.engine }} + path: external-api-index-plans.json test-frontend: name: Frontend Tests diff --git a/CLAUDE.md b/CLAUDE.md index da0199e80..0ee9104ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,9 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), ## Architecture ### Backend (server/) +- `routes/externalApi.js` and `middleware/externalApiAuth.js`: opt-in `/external-api/v1` routes with mandatory external-key authentication, bounded UTF-8 JSON bodies, private responses, and per-key limits. All External API business logic lives in `modules/externalApi/`; `externalApiConfig.js` (feature availability and `isExternalApiPath`), `externalApiResponse.js`, `externalPermissions.js`, and `externalEligibility.js` (media/rating policy; maps TV-Y7 to level 2 locally since `ratingMapper` has no numeric TV-Y7) centralize the shared rules, and `apiKeyChannelGrantModule.js` manages per-key channel grants. +- `modules/externalApi/externalCatalogService.js`: cached, grant-filtered catalog and asset lookup. Reads never spawn yt-dlp or write: video detail uses `videoMetadataModule.getCachedVideoMetadata` (cached `.info.json` only). Raw SQL stays portable (snake_case aliases, `POSITION(... IN ...)`), keyset cursors (v3) tie-break on `cv.id`, and single-channel lookups go through `Channel.findOne` with the `apiKeyGrants` include. `externalCatalogRequests.js` batches current-page request status, `externalPagination.js` shares bounded page cursors, and `externalThumbnailProxy.js` enforces upstream image URL, size, and timeout limits. +- `modules/externalApi/externalRequestService.js`: request creation (auto-approved requests are stored `approved` with `approval_source='auto'`) and owner history; reads never change request state. `externalRequestExecutor.js` is the background executor (started in `server.js` only when the External API is enabled; sweeps every minute, wakes on create/approve, and on `jobModule.onJobEnded`): it rechecks authority, claims `approved` -> `processing`, queues video downloads through `downloadModule.doSpecificDownloads` with the request id as the job id, provisions/enables/grants channels, and deletes videos through `videoDeletionModule`, holding requests with a message while downloads are paused or the video is busy, and recovering interrupted rows. `externalRequestTargets.js` validates grant/channel/video/policy targets, `externalRequestReconciliation.js` closes processing video requests whose job ended, `externalRequestReviewService.js` owns idempotent administrator approve/reject (approve only marks `approved` and wakes the executor), `externalRequestPrimitives.js` shares validation/DTOs, and `externalQuotaService.js` counts `approved` + `processing` as active jobs. Key-before-request locks serialize decisions; `externalWorkLimiter.js` bounds expensive work. `routes/externalRequests.js` exposes admin management endpoints that reject every API key; their UI is a separate follow-up. - `modules/externalCookies.js`: validates external cookie snapshots through the installed yt-dlp parser in `utils/validate-cookies.py`, memoizes unchanged results, and reports unusable files for omission; `modules/uploadedCookies.js` gives the uploaded `config/cookies.user.txt` a disposable private copy per run (yt-dlp rewrites its cookie file in place on exit, so concurrent runs sharing it could read a half-written jar or undo a Settings upload; nothing is written back, and Settings uploads are written to a temp file and renamed into place; if the copy fails, the run goes without cookies (error-level log; info when the upload was just deleted) and yt-dlp is never handed the uploaded file itself, whose exit-time jar save could overwrite a newer upload or recreate deleted cookies); `modules/ytdlpProcess.js` owns both kinds of copy's lifetime around yt-dlp processes. No persistent cookie fallback. - `modules/rescanRowUpdate.js` + `modules/videoRowGuard.js`: the filesystem rescan (`videosModule.backfillVideoMetadata`) walks the disk once and reads rows in chunks, so downloads and deletions in between make the walk stale. The walk only nominates rows (`isRescanCandidate`); `resolveRescanUpdate` recomputes each nominated row's write from a fresh per-format stat (present only for a regular file / missing on `ENOENT` or `ENOTDIR` or a non-file / unknown on any other error; both formats missing marks the row missing and keeps its stored paths; a missing format is cleared only when the other is present; any present format means `removed: false` even if the other is unknown; an unknown format is never cleared, and with nothing present an unknown writes nothing), and the write only lands if the row is unchanged since read (`videoRowGuard.unchangedSinceRead`: removed, both paths and sizes, `video_resolution`, `last_downloaded_at`; zero affected rows means a conflict only when the write includes a column that really differs, because Sequelize's MySQL dialect connects with -FOUND_ROWS and mysql2 then counts changed rows, not matched rows). Nominated rows left as recorded (files still at a stored path, e.g. outside the downloads folder, or files that could not be checked; re-checked every run) are counted as `kept` (`videosKept` in the run details, not the message). The walk's count of folders and files it could not read is `unreadableOnDisk` in the run details and log (not the message: those rows are re-checked by path). Rows another writer changed are counted as `skippedChanged` (`videosSkipped` in the run record) and failed writes as `failed` (`videosFailed`); both appear in the run message, and a finished scan with failed writes records as `error`/`partial` (a timed-out scan stays `timed-out` so Maintenance can continue it); the completion `rescanStatus` broadcast is built through the same `rescanRunSummary` record so the live Maintenance view matches the run history. Library repair (`jobModule.backfillFromCompleteList`) uses the same guard: it reads the row before searching the disk, writes file details and `removed: false` only for a file it actually found, and looks up a lost row's files by YouTube id with one `videosModule.scanForVideoFiles` walk per run (only when a lost row exists; video and audio, any folder layout), recreating it as missing when the walk finds nothing. The walk skips and counts (`unreadable`) folders and files it cannot read rather than stopping, logging unreadable files once per folder; repair still recreates unmatched rows as missing after a partial walk, because deferring would block recovery wherever a folder is never readable (lost+found, NAS recycle bins), and the next rescan restores a row whose file it finds. Such a run records as `error`/`partial`, naming the count in its message (`recreatedUnverified` and `unreadableOnDisk` in the details); a partial walk that affected no row stays `completed`. - `modules/cookieDetails.js`: summarizes the YouTube login cookies (names, counts, earliest expiry; never values) in the active cookie file as `details` on `GET /api/cookies/status` and the upload/delete responses, reading external files through `externalCookies.readExternalCookies`. `modules/cookieTest.js`: `POST /api/cookies/test`, one yt-dlp request to the subscriptions feed with `buildCommonArgs` (same proxy/IP family/cookies/cache as downloads) through `ytDlpRunner`, against a private temp copy of uploaded cookies (yt-dlp writes its jar back on exit, which would otherwise undo a replace/delete made mid-test; external files get spawnYtDlp's snapshot) and never logs stderr (yt-dlp echoes malformed cookie lines with their values); any exit 0 with JSON is signed in (even zero subscriptions), and the feed's "Incomplete yt initial data" warnings mean signed out (EXPIRED_COOKIES), a meaning specific to this feed, so it is not in the shared subscription-import classifier. One test at a time (409), rate-limited by `cookieTestRateLimiter`. @@ -66,6 +69,8 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `components/Configuration/sections/LoggingSection.tsx` (`/settings/logging`): log level select ('' = LOG_LEVEL), the current level's source from the saved setting plus `/getconfig`'s `logging.envLevel` (via `useConfig().loggingStatus`), log file limits or an unwritable-folder warning, and Download logs through `Configuration/hooks/useLogDownload.ts` (axios blob, since a link cannot send `x-access-token`). - `components/FindChannels/`: channel search page at `/subscriptions/find`; each result card has an Open in YouTube link below its clickable area; clicking an unsubscribed card opens the shared `AddChannelDialog`, whose confirm hands off to `Subscriptions` via `location.state.addChannelUrl` and auto-runs the add flow. - `components/FindVideos/`: video search page at `/videos/find`. The server returns YouTube's relevance order; `sortResults.ts` sorts the loaded set client-side (relevance, published, views, duration, title, channel; nulls last either way), through the desktop table's headers (natural direction, reversed, then back to relevance) or the shared `VideoList/VideoListSortButton` on phones and in grid view (its `SortOption.undirected` marks relevance, which has no arrow). `hooks/useFindVideosPreferences.ts` remembers view (table by default), sort, and minimum duration in localStorage; `hooks/useVideoSearch.ts` keeps the last search in sessionStorage and restores it when the page opens with the same `?q=`, which the page writes after every search (a `?q=` with nothing remembered runs the search). `components/resultParts.tsx` holds the pieces the table, phone list, and cards share: relative published date with the exact date as tooltip (yt-dlp dates are approximate), compact view count, a Short badge at 60 s or less, a status chip only for states other than never-downloaded, the per-row download button (`hooks/useResultDownload.ts` owns the dialog for either one row or the selection, never touching the selection for a single download), and the channel name through the shared `ChannelNameDisplay` (link when `channelSubscribed`, plus a Subscribed chip; otherwise the add-channel affordance opening `AddChannelDialog`). +- `components/ExternalRequests/`: administrator review queue for external API requests at `/requests` (routed and linked in the nav only when `/getconfig`'s `isPlatformManaged.externalApiEnabled` is true; the `showRequestsNavLink` setting can additionally hide the link). `RequestsPage.tsx` renders filters, a table on desktop and cards on phones, and the shared `PageControls`; `hooks/useExternalRequests.ts` loads the page (stale responses discarded, page clamped when a review empties the last page, and a silent refetch every 5 s while any visible request is `approved`/`processing`, since the server's background executor advances them; paused while the tab is hidden) and `hooks/useRequestReview.ts` owns details (kept in step with those refetches) and approve/reject confirmation for pending requests (cancelling an action started from the details dialog returns to it; an inline one returns to the list); `components/` holds `RequestActions`, `RequestTarget`, `RequestDetailsDialog`, and `RequestActionDialog`. +- `components/Configuration/sections/ApiKeysSection.tsx` (`/settings/api-keys`): bookmarklet keys always come first (`ApiKeysSection/BookmarkletKeysSection.tsx`, and `CreatedKeyDialog.tsx` shows the one-time key, the draggable Send to Youtarr bookmarklet, and phone shortcut details); the external access keys section (`ExternalKeysSection`, `ExternalKeyCard`, `CreateKeyDialog`, `EditKeyDialog` + `useExternalAccessEditor`, `PolicyEditor`, `ChannelGrantPicker`) appears only when the external API is enabled. Requests go through `ApiKeysSection/useApiKeys.ts` (axios); `WORKLOAD_LIMITS` there mirrors the server's per-key ceilings. - `themes/`: theme definitions (`playful`, `linear`, `flat`), shared layout policy (`layoutPolicy.ts`), and the `ALL_THEMES` registry. New themes add an entry here and implement the required token surface. - `hooks/`: app-wide custom hooks for data fetching and state. - `contexts/` and `providers/`: React Context for cross-cutting concerns (auth token, WebSocket, theme). `contexts/ThemeEngineContext.tsx` owns the active theme mode, resolves the layout policy for the current viewport, and injects theme CSS variables onto the document root. @@ -232,6 +237,8 @@ npm run test:frontend # all frontend test npm run test:frontend -- client/src/hooks/__tests__/foo.ts # one frontend test npm run test:backend # all backend tests npm run test:backend -- server/modules/__tests__/foo.test.js # one backend test +npm run test:external-api-database # real database migration/runtime regressions +npm run test:external-index-plans # local synthetic query/EXPLAIN report npm run lint # lint front + back npm run lint:ts # TypeScript typecheck ``` diff --git a/client/package-lock.json b/client/package-lock.json index aea3b1eee..97f59ffb7 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -806,21 +806,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "dev": true, "license": "MIT", "optional": true, @@ -829,9 +829,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -2000,9 +2000,9 @@ } }, "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.6.4.tgz", - "integrity": "sha512-6PyZBYKnnVNqOSB0YFly+62R7dmov8segT27A+RVTBVd4iAE6kbW9QBJGlyR2yG4D4ohzhZSTIu7BK1UTtmFFA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.7.0.tgz", + "integrity": "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2011,7 +2011,7 @@ }, "peerDependencies": { "typescript": ">= 4.3.x", - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -2043,27 +2043,27 @@ } }, "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/glob": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.5.tgz", - "integrity": "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.2.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -2071,13 +2071,13 @@ } }, "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -2087,9 +2087,9 @@ } }, "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -2097,7 +2097,7 @@ "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2114,137 +2114,886 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz", + "integrity": "sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.127.0.tgz", + "integrity": "sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.127.0.tgz", + "integrity": "sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.127.0.tgz", + "integrity": "sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.127.0.tgz", + "integrity": "sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.127.0.tgz", + "integrity": "sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.127.0.tgz", + "integrity": "sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.127.0.tgz", + "integrity": "sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.127.0.tgz", + "integrity": "sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.127.0.tgz", + "integrity": "sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.127.0.tgz", + "integrity": "sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.127.0.tgz", + "integrity": "sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.127.0.tgz", + "integrity": "sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.127.0.tgz", + "integrity": "sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.127.0.tgz", + "integrity": "sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.127.0.tgz", + "integrity": "sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.127.0.tgz", + "integrity": "sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.127.0.tgz", + "integrity": "sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.127.0.tgz", + "integrity": "sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.127.0.tgz", + "integrity": "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.2.tgz", + "integrity": "sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.2.tgz", + "integrity": "sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.2.tgz", + "integrity": "sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.2.tgz", + "integrity": "sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.2.tgz", + "integrity": "sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.2.tgz", + "integrity": "sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.2.tgz", + "integrity": "sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.2.tgz", + "integrity": "sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.2.tgz", + "integrity": "sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.2.tgz", + "integrity": "sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.2.tgz", + "integrity": "sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.2.tgz", + "integrity": "sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.2.tgz", + "integrity": "sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==", + "cpu": [ + "s390x" + ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.2.tgz", + "integrity": "sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==", + "cpu": [ + "x64" + ], "dev": true, - "engines": { - "node": ">=6.0.0" - } + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.2.tgz", + "integrity": "sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.2.tgz", + "integrity": "sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@mswjs/interceptors": { - "version": "0.41.3", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", - "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.2.tgz", + "integrity": "sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" + "@emnapi/core": "1.11.0", + "@emnapi/runtime": "1.11.0", + "@napi-rs/wasm-runtime": "^1.1.5" }, "engines": { - "node": ">=18" + "node": ">=14.0.0" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", + "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", + "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" + "tslib": "^2.4.0" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 8" + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@tybys/wasm-util": "^0.10.3" }, "engines": { - "node": ">= 8" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.2.tgz", + "integrity": "sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.2.tgz", + "integrity": "sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -4047,9 +4796,9 @@ } }, "node_modules/@storybook/addon-a11y": { - "version": "10.2.17", - "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.2.17.tgz", - "integrity": "sha512-J0ogEc4/XFC+Ytz+X1we6TOKreEk/shgUs/mtxdsLa0xJ6bp2n2OQPSjNtQHH/nK4SRBSfHWPm8ztfcXTzeG9w==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.6.2.tgz", + "integrity": "sha512-zleS+btwrTRcUf3opbZntKsEt6PTqAtxsek08tx5jh5fRvd6wWmtnwW3uaBcypfIKnheTB3KGljJwFX+Mc+Umg==", "dev": true, "license": "MIT", "dependencies": { @@ -4061,13 +4810,13 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.17" + "storybook": "^10.6.2" } }, "node_modules/@storybook/addon-links": { - "version": "10.2.17", - "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.2.17.tgz", - "integrity": "sha512-KY2usxhPpt9AAzD22uBEfdPj1NZyCNyaYXgKkr8r/UeCNt7E7OdVBLNA1QMYZZ5dtIWj9EtY8c55OPuBM7aUkQ==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.6.2.tgz", + "integrity": "sha512-pWwWp+KAcqdX9Cg5i3yGaKcD3+7OEDhHkaiYv8GHv1JIOl7Ao/CaoY07or8BdM785akOqxk5Z+UrY7zNKZJ6Gw==", "dev": true, "license": "MIT", "dependencies": { @@ -4078,23 +4827,26 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.17" + "storybook": "^10.6.2" }, "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, "react": { "optional": true } } }, "node_modules/@storybook/builder-vite": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.2.10.tgz", - "integrity": "sha512-Wd6CYL7LvRRNiXMz977x9u/qMm7nmMw/7Dow2BybQo+Xbfy1KhVjIoZ/gOiG515zpojSozctNrJUbM0+jH1jwg==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.6.2.tgz", + "integrity": "sha512-SzeQEGjvybdhP2coF4aIX6JX/7rZTrQCpRU3vAEO/R98Vr3xM8jdg7a3nvt91TeF/P8iWqljLIpaDjTdk2r/sQ==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "10.2.10", "ts-dedent": "^2.0.0" }, "funding": { @@ -4102,43 +4854,8 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.10", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@storybook/csf-plugin": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.10.tgz", - "integrity": "sha512-aFvgaNDAnKMjuyhPK5ialT22pPqMN0XfPBNPeeNVPYztngkdKBa8WFqF/umDd47HxAjebq+vn6uId1xHyOHH3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "unplugin": "^2.3.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "esbuild": "*", - "rollup": "*", - "storybook": "^10.2.10", - "vite": "*", - "webpack": "*" - }, - "peerDependenciesMeta": { - "esbuild": { - "optional": true - }, - "rollup": { - "optional": true - }, - "vite": { - "optional": true - }, - "webpack": { - "optional": true - } + "storybook": "^10.6.2", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@storybook/global": { @@ -4149,47 +4866,55 @@ "license": "MIT" }, "node_modules/@storybook/icons": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.1.tgz", - "integrity": "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.2.0.tgz", + "integrity": "sha512-FpUB/xizIrV1ILhCIYFo+TtQNN9YEoLjfcnAW0JtOXL+BcM2aisSysI0B54JMWDnljriXySBHgN6ps4s9lb75A==", "dev": true, "license": "MIT", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@storybook/react": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.2.10.tgz", - "integrity": "sha512-PcsChzPI8lhllB9exV7nFb96093i6sTwIl0jpPjaTFPQCRoueR9E/YeP3qSKQL9xt4cmii0cW7F0RUx25rW93Q==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.6.2.tgz", + "integrity": "sha512-kNWUU6Q8TrLmEqAzYmNYhC9A3i3kbjFxqIcLL3eu+o4gDu0afp1Of6Sy6jIF2qPGH05A3r6FWMzD03y4Mm5xRw==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.2.10", - "react-docgen": "^8.0.2" + "@storybook/react-dom-shim": "10.6.2", + "react-docgen": "^8.0.2", + "react-docgen-typescript": "^2.2.2" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" }, "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.10", + "storybook": "^10.6.2", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, "typescript": { "optional": true } } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.10.tgz", - "integrity": "sha512-TmBrhyLHn8B8rvDHKk5uW5BqzO1M1T+fqFNWg88NIAJOoyX4Uc90FIJjDuN1OJmWKGwB5vLmPwaKBYsTe1yS+w==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.6.2.tgz", + "integrity": "sha512-k0262Vqxjk3wHeg3g/QO/ENDrkC7kkbuwsFSXOdYr7Sl888tsF1DAS7uQ/gr+s/BRz1FLYRX7wBY3smEUV4MJg==", "dev": true, "license": "MIT", "funding": { @@ -4197,25 +4922,35 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.10" + "storybook": "^10.6.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@storybook/react-vite": { - "version": "10.2.10", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.2.10.tgz", - "integrity": "sha512-C652GhZHXURi+gFqqLKmZPskEq1FQto4VCf/eQea2exmdVS0nOB+FFWQZNCivX6mpkDHza8UxRZNFpDB0mWcJQ==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.6.2.tgz", + "integrity": "sha512-RfYSW9eefxRXk7IXKBTBkBYAspHKaxNVBR7mlXQAt+Z2bJZzj+S21mv6kBcpFIfAIT9wSIq8xMg0RbeR7CneVQ==", "dev": true, "license": "MIT", "dependencies": { - "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.4", + "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "10.2.10", - "@storybook/react": "10.2.10", + "@storybook/builder-vite": "10.6.2", + "@storybook/react": "10.6.2", "empathic": "^2.0.0", - "magic-string": "^0.30.0", - "react-docgen": "^8.0.0", + "magic-string": "^1.1.0", + "react-docgen": "^8.0.2", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, @@ -4226,8 +4961,14 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.10", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + "storybook": "^10.6.2", + "typescript": ">= 4.9.x", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@swc/core": { @@ -4479,7 +5220,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4495,10 +5235,11 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.8.0.tgz", - "integrity": "sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, + "license": "MIT", "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", @@ -4547,10 +5288,11 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12", "npm": ">=6" @@ -4588,9 +5330,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", "dev": true, "license": "MIT", "optional": true, @@ -4602,8 +5344,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5218,6 +5959,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webcontainer/env": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@webcontainer/env/-/env-1.1.1.tgz", + "integrity": "sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==", + "dev": true, + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -6418,8 +7166,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -8826,19 +9573,18 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, - "peer": true, "bin": { "lz-string": "bin/bin.js" } }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/make-dir": { @@ -9572,11 +10318,11 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -9874,6 +10620,75 @@ "dev": true, "license": "MIT" }, + "node_modules/oxc-parser": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz", + "integrity": "sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.127.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.127.0", + "@oxc-parser/binding-android-arm64": "0.127.0", + "@oxc-parser/binding-darwin-arm64": "0.127.0", + "@oxc-parser/binding-darwin-x64": "0.127.0", + "@oxc-parser/binding-freebsd-x64": "0.127.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.127.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.127.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.127.0", + "@oxc-parser/binding-linux-arm64-musl": "0.127.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.127.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.127.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.127.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.127.0", + "@oxc-parser/binding-linux-x64-gnu": "0.127.0", + "@oxc-parser/binding-linux-x64-musl": "0.127.0", + "@oxc-parser/binding-openharmony-arm64": "0.127.0", + "@oxc-parser/binding-wasm32-wasi": "0.127.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.127.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.127.0", + "@oxc-parser/binding-win32-x64-msvc": "0.127.0" + } + }, + "node_modules/oxc-resolver": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.2.tgz", + "integrity": "sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.21.2", + "@oxc-resolver/binding-android-arm64": "11.21.2", + "@oxc-resolver/binding-darwin-arm64": "11.21.2", + "@oxc-resolver/binding-darwin-x64": "11.21.2", + "@oxc-resolver/binding-freebsd-x64": "11.21.2", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.2", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.2", + "@oxc-resolver/binding-linux-arm64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-arm64-musl": "11.21.2", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-riscv64-musl": "11.21.2", + "@oxc-resolver/binding-linux-s390x-gnu": "11.21.2", + "@oxc-resolver/binding-linux-x64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-x64-musl": "11.21.2", + "@oxc-resolver/binding-openharmony-arm64": "11.21.2", + "@oxc-resolver/binding-wasm32-wasi": "11.21.2", + "@oxc-resolver/binding-win32-arm64-msvc": "11.21.2", + "@oxc-resolver/binding-win32-x64-msvc": "11.21.2" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -11050,24 +11865,29 @@ } }, "node_modules/storybook": { - "version": "10.2.17", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.17.tgz", - "integrity": "sha512-yueTpl5YJqLzQqs3CanxNdAAfFU23iP0j+JVJURE4ghfEtRmWfWoZWLGkVcyjmgum7UmjwAlqRuOjQDNvH89kw==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.6.2.tgz", + "integrity": "sha512-qHDINMiapxFUxYb+xC3j8u7Y+85X3u+/PlytE/wmnyg+bU0agCW3jUmWP4vIh2MioO2jTK/2B3Lcw6IpRFzQBA==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/icons": "^2.0.1", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/user-event": "^14.6.1", + "@storybook/icons": "^2.0.2", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/user-event": "^14.6.3", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", - "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "@webcontainer/env": "^1.1.1", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", + "jsonc-parser": "^3.3.1", "open": "^10.2.0", + "oxc-parser": "^0.127.0", + "oxc-resolver": "11.21.2", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", - "ws": "^8.18.0" + "ws": "^8.21.1" }, "bin": { "storybook": "dist/bin/dispatcher.js" @@ -11077,11 +11897,19 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "prettier": "^2 || ^3" + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "prettier": "^2 || ^3", + "vite-plus": "^0.1.15 || ^0.2.0" }, "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, "prettier": { "optional": true + }, + "vite-plus": { + "optional": true } } }, @@ -11653,9 +12481,9 @@ } }, "node_modules/ts-dedent": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", - "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", "dev": true, "license": "MIT", "engines": { @@ -11878,22 +12706,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unplugin": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", - "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "acorn": "^8.15.0", - "picomatch": "^4.0.3", - "webpack-virtual-modules": "^0.6.2" - }, - "engines": { - "node": ">=18.12.0" - } - }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -12228,13 +13040,6 @@ "node": ">=12" } }, - "node_modules/webpack-virtual-modules": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", - "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", - "dev": true, - "license": "MIT" - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index 9804af197..a76d35563 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -83,6 +83,15 @@ jest.mock('./components/ChannelPage', () => { }; }); +jest.mock('./components/Settings/Settings', () => ({ + Settings: () =>
Settings Component
, +})); + +jest.mock('./components/ExternalRequests/RequestsPage', () => ({ + __esModule: true, + default: () =>
Requests Component
, +})); + jest.mock('./components/ChangelogPage', () => { return function ChangelogPage() { return
Changelog Page Component
; @@ -171,6 +180,7 @@ Object.defineProperty(window, 'matchMedia', { beforeEach(() => { jest.clearAllMocks(); setMockLocation('http://localhost/'); + window.history.replaceState({}, '', '/'); // Re-apply matchMedia mock - resetMocks:true in jest.config.cjs clears implementations Object.defineProperty(window, 'matchMedia', { writable: true, @@ -533,6 +543,36 @@ describe('App Component', () => { // ElfHosted platform sets token via platform-managed auth }); + test('redirects direct Requests navigation when external API is disabled', async () => { + localStorageMock.getItem.mockReturnValue('test-token'); + window.history.replaceState({}, '', '/requests'); + (global.fetch as jest.Mock).mockImplementation((url) => { + if (url === '/getconfig') { + return Promise.resolve({ + ok: true, + json: async () => ({ isPlatformManaged: { externalApiEnabled: false } }), + }); + } + if (url === '/api/db-status') { + return Promise.resolve({ ok: true, json: async () => ({ status: 'healthy' }) }); + } + if (url === '/setup/status') { + return Promise.resolve({ ok: true, json: async () => ({ requiresSetup: false }) }); + } + if (url === '/auth/validate') { + return Promise.resolve({ ok: true, json: async () => ({}) }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('settings-page')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('requests-page')).not.toBeInTheDocument(); + }); + test('renders authenticated pages when user has token', async () => { localStorageMock.getItem.mockReturnValue('test-token'); diff --git a/client/src/App.tsx b/client/src/App.tsx index 83667dc25..642ae6dcf 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -32,6 +32,7 @@ import InitialSetup from './components/InitialSetup'; import ChannelPage from './components/ChannelPage'; import PlaylistPage from './components/PlaylistPage'; import ImportSubscriptionsPage from './components/SubscriptionImport'; +import RequestsPage from './components/ExternalRequests/RequestsPage'; import ChangelogPage from './components/ChangelogPage'; import { AuthSplash } from './components/AuthSplash'; import { useConfig } from './hooks/useConfig'; @@ -83,10 +84,11 @@ function AppContent() { const [dbErrors, setDbErrors] = useState([]); const [dbRecovered, setDbRecovered] = useState(false); const [countdown, setCountdown] = useState(15); + const [requestsNavLinkPreview, setRequestsNavLinkPreview] = useState(null); const location = useLocation(); // Use config hook for global configuration access - const { config: appConfig, deploymentEnvironment } = useConfig(token); + const { config: appConfig, isPlatformManaged: platformManagedConfig, deploymentEnvironment } = useConfig(token); const { version } = packageJson; const clientVersion = `v${version}`; // Create a version with 'v' prefix for comparison const tmpDirectory = '/tmp'; @@ -541,6 +543,7 @@ function AppContent() { serverVersion={serverVersion} ytDlpUpdateAvailable={ytDlpUpdateAvailable} ytDlpUpdateTooltip={ytDlpUpdateTooltip} + showRequestsNavLink={platformManagedConfig.externalApiEnabled && (requestsNavLinkPreview ?? appConfig.showRequestsNavLink)} onLogout={handleLogout} > } /> - } /> + + } + /> } /> } /> } /> @@ -564,6 +575,7 @@ function AppContent() { } /> } /> } /> + : } /> } /> } /> } /> diff --git a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx index fbda1af1d..f80f3f913 100644 --- a/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx +++ b/client/src/components/ChannelPage/__tests__/ChannelSettingsDialog.test.tsx @@ -79,7 +79,8 @@ describe('ChannelSettingsDialog', () => { plexUrl: false, authEnabled: true, useTmpForDownloads: false, - ytdlpUpdates: false + ytdlpUpdates: false, + externalApiEnabled: false }, deploymentEnvironment: { platform: null, @@ -362,7 +363,8 @@ describe('ChannelSettingsDialog', () => { plexUrl: false, authEnabled: true, useTmpForDownloads: false, - ytdlpUpdates: false + ytdlpUpdates: false, + externalApiEnabled: false }, deploymentEnvironment: { platform: null, diff --git a/client/src/components/Configuration/sections/ApiKeysSection.tsx b/client/src/components/Configuration/sections/ApiKeysSection.tsx index ae84c1ab6..503fa67bd 100644 --- a/client/src/components/Configuration/sections/ApiKeysSection.tsx +++ b/client/src/components/Configuration/sections/ApiKeysSection.tsx @@ -1,151 +1,113 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { - Box, Typography, - Button, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, - IconButton, - TextField, - Dialog, - DialogTitle, - DialogContent, - DialogActions, Alert, - Tooltip, - Chip, Skeleton, Snackbar, Divider, } from '../../ui'; -import { Trash2 as DeleteIcon, Plus as AddIcon, Copy as ContentCopyIcon, AlertTriangle as WarningIcon } from 'lucide-react'; +import { AlertTriangle as WarningIcon } from 'lucide-react'; import { ConfigurationAccordion } from '../common/ConfigurationAccordion'; -import { InfoTooltip } from '../common/InfoTooltip'; import { locationUtils } from '../../../utils/location'; +import BookmarkletKeysSection from './ApiKeysSection/BookmarkletKeysSection'; +import ConfirmKeyActionDialog from './ApiKeysSection/ConfirmKeyActionDialog'; +import CreateKeyDialog from './ApiKeysSection/CreateKeyDialog'; +import CreatedKeyDialog from './ApiKeysSection/CreatedKeyDialog'; +import EditKeyDialog from './ApiKeysSection/EditKeyDialog'; +import ExternalKeysSection from './ApiKeysSection/ExternalKeysSection'; +import { apiKeyError } from './ApiKeysSection/apiKeyError'; +import { ApiKey, ApiKeyRole, ApiKeyCreatedResponse, useApiKeys } from './ApiKeysSection/useApiKeys'; -interface ApiKey { - id: number; - name: string; - key_prefix: string; - created_at: string; - last_used_at: string | null; - is_active: boolean; - usage_count: number; -} - -interface ApiKeyCreatedResponse { - success: boolean; - message: string; - id: number; - name: string; - key: string; - prefix: string; -} +const SECTION_TITLE = 'API Keys & Bookmarklet'; interface ApiKeysSectionProps { token: string | null; apiKeyRateLimit: number; onRateLimitChange: (value: number) => void; + externalApiEnabled?: boolean; + showRequestsNavLink: boolean; + onShowRequestsNavLinkChange: (value: boolean) => void; } -const ApiKeysSection: React.FC = ({ token, apiKeyRateLimit, onRateLimitChange }) => { +const ApiKeysSection: React.FC = ({ + token, + apiKeyRateLimit, + onRateLimitChange, + externalApiEnabled = false, + showRequestsNavLink, + onShowRequestsNavLinkChange, +}) => { const [apiKeys, setApiKeys] = useState([]); const [loading, setLoading] = useState(true); const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [createKeyType, setCreateKeyType] = useState<'external' | 'legacy'>('external'); const [createdKeyDialogOpen, setCreatedKeyDialogOpen] = useState(false); - const [newKeyName, setNewKeyName] = useState(''); const [createdKey, setCreatedKey] = useState(null); + const [createdKeyRole, setCreatedKeyRole] = useState('legacy_download'); + const [createdKeyAction, setCreatedKeyAction] = useState<'created' | 'regenerated'>('created'); const [error, setError] = useState(null); + const [editKey, setEditKey] = useState(null); + const apiKeyApi = useApiKeys(token); const [snackbar, setSnackbar] = useState({ open: false, message: '' }); const [deleteConfirmDialog, setDeleteConfirmDialog] = useState<{ open: boolean; keyId: number | null; keyName: string }>({ open: false, keyId: null, keyName: '', }); + const [regenerateConfirmDialog, setRegenerateConfirmDialog] = useState<{ + open: boolean; + key: ApiKey | null; + }>({ open: false, key: null }); + const [regenerating, setRegenerating] = useState(false); const [isHttpWarning] = useState( locationUtils.getProtocol() !== 'https:' && locationUtils.getHostname() !== 'localhost' ); + const loadSequence = useRef(0); + const sessionSequence = useRef(0); + const mutationBusy = useRef(false); + const [revoking, setRevoking] = useState(false); const fetchApiKeys = useCallback(async () => { - if (!token) return; - + const sequence = ++loadSequence.current; try { - const response = await fetch('/api/keys', { - headers: { 'x-access-token': token }, - }); - - if (response.ok) { - const data = await response.json(); - setApiKeys(data.keys || []); - } else { - const errData = await response.json(); - setError(errData.error || 'Failed to fetch API keys'); - } + const keys = await apiKeyApi.fetchApiKeys(); + if (sequence === loadSequence.current) { setApiKeys(keys); setError(null); } } catch (err) { - setError('Failed to fetch API keys'); + if (sequence === loadSequence.current) setError(apiKeyError(err, 'Failed to fetch API keys')); } finally { - setLoading(false); + if (sequence === loadSequence.current) setLoading(false); } - }, [token]); + }, [apiKeyApi]); useEffect(() => { - fetchApiKeys(); + ++sessionSequence.current; + setLoading(true); + setCreateDialogOpen(false); setEditKey(null); setCreatedKeyDialogOpen(false); setCreatedKey(null); + void fetchApiKeys(); + return () => { ++loadSequence.current; ++sessionSequence.current; }; }, [fetchApiKeys]); - const handleCreateKey = async () => { - if (!token || !newKeyName.trim()) return; - - try { - const response = await fetch('/api/keys', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-access-token': token, - }, - body: JSON.stringify({ name: newKeyName.trim() }), - }); - - const data = await response.json(); - - if (response.ok && data.success) { - setCreatedKey(data); - setCreateDialogOpen(false); - setCreatedKeyDialogOpen(true); - setNewKeyName(''); - fetchApiKeys(); - } else { - setError(data.error || 'Failed to create API key'); - } - } catch (err) { - setError('Failed to create API key'); - } + const openCreateDialog = (type: 'external' | 'legacy' = 'external') => { + setCreateKeyType(type); setCreateDialogOpen(true); + }; + const openEditDialog = (key: ApiKey) => { + if (token && key.role !== 'legacy_download' && !key.revoked_at) setEditKey(key); }; const handleDeleteKey = async () => { - if (!token || !deleteConfirmDialog.keyId) return; - + if (!token || !deleteConfirmDialog.keyId || mutationBusy.current) return; + mutationBusy.current = true; setRevoking(true); setError(null); + const session = sessionSequence.current; try { - const response = await fetch(`/api/keys/${deleteConfirmDialog.keyId}`, { - method: 'DELETE', - headers: { 'x-access-token': token }, - }); - - if (response.ok) { - setSnackbar({ open: true, message: 'API key deleted' }); - fetchApiKeys(); - } else { - const data = await response.json(); - setError(data.error || 'Failed to delete API key'); - } + await apiKeyApi.revokeApiKey(deleteConfirmDialog.keyId); + if (session !== sessionSequence.current) return; + setSnackbar({ open: true, message: 'API key revoked' }); + void fetchApiKeys(); } catch (err) { - setError('Failed to delete API key'); + if (session === sessionSequence.current) setError(apiKeyError(err, 'Failed to revoke API key')); } finally { + mutationBusy.current = false; setRevoking(false); setDeleteConfirmDialog({ open: false, keyId: null, keyName: '' }); } }; @@ -154,71 +116,66 @@ const ApiKeysSection: React.FC = ({ token, apiKeyRateLimit, setDeleteConfirmDialog({ open: true, keyId: id, keyName: name }); }; - const copyToClipboard = (text: string, label: string) => { - navigator.clipboard.writeText(text); - setSnackbar({ open: true, message: `${label} copied to clipboard` }); + const handleRegenerateKey = async () => { + if (!token || !regenerateConfirmDialog.key || mutationBusy.current) return; + mutationBusy.current = true; setError(null); + const session = sessionSequence.current; + const key = regenerateConfirmDialog.key; + setRegenerating(true); + try { + const body = await apiKeyApi.regenerateApiKey(key.id); + if (session !== sessionSequence.current) return; + setRegenerateConfirmDialog({ open: false, key: null }); + setCreatedKey(body); + setCreatedKeyRole(key.role); + setCreatedKeyAction('regenerated'); + setCreatedKeyDialogOpen(true); + await fetchApiKeys(); + } catch (err) { + if (session === sessionSequence.current) setError(apiKeyError(err, 'Failed to regenerate API key')); + } finally { + mutationBusy.current = false; + setRegenerating(false); + } }; - const formatDate = (dateStr: string | null) => { - if (!dateStr) return 'Never'; - return new Date(dateStr).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); + const copyToClipboard = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + setSnackbar({ open: true, message: `${label} copied to clipboard` }); + } catch { + setSnackbar({ open: true, message: 'Unable to copy. Select and copy it manually.' }); + } }; - const generateBookmarklet = (apiKey: string) => { - const serverUrl = locationUtils.getOrigin(); - const code = `javascript:(function(){var k='${apiKey}';var s='${serverUrl}';var u=location.href;if(!/youtube\\.com|youtu\\.be/.test(u)){alert('Not YouTube');return;}fetch(s+'/api/videos/download',{method:'POST',headers:{'Content-Type':'application/json','x-api-key':k},body:JSON.stringify({url:u})}).then(function(r){return r.json()}).then(function(d){alert(d.success?'✓ Added: '+(d.video&&d.video.title?d.video.title:'Queued'):'✗ '+d.error)}).catch(function(){alert('✗ Connection failed')})})();`; - return code; + const closeCreatedKeyDialog = () => { + setCreatedKeyDialogOpen(false); + setCreatedKey(null); }; if (loading) { return ( - + ); } - return ( - - - - Get the "Send to Youtarr" button - -
    -
  1. - Click Create Key, give it a name like "Laptop browser", and click Create. -
  2. -
  3. In the window that opens, drag the Send to Youtarr button to your bookmarks bar.
  4. -
  5. On any YouTube video, click the button to queue it.
  6. -
- - The button is only shown once. If you lose it, create another key. - -
- - - Keys also work with Apple Shortcuts, Tasker, and other tools. - Note: API keys currently support single video downloads only. Playlists and channels require the web UI. - - - - Manage API Keys - - + const externalKeys = apiKeys.filter((key) => key.role !== 'legacy_download'); + const bookmarkletKeys = apiKeys.filter((key) => key.role === 'legacy_download'); + return ( + {isHttpWarning && ( }> Creating API keys over HTTP is insecure. Use HTTPS in production. @@ -231,245 +188,102 @@ const ApiKeysSection: React.FC = ({ token, apiKeyRateLimit, )} - {apiKeys.length === 0 ? ( - - - No API keys yet. Create one to get your Send to Youtarr button. - - - ) : ( - - - - - Name - Key - Created - Last Used - Uses - Actions - - - - {apiKeys.map((key) => ( - - {key.name} - - - - {formatDate(key.created_at)} - {formatDate(key.last_used_at)} - - 0 ? 'primary' : 'default'} - variant="outlined" - /> - - - - openDeleteConfirmDialog(key.id, key.name)} - color="error" - aria-label="Delete" - > - - - - - - ))} - -
-
- )} + openCreateDialog('legacy')} + onRevoke={openDeleteConfirmDialog} + /> - + {externalApiEnabled && ( + <> + + openCreateDialog('external')} + onEdit={openEditDialog} + onRegenerate={(key) => setRegenerateConfirmDialog({ open: true, key })} + onRevoke={openDeleteConfirmDialog} + /> + + )} - {/* Rate Limit Setting */} - - { - const val = parseInt(e.target.value, 10); - if (!isNaN(val) && val >= 1 && val <= 100) { - onRateLimitChange(val); - } + {createDialogOpen && (createKeyType === 'legacy' || externalApiEnabled) && ( + setCreateDialogOpen(false)} + onCreated={(key, role) => { + setCreatedKey(key); + setCreatedKeyRole(role); + setCreatedKeyAction('created'); + setCreateDialogOpen(false); + setCreatedKeyDialogOpen(true); + void fetchApiKeys(); }} - inputProps={{ min: 1, max: 100 }} - size="small" - className="w-[200px]" /> - - - - {/* Create Key Dialog */} - setCreateDialogOpen(false)} maxWidth="sm" fullWidth> - Create API Key - - setNewKeyName(e.target.value)} - inputProps={{ maxLength: 100 }} - helperText="A descriptive name to identify this key" - /> - - Next you'll get your key, the Send to Youtarr button, and the details for phone shortcuts. They're - shown only once. - - - - - - - + )} + {externalApiEnabled && editKey && ( + setEditKey(null)} + onSaved={() => { + setEditKey(null); + setSnackbar({ open: true, message: 'External access updated' }); + void fetchApiKeys(); + }} + /> + )} - {/* Key Created Dialog with Bookmarklet */} - setCreatedKeyDialogOpen(false)} - maxWidth="md" - fullWidth - > - ✓ API Key Created - - - Save this key now - it will not be shown again! - - - - Your API Key - - - {createdKey?.key} - copyToClipboard(createdKey?.key || '', 'API key')} - size="small" - > - - - - - - 📚 Add to Bookmarks - - - Drag this button to your bookmarks bar: - - - e.preventDefault()} - draggable="true" - style={{ - display: 'inline-block', - padding: '8px 16px', - backgroundColor: 'var(--primary)', - color: 'var(--primary-foreground)', - borderRadius: 'var(--radius-input)', - textDecoration: 'none', - fontWeight: 500, - cursor: 'grab', - }} - > - 📥 Send to Youtarr - - - - - Or copy the bookmarklet code: - - - - {createdKey ? generateBookmarklet(createdKey.key) : ''} - - - copyToClipboard( - createdKey ? generateBookmarklet(createdKey.key) : '', - 'Bookmarklet' - ) - } - size="small" - > - - - + createdKey={createdKey} + role={createdKeyRole} + action={createdKeyAction} + onClose={closeCreatedKeyDialog} + onCopy={(text, label) => void copyToClipboard(text, label)} + /> - - 📱 Mobile / Shortcuts - - - Use this URL in Apple Shortcuts, Tasker, or other tools: - - - - URL: {locationUtils.getOrigin()}/api/videos/download - - - Method: POST - - - Header: x-api-key: {createdKey?.key?.substring(0, 8)}... - - - Body: {`{ "url": "" }`} - - - - - - - + setRegenerateConfirmDialog({ open: false, key: null })} + onConfirm={handleRegenerateKey} + > + + The current key will stop working immediately. + + + Regenerate "{regenerateConfirmDialog.key?.name}"? + + + Its permissions and approved channels will stay the same. The replacement key is + shown only once, so copy it before closing the next dialog. + + - {/* Delete Confirmation Dialog */} - setDeleteConfirmDialog({ open: false, keyId: null, keyName: '' })} + title="Revoke API Key?" + confirmLabel="Revoke" + busy={revoking} + onCancel={() => setDeleteConfirmDialog({ open: false, keyId: null, keyName: '' })} + onConfirm={handleDeleteKey} > - Delete API Key? - - - Are you sure you want to delete the API key "{deleteConfirmDialog.keyName}"? - - - This action cannot be undone. Any integrations using this key will stop working. - - - - - - - + + Are you sure you want to revoke the API key "{deleteConfirmDialog.keyName}"? + + + Any integration using this key will stop working immediately. Its audit history is retained. + + = ({ token, apiKeyRateLimit, }; export default ApiKeysSection; - diff --git a/client/src/components/Configuration/sections/ApiKeysSection/BookmarkletKeysSection.tsx b/client/src/components/Configuration/sections/ApiKeysSection/BookmarkletKeysSection.tsx new file mode 100644 index 000000000..477879bde --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/BookmarkletKeysSection.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { Box, Button, Chip, IconButton, Paper, TextField, Tooltip, Typography } from '../../../ui'; +import { Plus as AddIcon, Trash2 as DeleteIcon } from 'lucide-react'; +import { InfoTooltip } from '../../common/InfoTooltip'; +import { formatKeyDate } from './ExternalKeyCard'; +import { ApiKey } from './useApiKeys'; + +const MIN_RATE_LIMIT = 1; +const MAX_RATE_LIMIT = 100; + +interface BookmarkletKeysSectionProps { + keys: ApiKey[]; + rateLimit: number; + onRateLimitChange: (value: number) => void; + onCreate: () => void; + onRevoke: (id: number, name: string) => void; +} + +export default function BookmarkletKeysSection({ + keys, + rateLimit, + onRateLimitChange, + onCreate, + onRevoke, +}: BookmarkletKeysSectionProps) { + return ( + <> + + + Get the "Send to Youtarr" button + +
    +
  1. + Click Create Key, give it a name like "Laptop browser", and click Create. +
  2. +
  3. In the window that opens, drag the Send to Youtarr button to your bookmarks bar.
  4. +
  5. On any YouTube video, click the button to queue it.
  6. +
+ + The button is only shown once. If you lose it, create another key. + +
+ + + Keys also work with Apple Shortcuts, Tasker, and other tools. + Note: API keys currently support single video downloads only. Playlists and channels require the web UI. + + + + Manage API Keys + + + + {keys.length === 0 ? ( + + No API keys yet. Create one to get your Send to Youtarr button. + + ) : ( +
+ {keys.map((key) => ( + +
+
+ + {key.name} + + + {key.revoked_at && } + +
+ + Created {formatKeyDate(key.created_at)} · Last used {formatKeyDate(key.last_used_at)} + +
+ {!key.revoked_at && ( + + onRevoke(key.id, key.name)} + color="error" + aria-label={`Revoke ${key.name}`} + > + + + + )} +
+ ))} +
+ )} + + + { + const val = parseInt(e.target.value, 10); + if (!isNaN(val) && val >= MIN_RATE_LIMIT && val <= MAX_RATE_LIMIT) { + onRateLimitChange(val); + } + }} + inputProps={{ min: MIN_RATE_LIMIT, max: MAX_RATE_LIMIT }} + size="small" + className="w-[200px]" + /> + + + + ); +} diff --git a/client/src/components/Configuration/sections/ApiKeysSection/ChannelGrantPicker.tsx b/client/src/components/Configuration/sections/ApiKeysSection/ChannelGrantPicker.tsx new file mode 100644 index 000000000..29beee286 --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/ChannelGrantPicker.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { + Box, + Checkbox, + FormControlLabel, + TextField, + Typography, +} from '../../../ui'; +import { ChannelListEntry } from '../../../Subscriptions/hooks/useChannelList'; + +interface ChannelGrantPickerProps { + channels: ChannelListEntry[]; + selectedIds: number[]; + search: string; + onSearchChange: (value: string) => void; + onSelectedIdsChange: (ids: number[]) => void; + label?: string; + maxHeight?: string; +} + +const channelLabel = (channel: ChannelListEntry) => + channel.title || + channel.uploader || + channel.channel_id || + `Channel ${channel.database_id}`; + +const ChannelGrantPicker: React.FC = ({ + channels, + selectedIds, + search, + onSearchChange, + onSelectedIdsChange, + label = 'Search channels', + maxHeight = '240px', +}) => { + const visibleChannels = channels.filter((channel) => { + const query = search.trim().toLowerCase(); + return ( + !query || + [channel.title, channel.uploader, channel.channel_id] + .filter(Boolean) + .some((value) => value?.toLowerCase().includes(query)) + ); + }); + const ids = channels + .map((channel) => channel.database_id) + .filter((id): id is number => typeof id === 'number'); + const update = (id: number, checked: boolean) => + onSelectedIdsChange( + checked + ? [...new Set([...selectedIds, id])].sort((a, b) => a - b) + : selectedIds.filter((value) => value !== id) + ); + return ( + <> + {channels.length > 0 && ( + selectedIds.includes(id))} + indeterminate={ + ids.some((id) => selectedIds.includes(id)) && + !ids.every((id) => selectedIds.includes(id)) + } + onChange={(event) => + onSelectedIdsChange(event.target.checked ? ids : []) + } + /> + } + label="Select all approved channels" + /> + )} + onSearchChange(event.target.value)} + fullWidth + size="small" + className="mb-3" + /> + {channels.length === 0 ? ( + + No enabled, non-terminated channels are available. + + ) : ( + + {visibleChannels.map((channel) => { + if (typeof channel.database_id !== 'number') return null; + return ( + + update(channel.database_id, event.target.checked) + } + /> + } + label={channelLabel(channel)} + /> + ); + })} + + )} + + ); +}; + +export default ChannelGrantPicker; diff --git a/client/src/components/Configuration/sections/ApiKeysSection/ConfirmKeyActionDialog.tsx b/client/src/components/Configuration/sections/ApiKeysSection/ConfirmKeyActionDialog.tsx new file mode 100644 index 000000000..0ae422ab1 --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/ConfirmKeyActionDialog.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '../../../ui'; + +interface ConfirmKeyActionDialogProps { + open: boolean; + title: string; + confirmLabel: string; + busyLabel?: string; + busy: boolean; + onCancel: () => void; + onConfirm: () => void; + children: React.ReactNode; +} + +export default function ConfirmKeyActionDialog({ + open, + title, + confirmLabel, + busyLabel, + busy, + onCancel, + onConfirm, + children, +}: ConfirmKeyActionDialogProps) { + return ( + !busy && onCancel()}> + {title} + {children} + + + + + + ); +} diff --git a/client/src/components/Configuration/sections/ApiKeysSection/CreateKeyDialog.tsx b/client/src/components/Configuration/sections/ApiKeysSection/CreateKeyDialog.tsx new file mode 100644 index 000000000..022b4d07c --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/CreateKeyDialog.tsx @@ -0,0 +1,224 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Alert, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Divider, + TextField, + Typography, +} from '../../../ui'; +import { AlertTriangle as WarningIcon } from 'lucide-react'; +import { + ApiKeyCreatedResponse, + ApiKeyRole, + normalizePolicy, + useApiKeys, +} from './useApiKeys'; +import { defaultPolicy } from './policy'; +import { apiKeyError } from './apiKeyError'; +import type { ChannelListEntry } from '../../../Subscriptions/hooks/useChannelList'; +import PolicyEditor from './PolicyEditor'; +import ChannelGrantPicker from './ChannelGrantPicker'; + +interface Props { + api: ReturnType; + createKeyType: 'external' | 'legacy'; + onClose: () => void; + onCreated: (key: ApiKeyCreatedResponse, role: ApiKeyRole) => void; +} +export default function CreateKeyDialog({ + api, + createKeyType, + onClose, + onCreated, +}: Props) { + const [newKeyName, setNewKeyName] = useState(''); + const [newKeyPolicy, setNewKeyPolicy] = useState(defaultPolicy); + const [newKeyChannelIds, setNewKeyChannelIds] = useState([]); + const [newKeyChannelSearch, setNewKeyChannelSearch] = useState(''); + const [channelState, setChannelState] = useState<{ + channels: ChannelListEntry[]; + loading: boolean; + error: string | null; + }>({ channels: [], loading: createKeyType === 'external', error: null }); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [reload, setReload] = useState(0); + const submitting = useRef(false); + const active = useRef(true); + const { + channels: channelOptions, + loading: channelsLoading, + error: channelsError, + } = channelState; + useEffect(() => { + active.current = true; + return () => { + active.current = false; + }; + }, []); + useEffect(() => { + if (createKeyType === 'legacy') return; + const controller = new AbortController(); + setChannelState({ channels: [], loading: true, error: null }); + void api + .fetchAvailableChannels(controller.signal) + .then((channels) => { + if (!controller.signal.aborted) + setChannelState({ + channels: channels.filter((channel) => !channel.terminated_at), + loading: false, + error: null, + }); + }) + .catch((error) => { + if (!controller.signal.aborted) + setChannelState({ + channels: [], + loading: false, + error: apiKeyError(error, 'Failed to load channels'), + }); + }); + return () => controller.abort(); + }, [api, createKeyType, reload]); + const close = () => { + if (!submitting.current) onClose(); + }; + const handleCreateKey = async () => { + if ( + submitting.current || + !newKeyName.trim() || + (createKeyType === 'external' && (channelsLoading || channelsError)) + ) + return; + const normalized = + createKeyType === 'legacy' ? null : normalizePolicy(newKeyPolicy); + if (normalized && !normalized.policy) { + setError(normalized.error || 'Invalid policy values'); + return; + } + submitting.current = true; + setBusy(true); + setError(null); + try { + const result = await api.createApiKey( + newKeyName.trim(), + normalized?.policy, + createKeyType === 'external' ? newKeyChannelIds : undefined + ); + if (!active.current) return; + if (!result.success) { + setError(result.message || 'Failed to create API key'); + return; + } + onCreated( + result, + createKeyType === 'legacy' ? 'legacy_download' : newKeyPolicy.role + ); + } catch (error) { + if (active.current) + setError(apiKeyError(error, 'Failed to create API key')); + } finally { + submitting.current = false; + if (active.current) setBusy(false); + } + }; + return ( + + + {createKeyType === 'legacy' + ? 'Create API Key' + : 'Create External Access Key'} + + + {error && {error}} + {channelsLoading && ( + Loading available channels... + )} + {channelsError && ( + + {channelsError} + + + )} + setNewKeyName(e.target.value)} + inputProps={{ maxLength: 100 }} + helperText="A descriptive name to identify this key" + /> + {createKeyType === 'legacy' && ( + + Next you'll get your key, the Send to Youtarr button, and the details for phone shortcuts. They're + shown only once. + + )} + {createKeyType === 'external' && ( + <> + + Access policy + + + + + Approved channels ({newKeyChannelIds.length}) + + + The key cannot browse or request from channels that are not + selected. + + {newKeyChannelIds.length === 0 && ( + } + > + Saving with zero approved channels is allowed, but the key will + fail closed and cannot view or request catalog content until + grants are added. + + )} + + + )} + + + + + + + ); +} diff --git a/client/src/components/Configuration/sections/ApiKeysSection/CreatedKeyDialog.tsx b/client/src/components/Configuration/sections/ApiKeysSection/CreatedKeyDialog.tsx new file mode 100644 index 000000000..c6f98741c --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/CreatedKeyDialog.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Paper, + Typography, +} from '../../../ui'; +import { Copy as ContentCopyIcon } from 'lucide-react'; +import { locationUtils } from '../../../../utils/location'; +import { ApiKeyCreatedResponse, ApiKeyRole } from './useApiKeys'; + +export const generateBookmarklet = (apiKey: string) => { + const serverUrl = locationUtils.getOrigin(); + return `javascript:(function(){var k='${apiKey}';var s='${serverUrl}';var u=location.href;if(!/youtube\\.com|youtu\\.be/.test(u)){alert('Not YouTube');return;}fetch(s+'/api/videos/download',{method:'POST',headers:{'Content-Type':'application/json','x-api-key':k},body:JSON.stringify({url:u})}).then(function(r){return r.json()}).then(function(d){alert(d.success?'✓ Added: '+(d.video&&d.video.title?d.video.title:'Queued'):'✗ '+d.error)}).catch(function(){alert('✗ Connection failed')})})();`; +}; + +interface CreatedKeyDialogProps { + createdKey: ApiKeyCreatedResponse | null; + role: ApiKeyRole; + action: 'created' | 'regenerated'; + open: boolean; + onClose: () => void; + onCopy: (text: string, label: string) => void; +} + +function BookmarkletDetails({ apiKey, onCopy }: { apiKey: string; onCopy: CreatedKeyDialogProps['onCopy'] }) { + const bookmarklet = generateBookmarklet(apiKey); + return ( + <> + + 📚 Add to Bookmarks + + + Drag this button to your bookmarks bar: + + + e.preventDefault()} + draggable="true" + style={{ + display: 'inline-block', + padding: '8px 16px', + backgroundColor: 'var(--primary)', + color: 'var(--primary-foreground)', + borderRadius: 'var(--radius-input)', + textDecoration: 'none', + fontWeight: 500, + cursor: 'grab', + }} + > + 📥 Send to Youtarr + + + + + Or copy the bookmarklet code: + + + {bookmarklet} + onCopy(bookmarklet, 'Bookmarklet')} size="small"> + + + + + + 📱 Mobile / Shortcuts + + + Use this URL in Apple Shortcuts, Tasker, or other tools: + + + + URL: {locationUtils.getOrigin()}/api/videos/download + + + Method: POST + + + Header: x-api-key: {apiKey.substring(0, 8)}... + + + Body: {`{ "url": "" }`} + + + + ); +} + +export default function CreatedKeyDialog({ createdKey, role, action, open, onClose, onCopy }: CreatedKeyDialogProps) { + const key = createdKey?.key || ''; + return ( + + ✓ API Key {action === 'regenerated' ? 'Regenerated' : 'Created'} + + + Save this key now - it will not be shown again! + + + + Your API Key + + + {key} + onCopy(key, 'API key')} size="small"> + + + + + {role === 'legacy_download' ? ( + key && + ) : ( + + + Use this key only with {locationUtils.getOrigin()}/external-api/v1. + + + Only approved channels are available. You can update grants from this key's edit action. + + + )} + + + + + + ); +} diff --git a/client/src/components/Configuration/sections/ApiKeysSection/EditKeyDialog.tsx b/client/src/components/Configuration/sections/ApiKeysSection/EditKeyDialog.tsx new file mode 100644 index 000000000..8e1b50e83 --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/EditKeyDialog.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { + Alert, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Divider, + Typography, +} from '../../../ui'; +import { AlertTriangle as WarningIcon } from 'lucide-react'; +import { ApiKey, useApiKeys } from './useApiKeys'; +import { useExternalAccessEditor } from './useExternalAccessEditor'; +import PolicyEditor from './PolicyEditor'; +import ChannelGrantPicker from './ChannelGrantPicker'; + +interface Props { + apiKey: ApiKey; + api: ReturnType; + onClose: () => void; + onSaved: () => void; +} +export default function EditKeyDialog(props: Props) { + const { apiKey } = props; + const { + editPolicy, + channelOptions, + channelSearch, + selectedChannelIds, + savingPolicy, + loading, + editLoadError, + editSubmitError, + pendingExternalUpdate, + setEditPolicy, + setChannelSearch, + setSelectedChannelIds, + closeEditDialog, + reload, + saveExternalAccess, + cancelPrivilegeConfirmation, + confirmPrivilegeIncrease, + } = useExternalAccessEditor(props); + return ( + <> + + Edit External Access — {apiKey.name} + + + Permissions, policy, and channel grants are enforced by Youtarr on + every request. + + {editLoadError && ( + +
+ {editLoadError} + +
+
+ )} + {editSubmitError && ( + + {editSubmitError} + + )} + {!editLoadError && loading && ( + + Loading channel grants and available channels... + + )} + + + + Approved channels ({selectedChannelIds.length}) + + {selectedChannelIds.length === 0 && ( + } + > + Saving with zero approved channels is allowed, but this key will + fail closed and cannot view or request catalog content until + grants are added. + + )} + +
+ + + + +
+ + + Confirm expanded external access? + + + This change may increase what the external integration can view or + request. + + + Continue saving these expanded permissions and channel grants? + + + + + + + + + ); +} diff --git a/client/src/components/Configuration/sections/ApiKeysSection/ExternalKeyCard.tsx b/client/src/components/Configuration/sections/ApiKeysSection/ExternalKeyCard.tsx new file mode 100644 index 000000000..7f6265187 --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/ExternalKeyCard.tsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { + Alert, + Chip, + IconButton, + Paper, + Tooltip, + Typography, +} from '../../../ui'; +import { + Trash2 as DeleteIcon, + AlertTriangle as WarningIcon, + Pencil as EditIcon, + RefreshCw as RegenerateIcon, + Video as VideoIcon, + Radio as ChannelIcon, + Clock3 as ClockIcon, + Zap as AutoApproveIcon, + Eye as ViewIcon, +} from 'lucide-react'; +import { getExternalRatingBand } from '../../../../utils/externalRatingPolicy'; +import RatingBadge from '../../../shared/RatingBadge'; +import { ApiKey } from './useApiKeys'; +import { permissionsFromKey } from './policy'; + +export const formatKeyDate = (dateStr: string | null) => { + if (!dateStr) return 'Never'; + return new Date(dateStr).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +interface Props { + apiKey: ApiKey; + onEdit: (key: ApiKey) => void; + onRegenerate: (key: ApiKey) => void; + onRevoke: (id: number, name: string) => void; +} +export default function ExternalKeyCard({ + apiKey: key, + onEdit, + onRegenerate, + onRevoke, +}: Props) { + const rawChannelGrantCount = key.channel_grant_count; + const channelGrantCount = + typeof rawChannelGrantCount === 'number' && + Number.isInteger(rawChannelGrantCount) && + rawChannelGrantCount >= 0 + ? rawChannelGrantCount + : null; + const permissions = permissionsFromKey(key); + const ratingBand = getExternalRatingBand(key.max_rating_level); + const movieCeiling = + ratingBand.movieRatings[ratingBand.movieRatings.length - 1]; + const tvCeiling = ratingBand.tvRatings[ratingBand.tvRatings.length - 1]; + const permissionChips = [ + permissions.allowVideoRequests && { + label: key.auto_approve_video_requests ? 'Videos · Auto' : 'Videos', + title: key.auto_approve_video_requests + ? 'Video requests are enabled and auto-approved after policy checks.' + : 'Video requests are enabled and require administrator approval.', + icon: , + auto: key.auto_approve_video_requests, + }, + permissions.allowChannelRequests && { + label: key.auto_approve_channel_requests ? 'Channels · Auto' : 'Channels', + title: key.auto_approve_channel_requests + ? 'Channel requests are enabled and auto-approved after policy checks.' + : 'Channel requests are enabled and require administrator approval.', + icon: , + auto: key.auto_approve_channel_requests, + }, + permissions.allowDeleteVideoRequests && { + label: key.auto_approve_delete_requests + ? 'Delete · Auto' + : 'Delete video', + title: key.auto_approve_delete_requests + ? 'Downloaded-video deletion requests are enabled and auto-approved after policy checks.' + : 'Downloaded-video deletion requests are enabled and require administrator approval.', + icon: , + auto: key.auto_approve_delete_requests, + }, + ].filter(Boolean) as Array<{ + label: string; + title: string; + icon: React.ReactElement; + auto: boolean; + }>; + + return ( + +
+
+ + {key.name} + + {key.revoked_at && ( + + )} + + + {key.allow_unrated && ( + + )} +
+
+ + {permissionChips.length === 0 ? ( + + } + /> + + ) : ( + permissionChips.map((permission) => ( + + + ) : ( + permission.icon + ) + } + /> + + )) + )} +
+ {channelGrantCount === 0 && ( + } + > + No approved channels. This key cannot view or request catalog + content until you add grants. + + )} +
+ +
+
+
+
+ + {formatKeyDate(key.last_used_at)} + +
+
+ {!key.revoked_at && ( + + onEdit(key)} + aria-label={`Edit ${key.name} external access`} + > + + + + )} + {!key.revoked_at && ( + + onRegenerate(key)} + aria-label={`Regenerate ${key.name}`} + > + + + + )} + {!key.revoked_at && ( + + onRevoke(key.id, key.name)} + color="error" + aria-label={`Revoke ${key.name}`} + > + + + + )} +
+
+
+ ); +} diff --git a/client/src/components/Configuration/sections/ApiKeysSection/ExternalKeysSection.tsx b/client/src/components/Configuration/sections/ApiKeysSection/ExternalKeysSection.tsx new file mode 100644 index 000000000..2eee8adb6 --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/ExternalKeysSection.tsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import { Box, Button, Paper, TextField, Typography } from '../../../ui'; +import { Filter as FilterIcon, Plus as AddIcon } from 'lucide-react'; +import ExternalKeyCard from './ExternalKeyCard'; +import { ApiKey } from './useApiKeys'; + +interface ExternalKeysSectionProps { + keys: ApiKey[]; + onCreate: () => void; + onEdit: (key: ApiKey) => void; + onRegenerate: (key: ApiKey) => void; + onRevoke: (id: number, name: string) => void; +} + +export default function ExternalKeysSection({ keys, onCreate, onEdit, onRegenerate, onRevoke }: ExternalKeysSectionProps) { + const [search, setSearch] = useState(''); + const [activeOnly, setActiveOnly] = useState(true); + const query = search.trim().toLocaleLowerCase(); + const visibleKeys = keys + .filter((key) => !activeOnly || (key.is_active && !key.revoked_at)) + .filter((key) => !query || key.name.toLocaleLowerCase().includes(query)) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + + return ( + <> + +
+ External access keys + + For integrations that use /external-api/v1. Every external key can view its approved + channels. Add only the request permissions the integration needs. + +
+ +
+ + {keys.length > 0 && ( + + setSearch(event.target.value)} + fullWidth + size="small" + /> + + + )} + + {visibleKeys.length === 0 ? ( + + + {keys.length === 0 + ? 'No external access keys yet.' + : 'No external access keys match the current search and filter.'} + + + ) : ( +
+ {visibleKeys.map((key) => ( + + ))} +
+ )} + + ); +} diff --git a/client/src/components/Configuration/sections/ApiKeysSection/PolicyEditor.tsx b/client/src/components/Configuration/sections/ApiKeysSection/PolicyEditor.tsx new file mode 100644 index 000000000..5903c41e7 --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/PolicyEditor.tsx @@ -0,0 +1,224 @@ +import React from 'react'; +import { + Box, + Typography, + Paper, + Tooltip, + Chip, + TextField, + Switch, + FormControlLabel, + Select, + MenuItem, +} from '../../../ui'; +import { Video as ViewIcon } from 'lucide-react'; +import { + EXTERNAL_RATING_BANDS, + formatExternalRatingBand, +} from '../../../../utils/externalRatingPolicy'; +import { ApiKeyPolicy, MediaType, WORKLOAD_LIMITS } from './useApiKeys'; +import { roleForPolicy } from './policy'; + +interface PolicyEditorProps { + policy: ApiKeyPolicy; + onChange: (policy: ApiKeyPolicy) => void; +} + +const PolicyEditor: React.FC = ({ policy, onChange }) => { + const updatePolicy = (changes: Partial) => { + const next = { ...policy, ...changes }; + onChange({ ...next, role: roleForPolicy(next) }); + }; + const togglePermission = ( + permission: + | 'allowVideoRequests' + | 'allowChannelRequests' + | 'allowDeleteVideoRequests', + autoApprove: + | 'autoApproveVideoRequests' + | 'autoApproveChannelRequests' + | 'autoApproveDeleteRequests', + enabled: boolean + ) => + updatePolicy({ + [permission]: enabled, + ...(!enabled ? { [autoApprove]: false } : {}), + }); + const toggleMedia = (mediaType: MediaType) => { + const selected = policy.allowedMediaTypes.includes(mediaType); + if (selected && policy.allowedMediaTypes.length === 1) return; + updatePolicy({ + allowedMediaTypes: selected + ? policy.allowedMediaTypes.filter((value) => value !== mediaType) + : [...policy.allowedMediaTypes, mediaType], + }); + }; + return ( + + + + Maximum allowed rating + + + + Uses the video rating first, then the channel's manually assigned + default. + + + + Content access + + updatePolicy({ allowUnrated: event.target.checked }) + } + /> + } + label="Allow unrated or unrecognized ratings" + /> + + {(['video', 'short', 'livestream'] as MediaType[]).map( + (mediaType) => ( + toggleMedia(mediaType)} + /> + } + label={mediaType.charAt(0).toUpperCase() + mediaType.slice(1)} + /> + ) + )} + + + +
+ Request permissions + + } + label="View included" + /> + +
+ {( + [ + [ + 'allowVideoRequests', + 'autoApproveVideoRequests', + 'Request videos', + 'Submit requests to download eligible videos.', + ], + [ + 'allowChannelRequests', + 'autoApproveChannelRequests', + 'Request channels', + 'Submit requests to add supported YouTube channels.', + ], + [ + 'allowDeleteVideoRequests', + 'autoApproveDeleteRequests', + 'Delete downloaded videos', + 'Submit approval-backed requests to remove downloaded video assets.', + ], + ] as const + ).map(([permission, autoApprove, label, description]) => { + const enabled = policy[permission]; + return ( + +
+
+ + {label} + + + {description} + +
+ + togglePermission( + permission, + autoApprove, + event.target.checked + ) + } + aria-label={label} + /> +
+ {enabled && ( +
+
+ Auto-approve + + Skip manual review when all current policy checks pass. + +
+ + updatePolicy({ + [autoApprove]: event.target.checked, + } as Partial) + } + aria-label={`Auto-approve ${String(label).toLowerCase()}`} + /> +
+ )} +
+ ); + })} +
+ + Workload limits + + Durable per-key ceilings. Limits can be reduced below the system + defaults. + +
+ {WORKLOAD_LIMITS.map(({ field, label, min, max }) => ( + + updatePolicy({ + [field]: event.target.value, + } as Partial) + } + /> + ))} +
+
+
+ ); +}; + +export default PolicyEditor; diff --git a/client/src/components/Configuration/sections/ApiKeysSection/__tests__/ConfirmKeyActionDialog.test.tsx b/client/src/components/Configuration/sections/ApiKeysSection/__tests__/ConfirmKeyActionDialog.test.tsx new file mode 100644 index 000000000..e7d81e623 --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/__tests__/ConfirmKeyActionDialog.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ConfirmKeyActionDialog from '../ConfirmKeyActionDialog'; + +const renderDialog = (overrides: Partial> = {}) => { + const props = { + open: true, + title: 'Revoke API Key?', + confirmLabel: 'Revoke', + busyLabel: 'Revoking…', + busy: false, + onCancel: jest.fn(), + onConfirm: jest.fn(), + ...overrides, + }; + render(Body text); + return props; +}; + +describe('ConfirmKeyActionDialog', () => { + test('confirms the action', async () => { + const { onConfirm } = renderDialog(); + + await userEvent.click(screen.getByRole('button', { name: 'Revoke' })); + + expect(onConfirm).toHaveBeenCalled(); + }); + + test('cancels the action', async () => { + const { onCancel } = renderDialog(); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onCancel).toHaveBeenCalled(); + }); + + test('shows the busy label and locks the buttons while working', () => { + renderDialog({ busy: true }); + + expect(screen.getByRole('button', { name: 'Revoking…' })).toBeDisabled(); + }); +}); diff --git a/client/src/components/Configuration/sections/ApiKeysSection/__tests__/CreatedKeyDialog.test.tsx b/client/src/components/Configuration/sections/ApiKeysSection/__tests__/CreatedKeyDialog.test.tsx new file mode 100644 index 000000000..351490e79 --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/__tests__/CreatedKeyDialog.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import CreatedKeyDialog, { generateBookmarklet } from '../CreatedKeyDialog'; + +const createdKey = { + success: true, + message: '', + id: 3, + name: 'Laptop browser', + key: 'abc12345def67890', + prefix: 'abc12345', +}; + +const renderDialog = (overrides: Partial> = {}) => { + const props = { + open: true, + createdKey, + role: 'legacy_download' as const, + action: 'created' as const, + onClose: jest.fn(), + onCopy: jest.fn(), + ...overrides, + }; + render(); + return props; +}; + +describe('generateBookmarklet', () => { + test('embeds the key and posts to the download endpoint', () => { + const code = generateBookmarklet('my-key'); + + expect(code).toContain("var k='my-key'"); + }); +}); + +describe('CreatedKeyDialog', () => { + test('shows the draggable bookmarklet for a bookmarklet key', () => { + renderDialog(); + + expect(screen.getByText(/Send to Youtarr/)).toHaveAttribute('href', generateBookmarklet(createdKey.key)); + }); + + test('shows phone shortcut details for a bookmarklet key', () => { + renderDialog(); + + expect(screen.getByText('Mobile / Shortcuts', { exact: false })).toBeInTheDocument(); + }); + + test('points an external key at the versioned API instead of the bookmarklet', () => { + renderDialog({ role: 'view' }); + + expect(screen.queryByText(/Send to Youtarr/)).not.toBeInTheDocument(); + }); + + test('copies the bookmarklet code', async () => { + const { onCopy } = renderDialog(); + + await userEvent.click(screen.getByRole('button', { name: 'Copy bookmarklet code' })); + + expect(onCopy).toHaveBeenCalledWith(generateBookmarklet(createdKey.key), 'Bookmarklet'); + }); + + test('titles a regenerated key', () => { + renderDialog({ action: 'regenerated', role: 'view' }); + + expect(screen.getByText(/API Key Regenerated/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Configuration/sections/ApiKeysSection/apiKeyError.ts b/client/src/components/Configuration/sections/ApiKeysSection/apiKeyError.ts new file mode 100644 index 000000000..1ca7c5f59 --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/apiKeyError.ts @@ -0,0 +1,8 @@ +import axios from 'axios'; + +export function apiKeyError(error: unknown, fallback: string) { + if (axios.isAxiosError<{ error?: string }>(error)) { + return typeof error.response?.data?.error === 'string' ? error.response.data.error : fallback; + } + return error instanceof Error ? error.message : fallback; +} diff --git a/client/src/components/Configuration/sections/ApiKeysSection/policy.ts b/client/src/components/Configuration/sections/ApiKeysSection/policy.ts new file mode 100644 index 000000000..9c8565653 --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/policy.ts @@ -0,0 +1,67 @@ +import { ApiKey, ApiKeyPolicy, ApiKeyRole, NormalizedApiKeyPolicy, workloadMax } from './useApiKeys'; + +export const defaultPolicy: ApiKeyPolicy = { + role: 'view', + allowVideoRequests: false, + allowChannelRequests: false, + allowDeleteVideoRequests: false, + autoApproveVideoRequests: false, + autoApproveChannelRequests: false, + autoApproveDeleteRequests: false, + maxRatingLevel: 3, + allowUnrated: false, + allowedMediaTypes: ['video'], + maxActiveJobs: workloadMax('maxActiveJobs'), + hourlyWriteLimit: workloadMax('hourlyWriteLimit'), + dailyWriteLimit: workloadMax('dailyWriteLimit'), +}; + +export const legacyRolePermissions = (role: ApiKeyRole) => ({ + allowVideoRequests: ['request', 'delete', 'admin'].includes(role), + allowChannelRequests: ['request', 'delete', 'admin'].includes(role), + allowDeleteVideoRequests: ['delete', 'admin'].includes(role), +}); + +export const permissionsFromKey = (key: ApiKey) => { + const fallback = legacyRolePermissions(key.role); + return { + allowVideoRequests: key.allow_video_requests ?? fallback.allowVideoRequests, + allowChannelRequests: key.allow_channel_requests ?? fallback.allowChannelRequests, + allowDeleteVideoRequests: + key.allow_delete_video_requests ?? fallback.allowDeleteVideoRequests, + }; +}; + +export const roleForPolicy = (policy: ApiKeyPolicy): ApiKeyRole => { + if (policy.role === 'admin') return 'admin'; + if (policy.allowDeleteVideoRequests) return 'delete'; + if (policy.allowVideoRequests || policy.allowChannelRequests) return 'request'; + return 'view'; +}; + +export const policyFromKey = (key: ApiKey): ApiKeyPolicy => ({ + role: key.role, + ...permissionsFromKey(key), + autoApproveVideoRequests: key.auto_approve_video_requests, + autoApproveChannelRequests: key.auto_approve_channel_requests, + autoApproveDeleteRequests: key.auto_approve_delete_requests, + maxRatingLevel: key.max_rating_level, + allowUnrated: key.allow_unrated, + allowedMediaTypes: key.allowed_media_types, + maxActiveJobs: key.max_active_jobs ?? workloadMax('maxActiveJobs'), + hourlyWriteLimit: key.hourly_write_limit ?? workloadMax('hourlyWriteLimit'), + dailyWriteLimit: key.daily_write_limit ?? workloadMax('dailyWriteLimit'), +}); + +export function increasesPrivilege(key: ApiKey, policy: NormalizedApiKeyPolicy, channelIds: number[], originalIds: number[]) { + const previous = policyFromKey(key); + const permissions = ['allowVideoRequests', 'allowChannelRequests', 'allowDeleteVideoRequests', + 'autoApproveVideoRequests', 'autoApproveChannelRequests', 'autoApproveDeleteRequests', 'allowUnrated'] as const; + return permissions.some(field => policy[field] && !previous[field]) || + policy.maxRatingLevel > previous.maxRatingLevel || + policy.allowedMediaTypes.some(type => !previous.allowedMediaTypes.includes(type)) || + policy.maxActiveJobs > Number(previous.maxActiveJobs) || + policy.hourlyWriteLimit > Number(previous.hourlyWriteLimit) || + policy.dailyWriteLimit > Number(previous.dailyWriteLimit) || + channelIds.some(id => !originalIds.includes(id)); +} diff --git a/client/src/components/Configuration/sections/ApiKeysSection/useApiKeys.ts b/client/src/components/Configuration/sections/ApiKeysSection/useApiKeys.ts new file mode 100644 index 000000000..5e499f6cd --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/useApiKeys.ts @@ -0,0 +1,174 @@ +import { useCallback, useMemo } from 'react'; +import axios from 'axios'; +import { + ChannelListEntry, + ChannelListResponse, +} from '../../../Subscriptions/hooks/useChannelList'; + +export interface ApiKey { + id: number; + name: string; + key_prefix: string; + created_at: string; + last_used_at: string | null; + is_active: boolean; + usage_count: number; + channel_grant_count?: number; + role: ApiKeyRole; + auto_approve_video_requests: boolean; + auto_approve_channel_requests: boolean; + auto_approve_delete_requests: boolean; + allow_video_requests: boolean; + allow_channel_requests: boolean; + allow_delete_video_requests: boolean; + max_rating_level: number; + allow_unrated: boolean; + allowed_media_types: MediaType[]; + max_active_jobs?: number; + hourly_write_limit?: number; + daily_write_limit?: number; + revoked_at: string | null; +} + +export type ApiKeyRole = 'legacy_download' | 'view' | 'request' | 'delete' | 'admin'; +export type MediaType = 'video' | 'short' | 'livestream'; + +export interface ApiKeyPolicy { + role: ApiKeyRole; + allowVideoRequests: boolean; + allowChannelRequests: boolean; + allowDeleteVideoRequests: boolean; + autoApproveVideoRequests: boolean; + autoApproveChannelRequests: boolean; + autoApproveDeleteRequests: boolean; + maxRatingLevel: number; + allowUnrated: boolean; + allowedMediaTypes: MediaType[]; + maxActiveJobs: number | string; + hourlyWriteLimit: number | string; + dailyWriteLimit: number | string; +} + +export type NormalizedApiKeyPolicy = Omit< + ApiKeyPolicy, + 'maxActiveJobs' | 'hourlyWriteLimit' | 'dailyWriteLimit' +> & { + maxActiveJobs: number; + hourlyWriteLimit: number; + dailyWriteLimit: number; +}; + +export interface ApiKeyCreatedResponse { + success: boolean; + message: string; + id: number; + name: string; + key: string; + prefix: string; +} + +interface ApiKeyListResponse { keys: ApiKey[] } +interface ChannelGrantsResponse { keyId: number; channelIds: number[] } +interface ApiKeyRequest { policy: NormalizedApiKeyPolicy; channelIds: number[] } + +export interface NormalizedPolicyResult { + policy?: NormalizedApiKeyPolicy; + error?: string; +} + +// Mirrors the per-key ceilings enforced by server/middleware/externalApiAuth.js. +export const WORKLOAD_LIMITS = [ + { field: 'maxActiveJobs', label: 'Active jobs', min: 1, max: 5 }, + { field: 'hourlyWriteLimit', label: 'Writes per hour', min: 1, max: 30 }, + { field: 'dailyWriteLimit', label: 'Writes per day', min: 1, max: 200 }, +] as const; + +export type WorkloadField = (typeof WORKLOAD_LIMITS)[number]['field']; + +export const workloadMax = (field: WorkloadField) => + WORKLOAD_LIMITS.find((limit) => limit.field === field)?.max ?? 1; + +export const normalizePolicy = (policy: ApiKeyPolicy): NormalizedPolicyResult => { + const normalized = { ...policy }; + for (const { field, label, min, max } of WORKLOAD_LIMITS) { + const value = String(policy[field]).trim(); + if (!/^\d+$/.test(value) || Number(value) < min || Number(value) > max) { + return { error: `${label} must be an integer from ${min} to ${max}.` }; + } + normalized[field] = Number(value); + } + return { policy: normalized as NormalizedApiKeyPolicy }; +}; + +export const useApiKeys = (token: string | null) => { + const headers = useMemo(() => (token ? { 'x-access-token': token } : undefined), [token]); + const fetchApiKeys = useCallback(async () => { + if (!token) return []; + const { data } = await axios.get('/api/keys', { headers }); + return data.keys || []; + }, [headers, token]); + + const fetchAvailableChannels = useCallback(async (signal?: AbortSignal) => { + if (!token) return []; + const channels: ChannelListEntry[] = []; + let page = 1; + let totalPages = 1; + do { + const { data } = await axios.get('/getchannels', { + headers, signal, + params: { page, pageSize: 100, sortOrder: 'asc' }, + }); + channels.push(...(data.channels || [])); + totalPages = Number.isInteger(Number(data.totalPages)) && Number(data.totalPages) > 0 + ? Number(data.totalPages) : 1; + page += 1; + } while (page <= totalPages); + return channels; + }, [headers, token]); + + const fetchChannelGrants = useCallback(async (keyId: number, signal?: AbortSignal) => { + const { data } = await axios.get(`/api/keys/${keyId}/channels`, { headers, signal }); + return data.channelIds || []; + }, [headers]); + + const createApiKey = useCallback(async (name: string, policy?: NormalizedApiKeyPolicy, channelIds?: number[]) => { + const { data } = await axios.post('/api/keys', { + name, + ...(policy ? { policy, channelIds: channelIds || [] } : {}), + }, { headers }); + return data; + }, [headers]); + + const updateExternalAccess = useCallback(async (keyId: number, request: ApiKeyRequest) => { + const { data } = await axios.put(`/api/keys/${keyId}/external-access`, request, { headers }); + return data; + }, [headers]); + + const revokeApiKey = useCallback(async (keyId: number) => { + const { data } = await axios.delete(`/api/keys/${keyId}`, { headers }); + return data; + }, [headers]); + + const regenerateApiKey = useCallback(async (keyId: number) => { + const { data } = await axios.post(`/api/keys/${keyId}/regenerate`, undefined, { headers }); + return data; + }, [headers]); + + return useMemo(() => ({ + fetchApiKeys, + fetchAvailableChannels, + fetchChannelGrants, + createApiKey, + updateExternalAccess, + revokeApiKey, + regenerateApiKey, + }), [ + createApiKey, + fetchApiKeys, + fetchAvailableChannels, + fetchChannelGrants, + regenerateApiKey, + revokeApiKey, + updateExternalAccess, + ]); +}; diff --git a/client/src/components/Configuration/sections/ApiKeysSection/useExternalAccessEditor.ts b/client/src/components/Configuration/sections/ApiKeysSection/useExternalAccessEditor.ts new file mode 100644 index 000000000..2b65b46d8 --- /dev/null +++ b/client/src/components/Configuration/sections/ApiKeysSection/useExternalAccessEditor.ts @@ -0,0 +1,78 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ChannelListEntry } from '../../../Subscriptions/hooks/useChannelList'; +import { ApiKey, ApiKeyPolicy, NormalizedApiKeyPolicy, normalizePolicy, useApiKeys } from './useApiKeys'; +import { increasesPrivilege, policyFromKey } from './policy'; +import { apiKeyError } from './apiKeyError'; + +interface Update { keyId: number; policy: NormalizedApiKeyPolicy; channelIds: number[]; } +interface Props { apiKey: ApiKey; api: ReturnType; onClose: () => void; onSaved: () => void; } +interface LoadState { channels: ChannelListEntry[]; originalIds: number[]; loading: boolean; error: string | null; } +export function useExternalAccessEditor({ apiKey, api, onClose, onSaved }: Props) { + const [editPolicy, setPolicy] = useState(() => policyFromKey(apiKey)); + const [selectedChannelIds, setSelectedChannelIds] = useState([]); + const [channelSearch, setChannelSearch] = useState(''); + const [load, setLoad] = useState({ channels: [], originalIds: [], loading: true, error: null }); + const [editSubmitError, setEditSubmitError] = useState(null); + const [pendingExternalUpdate, setPendingExternalUpdate] = useState(null); + const [savingPolicy, setSavingPolicy] = useState(false); + const [reloadToken, setReloadToken] = useState(0); + const busy = useRef(false); + const active = useRef(true); + useEffect(() => { + active.current = true; + return () => { active.current = false; }; + }, []); + useEffect(() => { + const controller = new AbortController(); + setLoad(state => ({ ...state, loading: true, error: null })); + void Promise.all([ + api.fetchAvailableChannels(controller.signal), api.fetchChannelGrants(apiKey.id, controller.signal), + ]).then(([channels, ids]) => { + if (controller.signal.aborted) return; + setSelectedChannelIds(ids); + setLoad({ channels: channels.filter(channel => !channel.terminated_at), originalIds: ids, loading: false, error: null }); + }).catch(error => { + if (!controller.signal.aborted) setLoad(state => ({ ...state, loading: false, error: apiKeyError(error, 'Failed to load channel grants and available channels') })); + }); + return () => controller.abort(); + }, [api, apiKey.id, reloadToken]); + + const submit = async (update: Update) => { + if (busy.current) return; + busy.current = true; + setSavingPolicy(true); + setEditSubmitError(null); + try { + await api.updateExternalAccess(update.keyId, { policy: update.policy, channelIds: update.channelIds }); + if (active.current) onSaved(); + } catch (error) { + if (active.current) { + setPendingExternalUpdate(null); + setEditSubmitError(apiKeyError(error, 'Failed to save external access')); + } + } finally { + busy.current = false; + if (active.current) setSavingPolicy(false); + } + }; + const saveExternalAccess = async () => { + if (busy.current || load.loading || load.error) return; + setEditSubmitError(null); + const result = normalizePolicy(editPolicy); + if (!result.policy) { setEditSubmitError(result.error || 'Invalid policy values'); return; } + const update = { keyId: apiKey.id, policy: result.policy, channelIds: selectedChannelIds }; + if (increasesPrivilege(apiKey, result.policy, selectedChannelIds, load.originalIds)) { + setPendingExternalUpdate(update); + } else await submit(update); + }; + return { + editPolicy, channelOptions: load.channels, channelSearch, selectedChannelIds, savingPolicy, + loading: load.loading, editLoadError: load.error, editSubmitError, pendingExternalUpdate, + setEditPolicy: (policy: ApiKeyPolicy) => { setPolicy(policy); setEditSubmitError(null); }, + setChannelSearch, setSelectedChannelIds, + closeEditDialog: () => { if (!busy.current) onClose(); }, + reload: () => setReloadToken(value => value + 1), saveExternalAccess, + cancelPrivilegeConfirmation: () => { if (!busy.current) setPendingExternalUpdate(null); }, + confirmPrivilegeIncrease: () => pendingExternalUpdate && submit(pendingExternalUpdate), + }; +} diff --git a/client/src/components/Configuration/sections/__tests__/ApiKeysSection.privilege.test.tsx b/client/src/components/Configuration/sections/__tests__/ApiKeysSection.privilege.test.tsx new file mode 100644 index 000000000..888437799 --- /dev/null +++ b/client/src/components/Configuration/sections/__tests__/ApiKeysSection.privilege.test.tsx @@ -0,0 +1,347 @@ +import React from 'react'; +import { act, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import ApiKeysSection from '../ApiKeysSection'; +import { renderWithProviders } from '../../../../test-utils'; +import { useApiKeys } from '../ApiKeysSection/useApiKeys'; +import type { ChannelListEntry } from '../../../Subscriptions/hooks/useChannelList'; + +jest.mock('../ApiKeysSection/useApiKeys', () => { + const actual = jest.requireActual('../ApiKeysSection/useApiKeys'); + return { ...actual, useApiKeys: jest.fn() }; +}); + +const mockedUseApiKeys = useApiKeys as jest.MockedFunction; +const externalKey = { + id: 7, + name: 'External Client', + key_prefix: 'client1234', + created_at: '2026-07-27T18:30:00.000Z', + last_used_at: null, + is_active: true, + usage_count: 0, + channel_grant_count: 0, + role: 'view' as const, + allow_video_requests: false, + allow_channel_requests: false, + allow_delete_video_requests: false, + auto_approve_video_requests: false, + auto_approve_channel_requests: false, + auto_approve_delete_requests: false, + max_rating_level: 3, + allow_unrated: false, + allowed_media_types: ['video' as const], + max_active_jobs: 5, + hourly_write_limit: 30, + daily_write_limit: 200, + revoked_at: null, +}; + +const props = (): React.ComponentProps => ({ + token: 'test-token', + apiKeyRateLimit: 10, + externalApiEnabled: true, + onRateLimitChange: jest.fn(), + showRequestsNavLink: true, + onShowRequestsNavLinkChange: jest.fn(), +}); + +const safeChannel: ChannelListEntry = { + database_id: 12, + url: 'https://www.youtube.com/channel/UC123', + channel_id: 'UC123', + uploader: 'Safe Channel', + title: 'Safe Channel', + terminated_at: null, +}; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +}; + +describe('ApiKeysSection privilege confirmation', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedUseApiKeys.mockReturnValue({ + fetchApiKeys: jest.fn().mockResolvedValue([externalKey]), + fetchAvailableChannels: jest.fn().mockResolvedValue([safeChannel]), + fetchChannelGrants: jest.fn().mockResolvedValue([]), + createApiKey: jest.fn(), + updateExternalAccess: jest.fn().mockResolvedValue({ success: true }), + revokeApiKey: jest.fn(), + regenerateApiKey: jest.fn(), + }); + }); + + async function openEditor() { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(await screen.findByRole('button', { + name: 'Edit External Client external access', + })); + await screen.findByLabelText('Request videos'); + return user; + } + + it('uses the app Dialog and never calls native confirm for an increase', async () => { + const nativeConfirm = jest.spyOn(window, 'confirm'); + const user = await openEditor(); + await user.click(screen.getByLabelText('Request videos')); + await user.click(screen.getByRole('button', { name: 'Save External Access' })); + + expect(nativeConfirm).not.toHaveBeenCalled(); + expect(screen.getByText('Confirm expanded external access?')).toBeInTheDocument(); + nativeConfirm.mockRestore(); + }); + + it('cancels an increase without submitting', async () => { + const user = await openEditor(); + await user.click(screen.getByLabelText('Request videos')); + await user.click(screen.getByRole('button', { name: 'Save External Access' })); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(mockedUseApiKeys.mock.results[0].value.updateExternalAccess).not.toHaveBeenCalled(); + }); + + it('confirms an increase with exactly one normalized update', async () => { + const user = await openEditor(); + await user.click(screen.getByLabelText('Request videos')); + await user.click(screen.getByRole('button', { name: 'Save External Access' })); + await user.click(screen.getByRole('button', { name: 'Continue' })); + + const update = mockedUseApiKeys.mock.results[0].value.updateExternalAccess; + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)); + expect(update).toHaveBeenCalledWith(7, expect.objectContaining({ + policy: expect.objectContaining({ allowVideoRequests: true, maxActiveJobs: 5 }), + channelIds: [], + })); + }); + + it('submits a direct non-increase without confirmation', async () => { + const user = await openEditor(); + await user.click(screen.getByRole('button', { name: 'Save External Access' })); + + const update = mockedUseApiKeys.mock.results[0].value.updateExternalAccess; + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)); + expect(screen.queryByText('Confirm expanded external access?')).not.toBeInTheDocument(); + }); + + it('saves a corrected numeric policy without reloading channel grants', async () => { + const user = await openEditor(); + const activeJobs = screen.getByRole('spinbutton', { name: 'Active jobs' }); + const save = screen.getByRole('button', { name: 'Save External Access' }); + await waitFor(() => expect(save).toBeEnabled()); + + await user.clear(activeJobs); + await user.click(save); + expect(screen.getByText('Active jobs must be an integer from 1 to 5.')).toBeInTheDocument(); + expect(save).toBeEnabled(); + + await user.type(activeJobs, '3'); + expect(screen.queryByText('Active jobs must be an integer from 1 to 5.')).not.toBeInTheDocument(); + await user.click(save); + + const api = mockedUseApiKeys.mock.results[0].value; + await waitFor(() => expect(api.updateExternalAccess).toHaveBeenCalledWith( + 7, + expect.objectContaining({ + policy: expect.objectContaining({ maxActiveJobs: 3 }), + channelIds: [], + }) + )); + expect(api.fetchChannelGrants).toHaveBeenCalledTimes(1); + expect(api.fetchAvailableChannels).toHaveBeenCalledTimes(1); + }); + + it('allows retrying a failed save without reloading channel grants', async () => { + const user = await openEditor(); + const api = mockedUseApiKeys.mock.results[0].value; + const update = api.updateExternalAccess as jest.Mock; + update.mockRejectedValueOnce(new Error('Temporary save failure')); + const save = screen.getByRole('button', { name: 'Save External Access' }); + await waitFor(() => expect(save).toBeEnabled()); + + await user.click(save); + expect(await screen.findByText('Temporary save failure')).toBeInTheDocument(); + expect(save).toBeEnabled(); + await user.click(save); + + await waitFor(() => expect(update).toHaveBeenCalledTimes(2)); + expect(api.fetchChannelGrants).toHaveBeenCalledTimes(1); + }); + + it('keeps Save disabled until channel grants and channel options finish loading', async () => { + const grants = deferred(); + const channels = deferred(); + const updateExternalAccess = jest.fn(); + mockedUseApiKeys.mockReturnValue({ + fetchApiKeys: jest.fn().mockResolvedValue([externalKey]), + fetchAvailableChannels: jest.fn().mockReturnValue(channels.promise), + fetchChannelGrants: jest.fn().mockReturnValue(grants.promise), + createApiKey: jest.fn(), + updateExternalAccess, + revokeApiKey: jest.fn(), + regenerateApiKey: jest.fn(), + }); + + const user = userEvent.setup(); + renderWithProviders(); + await user.click(await screen.findByRole('button', { + name: 'Edit External Client external access', + })); + + const save = screen.getByRole('button', { name: 'Save External Access' }); + expect(save).toBeDisabled(); + await user.click(save); + expect(updateExternalAccess).not.toHaveBeenCalled(); + + await act(async () => { + grants.resolve([12]); + channels.resolve([safeChannel]); + }); + await waitFor(() => expect(save).toBeEnabled()); + }); + + it('ignores a slow response from a previously opened key', async () => { + const slowGrants = deferred(); + const slowChannels = deferred(); + const secondChannel: ChannelListEntry = { + database_id: 22, + url: 'https://www.youtube.com/channel/UCSECOND', + channel_id: 'UCSECOND', + uploader: 'Second Channel', + title: 'Second Channel', + terminated_at: null, + }; + const secondKey = { ...externalKey, id: 8, name: 'Second Client' }; + const fetchAvailableChannels = jest.fn() + .mockReturnValueOnce(slowChannels.promise) + .mockResolvedValueOnce([secondChannel]); + const fetchChannelGrants = jest.fn((keyId: number) => ( + keyId === externalKey.id ? slowGrants.promise : Promise.resolve([22]) + )); + mockedUseApiKeys.mockReturnValue({ + fetchApiKeys: jest.fn().mockResolvedValue([externalKey, secondKey]), + fetchAvailableChannels, + fetchChannelGrants, + createApiKey: jest.fn(), + updateExternalAccess: jest.fn(), + revokeApiKey: jest.fn(), + regenerateApiKey: jest.fn(), + }); + + const user = userEvent.setup(); + renderWithProviders(); + await user.click(await screen.findByRole('button', { + name: 'Edit External Client external access', + })); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await user.click(await screen.findByRole('button', { + name: 'Edit Second Client external access', + })); + + expect(await screen.findByText('Edit External Access — Second Client')).toBeInTheDocument(); + const secondChannelCheckbox = await screen.findByLabelText('Second Channel'); + await waitFor(() => expect(secondChannelCheckbox).toBeChecked()); + + await act(async () => { + slowGrants.resolve([12]); + slowChannels.resolve([safeChannel]); + }); + + expect(screen.getByText('Edit External Access — Second Client')).toBeInTheDocument(); + expect(secondChannelCheckbox).toBeChecked(); + expect(screen.queryByLabelText('Safe Channel')).not.toBeInTheDocument(); + }); +}); + +describe('API key dialog lifecycle', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedUseApiKeys.mockReturnValue({ + fetchApiKeys: jest.fn().mockResolvedValue([externalKey]), + fetchAvailableChannels: jest.fn().mockResolvedValue([safeChannel]), + fetchChannelGrants: jest.fn().mockResolvedValue([]), + createApiKey: jest.fn(), updateExternalAccess: jest.fn().mockResolvedValue({ success: true }), + revokeApiKey: jest.fn(), regenerateApiKey: jest.fn(), + }); + }); + + it('ignores a cancelled create load while a different editor is loading', async () => { + const createChannels = deferred(); + const editChannels = deferred(); + const api = mockedUseApiKeys('test-token'); + (api.fetchAvailableChannels as jest.Mock).mockReturnValueOnce(createChannels.promise) + .mockReturnValueOnce(editChannels.promise); + const user = userEvent.setup(); + renderWithProviders(); + await user.click(await screen.findByRole('button', { name: 'Create external key' })); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await user.click(screen.getByRole('button', { name: 'Edit External Client external access' })); + await act(async () => createChannels.resolve([{ ...safeChannel, database_id: 99, title: 'Obsolete channel' }])); + expect(screen.getByRole('button', { name: 'Save External Access' })).toBeDisabled(); + expect(screen.queryByLabelText('Obsolete channel')).not.toBeInTheDocument(); + await act(async () => editChannels.resolve([safeChannel])); + expect(await screen.findByLabelText('Safe Channel')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save External Access' })).toBeEnabled(); + }); + + it('blocks duplicate creation and cannot dismiss a submission before the secret arrives', async () => { + const created = deferred<{ success: boolean; id: number; name: string; key: string; prefix: string; message: string }>(); + const api = mockedUseApiKeys('test-token'); + (api.createApiKey as jest.Mock).mockReturnValue(created.promise); + const user = userEvent.setup(); + renderWithProviders(); + await user.click(await screen.findByRole('button', { name: 'Create external key' })); + await user.type(screen.getByLabelText('Key Name'), 'Synthetic client'); + await user.dblClick(screen.getByRole('button', { name: 'Create' })); + expect(api.createApiKey).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + await user.keyboard('{Escape}'); + expect(screen.getByText('Create External Access Key')).toBeInTheDocument(); + await act(async () => created.resolve({ success: true, id: 1, name: 'Synthetic client', key: 'synthetic-only-secret', prefix: 'synthetic', message: '' })); + expect(await screen.findByText('synthetic-only-secret')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Done' })); + expect(screen.queryByText('synthetic-only-secret')).not.toBeInTheDocument(); + }); + + it('shows bookmarklet key creation errors inside the dialog when external access is disabled', async () => { + const api = mockedUseApiKeys('test-token'); + (api.createApiKey as jest.Mock).mockRejectedValue(new Error('Creation failed')); + const user = userEvent.setup(); + renderWithProviders(); + await user.click(await screen.findByRole('button', { name: 'Create Key' })); + await user.type(screen.getByLabelText('Key Name'), 'Bookmarklet'); + await user.click(screen.getByRole('button', { name: 'Create' })); + expect(await screen.findByText('Creation failed')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled(); + }); + + it('keeps an edit open during saving and only submits it once', async () => { + const saved = deferred<{ success: boolean }>(); + const api = mockedUseApiKeys('test-token'); + (api.updateExternalAccess as jest.Mock).mockReturnValue(saved.promise); + const user = userEvent.setup(); + renderWithProviders(); + await user.click(await screen.findByRole('button', { name: 'Edit External Client external access' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Save External Access' })).toBeEnabled()); + await user.dblClick(screen.getByRole('button', { name: 'Save External Access' })); + expect(api.updateExternalAccess).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + await user.keyboard('{Escape}'); + expect(screen.getByText('Edit External Access — External Client')).toBeInTheDocument(); + await act(async () => saved.resolve({ success: true })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('fails closed when effective feature availability has not been supplied', async () => { + renderWithProviders(); + await screen.findByRole('button', { name: 'Create Key' }); + expect(screen.queryByRole('button', { name: 'Create external key' })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Configuration/sections/__tests__/ApiKeysSection.story.tsx b/client/src/components/Configuration/sections/__tests__/ApiKeysSection.story.tsx index 5dc5ef227..0443516aa 100644 --- a/client/src/components/Configuration/sections/__tests__/ApiKeysSection.story.tsx +++ b/client/src/components/Configuration/sections/__tests__/ApiKeysSection.story.tsx @@ -9,12 +9,19 @@ const meta: Meta = { args: { token: 'storybook-token', apiKeyRateLimit: 10, + externalApiEnabled: true, onRateLimitChange: () => {}, + showRequestsNavLink: true, + onShowRequestsNavLinkChange: () => {}, }, parameters: { msw: { handlers: [ http.get('/api/keys', () => HttpResponse.json({ keys: [] })), + http.get('/getchannels', () => HttpResponse.json({ + channels: [], + totalPages: 1, + })), ], }, }, @@ -26,8 +33,85 @@ type Story = StoryObj; export const OpensCreateDialog: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click(await canvas.findByRole('button', { name: /create key/i })); + await userEvent.click(await canvas.findByRole('button', { name: /create external key/i })); const body = within(canvasElement.ownerDocument.body); - await expect(await body.findByText('Create API Key')).toBeInTheDocument(); + await expect(await body.findByText('Create External Access Key')).toBeInTheDocument(); + await expect(await body.findByText('View included')).toBeInTheDocument(); + }, +}; + +export const ResponsiveKeyCards: Story = { + parameters: { + viewport: { defaultViewport: 'mobile1' }, + msw: { + handlers: [ + http.get('/api/keys', () => HttpResponse.json({ + keys: [{ + id: 4, + name: 'External Client', + key_prefix: 'client1234', + created_at: '2026-07-27T18:30:00.000Z', + last_used_at: '2026-07-27T19:00:00.000Z', + is_active: true, + usage_count: 12, + role: 'delete', + allow_video_requests: true, + allow_channel_requests: true, + allow_delete_video_requests: true, + auto_approve_video_requests: false, + auto_approve_channel_requests: true, + auto_approve_delete_requests: false, + max_rating_level: 3, + allow_unrated: false, + allowed_media_types: ['video', 'short'], + revoked_at: null, + }], + })), + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('External Client')).toBeInTheDocument(); + await expect(await canvas.findByLabelText('Movie rating ceiling PG-13')).toBeInTheDocument(); + await expect(await canvas.findByLabelText('TV rating ceiling TV-14')).toBeInTheDocument(); + await expect(await canvas.findByText('Channels · Auto')).toBeInTheDocument(); + }, +}; + +export const BookmarkletKeysFirst: Story = { + parameters: { + msw: { + handlers: [ + http.get('/api/keys', () => HttpResponse.json({ + keys: [{ + id: 2, + name: 'Old Bookmarklet', + key_prefix: 'legacy12', + created_at: '2026-07-01T18:30:00.000Z', + last_used_at: null, + is_active: true, + usage_count: 3, + role: 'legacy_download', + allow_video_requests: false, + allow_channel_requests: false, + allow_delete_video_requests: false, + auto_approve_video_requests: false, + auto_approve_channel_requests: false, + auto_approve_delete_requests: false, + max_rating_level: 3, + allow_unrated: false, + allowed_media_types: ['video'], + revoked_at: null, + }], + })), + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('No external access keys yet.')).toBeInTheDocument(); + await expect(await canvas.findByText('Old Bookmarklet')).toBeInTheDocument(); + await expect(await canvas.findByText('legacy12...')).toBeInTheDocument(); }, }; diff --git a/client/src/components/Configuration/sections/__tests__/ApiKeysSection.test.tsx b/client/src/components/Configuration/sections/__tests__/ApiKeysSection.test.tsx index 7693d4383..228ce1349 100644 --- a/client/src/components/Configuration/sections/__tests__/ApiKeysSection.test.tsx +++ b/client/src/components/Configuration/sections/__tests__/ApiKeysSection.test.tsx @@ -4,701 +4,577 @@ import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import ApiKeysSection from '../ApiKeysSection'; import { renderWithProviders } from '../../../../test-utils'; +import { useApiKeys } from '../ApiKeysSection/useApiKeys'; +import type { ChannelListResponse } from '../../../Subscriptions/hooks/useChannelList'; + +jest.mock('../ApiKeysSection/useApiKeys', () => { + const actual = jest.requireActual('../ApiKeysSection/useApiKeys'); + return { ...actual, useApiKeys: jest.fn() }; +}); + +const mockedUseApiKeys = useApiKeys as jest.MockedFunction; -// Mock fetch globally const mockFetch = jest.fn(); global.fetch = mockFetch; -const createSectionProps = ( - overrides: Partial> = {} -): React.ComponentProps => ({ +const props = (): React.ComponentProps => ({ token: 'test-token-123', apiKeyRateLimit: 10, + externalApiEnabled: true, onRateLimitChange: jest.fn(), - ...overrides, + showRequestsNavLink: true, + onShowRequestsNavLinkChange: jest.fn(), }); -// Helper to expand accordion - -// Mock API key data -const mockApiKeys = [ - { - id: 1, - name: 'My Bookmarklet', - key_prefix: 'abc12345', - created_at: '2024-01-15T10:30:00Z', - last_used_at: '2024-01-20T15:45:00Z', - is_active: true, - usage_count: 42, - }, - { - id: 2, - name: 'iPhone Shortcut', - key_prefix: 'xyz98765', - created_at: '2024-01-10T08:00:00Z', - last_used_at: null, - is_active: true, - usage_count: 0, - }, -]; - -const mockCreatedKeyResponse = { +const externalKey = { + id: 7, + name: 'External Client', + key_prefix: 'client1234', + created_at: '2026-07-27T18:30:00.000Z', + last_used_at: '2026-07-27T19:00:00.000Z', + is_active: true, + usage_count: 12, + channel_grant_count: 1, + role: 'delete', + allow_video_requests: true, + allow_channel_requests: true, + allow_delete_video_requests: true, + auto_approve_video_requests: false, + auto_approve_channel_requests: true, + auto_approve_delete_requests: false, + max_rating_level: 3, + allow_unrated: false, + allowed_media_types: ['video', 'short'], + revoked_at: null as string | null, +}; + +const legacyKey = { + ...externalKey, + id: 2, + name: 'My Bookmarklet', + key_prefix: 'abc12345', + role: 'legacy_download', + allow_video_requests: false, + allow_channel_requests: false, + allow_delete_video_requests: false, + auto_approve_channel_requests: false, + usage_count: 42, +}; + +const createdKey = { success: true, message: 'API key created. Save this key - it will not be shown again!', - id: 3, + id: 8, name: 'New Key', key: 'abc12345def67890abc12345def67890abc12345def67890abc12345def67890', prefix: 'abc12345', }; -describe('ApiKeysSection Component', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockFetch.mockClear(); - - // Default mock for fetching API keys - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }); - }); - - describe('Component Rendering', () => { - test('renders without crashing', async () => { - const props = createSectionProps(); - renderWithProviders(); - expect(screen.getByText(/API Keys/i)).toBeInTheDocument(); - }); - - test('renders with ConfigurationAccordion wrapper', async () => { - const props = createSectionProps(); - renderWithProviders(); - expect(screen.getByText(/API Keys & Bookmarklet/i)).toBeInTheDocument(); - }); - - test('accordion is collapsed by default', () => { - const props = createSectionProps(); - renderWithProviders(); - - expect(screen.getByRole('heading', { name: /API Keys & Bookmarklet/i })).toBeInTheDocument(); - }); - - test('first setup step includes the Create click that opens the button window', async () => { - renderWithProviders(); - - expect(await screen.findByText(/give it a name like "Laptop browser", and click/i)).toBeInTheDocument(); - }); - - test('explains that creating a key opens the Send to Youtarr button', async () => { - renderWithProviders(); - - expect(await screen.findByText(/In the window that opens, drag the/i)).toBeInTheDocument(); - }); - - test('warns that the Send to Youtarr button is only shown once', async () => { - renderWithProviders(); - - expect(await screen.findByText(/The button is only shown once/i)).toBeInTheDocument(); - }); - - test('shows loading skeleton initially', () => { - const props = createSectionProps(); - renderWithProviders(); - // Skeleton should be visible while loading - expect(screen.getByText(/API Keys/i)).toBeInTheDocument(); - }); - - test('shows single video limitation note', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }); - - const props = createSectionProps(); - renderWithProviders(); +const channelListResponse: ChannelListResponse = { + channels: [{ + database_id: 12, + url: 'https://www.youtube.com/channel/UC1234567890123456789012', + channel_id: 'UC1234567890123456789012', + uploader: 'Safe Channel', + title: 'Safe Channel', + terminated_at: null, + }], + total: 1, + totalPages: 1, +}; +const jsonResponse = (body: unknown, ok = true) => ({ + ok, + json: jest.fn().mockResolvedValue(body), +}); - await waitFor(() => { - expect(screen.getByText(/single video downloads only/i)).toBeInTheDocument(); - }); - }); +const installDefaultFetch = ( + keys: Array> = [externalKey, legacyKey] +) => { + mockFetch.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === '/api/keys' && (!init?.method || init.method === 'GET')) { + return jsonResponse({ keys }); + } + if (url === '/getchannels?page=1&pageSize=100&sortOrder=asc') { + return jsonResponse(channelListResponse); + } + if (url === '/api/keys/7/channels') { + return jsonResponse({ keyId: 7, channelIds: [] }); + } + if (url === '/api/keys/7/external-access') { + return jsonResponse({ success: true, key: externalKey, channelIds: [12] }); + } + if (url === '/api/keys/7/regenerate' && init?.method === 'POST') { + return jsonResponse({ + ...createdKey, + message: 'API key regenerated. Save this key - it will not be shown again!', + id: 7, + name: 'External Client', + }); + } + if (url === '/api/keys' && init?.method === 'POST') { + return jsonResponse(createdKey); + } + if (url.startsWith('/api/keys/') && init?.method === 'DELETE') { + return jsonResponse({ success: true }); + } + return jsonResponse({}); }); +}; - describe('Rate Limit Setting', () => { - test('renders rate limit input with current value', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }); - - const props = createSectionProps({ apiKeyRateLimit: 15 }); - renderWithProviders(); - - - await waitFor(() => { - const input = screen.getByLabelText(/Rate Limit/i); - expect(input).toHaveValue(15); - }); - }); - - test('calls onRateLimitChange when value changes', async () => { - const user = userEvent.setup(); - const onRateLimitChange = jest.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }); - - const props = createSectionProps({ apiKeyRateLimit: 10, onRateLimitChange }); - renderWithProviders(); - - - await waitFor(() => { - expect(screen.getByLabelText(/Rate Limit/i)).toBeInTheDocument(); - }); - - const input = screen.getByLabelText(/Rate Limit/i) as HTMLInputElement; - // Triple click to select all, then type new value - await user.tripleClick(input); - await user.keyboard('15'); - - // onRateLimitChange is called with valid values during typing - expect(onRateLimitChange).toHaveBeenCalled(); - }); +const installApiKeyHook = () => { + const request = async (url: string, init?: RequestInit) => { + const response = await mockFetch(url, init); + return response.json(); + }; + mockedUseApiKeys.mockReturnValue({ + fetchApiKeys: async () => (await request('/api/keys')).keys || [], + fetchAvailableChannels: async () => (await request('/getchannels?page=1&pageSize=100&sortOrder=asc')).channels || [], + fetchChannelGrants: async (keyId) => (await request(`/api/keys/${keyId}/channels`)).channelIds || [], + createApiKey: async (name, policy, channelIds) => request('/api/keys', { + method: 'POST', + body: JSON.stringify({ name, ...(policy ? { policy, channelIds: channelIds || [] } : {}) }), + }), + updateExternalAccess: async (keyId, body) => request(`/api/keys/${keyId}/external-access`, { + method: 'PUT', + body: JSON.stringify(body), + }), + revokeApiKey: async (keyId) => request(`/api/keys/${keyId}`, { method: 'DELETE' }), + regenerateApiKey: async (keyId) => request(`/api/keys/${keyId}/regenerate`, { method: 'POST' }), }); +}; - describe('Empty State', () => { - test('shows empty state message when no keys exist', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }); - - const props = createSectionProps(); - renderWithProviders(); - - - await waitFor(() => { - expect(screen.getByText(/No API keys yet. Create one to get your Send to Youtarr button/i)).toBeInTheDocument(); - }); - }); - - test('shows Create Key button in empty state', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }); - - const props = createSectionProps(); - renderWithProviders(); - - - await waitFor(() => { - expect(screen.getByRole('button', { name: /Create Key/i })).toBeInTheDocument(); - }); - }); +describe('ApiKeysSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + installDefaultFetch(); + installApiKeyHook(); }); - describe('API Keys List', () => { - test('displays list of API keys', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: mockApiKeys }), - }); - - const props = createSectionProps(); - renderWithProviders(); + test('toggles the Requests navigation link setting', async () => { + const user = userEvent.setup(); + const onShowRequestsNavLinkChange = jest.fn(); + renderWithProviders( + + ); + const toggle = await screen.findByTestId('requests-nav-link-switch'); + expect(toggle).toBeChecked(); - expect(await screen.findByText('My Bookmarklet')).toBeInTheDocument(); - expect(screen.getByText('iPhone Shortcut')).toBeInTheDocument(); - }); + await user.click(toggle); - test('displays key prefix with ellipsis', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: mockApiKeys }), - }); - - const props = createSectionProps(); - renderWithProviders(); - - - expect(await screen.findByText('abc12345...')).toBeInTheDocument(); - expect(screen.getByText('xyz98765...')).toBeInTheDocument(); - }); - - test('displays "Never" for keys that have not been used', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: mockApiKeys }), - }); - - const props = createSectionProps(); - renderWithProviders(); - - - await waitFor(() => { - expect(screen.getByText('Never')).toBeInTheDocument(); - }); - }); - - test('shows delete button for each key', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: mockApiKeys }), - }); - - const props = createSectionProps(); - renderWithProviders(); - - - await waitFor(() => { - const deleteButtons = screen.getAllByRole('button', { name: /Delete/i }); - expect(deleteButtons).toHaveLength(2); - }); - }); - - test('displays usage count for each key', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: mockApiKeys }), - }); - - const props = createSectionProps(); - renderWithProviders(); - - - // Check that usage counts are displayed - expect(await screen.findByText('42')).toBeInTheDocument(); - expect(screen.getByText('0')).toBeInTheDocument(); - }); + expect(onShowRequestsNavLinkChange).toHaveBeenCalledWith(false); }); - describe('Create API Key', () => { - test('opens create dialog when Create Key button is clicked', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }); - - const props = createSectionProps(); - renderWithProviders(); - + test('puts the bookmarklet steps before the external access section', async () => { + renderWithProviders(); - await waitFor(() => { - expect(screen.getByRole('button', { name: /Create Key/i })).toBeInTheDocument(); - }); + const externalHeading = await screen.findByText('External access keys'); + const bookmarkletHeading = screen.getByText('Get the "Send to Youtarr" button'); + expect(bookmarkletHeading.compareDocumentPosition(externalHeading) & + Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); - await user.click(screen.getByRole('button', { name: /Create Key/i })); + test('names the section after the bookmarklet', async () => { + renderWithProviders(); - expect(screen.getByText('Create API Key')).toBeInTheDocument(); - expect(screen.getByLabelText(/Key Name/i)).toBeInTheDocument(); - }); + expect(await screen.findByRole('heading', { name: /API Keys & Bookmarklet/i })).toBeInTheDocument(); + }); - test('create dialog says the Send to Youtarr button comes next', async () => { - const user = userEvent.setup(); - renderWithProviders(); + test('first setup step includes the Create click that opens the button window', async () => { + renderWithProviders(); - await user.click(await screen.findByRole('button', { name: /Create Key/i })); + expect(await screen.findByText(/give it a name like "Laptop browser", and click/i)).toBeInTheDocument(); + }); - expect(screen.getByText(/Next you'll get your key, the Send to Youtarr button/i)).toBeInTheDocument(); - }); + test('warns that the Send to Youtarr button is only shown once', async () => { + renderWithProviders(); - test('Create button is disabled when name is empty', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }); - - const props = createSectionProps(); - renderWithProviders(); + expect(await screen.findByText(/The button is only shown once/i)).toBeInTheDocument(); + }); + test('hides the external access section when the external API is disabled', async () => { + renderWithProviders(); - await waitFor(() => { - expect(screen.getByRole('button', { name: /Create Key/i })).toBeInTheDocument(); - }); + await screen.findByLabelText('Bookmarklet API key rows'); + expect(screen.queryByText('External access keys')).not.toBeInTheDocument(); + }); - await user.click(screen.getByRole('button', { name: /Create Key/i })); + test('hides the Requests navigation toggle when the external API is disabled', async () => { + renderWithProviders(); - const createButton = screen.getByRole('button', { name: /^Create$/i }); - expect(createButton).toBeDisabled(); - }); + await screen.findByLabelText('Bookmarklet API key rows'); + expect(screen.queryByTestId('requests-nav-link-switch')).not.toBeInTheDocument(); + }); - test('successfully creates API key and shows bookmarklet dialog', async () => { - const user = userEvent.setup(); - - // First call: fetch keys (empty) - // Second call: create key - // Third call: refresh keys - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockCreatedKeyResponse), - }) - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [{ ...mockApiKeys[0], name: 'New Key' }] }), - }); - - const props = createSectionProps(); - renderWithProviders(); - - - await waitFor(() => { - expect(screen.getByRole('button', { name: /Create Key/i })).toBeInTheDocument(); - }); + test('lists bookmarklet keys even when the external API is disabled', async () => { + renderWithProviders(); - await user.click(screen.getByRole('button', { name: /Create Key/i })); + const rows = await screen.findByLabelText('Bookmarklet API key rows'); + expect(within(rows).getByText('My Bookmarklet')).toBeInTheDocument(); + }); - const nameInput = screen.getByLabelText(/Key Name/i); - await user.type(nameInput, 'New Key'); + test('uses compact cards with rating and permission chips but no key prefix', async () => { + renderWithProviders(); + + const cards = await screen.findByLabelText('External API key cards'); + expect(within(cards).getByText('External Client')).toBeInTheDocument(); + expect(within(cards).getByLabelText('Movie rating ceiling PG-13')).toBeInTheDocument(); + expect(within(cards).getByLabelText('TV rating ceiling TV-14')).toBeInTheDocument(); + expect(within(cards).getByText('Videos')).toBeInTheDocument(); + expect(within(cards).getByText('Channels · Auto')).toBeInTheDocument(); + expect(within(cards).getByText('Delete video')).toBeInTheDocument(); + expect(within(cards).getByText('1 approved channel')).toBeInTheDocument(); + expect(within(cards).getByText(/Last used/i)).toBeInTheDocument(); + expect(within(cards).queryByText('12 uses')).not.toBeInTheDocument(); + expect(screen.queryByText('client1234...')).not.toBeInTheDocument(); + }); - const createButton = screen.getByRole('button', { name: /^Create$/i }); - await user.click(createButton); + test('warns prominently when an external key has no effective channel grants', async () => { + installDefaultFetch([{ ...externalKey, channel_grant_count: 0 }]); + installApiKeyHook(); + renderWithProviders(); - await waitFor(() => { - expect(screen.getByText(/API Key Created/i)).toBeInTheDocument(); - }); + const cards = await screen.findByLabelText('External API key cards'); + expect(within(cards).getByText('0 approved channels')).toBeInTheDocument(); + expect(within(cards).getByText(/No approved channels\. This key cannot view or request catalog content/i)) + .toBeInTheDocument(); + }); - // Should show the key only once warning - expect(screen.getByText(/Save this key now/i)).toBeInTheDocument(); - }); + test('uses a neutral state when an older management response omits the grant count', async () => { + const { channel_grant_count: _omitted, ...olderExternalKey } = externalKey; + installDefaultFetch([olderExternalKey]); + installApiKeyHook(); + renderWithProviders(); - test('shows bookmarklet section in created key dialog', async () => { - const user = userEvent.setup(); - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockCreatedKeyResponse), - }) - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }); - - const props = createSectionProps(); - renderWithProviders(); - - - await waitFor(() => { - expect(screen.getByRole('button', { name: /Create Key/i })).toBeInTheDocument(); - }); + const cards = await screen.findByLabelText('External API key cards'); + expect(within(cards).getByText('Approved channel count unavailable')).toBeInTheDocument(); + expect(within(cards).queryByText(/No approved channels\./i)).not.toBeInTheDocument(); + }); - await user.click(screen.getByRole('button', { name: /Create Key/i })); - await user.type(screen.getByLabelText(/Key Name/i), 'Test Key'); - await user.click(screen.getByRole('button', { name: /^Create$/i })); + test('filters active external keys by default, sorts them alphabetically, and supports search and all-keys mode', async () => { + const alphaKey = { + ...externalKey, + id: 9, + name: 'Alpha Client', + }; + const revokedKey = { + ...externalKey, + id: 10, + name: 'Revoked Client', + is_active: false, + revoked_at: '2026-07-28T12:00:00.000Z', + }; + const inactiveKey = { + ...externalKey, + id: 11, + name: 'Inactive Client', + is_active: false, + }; + installDefaultFetch([externalKey, alphaKey, revokedKey, inactiveKey]); + installApiKeyHook(); + const user = userEvent.setup(); + renderWithProviders(); + + const cards = await screen.findByLabelText('External API key cards'); + expect(within(cards).getByText('Alpha Client')).toBeInTheDocument(); + expect(within(cards).getByText('External Client')).toBeInTheDocument(); + expect(within(cards).queryByText('Revoked Client')).not.toBeInTheDocument(); + expect(within(cards).queryByText('Inactive Client')).not.toBeInTheDocument(); + const alphaName = within(cards).getByText('Alpha Client'); + const externalName = within(cards).getByText('External Client'); + expect(alphaName.compareDocumentPosition(externalName) & Node.DOCUMENT_POSITION_FOLLOWING) + .toBeTruthy(); + + await user.click(screen.getByRole('button', { name: 'Active only' })); + expect(within(cards).getByText('Revoked Client')).toBeInTheDocument(); + expect(within(cards).getByText('Inactive Client')).toBeInTheDocument(); + + await user.type(screen.getByLabelText('Search external access keys'), 'revoked'); + expect(within(cards).getByText('Revoked Client')).toBeInTheDocument(); + expect(within(cards).queryByText('Alpha Client')).not.toBeInTheDocument(); + }); - expect(await screen.findByText(/Add to Bookmarks/i)).toBeInTheDocument(); - const createdDialog = screen.getByRole('dialog', { name: /API Key Created/i }); - expect(within(createdDialog).getByText(/Send to Youtarr/i)).toBeInTheDocument(); - expect(screen.getByText(/Mobile \/ Shortcuts/i)).toBeInTheDocument(); - }); + test('lists bookmarklet keys with their key prefix', async () => { + renderWithProviders(); - test('shows error when API key creation fails', async () => { - const user = userEvent.setup(); - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }) - .mockResolvedValueOnce({ - ok: false, - json: jest.fn().mockResolvedValue({ error: 'Maximum number of API keys reached' }), - }); - - const props = createSectionProps(); - renderWithProviders(); - - - await waitFor(() => { - expect(screen.getByRole('button', { name: /Create Key/i })).toBeInTheDocument(); - }); + const rows = await screen.findByLabelText('Bookmarklet API key rows'); + expect(within(rows).getByText('abc12345...')).toBeInTheDocument(); + }); - await user.click(screen.getByRole('button', { name: /Create Key/i })); - await user.type(screen.getByLabelText(/Key Name/i), 'Test Key'); - await user.click(screen.getByRole('button', { name: /^Create$/i })); + test('keeps external keys out of the bookmarklet list', async () => { + renderWithProviders(); - await waitFor(() => { - expect(screen.getByText(/Maximum number of API keys reached/i)).toBeInTheDocument(); - }); - }); + const rows = await screen.findByLabelText('Bookmarklet API key rows'); + expect(within(rows).queryByText('External Client')).not.toBeInTheDocument(); }); - describe('Delete API Key', () => { - test('opens confirmation dialog when delete button is clicked', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: mockApiKeys }), - }); - - const props = createSectionProps(); - renderWithProviders(); + test('shows the rate limit below the bookmarklet keys', async () => { + renderWithProviders(); + expect(await screen.findByLabelText('Rate Limit (requests/min)')).toHaveValue(10); + }); - await waitFor(() => { - expect(screen.getByText('My Bookmarklet')).toBeInTheDocument(); - }); + test('shows separate empty states for external and bookmarklet keys', async () => { + installDefaultFetch([]); + installApiKeyHook(); + renderWithProviders(); - const deleteButtons = screen.getAllByRole('button', { name: /Delete/i }); - await user.click(deleteButtons[0]); + expect(await screen.findByText('No external access keys yet.')).toBeInTheDocument(); + expect(screen.getByText('No API keys yet. Create one to get your Send to Youtarr button.')).toBeInTheDocument(); + }); - expect(screen.getByText(/Delete API Key\?/i)).toBeInTheDocument(); - // The key name appears in both the table and dialog, so check for all instances - expect(screen.getAllByText(/My Bookmarklet/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getByText(/cannot be undone/i)).toBeInTheDocument(); - }); + test('shows auto-approve only after its permission is enabled', async () => { + const viewOnly = { + ...externalKey, + role: 'view', + allow_video_requests: false, + allow_channel_requests: false, + allow_delete_video_requests: false, + auto_approve_video_requests: false, + auto_approve_channel_requests: false, + auto_approve_delete_requests: false, + }; + installDefaultFetch([viewOnly]); + installApiKeyHook(); + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(await screen.findByRole('button', { + name: 'Edit External Client external access', + })); + expect(screen.queryByLabelText('Auto-approve request videos')).not.toBeInTheDocument(); + + await user.click(screen.getByLabelText('Request videos')); + expect(screen.getByLabelText('Auto-approve request videos')).toBeInTheDocument(); + expect(screen.getByLabelText('Request videos')).toHaveAttribute('data-state', 'checked'); + }); - test('closes confirmation dialog when Cancel is clicked', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: mockApiKeys }), - }); - - const props = createSectionProps(); - renderWithProviders(); + test('saves independent permissions and channel grants atomically', async () => { + const viewOnly = { + ...externalKey, + role: 'view', + allow_video_requests: false, + allow_channel_requests: false, + allow_delete_video_requests: false, + auto_approve_video_requests: false, + auto_approve_channel_requests: false, + auto_approve_delete_requests: false, + }; + installDefaultFetch([viewOnly]); + installApiKeyHook(); + const confirm = jest.spyOn(window, 'confirm'); + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(await screen.findByRole('button', { + name: 'Edit External Client external access', + })); + await user.click(screen.getByLabelText('Request videos')); + await user.click(screen.getByLabelText('Safe Channel')); + await user.click(screen.getByRole('button', { name: 'Save External Access' })); + await user.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => expect(mockFetch.mock.calls.some(([url, init]) => + url === '/api/keys/7/external-access' && init?.method === 'PUT' + )).toBe(true)); + const call = mockFetch.mock.calls.find(([url, init]) => + url === '/api/keys/7/external-access' && init?.method === 'PUT' + ); + const requestBody = JSON.parse(call?.[1]?.body as string); + expect(requestBody).toEqual({ + policy: expect.objectContaining({ + role: 'request', + allowVideoRequests: true, + allowChannelRequests: false, + allowDeleteVideoRequests: false, + autoApproveVideoRequests: false, + }), + channelIds: [12], + }); + expect(confirm).not.toHaveBeenCalled(); + confirm.mockRestore(); + }); + test('selects and clears all approved channels when creating a key', async () => { + installDefaultFetch([]); + installApiKeyHook(); + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(await screen.findByRole('button', { name: 'Create external key' })); + expect(screen.getByText(/Saving with zero approved channels is allowed, but the key will fail closed/i)) + .toBeInTheDocument(); + const selectAll = await screen.findByLabelText('Select all approved channels'); + await user.click(selectAll); + expect(screen.getByLabelText('Safe Channel')).toBeChecked(); + expect(screen.getByText('Approved channels (1)')).toBeInTheDocument(); + await user.click(selectAll); + expect(screen.getByLabelText('Safe Channel')).not.toBeChecked(); + }); - await waitFor(() => { - expect(screen.getByText('My Bookmarklet')).toBeInTheDocument(); - }); + test('selects all approved channels when editing a key', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(await screen.findByRole('button', { + name: 'Edit External Client external access', + })); + expect(await screen.findByText(/Saving with zero approved channels is allowed, but this key will fail closed/i)) + .toBeInTheDocument(); + await user.click(await screen.findByLabelText('Select all approved channels')); + expect(screen.getByLabelText('Safe Channel')).toBeChecked(); + }); - const deleteButtons = screen.getAllByRole('button', { name: /Delete/i }); - await user.click(deleteButtons[0]); + test('turning off a permission also turns off its auto-approval', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(await screen.findByRole('button', { + name: 'Edit External Client external access', + })); + expect(screen.getByLabelText('Auto-approve request channels')) + .toHaveAttribute('data-state', 'checked'); + await user.click(screen.getByLabelText('Request channels')); + expect(screen.queryByLabelText('Auto-approve request channels')).not.toBeInTheDocument(); + }); - await user.click(screen.getByRole('button', { name: /Cancel/i })); + test('creates an external key as view-only by default', async () => { + installDefaultFetch([]); + installApiKeyHook(); + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(await screen.findByRole('button', { name: 'Create external key' })); + expect(screen.getByText('Create External Access Key')).toBeInTheDocument(); + expect(screen.getByText('View included')).toBeInTheDocument(); + expect(screen.getByLabelText('Request videos')).toHaveAttribute('data-state', 'unchecked'); + + await user.type(screen.getByLabelText('Key Name'), 'New Key'); + await user.click(screen.getByRole('button', { name: /^Create$/ })); + + await waitFor(() => { + const call = mockFetch.mock.calls.find(([url, init]) => + url === '/api/keys' && init?.method === 'POST' + ); + const requestBody = JSON.parse(call?.[1]?.body as string); + expect(requestBody.policy).toEqual(expect.objectContaining({ + role: 'view', + allowVideoRequests: false, + allowChannelRequests: false, + allowDeleteVideoRequests: false, + })); + }); + expect(await screen.findByText(/API Key Created/)).toBeInTheDocument(); + }); - await waitFor(() => { - expect(screen.queryByText(/Delete API Key\?/i)).not.toBeInTheDocument(); - }); - }); + const createBookmarkletKey = async () => { + installDefaultFetch([]); + installApiKeyHook(); + const user = userEvent.setup(); + renderWithProviders(); - test('deletes key when confirmed', async () => { - const user = userEvent.setup(); - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: mockApiKeys }), - }) - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ success: true }), - }) - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [mockApiKeys[1]] }), - }); - - const props = createSectionProps(); - renderWithProviders(); - - - await waitFor(() => { - expect(screen.getByText('My Bookmarklet')).toBeInTheDocument(); - }); + await user.click(await screen.findByRole('button', { name: 'Create Key' })); + await user.type(screen.getByLabelText('Key Name'), 'Bookmarklet'); + await user.click(screen.getByRole('button', { name: /^Create$/ })); + return screen.findByRole('dialog', { name: /API Key Created/i }); + }; - const deleteButtons = screen.getAllByRole('button', { name: /Delete/i }); - await user.click(deleteButtons[0]); + test('create dialog says the Send to Youtarr button comes next', async () => { + const user = userEvent.setup(); + renderWithProviders(); - // Click the Delete button in the confirmation dialog - const confirmDeleteButton = screen.getByRole('button', { name: /^Delete$/i }); - await user.click(confirmDeleteButton); + await user.click(await screen.findByRole('button', { name: 'Create Key' })); - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith('/api/keys/1', expect.objectContaining({ - method: 'DELETE', - })); - }); - }); + expect(screen.getByText(/Next you'll get your key, the Send to Youtarr button/i)).toBeInTheDocument(); }); - describe('Copy to Clipboard', () => { - test('copies API key to clipboard when copy button is clicked', async () => { - const user = userEvent.setup(); - - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockCreatedKeyResponse), - }) - .mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }); - - const props = createSectionProps(); - renderWithProviders(); - - - await waitFor(() => { - expect(screen.getByRole('button', { name: /Create Key/i })).toBeInTheDocument(); - }); - - await user.click(screen.getByRole('button', { name: /Create Key/i })); - await user.type(screen.getByLabelText(/Key Name/i), 'Test Key'); - await user.click(screen.getByRole('button', { name: /^Create$/i })); + test('create dialog for a bookmarklet key has no external policy', async () => { + const user = userEvent.setup(); + renderWithProviders(); - await waitFor(() => { - expect(screen.getByText(/API Key Created/i)).toBeInTheDocument(); - }); + await user.click(await screen.findByRole('button', { name: 'Create Key' })); - // The API key should be displayed in the dialog - expect(screen.getByText(mockCreatedKeyResponse.key)).toBeInTheDocument(); - }); + expect(screen.queryByText('Request permissions')).not.toBeInTheDocument(); }); - describe('HTTP Warning', () => { - // Note: HTTP warning tests are difficult to mock reliably due to window.location - // The component correctly shows warnings when protocol is http and hostname is not localhost - // These tests verify the default behavior (no warning on localhost) - - test('does not show warning on localhost (default test environment)', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }); - - const props = createSectionProps(); - renderWithProviders(); - - - await waitFor(() => { - expect(screen.getByText(/No API keys yet. Create one to get your Send to Youtarr button/i)).toBeInTheDocument(); - }); - - // Should not show HTTP warning on localhost (default in jsdom) - expect(screen.queryByText(/insecure/i)).not.toBeInTheDocument(); - }); - - test('shows warning on insecure external network', async () => { - setMockLocation('http://192.168.1.10:3000/'); - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }); - - const props = createSectionProps(); - renderWithProviders(); - + test('creates bookmarklet keys without an external policy', async () => { + await createBookmarkletKey(); - await waitFor(() => { - expect(screen.getByText(/insecure/i)).toBeInTheDocument(); - }); - }); + const call = mockFetch.mock.calls.find(([url, init]) => url === '/api/keys' && init?.method === 'POST'); + expect(JSON.parse(call?.[1]?.body as string)).toEqual({ name: 'Bookmarklet' }); }); - describe('Error Handling', () => { - test('shows error when fetching keys fails', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - json: jest.fn().mockResolvedValue({ error: 'Failed to fetch API keys' }), - }); - - const props = createSectionProps(); - renderWithProviders(); - + test('shows the draggable Send to Youtarr button after creating a bookmarklet key', async () => { + const dialog = await createBookmarkletKey(); - await waitFor(() => { - expect(screen.getByText(/Failed to fetch API keys/i)).toBeInTheDocument(); - }); - }); - - test('error alert can be dismissed', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - json: jest.fn().mockResolvedValue({ error: 'Failed to fetch API keys' }), - }); - - const props = createSectionProps(); - renderWithProviders(); - - - await waitFor(() => { - expect(screen.getByText(/Failed to fetch API keys/i)).toBeInTheDocument(); - }); + expect(within(dialog).getByText(/Send to Youtarr/i)).toHaveAttribute('href', expect.stringMatching(/^javascript:/)); + }); - // Close the error alert - const closeButton = screen.getByRole('button', { name: /close/i }); - await user.click(closeButton); + test('shows the phone shortcut details after creating a bookmarklet key', async () => { + const dialog = await createBookmarkletKey(); - await waitFor(() => { - expect(screen.queryByText(/Failed to fetch API keys/i)).not.toBeInTheDocument(); - }); - }); + expect(within(dialog).getByText(/Mobile \/ Shortcuts/)).toBeInTheDocument(); }); - describe('Accessibility', () => { - test('accordion has proper aria attributes', () => { - const props = createSectionProps(); - renderWithProviders(); + test('changes the bookmarklet rate limit', async () => { + const onRateLimitChange = jest.fn(); + const user = userEvent.setup(); + renderWithProviders(); - expect(screen.getByRole('heading', { name: /API Keys & Bookmarklet/i })).toBeInTheDocument(); - }); - - test('Create Key button is accessible', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: [] }), - }); - - const props = createSectionProps(); - renderWithProviders(); + const input = await screen.findByLabelText('Rate Limit (requests/min)'); + await user.tripleClick(input); + await user.keyboard('15'); + expect(onRateLimitChange).toHaveBeenCalled(); + }); + test('revokes an active key after confirmation', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(await screen.findByRole('button', { name: 'Revoke External Client' })); + expect(screen.getByText('Revoke API Key?')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /^Revoke$/ })); + await waitFor(() => expect(mockFetch).toHaveBeenCalledWith( + '/api/keys/7', + expect.objectContaining({ method: 'DELETE' }) + )); + }); - await waitFor(() => { - expect(screen.getByRole('button', { name: /Create Key/i })).toBeInTheDocument(); - }); - }); + test('regenerates a key only after the safeguard dialog and shows it once', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(await screen.findByRole('button', { name: 'Regenerate External Client' })); + expect(screen.getByText('Regenerate API Key?')).toBeInTheDocument(); + expect(screen.getByText(/current key will stop working immediately/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Regenerate Key' })); + + await waitFor(() => expect(mockFetch).toHaveBeenCalledWith( + '/api/keys/7/regenerate', + expect.objectContaining({ method: 'POST' }) + )); + expect(await screen.findByText(/API Key Regenerated/)).toBeInTheDocument(); + expect(screen.getByText(createdKey.key)).toBeInTheDocument(); + }); - test('delete buttons have accessible tooltips', async () => { - const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ keys: mockApiKeys }), - }); - - const props = createSectionProps(); - renderWithProviders(); + test('shows API loading failures and allows dismissing the alert', async () => { + mockFetch.mockRejectedValue(new Error('Failed to fetch API keys')); + const user = userEvent.setup(); + renderWithProviders(); + expect(await screen.findByText('Failed to fetch API keys')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Close' })); + expect(screen.queryByText('Failed to fetch API keys')).not.toBeInTheDocument(); + }); - await waitFor(() => { - const deleteButtons = screen.getAllByRole('button', { name: /Delete/i }); - expect(deleteButtons).toHaveLength(2); - }); - }); + test('does not show the insecure-network warning on localhost', async () => { + renderWithProviders(); + await screen.findByText('External access keys'); + expect(screen.queryByText(/Creating API keys over HTTP is insecure/i)).not.toBeInTheDocument(); }); }); - diff --git a/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.story.tsx b/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.story.tsx index 98857f190..b71720ade 100644 --- a/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.story.tsx +++ b/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.story.tsx @@ -41,7 +41,7 @@ const meta: Meta = { args: { token: 'storybook-token', deploymentEnvironment: { platform: null, isWsl: false }, - isPlatformManaged: { plexUrl: false, authEnabled: true, useTmpForDownloads: false, ytdlpUpdates: false }, + isPlatformManaged: { plexUrl: false, authEnabled: true, useTmpForDownloads: false, ytdlpUpdates: false, externalApiEnabled: false }, onMobileTooltipClick: fn(), }, }; diff --git a/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx b/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx index e561cfd4f..a373baf3e 100644 --- a/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx +++ b/client/src/components/Configuration/sections/__tests__/CoreSettingsSection.test.tsx @@ -77,6 +77,7 @@ const createPlatformManagedState = ( authEnabled: false, useTmpForDownloads: false, ytdlpUpdates: false, + externalApiEnabled: false, ...overrides, }); diff --git a/client/src/components/Configuration/sections/__tests__/PlexIntegrationSection.test.tsx b/client/src/components/Configuration/sections/__tests__/PlexIntegrationSection.test.tsx index 440c312bf..da8f4a449 100644 --- a/client/src/components/Configuration/sections/__tests__/PlexIntegrationSection.test.tsx +++ b/client/src/components/Configuration/sections/__tests__/PlexIntegrationSection.test.tsx @@ -19,6 +19,7 @@ const createPlatformManagedState = ( authEnabled: false, useTmpForDownloads: false, ytdlpUpdates: false, + externalApiEnabled: false, ...overrides, }); diff --git a/client/src/components/Configuration/sections/__tests__/SchedulingSection.story.tsx b/client/src/components/Configuration/sections/__tests__/SchedulingSection.story.tsx index 46a8c25c4..7da51d77c 100644 --- a/client/src/components/Configuration/sections/__tests__/SchedulingSection.story.tsx +++ b/client/src/components/Configuration/sections/__tests__/SchedulingSection.story.tsx @@ -62,7 +62,7 @@ const meta: Meta = { config: DEFAULT_CONFIG, savedConfig: DEFAULT_CONFIG, deploymentEnvironment: { timezone: 'Europe/Paris', isWsl: false }, - isPlatformManaged: { plexUrl: false, authEnabled: true, useTmpForDownloads: false, ytdlpUpdates: false }, + isPlatformManaged: { plexUrl: false, authEnabled: true, useTmpForDownloads: false, ytdlpUpdates: false, externalApiEnabled: false }, fieldErrors: {}, token: 'story-token', }, diff --git a/client/src/components/Configuration/sections/__tests__/SchedulingSection.test.tsx b/client/src/components/Configuration/sections/__tests__/SchedulingSection.test.tsx index 952894922..a2105eeee 100644 --- a/client/src/components/Configuration/sections/__tests__/SchedulingSection.test.tsx +++ b/client/src/components/Configuration/sections/__tests__/SchedulingSection.test.tsx @@ -36,7 +36,7 @@ const axios = require('axios'); const props = { config: DEFAULT_CONFIG, deploymentEnvironment: { timezone: 'Europe/Paris', platform: null, isWsl: false }, - isPlatformManaged: { plexUrl: false, authEnabled: true, useTmpForDownloads: false, ytdlpUpdates: false }, + isPlatformManaged: { plexUrl: false, authEnabled: true, useTmpForDownloads: false, ytdlpUpdates: false, externalApiEnabled: false }, onConfigChange: jest.fn(), fieldErrors: {}, token: 'tok', diff --git a/client/src/components/Configuration/sections/__tests__/YtdlpUpdateSection.test.tsx b/client/src/components/Configuration/sections/__tests__/YtdlpUpdateSection.test.tsx index 401a6bc44..7dc5bb4cf 100644 --- a/client/src/components/Configuration/sections/__tests__/YtdlpUpdateSection.test.tsx +++ b/client/src/components/Configuration/sections/__tests__/YtdlpUpdateSection.test.tsx @@ -28,6 +28,7 @@ const createPlatformManagedState = ( authEnabled: false, useTmpForDownloads: false, ytdlpUpdates: false, + externalApiEnabled: false, ...overrides, }); diff --git a/client/src/components/Configuration/sections/__tests__/useApiKeys.test.ts b/client/src/components/Configuration/sections/__tests__/useApiKeys.test.ts new file mode 100644 index 000000000..1f696ec18 --- /dev/null +++ b/client/src/components/Configuration/sections/__tests__/useApiKeys.test.ts @@ -0,0 +1,105 @@ +import { act, renderHook } from '@testing-library/react'; +import axios from 'axios'; +import { normalizePolicy, ApiKeyPolicy, useApiKeys } from '../ApiKeysSection/useApiKeys'; +import type { ChannelListResponse } from '../../../Subscriptions/hooks/useChannelList'; + +jest.mock('axios', () => ({ + __esModule: true, + default: { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + }, +})); + +const mockedAxios = axios as jest.Mocked; + +const basePolicy: ApiKeyPolicy = { + role: 'view', + allowVideoRequests: false, + allowChannelRequests: false, + allowDeleteVideoRequests: false, + autoApproveVideoRequests: false, + autoApproveChannelRequests: false, + autoApproveDeleteRequests: false, + maxRatingLevel: 3, + allowUnrated: false, + allowedMediaTypes: ['video'], + maxActiveJobs: 5, + hourlyWriteLimit: 30, + dailyWriteLimit: 200, +}; + +describe('normalizePolicy', () => { + test.each([ + ['maxActiveJobs', '0'], + ['hourlyWriteLimit', '31'], + ['dailyWriteLimit', '1.5'], + ['maxActiveJobs', ''], + ] as const)('rejects invalid %s value %s', (field, value) => { + const result = normalizePolicy({ ...basePolicy, [field]: value }); + expect(result.policy).toBeUndefined(); + expect(result.error).toBeTruthy(); + }); + + it('keeps draft strings editable and normalizes valid integer values', () => { + const result = normalizePolicy({ + ...basePolicy, + maxActiveJobs: '4', + hourlyWriteLimit: '12', + dailyWriteLimit: '180', + }); + expect(result.error).toBeUndefined(); + expect(result.policy).toMatchObject({ maxActiveJobs: 4, hourlyWriteLimit: 12, dailyWriteLimit: 180 }); + }); +}); + +describe('useApiKeys', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('loads every channel page and preserves the canonical database_id response shape', async () => { + const firstPage: ChannelListResponse = { + channels: [{ + database_id: 41, + url: 'https://www.youtube.com/channel/UCALPHA', + uploader: 'Alpha', + terminated_at: null, + }], + total: 2, + totalPages: 2, + }; + const secondPage: ChannelListResponse = { + channels: [{ + database_id: 42, + url: 'https://www.youtube.com/channel/UCBETA', + uploader: 'Beta', + terminated_at: null, + }], + total: 2, + totalPages: 2, + }; + mockedAxios.get + .mockResolvedValueOnce({ data: firstPage }) + .mockResolvedValueOnce({ data: secondPage }); + + const { result } = renderHook(() => useApiKeys('token')); + let channels; + await act(async () => { + channels = await result.current.fetchAvailableChannels(); + }); + + expect(channels).toEqual([ + firstPage.channels[0], + secondPage.channels[0], + ]); + expect(mockedAxios.get).toHaveBeenNthCalledWith(1, '/getchannels', expect.objectContaining({ + params: { page: 1, pageSize: 100, sortOrder: 'asc' }, + })); + expect(mockedAxios.get).toHaveBeenNthCalledWith(2, '/getchannels', expect.objectContaining({ + params: { page: 2, pageSize: 100, sortOrder: 'asc' }, + })); + }); +}); diff --git a/client/src/components/Configuration/types.ts b/client/src/components/Configuration/types.ts index b26a04276..ad7d46832 100644 --- a/client/src/components/Configuration/types.ts +++ b/client/src/components/Configuration/types.ts @@ -85,6 +85,7 @@ export interface PlatformManagedState { authEnabled: boolean; useTmpForDownloads: boolean; ytdlpUpdates: boolean; + externalApiEnabled: boolean; } export interface DeploymentEnvironment { diff --git a/client/src/components/ExternalRequests/RequestsPage.stories.tsx b/client/src/components/ExternalRequests/RequestsPage.stories.tsx new file mode 100644 index 000000000..d74fdca9c --- /dev/null +++ b/client/src/components/ExternalRequests/RequestsPage.stories.tsx @@ -0,0 +1,283 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, within } from 'storybook/test'; +import { fireEvent } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { MemoryRouter } from 'react-router-dom'; +import RequestsPage from './RequestsPage'; +import { ExternalRequestReview } from '../../types/externalRequest'; + +const requestId = '9b89e5bc-8c90-4e72-b245-270fed2eacc2'; +const requests: ExternalRequestReview[] = [ + { + id: requestId, + type: 'video', + status: 'pending', + requester: { + id: 4, + name: 'External Client', + keyPrefix: 'client1234', + role: 'request', + isActive: true, + revokedAt: null, + }, + target: { + youtubeId: 'dQw4w9WgXcQ', + channelId: 8, + youtubeChannelId: 'UCuAXFkgsw1L7xaCfnd5JJOw', + channelTitle: 'Rick Astley', + title: 'Rick Astley - Never Gonna Give You Up (Official Video)', + mediaType: 'video', + rating: 'TV-PG', + }, + job: null, + createdAt: '2026-07-27T18:30:00.000Z', + updatedAt: '2026-07-27T18:30:00.000Z', + }, + { + id: '64fd65fd-870b-43b3-818d-8f2a5344e687', + type: 'delete_video', + status: 'approved', + requester: { + id: 4, + name: 'External Client', + keyPrefix: 'client1234', + role: 'delete', + isActive: true, + revokedAt: null, + }, + target: { + youtubeId: 'yPYZpwSpKmA', + channelId: 8, + youtubeChannelId: 'UCuAXFkgsw1L7xaCfnd5JJOw', + channelTitle: 'Rick Astley', + title: 'Rick Astley - Together Forever (Official Video)', + mediaType: 'video', + rating: 'TV-PG', + }, + job: null, + createdAt: '2026-07-27T17:30:00.000Z', + updatedAt: '2026-07-27T17:35:00.000Z', + }, + { + id: '9f365adb-b718-4b53-99c9-cf42485b0367', + type: 'channel', + status: 'rejected', + requester: { + id: 4, + name: 'External Client', + keyPrefix: 'client1234', + role: 'request', + isActive: true, + revokedAt: null, + }, + target: { + youtubeId: null, + channelId: null, + channelUrl: 'https://www.youtube.com/@RickAstleyYT', + youtubeChannelId: null, + channelTitle: null, + title: null, + mediaType: null, + rating: null, + }, + job: null, + createdAt: '2026-07-27T16:30:00.000Z', + updatedAt: '2026-07-27T16:45:00.000Z', + message: 'Channel is outside this profile.', + }, +]; + +const page = { + data: requests, + pagination: { page: 1, pageSize: 25, total: requests.length, totalPages: 1 }, + filterOptions: { requesters: [requests[0].requester] }, +}; + +const pageFor = (data: ExternalRequestReview[]) => ({ + data, + pagination: { page: 1, pageSize: 25, total: data.length, totalPages: data.length ? 1 : 0 }, + filterOptions: { requesters: [requests[0].requester] }, +}); + +const lifecycleStatuses: ExternalRequestReview['status'][] = [ + 'pending', + 'approved', + 'processing', + 'completed', + 'rejected', + 'failed', + 'cancelled', +]; +const lifecycleRequests: ExternalRequestReview[] = lifecycleStatuses.map((status, index) => ({ + ...requests[0], + id: `lifecycle-${index}`, + status, + target: { + ...requests[0].target, + title: `Lifecycle ${status} request`, + }, +})); +const lifecyclePage = pageFor(lifecycleRequests); + +const staleRequestId = '1b89e5bc-8c90-4e72-b245-270fed2eacc2'; +const staleRequest: ExternalRequestReview = { + ...requests[0], + id: staleRequestId, + target: { ...requests[0].target, title: 'Request changed while open' }, +}; +const staleResponse: ExternalRequestReview = { + ...staleRequest, + status: 'completed', + job: { + id: 'stale-job', + status: 'completed', + type: 'External video request', + createdAt: staleRequest.createdAt, + startedAt: staleRequest.createdAt, + }, +}; + +const meta: Meta = { + title: 'Pages/External Requests', + component: RequestsPage, + args: { token: 'storybook-session-token' }, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + layout: 'fullscreen', + a11y: { disable: false }, + msw: { + handlers: [ + http.get('/api/external-requests', () => HttpResponse.json(page)), + http.get(`/api/external-requests/${requestId}`, () => HttpResponse.json(requests[0])), + ], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const DesktopQueue: Story = { + parameters: { + viewport: { defaultViewport: 'desktop' }, + }, +}; + +export const MobileCards: Story = { + parameters: { + viewport: { defaultViewport: 'mobile1' }, + }, +}; + +export const ReviewActions: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('Never Gonna Give You Up', { exact: false })) + .toBeInTheDocument(); + const requestCard = within(await canvas.findByTestId(`request-card-${requestId}`)); + await userEvent.click(await requestCard.findByRole('button', { name: 'Details' })); + const pageBody = within(canvasElement.ownerDocument.body); + await expect(await pageBody.findByText('Request details')).toBeInTheDocument(); + await userEvent.click(await pageBody.findByRole('button', { name: 'Approve' })); + await expect(await pageBody.findByText('Confirm approval')).toBeInTheDocument(); + }, +}; + +export const LifecycleStates: Story = { + parameters: { + msw: { + handlers: [ + http.get('/api/external-requests', () => HttpResponse.json(lifecyclePage)), + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + for (const status of ['Pending', 'Approved', 'Processing', 'Completed', 'Rejected', 'Failed', 'Cancelled']) { + await expect(await canvas.findByText(status, { exact: true })).toBeInTheDocument(); + } + }, +}; + +export const QueueErrorRetry: Story = { + parameters: { + msw: { + handlers: [ + http.get('/api/external-requests', () => HttpResponse.json( + { error: 'Request service unavailable' }, + { status: 503 } + )), + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText('Request service unavailable')).toBeInTheDocument(); + await expect(await canvas.findByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }, +}; + +export const StaleDetail: Story = { + parameters: { + msw: { + handlers: [ + http.get('/api/external-requests', () => HttpResponse.json(pageFor([staleRequest]))), + http.get(`/api/external-requests/${staleRequestId}`, () => HttpResponse.json(staleResponse)), + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const requestCard = within(await canvas.findByTestId(`request-card-${staleRequestId}`)); + await userEvent.click(await requestCard.findByRole('button', { name: 'Details' })); + const pageBody = within(canvasElement.ownerDocument.body); + await expect(await pageBody.findByText('Completed', { exact: true })).toBeInTheDocument(); + await expect(pageBody.queryByRole('button', { name: 'Approve' })).not.toBeInTheDocument(); + }, +}; + +export const ThumbnailFailure: Story = { + parameters: { + msw: { + handlers: [ + http.get('/api/external-requests', () => HttpResponse.json(pageFor([requests[0]]))), + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const image = await canvas.findByRole('img', { name: /Rick Astley.*thumbnail/ }); + await fireEvent.error(image); + await expect(await canvas.findByTestId('request-thumbnail-fallback')).toBeInTheDocument(); + }, +}; + +export const ApprovalActionError: Story = { + parameters: { + msw: { + handlers: [ + http.get('/api/external-requests', () => HttpResponse.json(pageFor([requests[0]]))), + http.get(`/api/external-requests/${requestId}`, () => HttpResponse.json(requests[0])), + http.post(`/api/external-requests/${requestId}/approve`, () => HttpResponse.json( + { error: 'Request is no longer pending.' }, + { status: 409 } + )), + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const requestCard = within(await canvas.findByTestId(`request-card-${requestId}`)); + await userEvent.click(await requestCard.findByRole('button', { name: 'Details' })); + const pageBody = within(canvasElement.ownerDocument.body); + await userEvent.click(await pageBody.findByRole('button', { name: 'Approve' })); + await userEvent.click(await pageBody.findByRole('button', { name: 'Confirm approval' })); + await expect(await pageBody.findByText('Request is no longer pending.')).toBeInTheDocument(); + }, +}; diff --git a/client/src/components/ExternalRequests/RequestsPage.tsx b/client/src/components/ExternalRequests/RequestsPage.tsx new file mode 100644 index 000000000..226818a03 --- /dev/null +++ b/client/src/components/ExternalRequests/RequestsPage.tsx @@ -0,0 +1,279 @@ +import React from 'react'; +import { useMediaQuery } from '../../hooks/useMediaQuery'; +import { Key } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { + Alert, + Button, + Card, + CardContent, + CircularProgress, + MenuItem, + Select, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '../ui'; +import PageControls from '../shared/PageControls'; +import { + ExternalRequestReview, + ExternalRequestStatus, +} from '../../types/externalRequest'; +import RequestActions from './components/RequestActions'; +import RequestActionDialog from './components/RequestActionDialog'; +import RequestDetailsDialog from './components/RequestDetailsDialog'; +import RequestTarget, { RequestStatus } from './components/RequestTarget'; +import { useExternalRequests } from './hooks/useExternalRequests'; +import { requestDateLabel as dateLabel, requestStatusLabel } from './requestPolicy'; + +const STATUSES: ExternalRequestStatus[] = [ + 'pending', + 'approved', + 'processing', + 'completed', + 'rejected', + 'failed', + 'cancelled', +]; + +interface RequestsPageProps { + token: string; +} + +const RequestsPage: React.FC = ({ token }) => { + const compact = useMediaQuery('(max-width: 767px)'); + const state = useExternalRequests(token); + const { + filters, + requests, + requesters, + loading, + error, + totalPages, + page, + selected, + action, + detailLoading, + reason, + actionError, + submitting, + grantToRequestingKey, + } = state; + const controls = (request: ExternalRequestReview) => ( + void state.openDetail(request)} + onAction={(next) => state.beginInlineAction(request, next)} + /> + ); + return ( +
+
+
+ External Requests + + Review video, channel, and deletion requests submitted by approved + external clients. + +
+ +
+ + +
+
+ + Status + + +
+
+ + Requester + + +
+
+ + Type + + +
+ +
+ {loading && ( +
+ + Loading requests... +
+ )} + {!loading && error && ( + +
+ {error} + +
+
+ )} + {!loading && !error && requests.length === 0 && ( +
+ + No requests match these filters. + +
+ )} + {!loading && !error && requests.length > 0 && (compact ? ( +
+ {requests.map(request => ( +
+
+ +
{controls(request)}
+
+
+ +
+
+

{request.requester?.name || 'Unavailable key'}

+

{dateLabel(request.createdAt)}

+
+
+ ))} +
+ ) : ( + + + + + Status + Request + Requester + Submitted + + Review + + + + + {requests.map((request) => ( + + + + + +
+ +
+
+ + {request.requester?.name || 'Unavailable key'} + + + {dateLabel(request.createdAt)} + + {controls(request)} +
+ ))} +
+
+
+ ))} + {!loading && !error && totalPages > 1 && ( + + )} +
+
+ {selected && action === null && ( + + )} + {selected && action !== null && ( + void state.submitAction()} + /> + )} +
+ ); +}; +export default RequestsPage; diff --git a/client/src/components/ExternalRequests/__tests__/RequestsPage.test.tsx b/client/src/components/ExternalRequests/__tests__/RequestsPage.test.tsx new file mode 100644 index 000000000..4fcd3ce86 --- /dev/null +++ b/client/src/components/ExternalRequests/__tests__/RequestsPage.test.tsx @@ -0,0 +1,358 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import axios from 'axios'; +import RequestsPage from '../RequestsPage'; +import { useMediaQuery } from '../../../hooks/useMediaQuery'; + +jest.mock('../../../hooks/useMediaQuery', () => ({ useMediaQuery: jest.fn(() => false) })); +import { ExternalRequestReview } from '../../../types/externalRequest'; + +jest.mock('axios', () => ({ + get: jest.fn(), + post: jest.fn(), + isAxiosError: (error: unknown) => Boolean((error as { response?: unknown })?.response), + isCancel: () => false, +})); + +const mockedAxios = axios as jest.Mocked; + +const requestId = '9b89e5bc-8c90-4e72-b245-270fed2eacc2'; +const pendingRequest = { + id: requestId, + type: 'video' as const, + status: 'pending' as const, + requester: { + id: 4, + name: 'External Client', + keyPrefix: 'abcd1234', + role: 'request', + isActive: true, + revokedAt: null, + }, + target: { + youtubeId: 'abcdefghijk', + channelId: 8, + youtubeChannelId: 'UC1234567890123456789012', + channelTitle: 'Safe Channel', + title: 'Safe video', + mediaType: 'video', + rating: 'TV-PG', + contentRating: 'TV-Y', + }, + job: null, + createdAt: '2026-07-26T12:00:00.000Z', + updatedAt: '2026-07-26T12:00:00.000Z', +}; + +const page = (data: ExternalRequestReview[] = [pendingRequest]) => ({ + data, + pagination: { page: 1, pageSize: 25, total: data.length, totalPages: data.length ? 1 : 0 }, + filterOptions: { requesters: [pendingRequest.requester] }, +}); + +const axiosResponse = (data: T) => ({ data }); +const axiosError = (message: string) => Object.assign(new Error(message), { + response: { data: { error: message } }, +}); + +const renderPage = () => render( + + + +); + +describe('RequestsPage', () => { + beforeEach(() => { + (useMediaQuery as jest.Mock).mockReturnValue(false); + mockedAxios.get.mockReset(); + mockedAxios.post.mockReset(); + }); + + test('loads the queue, opens details, confirms approval, and refreshes status', async () => { + const processing = { + ...pendingRequest, + status: 'processing' as const, + job: { + id: requestId, + status: 'In Progress', + type: 'External video request', + createdAt: pendingRequest.createdAt, + startedAt: pendingRequest.createdAt, + }, + }; + mockedAxios.get + .mockResolvedValueOnce(axiosResponse(page())) + .mockResolvedValueOnce(axiosResponse(pendingRequest)) + .mockResolvedValueOnce(axiosResponse(page([processing]))); + mockedAxios.post.mockResolvedValueOnce(axiosResponse(processing)); + + const user = userEvent.setup(); + renderPage(); + + expect(await screen.findByText('Safe video')).toBeInTheDocument(); + expect(screen.getByText('External Client')).toBeInTheDocument(); + expect(screen.getByText('Pending')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Manage API keys' })) + .toHaveAttribute('href', '/settings/api-keys'); + expect(screen.getByRole('img', { name: 'Safe video thumbnail' })) + .toHaveAttribute('src', 'https://i.ytimg.com/vi/abcdefghijk/mqdefault.jpg'); + await user.click(screen.getByRole('button', { name: 'Details' })); + expect(await screen.findByText('Request details')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Approve' })); + await user.click(screen.getByRole('button', { name: 'Confirm approval' })); + + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith( + `/api/external-requests/${requestId}/approve`, + {}, + expect.objectContaining({ + headers: { + 'x-access-token': 'session-token', + }, + }) + ); + }); + await waitFor(() => { + expect(mockedAxios.get).toHaveBeenCalledTimes(3); + }); + expect(mockedAxios.get).toHaveBeenLastCalledWith( + "/api/external-requests", + expect.objectContaining({ headers: { "x-access-token": "session-token" }, params: expect.objectContaining({ page: 1, pageSize: 25 }) }) + ); + }); + + test('shows empty and retryable error states', async () => { + mockedAxios.get + .mockRejectedValueOnce(axiosError('Unable to read request queue')) + .mockResolvedValueOnce(axiosResponse(page([]))); + + const user = userEvent.setup(); + renderPage(); + + expect(await screen.findByText('Unable to read request queue')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Retry' })); + expect(await screen.findByText('No requests match these filters.')).toBeInTheDocument(); + }); + + test('requires a rejection reason before submission', async () => { + mockedAxios.get + .mockResolvedValueOnce(axiosResponse(page())) + .mockResolvedValueOnce(axiosResponse(pendingRequest)); + + const user = userEvent.setup(); + renderPage(); + await user.click(await screen.findByRole('button', { name: 'Details' })); + await user.click(await screen.findByRole('button', { name: 'Reject' })); + + const confirm = screen.getByRole('button', { name: 'Confirm rejection' }); + expect(confirm).toBeDisabled(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Not approved' } }); + expect(confirm).toBeEnabled(); + }); + + test('reviews channel requests and sends the grant decision', async () => { + const channelRequest: ExternalRequestReview = { + ...pendingRequest, + type: 'channel', + target: { + youtubeId: null, + channelId: null, + channelUrl: 'https://www.youtube.com/@safechannel', + youtubeChannelId: null, + channelTitle: null, + title: null, + mediaType: null, + rating: null, + }, + }; + const completed = { + ...channelRequest, + status: 'completed' as const, + target: { + ...channelRequest.target, + channelId: 12, + youtubeChannelId: 'UC1234567890123456789012', + channelTitle: 'Safe Channel', + rating: 'TV-PG', + }, + }; + mockedAxios.get + .mockResolvedValueOnce(axiosResponse(page([channelRequest]))) + .mockResolvedValueOnce(axiosResponse(channelRequest)) + .mockResolvedValueOnce(axiosResponse(page([completed]))); + mockedAxios.post.mockResolvedValueOnce(axiosResponse(completed)); + + const user = userEvent.setup(); + renderPage(); + + await user.click(await screen.findByRole('button', { name: 'Details' })); + expect(screen.getAllByText('@safechannel').length).toBeGreaterThan(0); + expect(screen.getByText('New channel request')).toBeInTheDocument(); + expect(screen.queryByText('Channel null')).not.toBeInTheDocument(); + expect(screen.queryByText('https://www.youtube.com/@safechannel')).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Open channel' })) + .toHaveAttribute('href', 'https://www.youtube.com/@safechannel'); + await user.click(screen.getByRole('button', { name: 'Approve' })); + await user.click(screen.getByLabelText( + 'Grant the provisioned channel to the requesting key' + )); + await user.click(screen.getByRole('button', { name: 'Confirm approval' })); + + await waitFor(() => expect(mockedAxios.post).toHaveBeenCalledWith( + `/api/external-requests/${requestId}/approve`, + { grantToRequestingKey: false }, + expect.objectContaining({ + headers: { 'x-access-token': 'session-token' }, + }) + )); + }); + + test('renders compact request metadata and starts approval from the review column', async () => { + mockedAxios.get + .mockResolvedValueOnce(axiosResponse(page())) + .mockResolvedValueOnce(axiosResponse(page([{ ...pendingRequest, status: 'processing' }]))); + mockedAxios.post.mockResolvedValueOnce(axiosResponse({ ...pendingRequest, status: 'processing' })); + + const user = userEvent.setup(); + renderPage(); + + await screen.findByText('Safe video'); + const requestCell = screen.getByTestId(`request-summary-${requestId}`); + const cell = within(requestCell); + expect(cell.getAllByText('TV-Y').length).toBeGreaterThan(0); + expect(cell.queryByText("Downloaded")).not.toBeInTheDocument(); + + expect(screen.getByRole('button', { name: 'Details' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Approve request' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Reject request' })).toBeEnabled(); + + await user.click(screen.getByRole('button', { name: 'Approve request' })); + expect(screen.getByRole('button', { name: 'Confirm approval' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Confirm approval' })); + + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith( + `/api/external-requests/${requestId}/approve`, + {}, + expect.objectContaining({ headers: { 'x-access-token': 'session-token' } }) + ); + }); + }); + + test('keeps review controls aligned but disables decisions for a completed request', async () => { + const completed = { ...pendingRequest, status: 'completed' as const }; + mockedAxios.get.mockResolvedValueOnce(axiosResponse(page([completed]))); + + renderPage(); + + expect(await screen.findByRole('button', { name: 'Details' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Approve request' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Reject request' })).toBeDisabled(); + }); + + test('ignores an obsolete details response after starting an inline action', async () => { + const other = { + ...pendingRequest, + id: '8b89e5bc-8c90-4e72-b245-270fed2eacc2', + target: { ...pendingRequest.target, title: 'Other video' }, + }; + let resolveDetails!: (response: { data: ExternalRequestReview }) => void; + const deferredDetails = new Promise<{ data: ExternalRequestReview }>((resolve) => { + resolveDetails = resolve; + }); + mockedAxios.get + .mockResolvedValueOnce(axiosResponse(page([pendingRequest, other]))) + .mockImplementationOnce(() => deferredDetails) + .mockResolvedValue(axiosResponse(page([other]))); + mockedAxios.post.mockResolvedValueOnce(axiosResponse({ ...other, status: 'approved' })); + + const user = userEvent.setup(); + renderPage(); + await screen.findByText('Other video'); + await user.click(within(screen.getByTestId(`request-card-${requestId}`)).getByRole('button', { name: 'Details' })); + await user.click(within(screen.getByRole('dialog')).getByText('Close', { selector: 'button' })); + await user.click(within(screen.getByTestId(`request-card-${other.id}`)).getByRole('button', { name: 'Approve request' })); + resolveDetails(axiosResponse(pendingRequest)); + await user.click(screen.getByRole('button', { name: 'Confirm approval' })); + + await waitFor(() => expect(mockedAxios.post).toHaveBeenCalledWith( + `/api/external-requests/${other.id}/approve`, {}, expect.anything() + )); + }); + test('does not offer another approval for a request already approved', async () => { + const approved: ExternalRequestReview = { ...pendingRequest, status: 'approved' }; + mockedAxios.get.mockResolvedValue(axiosResponse(page([approved]))); + renderPage(); + + expect(await screen.findByRole('button', { name: 'Approve request' })).toBeDisabled(); + }); + + test('refreshes the queue on its own while a request is still being worked on', async () => { + jest.useFakeTimers(); + try { + const approved: ExternalRequestReview = { ...pendingRequest, status: 'approved' }; + mockedAxios.get + .mockResolvedValueOnce(axiosResponse(page([approved]))) + .mockResolvedValue(axiosResponse(page([{ ...approved, status: 'completed', updatedAt: '2026-07-26T12:05:00.000Z' }]))); + renderPage(); + await screen.findByText('Approved'); + + await act(async () => { jest.advanceTimersByTime(5000); }); + + expect(await screen.findByText('Completed')).toBeInTheDocument(); + } finally { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + } + }); + + test('stops refreshing once nothing is in flight', async () => { + jest.useFakeTimers(); + try { + mockedAxios.get.mockResolvedValue(axiosResponse(page())); + renderPage(); + await screen.findByText('Safe video'); + + await act(async () => { jest.advanceTimersByTime(15000); }); + + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + } finally { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + } + }); + + test('shows who approved a request in its details', async () => { + const approved: ExternalRequestReview = { ...pendingRequest, status: 'completed', approvalSource: 'auto' }; + mockedAxios.get.mockResolvedValueOnce(axiosResponse(page([approved]))).mockResolvedValueOnce(axiosResponse(approved)); + const user = userEvent.setup(); + renderPage(); + + await user.click(await screen.findByRole('button', { name: 'Details' })); + + expect(await screen.findByText('Automatically, by the key policy')).toBeInTheDocument(); + }); + + test('does not offer decisions using stale details after a permission failure', async () => { + mockedAxios.get.mockResolvedValueOnce(axiosResponse(page())) + .mockRejectedValueOnce({ response: { status: 403 } }); + const user = userEvent.setup(); + renderPage(); + await user.click(await screen.findByRole('button', { name: 'Details' })); + expect(await screen.findByText('Your session cannot review this request.')).toBeInTheDocument(); + expect(within(screen.getByRole('dialog')).queryByRole('button', { name: 'Approve' })).not.toBeInTheDocument(); + }); + + test('keeps request actions in a compact card on mobile', async () => { + (useMediaQuery as jest.Mock).mockReturnValue(true); + mockedAxios.get.mockResolvedValueOnce(axiosResponse(page())); + renderPage(); + const card = await screen.findByRole('article'); + expect(within(card).getByRole('button', { name: 'Approve request' })).toBeEnabled(); + expect(within(card).getByText('External Client')).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + }); + +}); diff --git a/client/src/components/ExternalRequests/components/RequestActionDialog.tsx b/client/src/components/ExternalRequests/components/RequestActionDialog.tsx new file mode 100644 index 000000000..b4103669a --- /dev/null +++ b/client/src/components/ExternalRequests/components/RequestActionDialog.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { + Alert, + Button, + Checkbox, + Dialog, + DialogActions, + DialogContentBody, + DialogContentText, + DialogTitle, + FormControlLabel, + TextField, +} from '../../ui'; +import { ExternalRequestReview } from '../../../types/externalRequest'; +import { ReviewAction } from '../requestPolicy'; + +export default function RequestActionDialog({ + request, + action, + reason, + setReason, + grant, + setGrant, + error, + submitting, + onCancel, + onSubmit, +}: { + request: ExternalRequestReview; + action: ReviewAction; + reason: string; + setReason: (value: string) => void; + grant: boolean; + setGrant: (value: boolean) => void; + error: string | null; + submitting: boolean; + onCancel: () => void; + onSubmit: () => void; +}) { + return ( + + + Confirm {action === 'approve' ? 'approval' : 'rejection'} + + + + {action === 'approve' + ? request.type === 'channel' + ? 'Youtarr rechecks the requester now, then adds the channel in the background and optionally grants it to that key.' + : request.type === 'delete_video' + ? 'Youtarr rechecks policy now, then deletes the local video files in the background.' + : 'Youtarr rechecks policy now, then downloads the video in the background.' + : 'Rejecting this request is final. The client may submit a new request later.'} + + {action === 'reject' && ( + setReason(event.target.value)} + inputProps={{ maxLength: 300 }} + helperText={`${reason.trim().length}/300 characters`} + multiline + rows={3} + fullWidth + required + /> + )} + {action === 'approve' && request.type === 'channel' && ( + setGrant(event.target.checked)} + /> + } + label="Grant the provisioned channel to the requesting key" + /> + )} + {action === 'approve' && ( + The status on this page updates as the work runs. + )} + {error && {error}} + + + + + + + ); +} diff --git a/client/src/components/ExternalRequests/components/RequestActions.tsx b/client/src/components/ExternalRequests/components/RequestActions.tsx new file mode 100644 index 000000000..cf679180e --- /dev/null +++ b/client/src/components/ExternalRequests/components/RequestActions.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Info, ThumbsDown, ThumbsUp } from 'lucide-react'; +import { IconButton } from '../../ui'; +import { ExternalRequestReview } from '../../../types/externalRequest'; +import { + canApproveRequest, + canRejectRequest, + ReviewAction, +} from '../requestPolicy'; +interface Props { + request: ExternalRequestReview; + submitting: boolean; + onDetails: () => void; + onAction: (action: ReviewAction) => void; +} +export default function RequestActions({ + request, + submitting, + onDetails, + onAction, +}: Props) { + const approveLabel = 'Approve request'; + return ( +
+ + + + onAction('approve')} + > + + + onAction('reject')} + > + + +
+ ); +} diff --git a/client/src/components/ExternalRequests/components/RequestDetailsDialog.tsx b/client/src/components/ExternalRequests/components/RequestDetailsDialog.tsx new file mode 100644 index 000000000..c97d2dbe6 --- /dev/null +++ b/client/src/components/ExternalRequests/components/RequestDetailsDialog.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { ExternalLink } from 'lucide-react'; +import { + Alert, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContentBody, + DialogTitle, +} from '../../ui'; +import { ExternalRequestReview } from '../../../types/externalRequest'; +import RequestTarget, { + requestChannelLabel, + RequestStatus, +} from './RequestTarget'; +import { + canApproveRequest, + canRejectRequest, + requestDateLabel as dateLabel, + ReviewAction, +} from '../requestPolicy'; + +export default function RequestDetailsDialog({ + request, + loading, + error, + onClose, + onAction, +}: { + request: ExternalRequestReview; + loading: boolean; + error: string | null; + onClose: () => void; + onAction: (action: ReviewAction) => void; +}) { + return ( + + Request details + + {loading ? ( +
+ +
+ ) : ( +
+ {error && {error}} + +
+ +
+
+ {request.target.youtubeId && ( + <> +
YouTube ID
+
{request.target.youtubeId}
+ + )} + {request.target.channelUrl && ( + <> +
YouTube channel
+
+ + Open channel + +
+ + )} +
Channel
+
{requestChannelLabel(request)}
+
Requester
+
+ {request.requester + ? `${request.requester.name} (${request.requester.keyPrefix})` + : 'Unavailable key'} +
+ {request.approvalSource && ( + <> +
Approved
+
{request.approvalSource === 'auto' ? 'Automatically, by the key policy' : 'By an administrator'}
+ + )} +
Submitted
+
{dateLabel(request.createdAt)}
+
Last updated
+
{dateLabel(request.updatedAt)}
+ {request.job && ( + <> +
Download job
+
+ {request.job.status} - {request.job.id} +
+ + )} + {request.message && ( + <> +
Message
+
{request.message}
+ + )} +
+
+ )} +
+ + + {!loading && + !error && + (canApproveRequest(request) || canRejectRequest(request)) && ( + <> + + + + )} + +
+ ); +} diff --git a/client/src/components/ExternalRequests/components/RequestTarget.tsx b/client/src/components/ExternalRequests/components/RequestTarget.tsx new file mode 100644 index 000000000..9fcdae046 --- /dev/null +++ b/client/src/components/ExternalRequests/components/RequestTarget.tsx @@ -0,0 +1,132 @@ +import React, { useState } from 'react'; +import { ExternalLink, Radio, Trash2, Video } from 'lucide-react'; +import { Avatar, Chip } from '../../ui'; +import RatingBadge from '../../shared/RatingBadge'; +import { ExternalRequestReview, ExternalRequestStatus } from '../../../types/externalRequest'; +import { requestStatusLabel } from '../requestPolicy'; + +type ChipColor = 'success' | 'error' | 'info' | 'warning'; + +const TYPE_LABELS: Record = { + video: 'Video download', + channel: 'Channel', + delete_video: 'Video deletion', +}; + +const STATUS_COLORS: Record = { + pending: 'warning', + approved: 'info', + processing: 'info', + completed: 'success', + rejected: 'error', + failed: 'error', + cancelled: 'error', +}; + +export const requestChannelLabel = (request: ExternalRequestReview) => { + const { channelTitle, youtubeChannelId } = request.target; + if (channelTitle) return channelTitle; + if (youtubeChannelId) return youtubeChannelId; + return request.type === 'channel' ? 'New channel request' : 'Unknown channel'; +}; + +const targetLabel = (request: ExternalRequestReview) => { + const { title, channelTitle, channelUrl, youtubeId } = request.target; + if (title) return title; + if (request.type === 'channel') { + if (channelTitle) return channelTitle; + return channelUrl?.split('/').filter(Boolean).pop() || 'Requested YouTube channel'; + } + return youtubeId ? `YouTube video ${youtubeId}` : 'Request target'; +}; + +const targetUrl = (request: ExternalRequestReview) => { + if (request.type === 'channel') return request.target.channelUrl || null; + return request.target.youtubeId ? `https://www.youtube.com/watch?v=${request.target.youtubeId}` : null; +}; + +const TypeIcon = ({ type }: { type: ExternalRequestReview['type'] }) => { + if (type === 'delete_video') return ; + if (type === 'channel') return ; + return