diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 7c4c79d3c..733e21eb9 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -32,4 +32,4 @@ jobs: run: | git diff --name-only --diff-filter=ACMR -z "origin/${BASE_REF}...HEAD" -- \ '*.css' '*.js' '*.json' '*.jsonc' '*.jsx' '*.md' '*.mdx' '*.scss' '*.ts' '*.tsx' '*.yaml' '*.yml' \ - | xargs -0 -r pnpm exec oxfmt --check + | xargs -0 -r pnpm exec oxfmt --check -- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79452a31e..57745bd94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: types: [closed] branches: ["*"] push: - branches: [main, dev, beta, next] + branches: [main, dev, beta, next, v2] merge_group: workflow_dispatch: inputs: @@ -34,8 +34,10 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }} - cancel-in-progress: false + # Every event for a PR shares one group so a close event cancels stale image + # publication and a newer synchronize run cannot be overwritten by an older run. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' || github.ref != 'refs/heads/main' }} env: FORCE_COLOR: 3 @@ -87,6 +89,17 @@ jobs: - name: Validate workspace configuration run: pnpm lint:ws + - name: Validate Custom Widget architecture + run: pnpm check:custom-widgets + + - name: Validate checked-in OpenAPI schema + run: pnpm check:openapi + + - name: Validate Custom Widget bundle budget + run: | + pnpm --filter nextjs build + pnpm check:custom-widget-bundle + - name: Run unit tests run: pnpm test @@ -229,7 +242,7 @@ jobs: needs: fast-gate if: | ( - (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') ) runs-on: ubuntu-24.04-arm @@ -286,7 +299,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 permissions: + contents: read packages: write + pull-requests: read env: PREVIEW_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }} steps: @@ -302,6 +317,26 @@ jobs: if: "!env.ACT" uses: docker/setup-buildx-action@v4 + - name: Verify PR head before publication + if: github.event_name == 'pull_request' + uses: actions/github-script@v8 + env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + with: + script: | + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + }); + if (pull.state !== "open") { + core.setFailed(`PR #${pull.number} is ${pull.state}; refusing to publish its preview.`); + return; + } + if (pull.head.sha !== process.env.EXPECTED_HEAD_SHA) { + core.setFailed(`PR #${pull.number} moved from ${process.env.EXPECTED_HEAD_SHA} to ${pull.head.sha}.`); + } + - name: Publish preview manifest if: "!env.ACT" env: diff --git a/.github/workflows/workshop.yml b/.github/workflows/workshop.yml index b1a414d07..def34e9c7 100644 --- a/.github/workflows/workshop.yml +++ b/.github/workflows/workshop.yml @@ -2,22 +2,8 @@ name: Workshop on: pull_request: - paths: - - "apps/workshop/**" - - "apps/docs/**" - - "packages/workshop/**" - - "packages/custom-widgets/**" - - "pnpm-lock.yaml" - - ".github/workflows/workshop.yml" push: branches: [dev, v2] - paths: - - "apps/workshop/**" - - "apps/docs/**" - - "packages/workshop/**" - - "packages/custom-widgets/**" - - "pnpm-lock.yaml" - - ".github/workflows/workshop.yml" permissions: contents: read diff --git a/apps/docs/docs/management/custom-widgets/component-reference.mdx b/apps/docs/docs/management/custom-widgets/component-reference.mdx new file mode 100644 index 000000000..15adc109a --- /dev/null +++ b/apps/docs/docs/management/custom-widgets/component-reference.mdx @@ -0,0 +1,13 @@ +--- +title: Component reference +sidebar_position: 3 +tags: [Custom Widgets, JSX, Mantine, Components] +--- + +import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-component-reference"; + +# Custom JSX component reference + +Search the components and properties exposed by Homarr's safe Custom JSX runtime. The live schema and diagnostics remain authoritative: properties marked as blocked cannot be passed from widget code, and denied components are shown only for explanation. + + diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index 31b4fe9a1..d58b73162 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -53,13 +53,14 @@ The browser renders JSX. Homarr's server executes named requests, applies board - [Create a Custom Widget](./authoring) visually or with one copied AI prompt. - [Use the JSX runtime](./custom-jsx) for data, Mantine components, temporary inputs, and diagnostics. +- [Browse the supported component reference](./component-reference). - [Configure requests and security](./requests-and-security). - [Use MCP and the skills.sh skill](./agent-authoring). - [Correct problems](./troubleshooting). -The workbench keeps one continuous layout with visual source, request, and option editors. Advanced raw JSON remains available for experts and AI paste. Invalid values stay inline and only disable the widget preview. +The workbench keeps one continuous layout with visual source, request, and option editors. Advanced raw JSON remains available for experts and AI paste. Invalid values stay inline and disable both preview and Save/Create until they are corrected. -Exports, imports, Workshop, MCP, and storage all validate the same schema. Old formats are rejected. A missing definition only renders **Custom widget unavailable** inside its tile. +Exports, imports, Workshop, MCP, and storage all validate the same schema. Widgets saved in the legacy format remain visible with a **Migration required** badge after upgrading. Use **Copy migration prompt**, give the redacted prompt to an LLM, then import its v2 JSON from that widget's action menu. Homarr keeps the legacy record until the replacement validates and is confirmed; credentials are never included in the prompt. When adding widgets while editing a board, the Custom Widgets section always includes **Install from Workshop**. The Workshop and API source review open above the widget picker without closing it. After import, the new definition appears in the picker. Its tile waits for the board to be saved before server requests run and loads automatically after a successful save; no page reload is required. diff --git a/apps/docs/docs/management/custom-widgets/troubleshooting.mdx b/apps/docs/docs/management/custom-widgets/troubleshooting.mdx index 20b216afe..7142711c0 100644 --- a/apps/docs/docs/management/custom-widgets/troubleshooting.mdx +++ b/apps/docs/docs/management/custom-widgets/troubleshooting.mdx @@ -30,7 +30,7 @@ An incomplete API source URL or icon URL causes a form validation error. The wor The workbench marks the invalid field while you type. An invalid URL does not replace the form with the error boundary. -An invalid definition disables only the widget preview. The Data, Options, Actions, and Diagnostics tabs remain available. +An invalid definition disables widget preview and Save/Create. The Data, Options, Actions, and Diagnostics tabs remain available so you can correct it. The Diagnostics tab shows the current schema errors. diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx index 37adbff22..887430e1e 100644 --- a/apps/docs/docs/management/workshop/index.mdx +++ b/apps/docs/docs/management/workshop/index.mdx @@ -80,7 +80,7 @@ Because Workshop is still unreleased, reset an older development volume if it pr GitHub needs only the central callback `https:///api/oauth2-redirect`. Keep PocketBase origins unrestricted unless every self-hosted Homarr caller is explicitly listed. Configure `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `WORKSHOP_PUBLIC_ORIGIN`, and PocketBase SMTP settings for authentication, comment email, report alerts, and submission-removal notices. -Homarr instances use `NEXT_PUBLIC_WORKSHOP_API_URL` to reach the central API and `NEXT_PUBLIC_WORKSHOP_URL` for links to the public page. Docusaurus uses `WORKSHOP_URL` only when its development server runs separately; the combined production image always uses its own origin. +Homarr instances use the server-side `WORKSHOP_API_URL` variable to reach the central API. The Docusaurus site uses `WORKSHOP_URL` when its development server runs separately; the combined production image always uses its own origin. Restart the corresponding process after changing either value because both are read at startup. ## Client architecture diff --git a/apps/docs/src/components/workshop/DetailPage.tsx b/apps/docs/src/components/workshop/DetailPage.tsx index 27acfd749..452534ddc 100644 --- a/apps/docs/src/components/workshop/DetailPage.tsx +++ b/apps/docs/src/components/workshop/DetailPage.tsx @@ -61,7 +61,7 @@ import { CodeBlock, DeleteConfirmButton, DetailSkeleton, ScreenshotGallery } fro import { formatRelativeTime } from "./format"; import { ScreenshotEditor } from "./ScreenshotEditor"; import { WorkshopErrorBoundary } from "./WorkshopErrorBoundary"; -import { downloadSubmissionJson, voteDelta } from "./workshop-utils"; +import { downloadSubmissionJson, voteAndReconcile } from "./workshop-utils"; const typeLabels: Record = { customCss: "CSS", customWidget: "Widget" }; const typeDotColors: Record = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" }; @@ -305,28 +305,16 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => { if (!submission || voting.current) return; voting.current = true; - const userId = await requireUserId("vote"); - if (!userId) { + if (!(await requireUserId("vote"))) { voting.current = false; return; } - const prev = userVote; - const isToggleOff = prev?.value === value; - const [upD, downD] = voteDelta(prev?.value, value); - - setUserVote( - isToggleOff - ? undefined - : ({ ...(prev ?? { id: "", submission: submission.id, user: userId }), value } as WorkshopVote), - ); - setSubmission((s) => (s ? { ...s, upvotes: s.upvotes + upD, downvotes: s.downvotes + downD } : s)); - try { - setUserVote((await backend.vote(submission.id, value)) ?? undefined); + const reconciled = await voteAndReconcile(backend, submission.id, value); + setSubmission(reconciled.submission); + setUserVote(reconciled.votes.find((vote) => vote.submission === submission.id)); } catch (caught) { - setUserVote(prev); - setSubmission((s) => (s ? { ...s, upvotes: s.upvotes - upD, downvotes: s.downvotes - downD } : s)); setError(errorMessage(caught, "Failed to register your vote")); } finally { voting.current = false; diff --git a/apps/docs/src/components/workshop/DetailSections.spec.tsx b/apps/docs/src/components/workshop/DetailSections.spec.tsx new file mode 100644 index 000000000..df0dea378 --- /dev/null +++ b/apps/docs/src/components/workshop/DetailSections.spec.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ScreenshotGallery } from "./DetailSections"; + +vi.mock("@docusaurus/theme-common", () => ({ useColorMode: () => ({ colorMode: "light" }) })); + +let host: HTMLDivElement; +let root: Root; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(() => root.unmount()); + host.remove(); +}); + +async function render(urls: string[]) { + await act(async () => root.render()); +} + +describe("ScreenshotGallery", () => { + it("resets an out-of-range selection when screenshots shrink", async () => { + await render(["/one.png", "/two.png", "/three.png"]); + const thirdThumbnail = host.querySelector('button[aria-label="Show screenshot 3"]'); + if (!thirdThumbnail) throw new Error("Third screenshot thumbnail was not rendered"); + await act(async () => thirdThumbnail.click()); + expect(host.querySelector('img[alt="Gallery screenshot 3"]')?.src).toContain("/three.png"); + + await render(["/one.png"]); + expect(host.querySelector('img[alt="Gallery screenshot 1"]')?.src).toContain("/one.png"); + expect(host.querySelector('img[alt="Gallery screenshot 3"]')).toBeNull(); + }); +}); diff --git a/apps/docs/src/components/workshop/DetailSections.tsx b/apps/docs/src/components/workshop/DetailSections.tsx index 6a11073e1..fa1bce490 100644 --- a/apps/docs/src/components/workshop/DetailSections.tsx +++ b/apps/docs/src/components/workshop/DetailSections.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useColorMode } from "@docusaurus/theme-common"; import { IconChevronLeft, IconChevronRight, IconTrash } from "@tabler/icons-react"; import { Highlight, themes } from "prism-react-renderer"; @@ -18,6 +18,7 @@ import { } from "@/components/ui/alert-dialog"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@site/src/lib/utils"; +import { clampScreenshotIndex } from "./workshop-utils"; const prismThemes = { light: themes.github, dark: themes.dracula } as const; const colorModeKeys = { dark: "dark", light: "light" } as const; @@ -44,11 +45,19 @@ export const DetailSkeleton = () => ( export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => { const [idx, setIdx] = useState(0); + const selectedIndex = clampScreenshotIndex(idx, urls.length); + useEffect(() => setIdx((current) => clampScreenshotIndex(current, urls.length)), [urls.length]); + + if (urls.length === 0) return null; return (
- {`${title} + {`${title} {urls.length > 1 && ( <>
{ diff --git a/apps/docs/src/components/workshop/submission-import.spec.ts b/apps/docs/src/components/workshop/submission-import.spec.ts new file mode 100644 index 000000000..4474fa452 --- /dev/null +++ b/apps/docs/src/components/workshop/submission-import.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { getJsonImportSubmissionType } from "./submission-import"; + +describe("Workshop JSON import classification", () => { + it("switches an existing Custom CSS draft to Custom Widget", () => { + expect(getJsonImportSubmissionType("customCss")).toBe("customWidget"); + }); + + it("classifies JSON before a submission type is selected", () => { + expect(getJsonImportSubmissionType(null)).toBe("customWidget"); + }); +}); diff --git a/apps/docs/src/components/workshop/submission-import.ts b/apps/docs/src/components/workshop/submission-import.ts new file mode 100644 index 000000000..b97263421 --- /dev/null +++ b/apps/docs/src/components/workshop/submission-import.ts @@ -0,0 +1,3 @@ +import type { SubmissionType } from "@site/src/lib/workshop-schema"; + +export const getJsonImportSubmissionType = (_currentType: SubmissionType | null): SubmissionType => "customWidget"; diff --git a/apps/docs/src/components/workshop/useWorkshop.ts b/apps/docs/src/components/workshop/useWorkshop.ts index 9cd77ce20..799595fa0 100644 --- a/apps/docs/src/components/workshop/useWorkshop.ts +++ b/apps/docs/src/components/workshop/useWorkshop.ts @@ -7,7 +7,7 @@ import type { SubmissionType } from "@site/src/lib/workshop-schema"; import { validateSubmissionContent } from "@site/src/lib/workshop-schema"; import { errorMessage, oauthErrorMessage } from "@site/src/lib/utils"; -import { voteDelta } from "./workshop-utils"; +import { voteAndReconcile } from "./workshop-utils"; export type SortKey = "top" | "new" | "recent" | "discussed"; export type TypeFilter = "all" | "yours" | SubmissionType; @@ -131,58 +131,32 @@ export const useWorkshop = (workshopUrl: string) => { if (votingIds.current.has(submissionId)) return; votingIds.current.add(submissionId); - const userId = await requireUserId("vote"); - if (!userId) { + if (!(await requireUserId("vote"))) { votingIds.current.delete(submissionId); return; } - const prev = votes[submissionId]; - const isToggleOff = prev?.value === value; - const [upD, downD] = voteDelta(prev?.value, value); - - setVotes((v) => { - const next = { ...v }; - if (isToggleOff) delete next[submissionId]; - else - next[submissionId] = { - ...(prev ?? { id: "", submission: submissionId, user: userId, created: "", updated: "" }), - value, - } as WorkshopVote; - return next; - }); - setSubmissions((s) => - s.map((sub) => - sub.id === submissionId ? { ...sub, upvotes: sub.upvotes + upD, downvotes: sub.downvotes + downD } : sub, - ), - ); - try { - const saved = await backend.vote(submissionId, value); - setVotes((current) => { - const next = { ...current }; - if (saved) next[submissionId] = saved; - else delete next[submissionId]; - return next; - }); - } catch (caught) { - setVotes((v) => { - const next = { ...v }; - if (prev) next[submissionId] = prev; - else delete next[submissionId]; - return next; - }); - setSubmissions((s) => - s.map((sub) => - sub.id === submissionId ? { ...sub, upvotes: sub.upvotes - upD, downvotes: sub.downvotes - downD } : sub, + const reconciled = await voteAndReconcile(backend, submissionId, value); + setVotes(Object.fromEntries(reconciled.votes.map((row) => [row.submission, row]))); + setSubmissions((current) => + current.map((submission) => + submission.id === submissionId + ? { + ...submission, + upvotes: reconciled.submission.upvotes, + downvotes: reconciled.submission.downvotes, + } + : submission, ), ); + } catch (caught) { setError(errorMessage(caught, "Failed to register your vote")); } finally { votingIds.current.delete(submissionId); } }, - [backend, votes, requireUserId], + [backend, requireUserId], ); const report = useCallback( diff --git a/apps/docs/src/components/workshop/workshop-utils.spec.ts b/apps/docs/src/components/workshop/workshop-utils.spec.ts new file mode 100644 index 000000000..06a3b92ab --- /dev/null +++ b/apps/docs/src/components/workshop/workshop-utils.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase"; + +import { clampScreenshotIndex, voteAndReconcile } from "./workshop-utils"; + +describe("Workshop state reconciliation", () => { + it("reloads both the submission counts and authenticated vote after voting", async () => { + const submission = { id: "submission-1", upvotes: 8, downvotes: 2 } as WorkshopSubmission; + const vote = { id: "vote-1", submission: "submission-1", value: 1 } as WorkshopVote; + const backend = { + vote: vi.fn().mockResolvedValue(vote), + get: vi.fn().mockResolvedValue(submission), + listVotesForCurrentUser: vi.fn().mockResolvedValue([vote]), + }; + + await expect(voteAndReconcile(backend, submission.id, 1)).resolves.toEqual({ submission, votes: [vote] }); + expect(backend.vote).toHaveBeenCalledWith(submission.id, 1); + expect(backend.get).toHaveBeenCalledWith(submission.id); + expect(backend.listVotesForCurrentUser).toHaveBeenCalledOnce(); + }); + + it("clamps a selected screenshot when the gallery shrinks", () => { + expect(clampScreenshotIndex(3, 2)).toBe(1); + expect(clampScreenshotIndex(2, 0)).toBe(0); + }); +}); diff --git a/apps/docs/src/components/workshop/workshop-utils.ts b/apps/docs/src/components/workshop/workshop-utils.ts index b9129384a..4a6dca79b 100644 --- a/apps/docs/src/components/workshop/workshop-utils.ts +++ b/apps/docs/src/components/workshop/workshop-utils.ts @@ -1,4 +1,4 @@ -import type { WorkshopSubmission } from "@site/src/lib/pocketbase"; +import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase"; export const downloadSubmissionJson = (submission: WorkshopSubmission) => { const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" })); @@ -9,8 +9,17 @@ export const downloadSubmissionJson = (submission: WorkshopSubmission) => { URL.revokeObjectURL(url); }; -export const voteDelta = (prev: 1 | -1 | undefined, next: 1 | -1): [up: number, down: number] => { - if (!prev) return next === 1 ? [1, 0] : [0, 1]; - if (prev === next) return next === 1 ? [-1, 0] : [0, -1]; - return next === 1 ? [1, -1] : [-1, 1]; +interface WorkshopVoteBackend { + vote(submissionId: string, value: 1 | -1): Promise; + get(submissionId: string): Promise; + listVotesForCurrentUser(): Promise; +} + +export const voteAndReconcile = async (backend: WorkshopVoteBackend, submissionId: string, value: 1 | -1) => { + await backend.vote(submissionId, value); + const [submission, votes] = await Promise.all([backend.get(submissionId), backend.listVotesForCurrentUser()]); + return { submission, votes }; }; + +export const clampScreenshotIndex = (index: number, screenshotCount: number) => + Math.min(Math.max(0, index), Math.max(0, screenshotCount - 1)); diff --git a/apps/docs/static/api/open-api-schema.json b/apps/docs/static/api/open-api-schema.json index 4805d35d5..76f954a20 100644 --- a/apps/docs/static/api/open-api-schema.json +++ b/apps/docs/static/api/open-api-schema.json @@ -1,25 +1,54 @@ { "openapi": "3.1.0", - "info": { "title": "Homarr API documentation", "version": "1.1.0" }, - "servers": [{ "url": "http://localhost:3000" }], - "externalDocs": { "url": "https://homarr.dev" }, + "info": { + "title": "Homarr API documentation", + "version": "1.1.0" + }, + "servers": [ + { + "url": "http://localhost:3000" + } + ], + "externalDocs": { + "url": "https://homarr.dev" + }, "paths": { "/api/apps/paginated": { "get": { "operationId": "appRouter-getPaginated", "tags": ["apps"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "parameters": [ - { "in": "query", "name": "search", "schema": { "type": "string" } }, + { + "in": "query", + "name": "search", + "schema": { + "type": "string" + } + }, { "in": "query", "name": "pageSize", - "schema": { "default": 10, "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 } + "schema": { + "default": 10, + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } }, { "in": "query", "name": "page", - "schema": { "default": 1, "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 } + "schema": { + "default": 1, + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } } ], "responses": { @@ -35,18 +64,53 @@ "items": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "description": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "iconUrl": { "type": "string" }, - "href": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "pingUrl": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "iconUrl": { + "type": "string" + }, + "href": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "pingUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } }, "required": ["id", "name", "description", "iconUrl", "href", "pingUrl"], "additionalProperties": false } }, - "totalCount": { "type": "number" } + "totalCount": { + "type": "number" + } }, "required": ["items", "totalCount"], "additionalProperties": false @@ -56,24 +120,52 @@ }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -83,7 +175,11 @@ "get": { "operationId": "appRouter-all", "tags": ["apps"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -94,12 +190,45 @@ "items": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "description": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "iconUrl": { "type": "string" }, - "href": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "pingUrl": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "iconUrl": { + "type": "string" + }, + "href": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "pingUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } }, "required": ["id", "name", "description", "iconUrl", "href", "pingUrl"], "additionalProperties": false @@ -110,16 +239,32 @@ }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -127,7 +272,11 @@ "post": { "operationId": "appRouter-create", "tags": ["apps"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "parameters": [], "requestBody": { "required": true, @@ -136,29 +285,62 @@ "schema": { "type": "object", "properties": { - "name": { "type": "string", "minLength": 1, "maxLength": 64 }, - "description": { "anyOf": [{ "type": "string", "maxLength": 512 }, { "type": "null" }] }, - "iconUrl": { "type": "string", "minLength": 1 }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ] + }, + "iconUrl": { + "type": "string", + "minLength": 1 + }, "href": { "anyOf": [ { "anyOf": [ - { "type": "string", "pattern": "^(?!javascript)[a-zA-Z]*:\\/\\/" }, - { "type": "string", "const": "" } + { + "type": "string", + "pattern": "^(?!javascript)[a-zA-Z]*:\\/\\/" + }, + { + "type": "string", + "const": "" + } ] }, - { "type": "null" } + { + "type": "null" + } ] }, "pingUrl": { "anyOf": [ { "anyOf": [ - { "type": "string", "pattern": "^https?:\\/\\/" }, - { "type": "string", "const": "" } + { + "type": "string", + "pattern": "^https?:\\/\\/" + }, + { + "type": "string", + "const": "" + } ] }, - { "type": "null" } + { + "type": "null" + } ] } }, @@ -176,19 +358,56 @@ "allOf": [ { "type": "object", - "properties": { "appId": { "type": "string" } }, + "properties": { + "appId": { + "type": "string" + } + }, "required": ["appId"], "additionalProperties": false }, { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "description": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "iconUrl": { "type": "string" }, - "href": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "pingUrl": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "iconUrl": { + "type": "string" + }, + "href": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "pingUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } }, "required": ["id", "name", "description", "iconUrl", "href", "pingUrl"], "additionalProperties": false @@ -200,20 +419,42 @@ }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -223,13 +464,29 @@ "get": { "operationId": "appRouter-search", "tags": ["apps"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "parameters": [ - { "in": "query", "name": "query", "schema": { "type": "string" }, "required": true }, + { + "in": "query", + "name": "query", + "schema": { + "type": "string" + }, + "required": true + }, { "in": "query", "name": "limit", - "schema": { "default": 10, "type": "number", "minimum": 1, "maximum": 100 } + "schema": { + "default": 10, + "type": "number", + "minimum": 1, + "maximum": 100 + } } ], "responses": { @@ -242,12 +499,45 @@ "items": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "description": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "iconUrl": { "type": "string" }, - "href": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "pingUrl": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "iconUrl": { + "type": "string" + }, + "href": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "pingUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } }, "required": ["id", "name", "description", "iconUrl", "href", "pingUrl"], "additionalProperties": false @@ -258,24 +548,52 @@ }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -285,7 +603,11 @@ "get": { "operationId": "appRouter-selectable", "tags": ["apps"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -296,12 +618,45 @@ "items": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "iconUrl": { "type": "string" }, - "href": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "pingUrl": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "description": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "iconUrl": { + "type": "string" + }, + "href": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "pingUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } }, "required": ["id", "name", "iconUrl", "href", "pingUrl", "description"], "additionalProperties": false @@ -312,16 +667,32 @@ }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -331,8 +702,21 @@ "get": { "operationId": "appRouter-byId", "tags": ["apps"], - "security": [{ "apikey": [] }], - "parameters": [{ "in": "path", "name": "id", "schema": { "type": "string" }, "required": true }], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], "responses": { "200": { "description": "Successful response", @@ -341,12 +725,45 @@ "schema": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "description": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "iconUrl": { "type": "string" }, - "href": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "pingUrl": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "iconUrl": { + "type": "string" + }, + "href": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "pingUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } }, "required": ["id", "name", "description", "iconUrl", "href", "pingUrl"], "additionalProperties": false @@ -356,24 +773,52 @@ }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -381,8 +826,21 @@ "patch": { "operationId": "appRouter-update", "tags": ["apps"], - "security": [{ "apikey": [] }], - "parameters": [{ "in": "path", "name": "id", "schema": { "type": "string" }, "required": true }], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], "requestBody": { "required": true, "content": { @@ -390,29 +848,62 @@ "schema": { "type": "object", "properties": { - "name": { "type": "string", "minLength": 1, "maxLength": 64 }, - "description": { "anyOf": [{ "type": "string", "maxLength": 512 }, { "type": "null" }] }, - "iconUrl": { "type": "string", "minLength": 1 }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ] + }, + "iconUrl": { + "type": "string", + "minLength": 1 + }, "href": { "anyOf": [ { "anyOf": [ - { "type": "string", "pattern": "^(?!javascript)[a-zA-Z]*:\\/\\/" }, - { "type": "string", "const": "" } + { + "type": "string", + "pattern": "^(?!javascript)[a-zA-Z]*:\\/\\/" + }, + { + "type": "string", + "const": "" + } ] }, - { "type": "null" } + { + "type": "null" + } ] }, "pingUrl": { "anyOf": [ { "anyOf": [ - { "type": "string", "pattern": "^https?:\\/\\/" }, - { "type": "string", "const": "" } + { + "type": "string", + "pattern": "^https?:\\/\\/" + }, + { + "type": "string", + "const": "" + } ] }, - { "type": "null" } + { + "type": "null" + } ] } }, @@ -422,27 +913,62 @@ } }, "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -450,30 +976,78 @@ "delete": { "operationId": "appRouter-delete", "tags": ["apps"], - "security": [{ "apikey": [] }], - "parameters": [{ "in": "path", "name": "id", "schema": { "type": "string" }, "required": true }], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -483,7 +1057,11 @@ "get": { "operationId": "boardRouter-getAllBoards", "tags": ["boards"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -494,33 +1072,88 @@ "items": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "logoImageUrl": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "isPublic": { "type": "boolean" }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "logoImageUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "isPublic": { + "type": "boolean" + }, "creator": { "anyOf": [ { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "image": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "email": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + "id": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } }, "required": ["id", "name", "image", "email"], "additionalProperties": false }, - { "type": "null" } + { + "type": "null" + } ] }, - "isHome": { "type": "boolean" }, - "isMobileHome": { "type": "boolean" }, + "isHome": { + "type": "boolean" + }, + "isMobileHome": { + "type": "boolean" + }, "userPermissions": { "type": "array", "items": { "type": "object", - "properties": { "permission": { "type": "string", "enum": ["view", "modify", "full"] } }, + "properties": { + "permission": { + "type": "string", + "enum": ["view", "modify", "full"] + } + }, "required": ["permission"], "additionalProperties": false } @@ -529,7 +1162,12 @@ "type": "array", "items": { "type": "object", - "properties": { "permission": { "type": "string", "enum": ["view", "modify", "full"] } }, + "properties": { + "permission": { + "type": "string", + "enum": ["view", "modify", "full"] + } + }, "required": ["permission"], "additionalProperties": false } @@ -554,16 +1192,32 @@ }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -571,7 +1225,11 @@ "post": { "operationId": "boardRouter-createBoard", "tags": ["boards"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "parameters": [], "requestBody": { "required": true, @@ -580,9 +1238,20 @@ "schema": { "type": "object", "properties": { - "name": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[A-Za-z0-9-_]*$" }, - "columnCount": { "type": "number", "minimum": 1, "maximum": 24 }, - "isPublic": { "type": "boolean" } + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9-_]*$" + }, + "columnCount": { + "type": "number", + "minimum": 1, + "maximum": 24 + }, + "isPublic": { + "type": "boolean" + } }, "required": ["name", "columnCount", "isPublic"] } @@ -596,7 +1265,11 @@ "application/json": { "schema": { "type": "object", - "properties": { "boardId": { "type": "string" } }, + "properties": { + "boardId": { + "type": "string" + } + }, "required": ["boardId"], "additionalProperties": false } @@ -605,20 +1278,42 @@ }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -628,8 +1323,21 @@ "post": { "operationId": "boardRouter-duplicateBoard", "tags": ["boards"], - "security": [{ "apikey": [] }], - "parameters": [{ "in": "path", "name": "id", "schema": { "type": "string" }, "required": true }], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], "requestBody": { "required": true, "content": { @@ -637,7 +1345,12 @@ "schema": { "type": "object", "properties": { - "name": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[A-Za-z0-9-_]*$" } + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9-_]*$" + } }, "required": ["name"] } @@ -651,7 +1364,11 @@ "application/json": { "schema": { "type": "object", - "properties": { "boardId": { "type": "string" } }, + "properties": { + "boardId": { + "type": "string" + } + }, "required": ["boardId"], "additionalProperties": false } @@ -660,20 +1377,42 @@ }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -683,8 +1422,21 @@ "patch": { "operationId": "boardRouter-renameBoard", "tags": ["boards"], - "security": [{ "apikey": [] }], - "parameters": [{ "in": "path", "name": "id", "schema": { "type": "string" }, "required": true }], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], "requestBody": { "required": true, "content": { @@ -692,7 +1444,12 @@ "schema": { "type": "object", "properties": { - "name": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[A-Za-z0-9-_]*$" } + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9-_]*$" + } }, "required": ["name"] } @@ -700,27 +1457,62 @@ } }, "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -730,42 +1522,95 @@ "patch": { "operationId": "boardRouter-changeBoardVisibility", "tags": ["boards"], - "security": [{ "apikey": [] }], - "parameters": [{ "in": "path", "name": "id", "schema": { "type": "string" }, "required": true }], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "properties": { "visibility": { "type": "string", "enum": ["public", "private"] } }, + "properties": { + "visibility": { + "type": "string", + "enum": ["public", "private"] + } + }, "required": ["visibility"] } } } }, "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -775,30 +1620,78 @@ "delete": { "operationId": "boardRouter-deleteBoard", "tags": ["boards"], - "security": [{ "apikey": [] }], - "parameters": [{ "in": "path", "name": "id", "schema": { "type": "string" }, "required": true }], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -808,30 +1701,78 @@ "patch": { "operationId": "boardRouter-setHomeBoard", "tags": ["boards"], - "security": [{ "apikey": [] }], - "parameters": [{ "in": "path", "name": "id", "schema": { "type": "string" }, "required": true }], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -841,30 +1782,78 @@ "patch": { "operationId": "boardRouter-setMobileHomeBoard", "tags": ["boards"], - "security": [{ "apikey": [] }], - "parameters": [{ "in": "path", "name": "id", "schema": { "type": "string" }, "required": true }], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -874,8 +1863,21 @@ "patch": { "operationId": "boardRouter-savePartialBoardSettings", "tags": ["boards"], - "security": [{ "apikey": [] }], - "parameters": [{ "in": "path", "name": "id", "schema": { "type": "string" }, "required": true }], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], "requestBody": { "required": true, "content": { @@ -883,69 +1885,193 @@ "schema": { "type": "object", "properties": { - "pageTitle": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "metaTitle": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "logoImageUrl": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "faviconImageUrl": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "backgroundImageUrl": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "backgroundImageAttachment": { "type": "string", "enum": ["fixed", "scroll"] }, + "pageTitle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "metaTitle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "logoImageUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "faviconImageUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "backgroundImageUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "backgroundImageAttachment": { + "type": "string", + "enum": ["fixed", "scroll"] + }, "backgroundImageRepeat": { "type": "string", "enum": ["repeat", "repeat-x", "repeat-y", "no-repeat"] }, - "backgroundImageSize": { "type": "string", "enum": ["cover", "contain"] }, - "primaryColor": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" }, - "secondaryColor": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" }, - "opacity": { "type": "number", "minimum": 0, "maximum": 100 }, - "customCss": { "type": "string", "maxLength": 16384 }, + "backgroundImageSize": { + "type": "string", + "enum": ["cover", "contain"] + }, + "primaryColor": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "secondaryColor": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "customCss": { + "type": "string", + "maxLength": 16384 + }, "iconColor": { "anyOf": [ { "anyOf": [ - { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" }, - { "type": "string", "const": "" } + { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + { + "type": "string", + "const": "" + } ] }, - { "type": "null" } + { + "type": "null" + } ] }, "itemRadius": { "anyOf": [ - { "type": "string", "const": "xs" }, - { "type": "string", "const": "sm" }, - { "type": "string", "const": "md" }, - { "type": "string", "const": "lg" }, - { "type": "string", "const": "xl" } + { + "type": "string", + "const": "xs" + }, + { + "type": "string", + "const": "sm" + }, + { + "type": "string", + "const": "md" + }, + { + "type": "string", + "const": "lg" + }, + { + "type": "string", + "const": "xl" + } ] }, - "disableStatus": { "type": "boolean" } + "disableStatus": { + "type": "boolean" + } } } } } }, "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -955,7 +2081,11 @@ "post": { "operationId": "boardRouter-addItem", "tags": ["boards"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "parameters": [], "requestBody": { "required": true, @@ -964,7 +2094,9 @@ "schema": { "type": "object", "properties": { - "boardId": { "type": "string" }, + "boardId": { + "type": "string" + }, "kind": { "type": "string", "enum": [ @@ -986,6 +2118,7 @@ "mediaRequests-requestList", "mediaRequests-requestStats", "mediaTranscoding", + "mediaMissing", "minecraftServerStatus", "networkControllerSummary", "networkControllerStatus", @@ -1005,6 +2138,8 @@ "immich-serverStats", "immich-albumCarousel", "paperlessNgx", + "patchmon", + "bazarr", "tracearr", "speedtestTracker", "uptimeKuma", @@ -1017,16 +2152,25 @@ "beszelSystemGrid", "beszelAlerts", "beszelSystemStats", + "traefik", "customApi" ] }, "options": { "default": {}, "type": "object", - "propertyNames": { "type": "string" }, + "propertyNames": { + "type": "string" + }, "additionalProperties": {} }, - "integrationIds": { "default": [], "type": "array", "items": { "type": "string" } } + "integrationIds": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } }, "required": ["boardId", "kind"] } @@ -1040,7 +2184,11 @@ "application/json": { "schema": { "type": "object", - "properties": { "itemId": { "type": "string" } }, + "properties": { + "itemId": { + "type": "string" + } + }, "required": ["itemId"], "additionalProperties": false } @@ -1049,20 +2197,42 @@ }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1072,7 +2242,11 @@ "get": { "operationId": "infoRouter-getInfo", "tags": ["info"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -1080,7 +2254,11 @@ "application/json": { "schema": { "type": "object", - "properties": { "version": { "type": "string" } }, + "properties": { + "version": { + "type": "string" + } + }, "required": ["version"], "additionalProperties": false } @@ -1089,16 +2267,32 @@ }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1108,7 +2302,11 @@ "get": { "operationId": "inviteRouter-getAll", "tags": ["invites"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -1119,13 +2317,28 @@ "items": { "type": "object", "properties": { - "id": { "type": "string" }, - "expirationDate": { "type": "string" }, + "id": { + "type": "string" + }, + "expirationDate": { + "type": "string" + }, "creator": { "type": "object", "properties": { - "name": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "id": { "type": "string" } + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + } }, "required": ["name", "id"], "additionalProperties": false @@ -1140,16 +2353,32 @@ }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1157,7 +2386,11 @@ "post": { "operationId": "inviteRouter-createInvite", "tags": ["invites"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "parameters": [], "requestBody": { "required": true, @@ -1165,7 +2398,11 @@ "application/json": { "schema": { "type": "object", - "properties": { "expirationDate": { "type": "string" } }, + "properties": { + "expirationDate": { + "type": "string" + } + }, "required": ["expirationDate"] } } @@ -1178,7 +2415,14 @@ "application/json": { "schema": { "type": "object", - "properties": { "id": { "type": "string" }, "token": { "type": "string" } }, + "properties": { + "id": { + "type": "string" + }, + "token": { + "type": "string" + } + }, "required": ["id", "token"], "additionalProperties": false } @@ -1187,20 +2431,42 @@ }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1210,33 +2476,80 @@ "delete": { "operationId": "inviteRouter-deleteInvite", "tags": ["invites"], - "security": [{ "apikey": [] }], - "parameters": [{ "in": "path", "name": "id", "schema": { "type": "string" }, "required": true }], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], "responses": { "200": { "description": "Successful response", - "content": { "application/json": { "schema": { "not": {} } } } + "content": { + "application/json": { + "schema": { + "not": {} + } + } + } }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1246,7 +2559,11 @@ "get": { "operationId": "serverSettingsRouter-getBoardSettings", "tags": ["settings"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "responses": { "200": { "description": "Successful response", @@ -1255,10 +2572,32 @@ "schema": { "type": "object", "properties": { - "homeBoardId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "mobileHomeBoardId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "enableStatusByDefault": { "type": "boolean" }, - "forceDisableStatus": { "type": "boolean" } + "homeBoardId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mobileHomeBoardId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "enableStatusByDefault": { + "type": "boolean" + }, + "forceDisableStatus": { + "type": "boolean" + } }, "required": ["homeBoardId", "mobileHomeBoardId", "enableStatusByDefault", "forceDisableStatus"], "additionalProperties": false @@ -1268,16 +2607,32 @@ }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1285,7 +2640,11 @@ "patch": { "operationId": "serverSettingsRouter-updateBoardSettings", "tags": ["settings"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "parameters": [], "requestBody": { "required": true, @@ -1294,10 +2653,32 @@ "schema": { "type": "object", "properties": { - "homeBoardId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "mobileHomeBoardId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "enableStatusByDefault": { "type": "boolean" }, - "forceDisableStatus": { "type": "boolean" } + "homeBoardId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mobileHomeBoardId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "enableStatusByDefault": { + "type": "boolean" + }, + "forceDisableStatus": { + "type": "boolean" + } } } } @@ -1311,10 +2692,32 @@ "schema": { "type": "object", "properties": { - "homeBoardId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "mobileHomeBoardId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "enableStatusByDefault": { "type": "boolean" }, - "forceDisableStatus": { "type": "boolean" } + "homeBoardId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mobileHomeBoardId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "enableStatusByDefault": { + "type": "boolean" + }, + "forceDisableStatus": { + "type": "boolean" + } }, "required": ["homeBoardId", "mobileHomeBoardId", "enableStatusByDefault", "forceDisableStatus"], "additionalProperties": false @@ -1324,24 +2727,52 @@ }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1351,7 +2782,11 @@ "post": { "operationId": "userRouter-create", "tags": ["users"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "parameters": [], "requestBody": { "required": true, @@ -1360,20 +2795,39 @@ "schema": { "type": "object", "properties": { - "username": { "type": "string", "minLength": 3, "maxLength": 255 }, - "password": { "type": "string", "minLength": 8, "maxLength": 255 }, - "confirmPassword": { "type": "string" }, + "username": { + "type": "string", + "minLength": 3, + "maxLength": 255 + }, + "password": { + "type": "string", + "minLength": 8, + "maxLength": 255 + }, + "confirmPassword": { + "type": "string" + }, "email": { "anyOf": [ { "type": "string", "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + "pattern": "^[^\\s@\"]{1,64}@[^\\s@]{1,255}$" }, - { "type": "string", "minLength": 0, "maxLength": 0 } + { + "type": "string", + "minLength": 0, + "maxLength": 0 + } ] }, - "groupIds": { "type": "array", "items": { "type": "string" } } + "groupIds": { + "type": "array", + "items": { + "type": "string" + } + } }, "required": ["username", "password", "confirmPassword", "groupIds"] } @@ -1381,23 +2835,52 @@ } }, "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1405,114 +2888,9 @@ "get": { "operationId": "userRouter-getAll", "tags": ["users"], - "security": [{ "apikey": [] }], - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { "type": "string" }, - "name": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "email": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "emailVerified": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "image": { "anyOf": [{ "type": "string" }, { "type": "null" }] } - }, - "required": ["id", "name", "email", "emailVerified", "image"], - "additionalProperties": false - } - } - } - } - }, - "401": { - "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } - }, - "403": { - "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } - } - } - } - } - }, - "/api/users/profileImage": { - "put": { - "operationId": "userRouter-setProfileImage", - "tags": ["users"], - "security": [{ "apikey": [] }], - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "userId": { "type": "string" }, - "image": { - "anyOf": [ - { - "type": "string", - "maxLength": 350000, - "pattern": "^data:image\\/(png|jpeg|gif|webp);base64,[A-Za-z0-9/+]+=*$" - }, - { "type": "null" } - ] - } - }, - "required": ["userId", "image"] - } - } - } - }, - "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, - "400": { - "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } - }, - "401": { - "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } - }, - "403": { - "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } - }, - "404": { - "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } - } - } - } - } - }, - "/api/users/selectable": { - "get": { - "operationId": "userRouter-selectable", - "tags": ["users"], - "security": [{ "apikey": [] }], - "parameters": [ + "security": [ { - "in": "query", - "name": "providers", - "schema": { "type": "array", "items": { "type": "string", "enum": ["credentials", "oidc", "ldap"] } } + "apikey": [] } ], "responses": { @@ -1525,10 +2903,255 @@ "items": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "image": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "email": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + "id": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "emailVerified": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "name", "email", "emailVerified", "image"], + "additionalProperties": false + } + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, + "/api/users/profileImage": { + "put": { + "operationId": "userRouter-setProfileImage", + "tags": ["users"], + "security": [ + { + "apikey": [] + } + ], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "image": { + "anyOf": [ + { + "type": "string", + "maxLength": 350000, + "pattern": "^data:image\\/(png|jpeg|gif|webp);base64,[A-Za-z0-9/+]+=*$" + }, + { + "type": "null" + } + ] + } + }, + "required": ["userId", "image"] + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, + "/api/users/selectable": { + "get": { + "operationId": "userRouter-selectable", + "tags": ["users"], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "providers", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": ["credentials", "oidc", "ldap"] + } + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } }, "required": ["id", "name", "image", "email"], "additionalProperties": false @@ -1539,24 +3162,52 @@ }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1566,7 +3217,11 @@ "post": { "operationId": "userRouter-search", "tags": ["users"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "parameters": [], "requestBody": { "required": true, @@ -1575,8 +3230,15 @@ "schema": { "type": "object", "properties": { - "query": { "type": "string" }, - "limit": { "default": 10, "type": "number", "minimum": 1, "maximum": 100 } + "query": { + "type": "string" + }, + "limit": { + "default": 10, + "type": "number", + "minimum": 1, + "maximum": 100 + } }, "required": ["query"] } @@ -1593,10 +3255,39 @@ "items": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "image": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "email": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + "id": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } }, "required": ["id", "name", "image", "email"], "additionalProperties": false @@ -1607,20 +3298,42 @@ }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1630,8 +3343,21 @@ "get": { "operationId": "userRouter-getById", "tags": ["users"], - "security": [{ "apikey": [] }], - "parameters": [{ "in": "path", "name": "userId", "schema": { "type": "string" }, "required": true }], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userId", + "schema": { + "type": "string" + }, + "required": true + } + ], "responses": { "200": { "description": "Successful response", @@ -1640,21 +3366,105 @@ "schema": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "email": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "emailVerified": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "image": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "provider": { "type": "string" }, - "homeBoardId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "mobileHomeBoardId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "firstDayOfWeek": { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, - "pingIconsEnabled": { "type": "boolean" }, - "defaultSearchEngineId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "openSearchInNewTab": { "type": "boolean" }, - "ddgBangs": { "type": "boolean" }, - "completedManageTour": { "type": "boolean" }, - "completedBoardTour": { "type": "boolean" } + "id": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "emailVerified": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "provider": { + "type": "string" + }, + "homeBoardId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mobileHomeBoardId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "firstDayOfWeek": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "pingIconsEnabled": { + "type": "boolean" + }, + "enableRightClickOnWidgets": { + "type": "boolean" + }, + "defaultSearchEngineId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "openSearchInNewTab": { + "type": "boolean" + }, + "ddgBangs": { + "type": "boolean" + }, + "completedManageTour": { + "type": "boolean" + }, + "completedBoardTour": { + "type": "boolean" + } }, "required": [ "id", @@ -1667,6 +3477,7 @@ "mobileHomeBoardId", "firstDayOfWeek", "pingIconsEnabled", + "enableRightClickOnWidgets", "defaultSearchEngineId", "openSearchInNewTab", "ddgBangs", @@ -1680,24 +3491,52 @@ }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1705,30 +3544,78 @@ "delete": { "operationId": "userRouter-delete", "tags": ["users"], - "security": [{ "apikey": [] }], - "parameters": [{ "in": "path", "name": "userId", "schema": { "type": "string" }, "required": true }], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userId", + "schema": { + "type": "string" + }, + "required": true + } + ], "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1738,7 +3625,11 @@ "put": { "operationId": "userRouter-editProfile", "tags": ["users"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "parameters": [], "requestBody": { "required": true, @@ -1747,8 +3638,14 @@ "schema": { "type": "object", "properties": { - "id": { "type": "string" }, - "name": { "type": "string", "minLength": 3, "maxLength": 255 }, + "id": { + "type": "string" + }, + "name": { + "type": "string", + "minLength": 3, + "maxLength": 255 + }, "email": { "anyOf": [ { @@ -1756,12 +3653,17 @@ { "type": "string", "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + "pattern": "^[^\\s@\"]{1,64}@[^\\s@]{1,255}$" }, - { "type": "string", "const": "" } + { + "type": "string", + "const": "" + } ] }, - { "type": "null" } + { + "type": "null" + } ] } }, @@ -1771,27 +3673,62 @@ } }, "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1801,8 +3738,21 @@ "patch": { "operationId": "userRouter-changePassword", "tags": ["users"], - "security": [{ "apikey": [] }], - "parameters": [{ "in": "path", "name": "userId", "schema": { "type": "string" }, "required": true }], + "security": [ + { + "apikey": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userId", + "schema": { + "type": "string" + }, + "required": true + } + ], "requestBody": { "required": true, "content": { @@ -1810,9 +3760,18 @@ "schema": { "type": "object", "properties": { - "previousPassword": { "type": "string", "minLength": 1 }, - "password": { "type": "string", "minLength": 8, "maxLength": 255 }, - "confirmPassword": { "type": "string" } + "previousPassword": { + "type": "string", + "minLength": 1 + }, + "password": { + "type": "string", + "minLength": 8, + "maxLength": 255 + }, + "confirmPassword": { + "type": "string" + } }, "required": ["previousPassword", "password", "confirmPassword"] } @@ -1820,27 +3779,62 @@ } }, "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1850,7 +3844,11 @@ "patch": { "operationId": "userRouter-changeHomeBoards", "tags": ["users"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "parameters": [], "requestBody": { "required": true, @@ -1859,9 +3857,29 @@ "schema": { "type": "object", "properties": { - "homeBoardId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "mobileHomeBoardId": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, - "userId": { "type": "string" } + "homeBoardId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mobileHomeBoardId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "userId": { + "type": "string" + } }, "required": ["homeBoardId", "mobileHomeBoardId", "userId"] } @@ -1869,27 +3887,62 @@ } }, "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1899,7 +3952,11 @@ "patch": { "operationId": "userRouter-changeDefaultSearchEngine", "tags": ["users"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "deprecated": true, "parameters": [], "requestBody": { @@ -1909,9 +3966,23 @@ "schema": { "type": "object", "properties": { - "defaultSearchEngineId": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] }, - "ddgBangsEnabled": { "type": "boolean" }, - "userId": { "type": "string" } + "defaultSearchEngineId": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "ddgBangsEnabled": { + "type": "boolean" + }, + "userId": { + "type": "string" + } }, "required": ["defaultSearchEngineId", "ddgBangsEnabled", "userId"] } @@ -1919,27 +3990,62 @@ } }, "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1949,7 +4055,11 @@ "patch": { "operationId": "userRouter-changeSearchPreferences", "tags": ["users"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "parameters": [], "requestBody": { "required": true, @@ -1958,10 +4068,26 @@ "schema": { "type": "object", "properties": { - "defaultSearchEngineId": { "anyOf": [{ "type": "string", "minLength": 1 }, { "type": "null" }] }, - "openInNewTab": { "type": "boolean" }, - "ddgBangsEnabled": { "type": "boolean" }, - "userId": { "type": "string" } + "defaultSearchEngineId": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "openInNewTab": { + "type": "boolean" + }, + "ddgBangsEnabled": { + "type": "boolean" + }, + "userId": { + "type": "string" + } }, "required": ["defaultSearchEngineId", "openInNewTab", "ddgBangsEnabled", "userId"] } @@ -1969,27 +4095,62 @@ } }, "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -1999,7 +4160,11 @@ "patch": { "operationId": "userRouter-changeColorScheme", "tags": ["users"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "parameters": [], "requestBody": { "required": true, @@ -2007,34 +4172,165 @@ "application/json": { "schema": { "type": "object", - "properties": { "colorScheme": { "type": "string", "enum": ["auto", "light", "dark"] } }, + "properties": { + "colorScheme": { + "type": "string", + "enum": ["auto", "light", "dark"] + } + }, "required": ["colorScheme"] } } } }, "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } + } + } + } + } + }, + "/api/users/right-click-widgets": { + "patch": { + "operationId": "userRouter-changeEnableRightClickOnWidgets", + "tags": ["users"], + "security": [ + { + "apikey": [] + } + ], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enableRightClickOnWidgets": { + "type": "boolean" + }, + "id": { + "type": "string" + } + }, + "required": ["enableRightClickOnWidgets", "id"] + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "400": { + "description": "Invalid input data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } + }, + "401": { + "description": "Authorization not provided", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } + }, + "403": { + "description": "Insufficient access", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -2044,7 +4340,11 @@ "patch": { "operationId": "userRouter-changeDdgBangs", "tags": ["users"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "deprecated": true, "parameters": [], "requestBody": { @@ -2053,34 +4353,76 @@ "application/json": { "schema": { "type": "object", - "properties": { "ddgBangs": { "type": "boolean" }, "id": { "type": "string" } }, + "properties": { + "ddgBangs": { + "type": "boolean" + }, + "id": { + "type": "string" + } + }, "required": ["ddgBangs", "id"] } } } }, "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -2090,7 +4432,11 @@ "patch": { "operationId": "userRouter-changeFirstDayOfWeek", "tags": ["users"], - "security": [{ "apikey": [] }], + "security": [ + { + "apikey": [] + } + ], "parameters": [], "requestBody": { "required": true, @@ -2099,8 +4445,14 @@ "schema": { "type": "object", "properties": { - "firstDayOfWeek": { "type": "number", "minimum": 0, "maximum": 6 }, - "id": { "type": "string" } + "firstDayOfWeek": { + "type": "number", + "minimum": 0, + "maximum": 6 + }, + "id": { + "type": "string" + } }, "required": ["firstDayOfWeek", "id"] } @@ -2108,27 +4460,62 @@ } }, "responses": { - "200": { "description": "Successful response", "content": { "application/json": { "schema": {} } } }, + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": {} + } + } + }, "400": { "description": "Invalid input data", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.BAD_REQUEST" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.BAD_REQUEST" + } + } + } }, "401": { "description": "Authorization not provided", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.UNAUTHORIZED" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.UNAUTHORIZED" + } + } + } }, "403": { "description": "Insufficient access", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.FORBIDDEN" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.FORBIDDEN" + } + } + } }, "404": { "description": "Not found", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/error.NOT_FOUND" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.NOT_FOUND" + } + } + } }, "500": { "description": "Internal server error", "content": { - "application/json": { "schema": { "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" } } + "application/json": { + "schema": { + "$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR" + } + } } } } @@ -2140,15 +4527,27 @@ "error.BAD_REQUEST": { "type": "object", "properties": { - "message": { "type": "string", "description": "The error message", "example": "Invalid input data" }, - "code": { "type": "string", "description": "The error code", "example": "BAD_REQUEST" }, + "message": { + "type": "string", + "description": "The error message", + "example": "Invalid input data" + }, + "code": { + "type": "string", + "description": "The error code", + "example": "BAD_REQUEST" + }, "issues": { "description": "An array of issues that were responsible for the error", "example": [], "type": "array", "items": { "type": "object", - "properties": { "message": { "type": "string" } }, + "properties": { + "message": { + "type": "string" + } + }, "required": ["message"], "additionalProperties": false } @@ -2158,20 +4557,36 @@ "additionalProperties": false, "title": "Invalid input data error (400)", "description": "The error information", - "example": { "code": "BAD_REQUEST", "message": "Invalid input data", "issues": [] } + "example": { + "code": "BAD_REQUEST", + "message": "Invalid input data", + "issues": [] + } }, "error.UNAUTHORIZED": { "type": "object", "properties": { - "message": { "type": "string", "description": "The error message", "example": "Authorization not provided" }, - "code": { "type": "string", "description": "The error code", "example": "UNAUTHORIZED" }, + "message": { + "type": "string", + "description": "The error message", + "example": "Authorization not provided" + }, + "code": { + "type": "string", + "description": "The error code", + "example": "UNAUTHORIZED" + }, "issues": { "description": "An array of issues that were responsible for the error", "example": [], "type": "array", "items": { "type": "object", - "properties": { "message": { "type": "string" } }, + "properties": { + "message": { + "type": "string" + } + }, "required": ["message"], "additionalProperties": false } @@ -2181,20 +4596,36 @@ "additionalProperties": false, "title": "Authorization not provided error (401)", "description": "The error information", - "example": { "code": "UNAUTHORIZED", "message": "Authorization not provided", "issues": [] } + "example": { + "code": "UNAUTHORIZED", + "message": "Authorization not provided", + "issues": [] + } }, "error.FORBIDDEN": { "type": "object", "properties": { - "message": { "type": "string", "description": "The error message", "example": "Insufficient access" }, - "code": { "type": "string", "description": "The error code", "example": "FORBIDDEN" }, + "message": { + "type": "string", + "description": "The error message", + "example": "Insufficient access" + }, + "code": { + "type": "string", + "description": "The error code", + "example": "FORBIDDEN" + }, "issues": { "description": "An array of issues that were responsible for the error", "example": [], "type": "array", "items": { "type": "object", - "properties": { "message": { "type": "string" } }, + "properties": { + "message": { + "type": "string" + } + }, "required": ["message"], "additionalProperties": false } @@ -2204,20 +4635,36 @@ "additionalProperties": false, "title": "Insufficient access error (403)", "description": "The error information", - "example": { "code": "FORBIDDEN", "message": "Insufficient access", "issues": [] } + "example": { + "code": "FORBIDDEN", + "message": "Insufficient access", + "issues": [] + } }, "error.NOT_FOUND": { "type": "object", "properties": { - "message": { "type": "string", "description": "The error message", "example": "Not found" }, - "code": { "type": "string", "description": "The error code", "example": "NOT_FOUND" }, + "message": { + "type": "string", + "description": "The error message", + "example": "Not found" + }, + "code": { + "type": "string", + "description": "The error code", + "example": "NOT_FOUND" + }, "issues": { "description": "An array of issues that were responsible for the error", "example": [], "type": "array", "items": { "type": "object", - "properties": { "message": { "type": "string" } }, + "properties": { + "message": { + "type": "string" + } + }, "required": ["message"], "additionalProperties": false } @@ -2227,20 +4674,36 @@ "additionalProperties": false, "title": "Not found error (404)", "description": "The error information", - "example": { "code": "NOT_FOUND", "message": "Not found", "issues": [] } + "example": { + "code": "NOT_FOUND", + "message": "Not found", + "issues": [] + } }, "error.INTERNAL_SERVER_ERROR": { "type": "object", "properties": { - "message": { "type": "string", "description": "The error message", "example": "Internal server error" }, - "code": { "type": "string", "description": "The error code", "example": "INTERNAL_SERVER_ERROR" }, + "message": { + "type": "string", + "description": "The error message", + "example": "Internal server error" + }, + "code": { + "type": "string", + "description": "The error code", + "example": "INTERNAL_SERVER_ERROR" + }, "issues": { "description": "An array of issues that were responsible for the error", "example": [], "type": "array", "items": { "type": "object", - "properties": { "message": { "type": "string" } }, + "properties": { + "message": { + "type": "string" + } + }, "required": ["message"], "additionalProperties": false } @@ -2250,7 +4713,11 @@ "additionalProperties": false, "title": "Internal server error error (500)", "description": "The error information", - "example": { "code": "INTERNAL_SERVER_ERROR", "message": "Internal server error", "issues": [] } + "example": { + "code": "INTERNAL_SERVER_ERROR", + "message": "Internal server error", + "issues": [] + } } }, "securitySchemes": { diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_beta-banner.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_beta-banner.tsx index 6ac28cd7c..c26960c4d 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_beta-banner.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_beta-banner.tsx @@ -1,18 +1,23 @@ +"use client"; + import { Alert, Anchor, Text } from "@mantine/core"; import { IconFlask } from "@tabler/icons-react"; +import { useScopedI18n } from "@homarr/translation/client"; + const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets"; export function CustomWidgetBetaBanner() { + const t = useScopedI18n("customWidget.betaBanner"); + return ( - }> + }> - This feature is experimental and will probably change a lot in the future. If you encounter bugs, wish it did - more, or find something confusing, then please complain to us as much as possible! Only feedback posted on the{" "} + {t("description")}{" "} - official discussion thread + {t("discussionLink")} {" "} - will be taken into account. Happy Hacking! + {t("closing")} ); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx index 79c82666b..062b910b0 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx @@ -1,23 +1,26 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { ActionIcon, Menu } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import { IconBuildingStore, IconCopy, IconDots, IconDownload, IconTrash, IconUpload } from "@tabler/icons-react"; +import { + IconBuildingStore, + IconCopy, + IconDots, + IconDownload, + IconSparkles, + IconTrash, + IconUpload, +} from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { - formatCustomWidgetImportIssues, - looksLikeCustomWidgetClipboard, - parseCustomWidgetClipboardDetailed, -} from "@homarr/custom-widgets/core"; +import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; -import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog"; import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal"; @@ -28,15 +31,59 @@ interface WidgetRef { name: string; enabled: boolean; valid: boolean; + migrationRequired: boolean; } export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { const t = useScopedI18n("customWidget"); const { openConfirmModal } = useConfirmModal(); const deleteMutation = clientApi.customWidget.delete.useMutation(); + const deleteLegacyMutation = clientApi.customWidget.deleteLegacy.useMutation(); const duplicateMutation = clientApi.customWidget.duplicate.useMutation(); const utils = clientApi.useUtils(); const [publishOpened, publishControls] = useDisclosure(false); + const [migrationOpened, migrationControls] = useDisclosure(false); + const migrationFileInputRef = useRef(null); + const [migratedWidget, setMigratedWidget] = useState(null); + + const copyMigrationPrompt = async () => { + try { + const result = await utils.customWidget.legacyMigrationPrompt.fetch({ id: widget.id }); + await navigator.clipboard.writeText(result.prompt); + showSuccessNotification({ + title: t("action.copyMigrationPrompt"), + message: t("notification.migrationPromptCopied"), + }); + } catch (error) { + showErrorNotification({ + title: t("action.copyMigrationPrompt"), + message: error instanceof Error ? error.message : t("notification.migrationPromptCopyError"), + }); + } + }; + + const handleMigrationFile = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.addEventListener("load", (loadEvent) => { + const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string); + if (!result.success) { + showErrorNotification({ + title: t("action.migrate"), + message: formatCustomWidgetImportIssues(result.issues), + }); + return; + } + setMigratedWidget(result.widget); + migrationControls.open(); + }); + reader.addEventListener("error", () => { + showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") }); + }); + reader.readAsText(file); + event.target.value = ""; + }; const handleExport = async () => { try { @@ -77,23 +124,26 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { title: t("action.delete"), children: t("action.deleteConfirm", { name: widget.name }), onConfirm: () => { - deleteMutation.mutate( - { id: widget.id }, - { - onSuccess: () => { - showSuccessNotification({ - title: t("action.delete"), - message: t("notification.deleted", { name: widget.name }), - }); - void utils.customWidget.list.invalidate(); - void utils.widget.customApi.getData.invalidate(); - void revalidatePathActionAsync("/manage/custom-widgets"); - }, - onError: () => { - showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") }); - }, + const callbacks = { + onSuccess: () => { + showSuccessNotification({ + title: t("action.delete"), + message: t("notification.deleted", { name: widget.name }), + }); + void utils.customWidget.list.invalidate(); + void utils.customWidget.available.invalidate(); + void utils.widget.customApi.getData.invalidate(); + void revalidatePathActionAsync("/manage/custom-widgets"); }, - ); + onError: () => { + showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") }); + }, + }; + if (widget.migrationRequired) { + deleteLegacyMutation.mutate({ id: widget.id }, callbacks); + return; + } + deleteMutation.mutate({ id: widget.id }, callbacks); }, }); }; @@ -125,104 +175,52 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { )} + {widget.migrationRequired && ( + <> + void copyMigrationPrompt()} leftSection={}> + {t("action.copyMigrationPrompt")} + + migrationFileInputRef.current?.click()} + leftSection={} + > + {t("action.importMigration")} + + + + )} } onClick={handleDelete} - disabled={deleteMutation.isPending} + disabled={deleteMutation.isPending || deleteLegacyMutation.isPending} > {t("action.delete")} {widget.valid && } - - ); -}; - -export const ImportCustomWidgetButton = () => { - const t = useScopedI18n("customWidget"); - const fileInputRef = useRef(null); - const [pendingImport, setPendingImport] = useState(null); - const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false); - const queueImport = useCallback( - (value: HomarrCustomWidgetV2) => { - setPendingImport(value); - openReview(); - }, - [openReview], - ); - - useEffect(() => { - const handlePaste = (event: ClipboardEvent) => { - if (event.target instanceof Element && event.target.matches("input, textarea, [contenteditable='true']")) return; - const text = event.clipboardData?.getData("text/plain") ?? ""; - if (!looksLikeCustomWidgetClipboard(text)) return; - event.preventDefault(); - const result = parseCustomWidgetClipboardDetailed(text); - if (!result.success) { - showErrorNotification({ - title: t("action.import"), - message: formatCustomWidgetImportIssues(result.issues), - }); - return; - } - queueImport(result.widget); - }; - window.addEventListener("paste", handlePaste); - return () => window.removeEventListener("paste", handlePaste); - }, [queueImport, t]); - - const handleImport = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - const reader = new FileReader(); - reader.addEventListener("load", (e) => { - try { - const result = parseCustomWidgetClipboardDetailed(e.target?.result as string); - if (!result.success) throw new Error(formatCustomWidgetImportIssues(result.issues)); - queueImport(result.widget); - } catch (error) { - showErrorNotification({ - title: t("action.import"), - message: error instanceof Error ? error.message : t("notification.importError"), - }); - } - }); - reader.addEventListener("error", () => { - showErrorNotification({ title: t("action.import"), message: t("notification.importError") }); - }); - reader.readAsText(file); - event.target.value = ""; - }; - - return ( - <> - } - onClick={() => fileInputRef.current?.click()} - title={t("action.pasteImportHint")} - > - {t("action.import")} - - - { - closeReview(); - setPendingImport(null); - }} - /> + {widget.migrationRequired && ( + <> + + { + migrationControls.close(); + setMigratedWidget(null); + }} + /> + + )} ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.spec.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.spec.ts index b804c7821..e82bf4282 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.spec.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core"; import { + filterSecretsForSourceAuthentication, getChangedSecrets, getCustomWidgetPreviewOptionIssues, loadPreviewQueries, @@ -62,4 +63,18 @@ describe("Custom Widget workbench preview options", () => { }), ).toEqual([{ sourceId: "other", kind: "password", value: "replacement" }]); }); + + it("drops credentials that do not apply after an authentication transition", () => { + expect( + filterSecretsForSourceAuthentication( + [ + { sourceId: "default", kind: "username", value: "admin", hasValue: true }, + { sourceId: "default", kind: "password", value: "secret", hasValue: true }, + { sourceId: "secondary", kind: "apiKey", value: "unchanged", hasValue: true }, + ], + "default", + "bearer", + ), + ).toEqual([{ sourceId: "secondary", kind: "apiKey", value: "unchanged", hasValue: true }]); + }); }); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts index f68e6504a..20df769cb 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts @@ -2,6 +2,7 @@ import { fetchApi } from "@homarr/api/client"; import { customWidgetDefinitionSchema, getCustomWidgetDefaultOptions, + getCustomWidgetRequiredSecretKinds, validateCustomWidgetOptions, } from "@homarr/custom-widgets/core"; import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; @@ -33,6 +34,15 @@ export function getChangedSecrets(values: CustomWidgetFormValues) { })); } +export function filterSecretsForSourceAuthentication( + secrets: CustomWidgetFormValues["secrets"], + sourceId: string, + authType: Parameters[0], +) { + const requiredKinds = new Set(getCustomWidgetRequiredSecretKinds(authType)); + return secrets.filter((secret) => secret.sourceId !== sourceId || requiredKinds.has(secret.kind)); +} + export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) { form.setValues({ ...form.values, diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx index d946bd5e6..65269f673 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Accordion, Box, Button, Paper, SegmentedControl, Stack, TextInput, Textarea } from "@mantine/core"; import { IconAlertCircle, @@ -39,6 +39,7 @@ import { createCustomWidgetRenameHandlers } from "./_custom-widget-rename-handle import { CustomWidgetSaveIssuesAlert } from "./_custom-widget-save-issues-alert"; import { CustomWidgetSourcesEditor } from "./_custom-widget-sources-editor"; import { useCustomWidgetFormActions } from "./_use-custom-widget-form-actions"; +import { useUnsavedChangesGuard } from "./_use-unsaved-changes-guard"; import classes from "./_custom-widget-form.module.css"; interface CustomWidgetFormProps { @@ -71,8 +72,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi const initialDefinition = buildDefinition(formInitialValues); return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {}; }); - const dirtyRef = useRef(false); - dirtyRef.current = form.isDirty(); + useUnsavedChangesGuard(form.isDirty()); const candidate = useMemo(() => buildDefinition(form.values), [form.values]); const parsedOptions = useMemo( @@ -117,13 +117,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi optionsSnapshot, setOptionsSnapshot, }); - useEffect(() => { - const beforeUnload = (event: BeforeUnloadEvent) => { - if (dirtyRef.current) event.preventDefault(); - }; - window.addEventListener("beforeunload", beforeUnload); - return () => window.removeEventListener("beforeunload", beforeUnload); - }, []); useEffect(() => { if (parsedOptions.success) setOptionsSnapshot(getCustomWidgetDefaultOptions(parsedOptions.data)); }, [parsedOptions]); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-legacy-migration.spec.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-legacy-migration.spec.ts new file mode 100644 index 000000000..6b78a9365 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-legacy-migration.spec.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const readSource = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8"); + +describe("legacy custom widget migration UI", () => { + it("keeps legacy definitions visible and exposes the redacted LLM workflow", () => { + const list = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx"); + const actions = readSource("apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx"); + + expect(list).toContain("widget.migrationRequired"); + expect(list).toContain('t("page.list.migrationInstructions")'); + expect(actions).toContain("legacyMigrationPrompt.fetch"); + expect(actions).toContain("navigator.clipboard.writeText(result.prompt)"); + expect(actions).toContain("parseCustomWidgetClipboardDetailed"); + expect(actions).toContain("legacyId={widget.id}"); + }); + + it("replaces the legacy record through migration and refreshes every consumer", () => { + const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx"); + + expect(dialog).toContain("customWidget.migrateLegacy.useMutation"); + expect(dialog).toContain("migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets })"); + expect(dialog).toContain("utils.customWidget.list.invalidate()"); + expect(dialog).toContain("utils.customWidget.available.invalidate()"); + expect(dialog).toContain("utils.widget.customApi.getData.invalidate()"); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx index d6fb4d009..7e4386d20 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx @@ -1,7 +1,7 @@ "use client"; import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core"; -import { IconAlertTriangle, IconApi, IconPencil } from "@tabler/icons-react"; +import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; @@ -70,7 +70,13 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) { {widget.name} - {!widget.valid && ( + {widget.migrationRequired ? ( + + {t("page.list.migrationDescription")} + + ) : !widget.valid ? ( {t("page.list.invalidDefinitionDescription")} - )} + ) : null} {widget.sources[0] && ( {widget.sources.map((source) => source.origin).join(" · ")} )} - {widget.valid ? ( + {widget.migrationRequired ? ( + + } + style={{ flexShrink: 0 }} + > + {t("page.list.migrationRequired")} + + + ) : widget.valid ? ( JSX · {widget.requestCount} @@ -166,7 +188,13 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) { )} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-source-dependencies.spec.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-source-dependencies.spec.ts new file mode 100644 index 000000000..51281e800 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-source-dependencies.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { getDependentRequestIds, removeDependentRequests } from "./_custom-widget-source-dependencies"; + +const requests = JSON.stringify({ + status: { source: "default", path: "/status" }, + restart: { source: "secondary", path: "/restart", kind: "action" }, + logs: { source: "secondary", path: "/logs" }, +}); + +describe("custom widget source dependencies", () => { + it("lists every request that would be removed with a source", () => { + expect(getDependentRequestIds(requests, "secondary")).toEqual(["restart", "logs"]); + }); + + it("removes only requests that use the selected source", () => { + expect(removeDependentRequests(requests, "secondary")).toEqual({ + status: { source: "default", path: "/status" }, + }); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-source-dependencies.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-source-dependencies.ts new file mode 100644 index 000000000..6b1c8baa8 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-source-dependencies.ts @@ -0,0 +1,27 @@ +export function getDependentRequestIds(requestsJson: string, sourceId: string) { + const requests = parseRequestRecord(requestsJson); + return Object.entries(requests).flatMap(([requestId, request]) => + isRecord(request) && request.source === sourceId ? [requestId] : [], + ); +} + +export function removeDependentRequests(requestsJson: string, sourceId: string) { + return Object.fromEntries( + Object.entries(parseRequestRecord(requestsJson)).filter( + ([, request]) => !isRecord(request) || request.source !== sourceId, + ), + ); +} + +function parseRequestRecord(requestsJson: string): Record { + try { + const requests = JSON.parse(requestsJson) as unknown; + return isRecord(requests) ? requests : {}; + } catch { + return {}; + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-source-field.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-source-field.tsx new file mode 100644 index 000000000..8f2240aa6 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-source-field.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { Button, Fieldset, Group, PasswordInput, Select, Stack, TextInput } from "@mantine/core"; +import { IconKey, IconTrash } from "@tabler/icons-react"; + +import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core"; +import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; +import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input"; + +type SecretKind = "apiKey" | "username" | "password"; + +const secretFields: Record> = { + bearer: [{ kind: "apiKey" }], + basic: [{ kind: "username" }, { kind: "password" }], + apiKeyHeader: [{ kind: "apiKey" }], + apiKeyQuery: [{ kind: "apiKey" }], +}; + +const sourceUrlErrorKeys: Record = { + invalid: "baseUrlError.invalid", + protocol: "baseUrlError.protocol", + credentials: "baseUrlError.credentials", + queryOrFragment: "baseUrlError.queryOrFragment", +}; + +interface CustomWidgetSourceFieldProps { + source: CustomWidgetSource & { id: string }; + index: number; + form: CustomWidgetWorkbenchForm; + definitionId?: string; + clearSecretPending: boolean; + onUpdate(index: number, changes: Partial & { id?: string }): void; + onSetAuthentication(index: number, type: string): void; + onSetSecret(sourceId: string, kind: string, value: string): void; + onClearSecret(sourceId: string, kind: SecretKind): Promise; + onRemove(index: number): void; +} + +export function CustomWidgetSourceField({ + source, + index, + form, + definitionId, + clearSecretPending, + onUpdate, + onSetAuthentication, + onSetSecret, + onClearSecret, + onRemove, +}: CustomWidgetSourceFieldProps) { + const t = useScopedI18n("customWidget.workbench.sources"); + const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl); + const authType = typeof source.auth === "string" ? source.auth : source.auth.type; + + return ( +
+ + + onUpdate(index, { id: value })} + /> + onUpdate(index, { name: event.currentTarget.value })} + /> + + onUpdate(index, { baseUrl: event.currentTarget.value })} + /> + + onSetAuthentication(index, value ?? "none")} + allowDeselect={false} + /> + + {typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && ( + onUpdate(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })} + /> + )} + {typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && ( + onUpdate(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } })} + /> + )} + {(secretFields[authType] ?? []).map(({ kind }) => { + const secret = form.values.secrets.find((entry) => entry.sourceId === source.id && entry.kind === kind); + const Input = kind === "username" ? TextInput : PasswordInput; + return ( + + } + onChange={(event) => onSetSecret(source.id, kind, event.currentTarget.value)} + /> + {definitionId && secret?.hasValue && ( + + )} + + ); + })} + {index > 0 && ( + + )} + +
+ ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx index 967737908..c3a2c44cc 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-sources-editor.tsx @@ -1,31 +1,18 @@ "use client"; -import { Accordion, Button, Fieldset, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core"; -import { IconKey, IconPlus, IconTrash } from "@tabler/icons-react"; +import { Accordion, Button, List, Stack, Text } from "@mantine/core"; +import { IconPlus } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; -import { getCustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core"; -import type { CustomWidgetSource, CustomWidgetSourceUrlIssue } from "@homarr/custom-widgets/core"; +import type { CustomWidgetSource } from "@homarr/custom-widgets/core"; +import { useConfirmModal } from "@homarr/modals"; import { showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; -import { isRecord, parseJson, parseSources } from "./_custom-widget-form-utils"; +import { filterSecretsForSourceAuthentication, isRecord, parseJson, parseSources } from "./_custom-widget-form-utils"; import type { CustomWidgetWorkbenchForm } from "./_custom-widget-form-utils"; -import { CustomWidgetIdentifierInput } from "./_custom-widget-identifier-input"; - -const secretFields: Record> = { - bearer: [{ kind: "apiKey" }], - basic: [{ kind: "username" }, { kind: "password" }], - apiKeyHeader: [{ kind: "apiKey" }], - apiKeyQuery: [{ kind: "apiKey" }], -}; - -const sourceUrlErrorKeys: Record = { - invalid: "baseUrlError.invalid", - protocol: "baseUrlError.protocol", - credentials: "baseUrlError.credentials", - queryOrFragment: "baseUrlError.queryOrFragment", -}; +import { getDependentRequestIds, removeDependentRequests } from "./_custom-widget-source-dependencies"; +import { CustomWidgetSourceField } from "./_custom-widget-source-field"; export function CustomWidgetSourcesEditor({ form, @@ -35,6 +22,8 @@ export function CustomWidgetSourcesEditor({ definitionId?: string; }) { const t = useScopedI18n("customWidget.workbench.sources"); + const w = useScopedI18n("customWidget.workbench"); + const { openConfirmModal } = useConfirmModal(); const utils = clientApi.useUtils(); const clearSecretMutation = clientApi.customWidget.secretClear.useMutation(); const sources = parseSources(form.values.sources); @@ -83,6 +72,14 @@ export function CustomWidgetSourcesEditor({ const auth = type === "apiKeyHeader" ? { type, name: "X-API-Key" } : type === "apiKeyQuery" ? { type, name: "api_key" } : type; update(index, { auth: auth as CustomWidgetSource["auth"] }); + form.setFieldValue( + "secrets", + filterSecretsForSourceAuthentication( + form.values.secrets, + sources[index]?.id ?? "", + type as Parameters[2], + ), + ); }; const setSecret = (sourceId: string, kind: string, value: string) => { const current = form.values.secrets.filter((secret) => !(secret.sourceId === sourceId && secret.kind === kind)); @@ -111,7 +108,7 @@ export function CustomWidgetSourcesEditor({ ), ); }; - const removeSource = (index: number) => { + const removeSourceAndDependents = (index: number) => { const removedId = sources[index]?.id; form.setFieldValue( "sources", @@ -122,25 +119,38 @@ export function CustomWidgetSourcesEditor({ ), ); if (!removedId) return; - form.setFieldValue( - "requests", - JSON.stringify( - Object.fromEntries( - Object.entries( - isRecord(parseJson(form.values.requests)) - ? (parseJson(form.values.requests) as Record) - : {}, - ).filter(([, request]) => !isRecord(request) || request.source !== removedId), - ), - null, - 2, - ), - ); + form.setFieldValue("requests", JSON.stringify(removeDependentRequests(form.values.requests, removedId), null, 2)); form.setFieldValue( "secrets", form.values.secrets.filter((secret) => secret.sourceId !== removedId), ); }; + const removeSource = (index: number) => { + const removedId = sources[index]?.id; + if (!removedId) return; + const dependentRequestIds = getDependentRequestIds(form.values.requests, removedId); + if (dependentRequestIds.length === 0) { + removeSourceAndDependents(index); + return; + } + openConfirmModal({ + title: t("remove"), + children: ( + + + {w("requests.title")} ({dependentRequestIds.length}) + + + {dependentRequestIds.map((requestId) => ( + {requestId} + ))} + + + ), + confirmProps: { children: t("remove") }, + onConfirm: () => removeSourceAndDependents(index), + }); + }; const clearSecret = async (sourceId: string, kind: "apiKey" | "username" | "password") => { if (!definitionId) return; await clearSecretMutation.mutateAsync({ definitionId, sourceId, kind }); @@ -153,109 +163,20 @@ export function CustomWidgetSourcesEditor({ return ( {sources.map((source, index) => { - const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl); return ( -
- - - update(index, { id: value })} - /> - update(index, { name: event.currentTarget.value })} - /> - - update(index, { baseUrl: event.currentTarget.value })} - /> - - setAuth(index, value ?? "none")} - allowDeselect={false} - /> - - {typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && ( - - update(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } }) - } - /> - )} - {typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && ( - - update(index, { auth: { type: "apiKeyQuery", name: event.currentTarget.value } }) - } - /> - )} - {(secretFields[typeof source.auth === "string" ? source.auth : source.auth.type] ?? []).map((field) => { - const secret = form.values.secrets.find( - (entry) => entry.sourceId === source.id && entry.kind === field.kind, - ); - const Input = field.kind === "username" ? TextInput : PasswordInput; - return ( - - } - onChange={(event) => setSecret(source.id, field.kind, event.currentTarget.value)} - /> - {definitionId && secret?.hasValue && ( - - )} - - ); - })} - {index > 0 && ( - - )} - -
+ ); })} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_import-custom-widget-button.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_import-custom-widget-button.tsx new file mode 100644 index 000000000..654ed6d4b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_import-custom-widget-button.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useDisclosure } from "@mantine/hooks"; +import { IconUpload } from "@tabler/icons-react"; + +import { + formatCustomWidgetImportIssues, + looksLikeCustomWidgetClipboard, + parseCustomWidgetClipboardDetailed, +} from "@homarr/custom-widgets/core"; +import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; +import { showErrorNotification } from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog"; +import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; + +export const ImportCustomWidgetButton = () => { + const t = useScopedI18n("customWidget"); + const fileInputRef = useRef(null); + const [pendingImport, setPendingImport] = useState(null); + const [reviewOpened, { open: openReview, close: closeReview }] = useDisclosure(false); + const queueImport = useCallback( + (value: HomarrCustomWidgetV2) => { + setPendingImport(value); + openReview(); + }, + [openReview], + ); + + useEffect(() => { + const handlePaste = (event: ClipboardEvent) => { + if (event.target instanceof Element && event.target.matches("input, textarea, [contenteditable='true']")) return; + const text = event.clipboardData?.getData("text/plain") ?? ""; + if (!looksLikeCustomWidgetClipboard(text)) return; + event.preventDefault(); + const result = parseCustomWidgetClipboardDetailed(text); + if (!result.success) { + showErrorNotification({ + title: t("action.import"), + message: formatCustomWidgetImportIssues(result.issues), + }); + return; + } + queueImport(result.widget); + }; + window.addEventListener("paste", handlePaste); + return () => window.removeEventListener("paste", handlePaste); + }, [queueImport, t]); + + const handleImport = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.addEventListener("load", (loadEvent) => { + try { + const result = parseCustomWidgetClipboardDetailed(loadEvent.target?.result as string); + if (!result.success) throw new Error(formatCustomWidgetImportIssues(result.issues)); + queueImport(result.widget); + } catch (error) { + showErrorNotification({ + title: t("action.import"), + message: error instanceof Error ? error.message : t("notification.importError"), + }); + } + }); + reader.addEventListener("error", () => { + showErrorNotification({ title: t("action.import"), message: t("notification.importError") }); + }); + reader.readAsText(file); + event.target.value = ""; + }; + + return ( + <> + } + onClick={() => fileInputRef.current?.click()} + title={t("action.pasteImportHint")} + > + {t("action.import")} + + + { + closeReview(); + setPendingImport(null); + }} + /> + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.ts new file mode 100644 index 000000000..280138de8 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_unsaved-changes-guard.ts @@ -0,0 +1,42 @@ +interface UnsavedChangesGuardOptions { + isDirty(): boolean; + confirmNavigation(href: string): void; +} + +export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) { + const handleClick = (event: MouseEvent) => { + if (!isDirty() || event.defaultPrevented || event.button !== 0) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + + const eventTarget = event.target instanceof Element ? event.target : null; + const anchor = eventTarget?.closest("a[href]"); + if (!anchor || anchor.target === "_blank" || anchor.hasAttribute("download")) return; + + const destination = new URL(anchor.href, window.location.href); + const current = new URL(window.location.href); + if (destination.origin !== current.origin) return; + if ( + destination.pathname === current.pathname && + destination.search === current.search && + destination.hash !== current.hash + ) { + return; + } + + event.preventDefault(); + event.stopImmediatePropagation(); + confirmNavigation(`${destination.pathname}${destination.search}${destination.hash}`); + }; + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!isDirty()) return; + event.preventDefault(); + event.returnValue = true; + }; + + document.addEventListener("click", handleClick, true); + window.addEventListener("beforeunload", handleBeforeUnload); + return () => { + document.removeEventListener("click", handleClick, true); + window.removeEventListener("beforeunload", handleBeforeUnload); + }; +} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-unsaved-changes-guard.spec.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-unsaved-changes-guard.spec.ts new file mode 100644 index 000000000..353806380 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-unsaved-changes-guard.spec.ts @@ -0,0 +1,51 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard"; + +const cleanups: Array<() => void> = []; + +afterEach(() => { + cleanups.splice(0).forEach((cleanup) => cleanup()); + document.body.replaceChildren(); + window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1"); +}); + +describe("custom widget unsaved changes guard", () => { + it("blocks client-side link navigation and requests confirmation for a dirty form", () => { + window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1"); + const confirmNavigation = vi.fn(); + cleanups.push(registerUnsavedChangesGuard({ isDirty: () => true, confirmNavigation })); + const link = document.createElement("a"); + link.href = "/en/manage/custom-widgets"; + link.textContent = "Custom widgets"; + document.body.append(link); + + const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }); + link.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(confirmNavigation).toHaveBeenCalledWith("/en/manage/custom-widgets"); + }); + + it("allows section links and navigation after the form is clean", () => { + window.history.replaceState(null, "", "/en/manage/custom-widgets/edit/widget-1"); + let dirty = true; + const confirmNavigation = vi.fn(); + cleanups.push(registerUnsavedChangesGuard({ isDirty: () => dirty, confirmNavigation })); + const sectionLink = document.createElement("a"); + sectionLink.href = "#sources"; + document.body.append(sectionLink); + + expect(sectionLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe( + true, + ); + dirty = false; + const listLink = document.createElement("a"); + listLink.href = "/en/manage/custom-widgets"; + document.body.append(listLink); + expect(listLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(true); + expect(confirmNavigation).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-unsaved-changes-guard.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-unsaved-changes-guard.ts new file mode 100644 index 000000000..c69a845db --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-unsaved-changes-guard.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; + +import { useConfirmModal } from "@homarr/modals"; +import { useI18n } from "@homarr/translation/client"; +import { registerUnsavedChangesGuard } from "./_unsaved-changes-guard"; + +export function useUnsavedChangesGuard(isDirty: boolean) { + const t = useI18n(); + const router = useRouter(); + const { openConfirmModal } = useConfirmModal(); + const dirtyRef = useRef(isDirty); + const confirmNavigationRef = useRef<(href: string) => void>(() => undefined); + dirtyRef.current = isDirty; + confirmNavigationRef.current = (href) => { + openConfirmModal({ + title: t("board.action.edit.confirmLeave.title"), + children: t("board.action.edit.confirmLeave.message"), + confirmProps: { children: t("common.action.discard") }, + onConfirm: () => router.push(href), + }); + }; + + useEffect( + () => + registerUnsavedChangesGuard({ + isDirty: () => dirtyRef.current, + confirmNavigation: (href) => confirmNavigationRef.current(href), + }), + [], + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx index 8c71cf593..bcc20920b 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx @@ -11,7 +11,7 @@ import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { CustomWidgetBetaBanner } from "./_beta-banner"; import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button"; -import { ImportCustomWidgetButton } from "./_custom-widget-actions"; +import { ImportCustomWidgetButton } from "./_import-custom-widget-button"; import { CustomWidgetList } from "./_custom-widget-list"; export default async function CustomWidgetsPage() { diff --git a/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts b/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts index a3dd83c79..2d5e58d10 100644 --- a/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts +++ b/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts @@ -16,6 +16,8 @@ import { customWidgetSourceSchema, hasSameCustomWidgetSourceAuthentication } fro import { and, db, eq, handleTransactionsAsync } from "@homarr/db"; import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema"; +import { readConfigurationRequestBody } from "../body"; + interface RouteContext { params: Promise<{ token: string }>; } @@ -44,15 +46,18 @@ export async function POST(request: NextRequest, context: RouteContext) { if (!pending || pending.status !== "pending") { return NextResponse.json({ error: "This credential request is invalid, completed, or expired." }, { status: 404 }); } - const body = (await request.json().catch(() => null)) as { - baseUrl?: unknown; - networkScope?: unknown; - secrets?: Record; - } | null; + const parsedBody = await readConfigurationRequestBody(request); + if (parsedBody.status === "too-large") { + return NextResponse.json({ error: "The configuration payload is too large." }, { status: 413 }); + } + if (parsedBody.status !== "ok") { + return NextResponse.json({ error: "Enter a valid source configuration." }, { status: 400 }); + } + const body = parsedBody.data; const sourceResult = customWidgetSourceSchema.safeParse({ ...pending.source, - baseUrl: body?.baseUrl, - networkScope: body?.networkScope, + baseUrl: body.baseUrl, + networkScope: body.networkScope, }); if (!sourceResult.success) { return NextResponse.json( @@ -61,7 +66,7 @@ export async function POST(request: NextRequest, context: RouteContext) { ); } const secrets = pending.kinds.flatMap((kind) => { - const value = body?.secrets?.[kind]; + const value = body.secrets[kind]; return typeof value === "string" && value.length > 0 && value.length <= 8192 ? [{ sourceId: pending.sourceId, kind, value }] : []; diff --git a/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.spec.ts b/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.spec.ts new file mode 100644 index 000000000..a178774ba --- /dev/null +++ b/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { MAX_CONFIGURATION_REQUEST_BODY_BYTES, readConfigurationRequestBody } from "./body"; + +describe("custom widget configuration request body", () => { + it("rejects a streaming payload once it exceeds the byte budget", async () => { + const request = new Request("http://localhost/configure", { + method: "POST", + body: JSON.stringify({ + baseUrl: "https://example.com", + networkScope: "public", + secrets: { apiKey: "x".repeat(MAX_CONFIGURATION_REQUEST_BODY_BYTES) }, + }), + }); + + await expect(readConfigurationRequestBody(request)).resolves.toEqual({ status: "too-large" }); + }); + + it("strictly parses a bounded configuration object", async () => { + const body = { baseUrl: "https://example.com", networkScope: "public", secrets: { apiKey: "secret" } }; + await expect( + readConfigurationRequestBody( + new Request("http://localhost/configure", { method: "POST", body: JSON.stringify(body) }), + ), + ).resolves.toEqual({ status: "ok", data: body }); + + await expect( + readConfigurationRequestBody( + new Request("http://localhost/configure", { + method: "POST", + body: JSON.stringify({ ...body, unexpected: true }), + }), + ), + ).resolves.toEqual({ status: "invalid" }); + }); +}); diff --git a/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.ts b/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.ts new file mode 100644 index 000000000..dd061d1ec --- /dev/null +++ b/apps/nextjs/src/app/api/custom-widgets/configuration-request/body.ts @@ -0,0 +1,52 @@ +import { z } from "zod/v4"; + +export const MAX_CONFIGURATION_REQUEST_BODY_BYTES = 24 * 1024; +const configurationRequestBodySchema = z.strictObject({ + baseUrl: z.string(), + networkScope: z.enum(["public", "private", "loopback"]), + secrets: z.record(z.string(), z.string()), +}); + +export async function readConfigurationRequestBody( + request: Request, +): Promise< + { status: "ok"; data: z.infer } | { status: "invalid" | "too-large" } +> { + const contentLength = request.headers.get("content-length"); + if (contentLength && Number(contentLength) > MAX_CONFIGURATION_REQUEST_BODY_BYTES) { + return { status: "too-large" }; + } + if (!request.body) return { status: "invalid" }; + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let byteLength = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + byteLength += value.byteLength; + if (byteLength > MAX_CONFIGURATION_REQUEST_BODY_BYTES) { + await reader.cancel(); + return { status: "too-large" }; + } + chunks.push(value); + } + } catch { + return { status: "invalid" }; + } + + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + const candidate = JSON.parse(new TextDecoder().decode(bytes)) as unknown; + const result = configurationRequestBodySchema.safeParse(candidate); + return result.success ? { status: "ok", data: result.data } : { status: "invalid" }; + } catch { + return { status: "invalid" }; + } +} diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts new file mode 100644 index 000000000..4621d0542 --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts @@ -0,0 +1,196 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + buildPrompt: vi.fn((request?: string, documentationUrl?: string) => + [`Request: ${request ?? ""}`, `Documentation: ${documentationUrl ?? ""}`].join("\n"), + ), + getComponent: vi.fn((name: string) => (name === "Text Input" ? { name, safety: "allowed" } : null)), +})); + +vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) })); +vi.mock("@homarr/api/mcp", () => ({ + createTRPCContext: vi.fn(() => ({})), + mcpRouter: { createCaller: vi.fn(() => ({})) }, +})); +vi.mock("@homarr/auth/api-key", () => ({ + API_KEY_HEADER_NAME: "ApiKey", + getSessionFromApiKeyAsync: mocks.authenticate, +})); +vi.mock("@homarr/common", () => ({ extractBaseUrlFromHeaders: () => "http://localhost" })); +vi.mock("@homarr/common/server", () => ({ ipAddressFromHeaders: () => "127.0.0.1" })); +vi.mock("@homarr/core/infrastructure/logs", () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); +vi.mock("@homarr/custom-widgets/authoring-prompt", () => ({ buildCustomWidgetMcpPrompt: mocks.buildPrompt })); +vi.mock("@homarr/custom-widgets/authoring-resources", () => ({ + getCustomWidgetComponent: mocks.getComponent, + getCustomWidgetComponentCatalog: () => ({ components: [{ name: "Text Input" }] }), + getCustomWidgetSkillContent: () => "# Custom Widget Skill", +})); +vi.mock("@homarr/custom-widgets/core", () => ({ + customJsxExamples: [{ id: "status-card", title: "Status card", template: "Status" }], + getCustomWidgetJsonSchema: () => ({ type: "object", title: "Custom Widget" }), +})); +vi.mock("@homarr/db", () => ({ db: {} })); +vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" })); +vi.mock("../_extract-tools", () => ({ extractMcpTools: () => [] })); + +import { POST } from "./route"; + +interface JsonRpcResponse { + jsonrpc: "2.0"; + id: number | null; + result?: Record; + error?: { code: number; message: string }; +} + +beforeEach(() => { + mocks.authenticate.mockResolvedValue({ user: { id: "user-1" } }); + mocks.buildPrompt.mockClear(); + mocks.getComponent.mockClear(); +}); + +async function callMcp(method: string, params: Record = {}, id = 1) { + const request = new Request("http://localhost/api/mcp/mcp", { + method: "POST", + headers: { + Accept: "application/json, text/event-stream", + ApiKey: "key.secret", + "Content-Type": "application/json", + }, + body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), + }); + const response = await POST(request as never); + const text = await response.text(); + const data = response.headers.get("content-type")?.includes("text/event-stream") + ? text + .split("\n") + .find((line) => line.startsWith("data: ")) + ?.slice("data: ".length) + : text; + if (!data) throw new Error(`MCP response did not contain JSON-RPC data: ${text}`); + return { response, body: JSON.parse(data) as JsonRpcResponse }; +} + +describe("authenticated MCP prompt protocol", () => { + test("initializes and advertises prompt and resource capabilities", async () => { + const { response, body } = await callMcp("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "route-test", version: "1.0.0" }, + }); + + expect(response.status).toBe(200); + expect(body.error).toBeUndefined(); + expect(body.result).toMatchObject({ + protocolVersion: expect.any(String), + capabilities: { prompts: {}, resources: {} }, + serverInfo: { name: "homarr", version: "test-version" }, + }); + expect(mocks.authenticate).toHaveBeenCalledWith({}, "key.secret", "127.0.0.1", "MCP route test"); + }); + + test("lists and interpolates the custom-widget authoring prompt", async () => { + const listed = await callMcp("prompts/list"); + expect(listed.body.result).toEqual({ + prompts: [ + { + name: "homarr-custom-widget-author", + description: "Author and iterate on one Homarr Custom JSX v2 widget.", + arguments: [ + { name: "request", description: "The widget the user wants.", required: false }, + { name: "documentationUrl", description: "External API documentation URL.", required: false }, + ], + }, + ], + }); + + const rendered = await callMcp("prompts/get", { + name: "homarr-custom-widget-author", + arguments: { request: "Build a status card", documentationUrl: "https://docs.example.test/api" }, + }); + expect(rendered.body.result).toMatchObject({ + description: "Current Homarr Custom Widget authoring workflow.", + messages: [ + { + role: "user", + content: { + type: "text", + text: "Request: Build a status card\nDocumentation: https://docs.example.test/api", + }, + }, + ], + }); + expect(mocks.buildPrompt).toHaveBeenCalledWith("Build a status card", "https://docs.example.test/api"); + }); +}); + +describe("authenticated MCP resource protocol", () => { + test("lists concrete resources and URI templates with reviewed MIME types", async () => { + const listed = await callMcp("resources/list"); + expect(listed.body.result).toEqual({ + resources: [ + { uri: "homarr://custom-widgets/schema", name: "Custom Widget schema", mimeType: "application/schema+json" }, + { uri: "homarr://custom-widgets/components", name: "Custom Widget components", mimeType: "application/json" }, + { uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" }, + { + uri: "homarr://custom-widgets/examples/status-card", + name: "Custom Widget example: Status card", + mimeType: "application/json", + }, + ], + }); + + const templates = await callMcp("resources/templates/list"); + expect(templates.body.result).toEqual({ + resourceTemplates: [ + expect.objectContaining({ + uriTemplate: "homarr://custom-widgets/components/{name}", + mimeType: "application/json", + }), + expect.objectContaining({ + uriTemplate: "homarr://custom-widgets/examples/{name}", + mimeType: "application/json", + }), + ], + }); + }); + + test.each([ + ["homarr://custom-widgets/schema", "application/schema+json", { type: "object", title: "Custom Widget" }], + ["homarr://custom-widgets/components", "application/json", { components: [{ name: "Text Input" }] }], + ["homarr://custom-widgets/skill", "text/markdown", "# Custom Widget Skill"], + [ + "homarr://custom-widgets/examples/status-card", + "application/json", + { id: "status-card", title: "Status card", template: "Status" }, + ], + ["homarr://custom-widgets/components/Text%20Input", "application/json", { name: "Text Input", safety: "allowed" }], + ])("dispatches %s with its MIME type", async (uri, mimeType, expected) => { + const { body } = await callMcp("resources/read", { uri }); + const contents = body.result?.contents as Array<{ uri: string; mimeType: string; text: string }> | undefined; + const content = contents?.[0]; + expect(content).toMatchObject({ uri, mimeType }); + expect(mimeType === "text/markdown" ? content?.text : JSON.parse(content?.text ?? "null")).toEqual(expected); + }); +}); + +describe("MCP protocol errors", () => { + test.each([ + ["prompts/get", { name: "unknown" }], + ["resources/read", { uri: "homarr://custom-widgets/unknown" }], + ])("returns JSON-RPC errors for unknown %s targets", async (method, params) => { + const { body } = await callMcp(method, params); + expect(body.result).toBeUndefined(); + expect(body.error).toEqual(expect.objectContaining({ code: expect.any(Number), message: expect.any(String) })); + }); + + test("returns a protocol error for a malformed resource request", async () => { + const { body } = await callMcp("resources/read", {}); + expect(body.result).toBeUndefined(); + expect(body.error).toEqual(expect.objectContaining({ code: -32603, message: expect.any(String) })); + }); +}); diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.ts index 0ab7ba73d..acfff14d1 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.ts @@ -249,8 +249,10 @@ const mcpHandler = createMcpHandler( const { uri } = request.params; let text: string; let mimeType = "application/json"; - if (uri === "homarr://custom-widgets/schema") text = JSON.stringify(getCustomWidgetJsonSchema()); - else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog()); + if (uri === "homarr://custom-widgets/schema") { + text = JSON.stringify(getCustomWidgetJsonSchema()); + mimeType = "application/schema+json"; + } else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog()); else if (uri === "homarr://custom-widgets/skill") { text = getCustomWidgetSkillContent(); mimeType = "text/markdown"; diff --git a/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx b/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx index 417cec9c6..f406a99be 100644 --- a/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx +++ b/apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx @@ -24,6 +24,7 @@ import { useScopedI18n } from "@homarr/translation/client"; interface CustomWidgetImportDialogProps { opened: boolean; widget: HomarrCustomWidgetV2 | null; + legacyId?: string; stackId?: string; zIndex?: number; onClose(): void; @@ -33,6 +34,7 @@ interface CustomWidgetImportDialogProps { export function CustomWidgetImportDialog({ opened, widget, + legacyId, stackId, zIndex, onClose, @@ -59,19 +61,27 @@ export function CustomWidgetImportDialog({ }; }, [values, widget]); const review = useMemo(() => getImportReview(configuredWidget), [configuredWidget]); - const mutation = clientApi.customWidget.import.useMutation({ - onSuccess: (result) => { - showSuccessNotification({ title: t("action.import"), message: t("notification.imported") }); - void utils.customWidget.list.invalidate(); - void utils.customWidget.available.invalidate(); - void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh()); - onImported?.(result); - onClose(); - }, - onError: (error) => { - showErrorNotification({ title: t("action.import"), message: error.message || t("notification.importError") }); - }, - }); + const onSuccess = (result: { id: string }) => { + showSuccessNotification({ + title: legacyId ? t("action.migrate") : t("action.import"), + message: legacyId ? t("notification.migrated") : t("notification.imported"), + }); + void utils.customWidget.list.invalidate(); + void utils.customWidget.available.invalidate(); + void utils.widget.customApi.getData.invalidate(); + void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh()); + onImported?.(result); + onClose(); + }; + const onError = (error: { message?: string }) => { + showErrorNotification({ + title: legacyId ? t("action.migrate") : t("action.import"), + message: error.message || (legacyId ? t("notification.migrationError") : t("notification.importError")), + }); + }; + const importMutation = clientApi.customWidget.import.useMutation({ onSuccess, onError }); + const migrateMutation = clientApi.customWidget.migrateLegacy.useMutation({ onSuccess, onError }); + const pending = importMutation.isPending || migrateMutation.isPending; useEffect(() => { if (opened) setValues(createCustomWidgetSourceSetupValues(setups)); @@ -84,7 +94,8 @@ export function CustomWidgetImportDialog({ secret?.trim() ? [{ sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [], ), ); - mutation.mutate({ widget: configuredWidget, secrets }); + if (legacyId) migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets }); + else importMutation.mutate({ widget: configuredWidget, secrets }); }; return ( @@ -93,13 +104,13 @@ export function CustomWidgetImportDialog({ stackId={stackId} zIndex={zIndex} review={review} - pending={mutation.isPending} + pending={pending} confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)} onClose={onClose} onConfirm={importWidget} messages={{ - title: t("importReview.title"), - description: t("importReview.description"), + title: legacyId ? t("importReview.migrationTitle") : t("importReview.title"), + description: legacyId ? t("importReview.migrationDescription") : t("importReview.description"), name: t("importReview.name"), origin: t("importReview.origin"), authentication: t("importReview.authentication"), @@ -109,7 +120,7 @@ export function CustomWidgetImportDialog({ actionWarningTitle: t("importReview.actionWarning.title"), actionWarningDescription: t("importReview.actionWarning.description"), cancel: t("importReview.cancel"), - confirm: t("importReview.confirm"), + confirm: legacyId ? t("importReview.confirmMigration") : t("importReview.confirm"), permission: (permission) => t(`preview.request.permission.${permission}` as never), }} > diff --git a/apps/nextjs/src/components/workshop/workshop-publish-definition.spec.ts b/apps/nextjs/src/components/workshop/workshop-publish-definition.spec.ts new file mode 100644 index 000000000..34b134d3b --- /dev/null +++ b/apps/nextjs/src/components/workshop/workshop-publish-definition.spec.ts @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { + getPrivateWorkshopSourceNames, + serializeWorkshopDefinition, + workshopDefinitionChanged, +} from "./workshop-publish-definition"; + +const definition = { + name: "Status", + sources: { + public: { name: "Public API", networkScope: "public" }, + private: { name: "Home server", networkScope: "private" }, + local: { networkScope: "loopback" }, + }, +}; + +describe("Workshop publish definition inspection", () => { + it("reports every source URL that requires review", () => { + expect(getPrivateWorkshopSourceNames(definition)).toEqual(["Home server", "local"]); + }); + + it("detects an export that changed after inspection", () => { + expect(workshopDefinitionChanged(definition, definition)).toBe(false); + expect(workshopDefinitionChanged(definition, { ...definition, name: "Updated" })).toBe(true); + }); + + it("submits the same refreshed export that passed the change check", () => { + const source = readFileSync( + `${process.cwd()}/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx`, + "utf8", + ); + expect(source).toContain("customWidget.export.useQuery"); + expect(source).toContain("await definition.refetch()"); + expect(source).toContain("workshopDefinitionChanged(inspectedDefinition, refreshed.data)"); + expect(source).toContain("content: serializeWorkshopDefinition(refreshed.data)"); + expect(source).toMatch(/disabled=\{[\s\S]*!definition\.data[\s\S]*definition\.isError/u); + expect(serializeWorkshopDefinition(definition)).toBe(JSON.stringify(definition)); + }); +}); diff --git a/apps/nextjs/src/components/workshop/workshop-publish-definition.ts b/apps/nextjs/src/components/workshop/workshop-publish-definition.ts new file mode 100644 index 000000000..583bfff00 --- /dev/null +++ b/apps/nextjs/src/components/workshop/workshop-publish-definition.ts @@ -0,0 +1,17 @@ +interface PublishableWorkshopDefinition { + sources: Record; +} + +export const serializeWorkshopDefinition = (definition: unknown): string => { + const serialized = JSON.stringify(definition); + if (serialized === undefined) throw new Error("Workshop definition is not serializable"); + return serialized; +}; + +export const workshopDefinitionChanged = (previous: unknown, current: unknown) => + serializeWorkshopDefinition(previous) !== serializeWorkshopDefinition(current); + +export const getPrivateWorkshopSourceNames = (definition: PublishableWorkshopDefinition | undefined) => + Object.entries(definition?.sources ?? {}).flatMap(([sourceId, source]) => + source.networkScope === "public" ? [] : [source.name ?? sourceId], + ); diff --git a/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx b/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx index c5b9a3662..dd612e0b9 100644 --- a/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx +++ b/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx @@ -10,6 +10,11 @@ import type { WorkshopUser } from "@homarr/workshop/schema"; import { useScopedI18n } from "@homarr/translation/client"; import { createWorkshopClient } from "./workshop-client"; +import { + getPrivateWorkshopSourceNames, + serializeWorkshopDefinition, + workshopDefinitionChanged, +} from "./workshop-publish-definition"; export function WorkshopPublishModal({ opened, @@ -22,7 +27,6 @@ export function WorkshopPublishModal({ }) { const t = useScopedI18n("workshop"); const client = useMemo(createWorkshopClient, []); - const utils = clientApi.useUtils(); const [user, setUser] = useState(null); const [title, setTitle] = useState(widget.name); const [description, setDescription] = useState(""); @@ -31,10 +35,9 @@ export function WorkshopPublishModal({ const [error, setError] = useState(null); const [published, setPublished] = useState(false); const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false); - const definition = clientApi.customWidget.get.useQuery({ id: widget.id }, { enabled: opened }); - const privateSources = Object.entries(definition.data?.sources ?? {}).filter( - ([, source]) => source.networkScope !== "public", - ); + const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: opened }); + const privateSourceNames = getPrivateWorkshopSourceNames(definition.data); + const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null; useEffect(() => { const unsubscribe = client.subscribeToAuth(setUser); @@ -50,13 +53,27 @@ export function WorkshopPublishModal({ setPublished(false); setSourceUrlsReviewed(false); }, [opened, widget.name]); + useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]); const publish = async () => { setError(null); try { - const definition = await utils.customWidget.export.fetch({ id: widget.id }); + if (!definition.data) throw new Error(t("publish.error")); + const inspectedDefinition = definition.data; + const refreshed = await definition.refetch(); + if (!refreshed.data) throw new Error(t("publish.error")); + if (workshopDefinitionChanged(inspectedDefinition, refreshed.data)) { + setSourceUrlsReviewed(false); + setError(t("publish.error")); + return; + } await createSubmission.mutateAsync({ - input: { type: "customWidget", title, description, content: JSON.stringify(definition) }, + input: { + type: "customWidget", + title, + description, + content: serializeWorkshopDefinition(refreshed.data), + }, screenshots, }); setPublished(true); @@ -113,10 +130,10 @@ export function WorkshopPublishModal({ value={screenshots} onChange={setScreenshots} /> - {privateSources.length > 0 && ( + {privateSourceNames.length > 0 && ( {t("publish.privateSourceWarning", { - sources: privateSources.map(([sourceId, source]) => source.name ?? sourceId).join(", "), + sources: privateSourceNames.join(", "), })} )} + {definition.isError && {t("publish.error")}} {error && {error}}