fix(custom-widgets): address v2 merge review
This commit is contained in:
1 parent
6aceb2fd1f
commit
48ce83806d
122 files changed
+8534
-1447
No files matched your search
@@ -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.
|
||||
|
||||
<CustomJsxComponentReference />
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ Because Workshop is still unreleased, reset an older development volume if it pr
|
||||
|
||||
GitHub needs only the central callback `https://<workshop-host>/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
|
||||
|
||||
|
||||
@@ -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<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
|
||||
const typeDotColors: Record<SubmissionType, string> = { 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;
|
||||
|
||||
@@ -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(<ScreenshotGallery urls={urls} title="Gallery" />));
|
||||
}
|
||||
|
||||
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<HTMLButtonElement>('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<HTMLImageElement>('img[alt="Gallery screenshot 3"]')?.src).toContain("/three.png");
|
||||
|
||||
await render(["/one.png"]);
|
||||
expect(host.querySelector<HTMLImageElement>('img[alt="Gallery screenshot 1"]')?.src).toContain("/one.png");
|
||||
expect(host.querySelector('img[alt="Gallery screenshot 3"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="group/gallery w-full">
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg border border-border bg-muted">
|
||||
<img className="h-full w-full object-contain" src={urls[idx]} alt={`${title} screenshot ${idx + 1}`} />
|
||||
<img
|
||||
className="h-full w-full object-contain"
|
||||
src={urls[selectedIndex]}
|
||||
alt={`${title} screenshot ${selectedIndex + 1}`}
|
||||
/>
|
||||
{urls.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
@@ -75,7 +84,7 @@ export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: stri
|
||||
<legend className="sr-only">Screenshot previews</legend>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{urls.map((url, i) => {
|
||||
const selected = i === idx;
|
||||
const selected = i === selectedIndex;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -31,6 +31,7 @@ import { cn, errorMessage } from "@/lib/utils";
|
||||
|
||||
import type { SubmitInput } from "./useWorkshop";
|
||||
import { ScreenshotEditor } from "./ScreenshotEditor";
|
||||
import { getJsonImportSubmissionType } from "./submission-import";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -104,7 +105,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (e) => {
|
||||
const text = e.target?.result as string;
|
||||
if (!type) setType("customWidget");
|
||||
setType(getJsonImportSubmissionType(type));
|
||||
setContentAndAutofill(text, title, description);
|
||||
if (step === 0) goTo(1);
|
||||
});
|
||||
@@ -400,6 +401,7 @@ const StepType = ({
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
aria-label="Choose a widget JSON file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
||||
|
||||
export const getJsonImportSubmissionType = (_currentType: SubmissionType | null): SubmissionType => "customWidget";
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<WorkshopVote | null>;
|
||||
get(submissionId: string): Promise<WorkshopSubmission>;
|
||||
listVotesForCurrentUser(): Promise<WorkshopVote[]>;
|
||||
}
|
||||
|
||||
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));
|
||||
+3010
-543
File diff suppressed because it is too large.
Load diff
@@ -1,18 +1,21 @@
|
||||
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 (
|
||||
<Alert variant="light" color="yellow" title="Custom Widgets are in Beta" icon={<IconFlask />}>
|
||||
<Alert variant="light" color="yellow" title={t("title")} icon={<IconFlask />}>
|
||||
<Text size="sm">
|
||||
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")}{" "}
|
||||
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
|
||||
official discussion thread
|
||||
{t("discussionLink")}
|
||||
</Anchor>{" "}
|
||||
will be taken into account. Happy Hacking!
|
||||
{t("closing")}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const [migratedWidget, setMigratedWidget] = useState<HomarrCustomWidgetV2 | null>(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<HTMLInputElement>) => {
|
||||
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 }) => {
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
|
||||
{t("action.copyMigrationPrompt")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => migrationFileInputRef.current?.click()}
|
||||
leftSection={<IconUpload {...iconProps} />}
|
||||
>
|
||||
{t("action.importMigration")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
disabled={deleteMutation.isPending || deleteLegacyMutation.isPending}
|
||||
>
|
||||
{t("action.delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImportCustomWidgetButton = () => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<>
|
||||
<MobileAffixButton
|
||||
variant="default"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t("action.pasteImportHint")}
|
||||
>
|
||||
{t("action.import")}
|
||||
</MobileAffixButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.md,text/markdown,application/json"
|
||||
hidden
|
||||
onChange={handleImport}
|
||||
aria-label={t("importReview.fileLabel")}
|
||||
/>
|
||||
<CustomWidgetImportDialog
|
||||
opened={reviewOpened}
|
||||
widget={pendingImport}
|
||||
onClose={() => {
|
||||
closeReview();
|
||||
setPendingImport(null);
|
||||
}}
|
||||
/>
|
||||
{widget.migrationRequired && (
|
||||
<>
|
||||
<input
|
||||
ref={migrationFileInputRef}
|
||||
type="file"
|
||||
accept=".json,.md,text/markdown,application/json"
|
||||
hidden
|
||||
onChange={handleMigrationFile}
|
||||
aria-label={t("action.importMigration")}
|
||||
/>
|
||||
<CustomWidgetImportDialog
|
||||
opened={migrationOpened}
|
||||
widget={migratedWidget}
|
||||
legacyId={widget.id}
|
||||
onClose={() => {
|
||||
migrationControls.close();
|
||||
setMigratedWidget(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof getCustomWidgetRequiredSecretKinds>[0],
|
||||
) {
|
||||
const requiredKinds = new Set<string>(getCustomWidgetRequiredSecretKinds(authType));
|
||||
return secrets.filter((secret) => secret.sourceId !== sourceId || requiredKinds.has(secret.kind));
|
||||
}
|
||||
|
||||
export function applyDefinition(form: CustomWidgetWorkbenchForm, widget: HomarrCustomWidgetV2) {
|
||||
form.setValues({
|
||||
...form.values,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
+28
@@ -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()");
|
||||
});
|
||||
});
|
||||
@@ -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 }) {
|
||||
<Card
|
||||
padding="sm"
|
||||
withBorder={!widget.valid}
|
||||
bd={!widget.valid ? "1px solid var(--mantine-color-red-6)" : undefined}
|
||||
bd={
|
||||
widget.migrationRequired
|
||||
? "1px solid var(--mantine-color-yellow-6)"
|
||||
: !widget.valid
|
||||
? "1px solid var(--mantine-color-red-6)"
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
opacity: widget.valid && !widget.enabled ? 0.55 : 1,
|
||||
transition: "opacity 150ms ease",
|
||||
@@ -89,18 +95,34 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{widget.name}
|
||||
</Text>
|
||||
{!widget.valid && (
|
||||
{widget.migrationRequired ? (
|
||||
<Text size="xs" c="yellow.8">
|
||||
{t("page.list.migrationDescription")}
|
||||
</Text>
|
||||
) : !widget.valid ? (
|
||||
<Text size="xs" c="red">
|
||||
{t("page.list.invalidDefinitionDescription")}
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
{widget.sources[0] && (
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
|
||||
{widget.sources.map((source) => source.origin).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{widget.valid ? (
|
||||
{widget.migrationRequired ? (
|
||||
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
|
||||
<Badge
|
||||
color="yellow"
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={12} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("page.list.migrationRequired")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : widget.valid ? (
|
||||
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
|
||||
JSX · {widget.requestCount}
|
||||
</Badge>
|
||||
@@ -166,7 +188,13 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
|
||||
</>
|
||||
)}
|
||||
<CustomWidgetRowActions
|
||||
widget={{ id: widget.id, name: widget.name, enabled: widget.enabled, valid: widget.valid }}
|
||||
widget={{
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
enabled: widget.enabled,
|
||||
valid: widget.valid,
|
||||
migrationRequired: widget.migrationRequired,
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
+21
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
+27
@@ -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<string, unknown> {
|
||||
try {
|
||||
const requests = JSON.parse(requestsJson) as unknown;
|
||||
return isRecord(requests) ? requests : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -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<string, Array<{ kind: SecretKind }>> = {
|
||||
bearer: [{ kind: "apiKey" }],
|
||||
basic: [{ kind: "username" }, { kind: "password" }],
|
||||
apiKeyHeader: [{ kind: "apiKey" }],
|
||||
apiKeyQuery: [{ kind: "apiKey" }],
|
||||
};
|
||||
|
||||
const sourceUrlErrorKeys: Record<CustomWidgetSourceUrlIssue, `baseUrlError.${CustomWidgetSourceUrlIssue}`> = {
|
||||
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<CustomWidgetSource> & { id?: string }): void;
|
||||
onSetAuthentication(index: number, type: string): void;
|
||||
onSetSecret(sourceId: string, kind: string, value: string): void;
|
||||
onClearSecret(sourceId: string, kind: SecretKind): Promise<void>;
|
||||
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 (
|
||||
<Fieldset legend={index === 0 ? t("primary") : source.name}>
|
||||
<Stack gap="sm">
|
||||
<Group grow align="start">
|
||||
<CustomWidgetIdentifierInput
|
||||
label={t("id")}
|
||||
value={source.id}
|
||||
disabled={source.id === "default" || Boolean(definitionId)}
|
||||
error={form.errors.sources}
|
||||
onCommit={(value) => onUpdate(index, { id: value })}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("name")}
|
||||
value={source.name}
|
||||
onChange={(event) => onUpdate(index, { name: event.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t("baseUrl")}
|
||||
type="url"
|
||||
value={source.baseUrl}
|
||||
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
|
||||
onChange={(event) => onUpdate(index, { baseUrl: event.currentTarget.value })}
|
||||
/>
|
||||
<Group grow align="start">
|
||||
<Select
|
||||
label={t("networkScope")}
|
||||
data={["public", "private", "loopback"]}
|
||||
value={source.networkScope}
|
||||
onChange={(value) =>
|
||||
value && onUpdate(index, { networkScope: value as CustomWidgetSource["networkScope"] })
|
||||
}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("authentication")}
|
||||
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
|
||||
value={authType}
|
||||
onChange={(value) => onSetAuthentication(index, value ?? "none")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
|
||||
<TextInput
|
||||
label={t("headerName")}
|
||||
value={source.auth.name}
|
||||
onChange={(event) => onUpdate(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })}
|
||||
/>
|
||||
)}
|
||||
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
|
||||
<TextInput
|
||||
label={t("queryParameter")}
|
||||
value={source.auth.name}
|
||||
onChange={(event) => 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 (
|
||||
<Group key={kind} align="end" wrap="nowrap">
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
label={t(`secret.${kind}`)}
|
||||
value={secret?.value ?? ""}
|
||||
placeholder={secret?.hasValue ? t("configured") : undefined}
|
||||
leftSection={<IconKey size={15} />}
|
||||
onChange={(event) => onSetSecret(source.id, kind, event.currentTarget.value)}
|
||||
/>
|
||||
{definitionId && secret?.hasValue && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
loading={clearSecretPending}
|
||||
onClick={() => void onClearSecret(source.id, kind)}
|
||||
>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{index > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => onRemove(index)}
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
+58
-137
@@ -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<string, Array<{ kind: "apiKey" | "username" | "password" }>> = {
|
||||
bearer: [{ kind: "apiKey" }],
|
||||
basic: [{ kind: "username" }, { kind: "password" }],
|
||||
apiKeyHeader: [{ kind: "apiKey" }],
|
||||
apiKeyQuery: [{ kind: "apiKey" }],
|
||||
};
|
||||
|
||||
const sourceUrlErrorKeys: Record<CustomWidgetSourceUrlIssue, `baseUrlError.${CustomWidgetSourceUrlIssue}`> = {
|
||||
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<typeof filterSecretsForSourceAuthentication>[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<string, unknown>)
|
||||
: {},
|
||||
).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: (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{w("requests.title")} ({dependentRequestIds.length})
|
||||
</Text>
|
||||
<List size="sm">
|
||||
{dependentRequestIds.map((requestId) => (
|
||||
<List.Item key={requestId}>{requestId}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
),
|
||||
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 (
|
||||
<Stack gap="sm">
|
||||
{sources.map((source, index) => {
|
||||
const baseUrlIssue = getCustomWidgetSourceUrlIssue(source.baseUrl);
|
||||
return (
|
||||
<Fieldset key={index} legend={index === 0 ? t("primary") : source.name}>
|
||||
<Stack gap="sm">
|
||||
<Group grow align="start">
|
||||
<CustomWidgetIdentifierInput
|
||||
label={t("id")}
|
||||
value={source.id}
|
||||
disabled={source.id === "default" || Boolean(definitionId)}
|
||||
error={form.errors.sources}
|
||||
onCommit={(value) => update(index, { id: value })}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("name")}
|
||||
value={source.name}
|
||||
onChange={(event) => update(index, { name: event.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t("baseUrl")}
|
||||
type="url"
|
||||
value={source.baseUrl}
|
||||
error={baseUrlIssue ? t(sourceUrlErrorKeys[baseUrlIssue]) : undefined}
|
||||
onChange={(event) => update(index, { baseUrl: event.currentTarget.value })}
|
||||
/>
|
||||
<Group grow align="start">
|
||||
<Select
|
||||
label={t("networkScope")}
|
||||
data={["public", "private", "loopback"]}
|
||||
value={source.networkScope}
|
||||
onChange={(value) =>
|
||||
value && update(index, { networkScope: value as CustomWidgetSource["networkScope"] })
|
||||
}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Select
|
||||
label={t("authentication")}
|
||||
data={["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"]}
|
||||
value={typeof source.auth === "string" ? source.auth : source.auth.type}
|
||||
onChange={(value) => setAuth(index, value ?? "none")}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
{typeof source.auth === "object" && source.auth.type === "apiKeyHeader" && (
|
||||
<TextInput
|
||||
label={t("headerName")}
|
||||
value={source.auth.name}
|
||||
onChange={(event) =>
|
||||
update(index, { auth: { type: "apiKeyHeader", name: event.currentTarget.value } })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{typeof source.auth === "object" && source.auth.type === "apiKeyQuery" && (
|
||||
<TextInput
|
||||
label={t("queryParameter")}
|
||||
value={source.auth.name}
|
||||
onChange={(event) =>
|
||||
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 (
|
||||
<Group key={field.kind} align="end" wrap="nowrap">
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
label={t(`secret.${field.kind}`)}
|
||||
value={secret?.value ?? ""}
|
||||
placeholder={secret?.hasValue ? t("configured") : undefined}
|
||||
leftSection={<IconKey size={15} />}
|
||||
onChange={(event) => setSecret(source.id, field.kind, event.currentTarget.value)}
|
||||
/>
|
||||
{definitionId && secret?.hasValue && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
loading={clearSecretMutation.isPending}
|
||||
onClick={() => void clearSecret(source.id, field.kind)}
|
||||
>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{index > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
variant="subtle"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={() => removeSource(index)}
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
<CustomWidgetSourceField
|
||||
key={source.id}
|
||||
source={source}
|
||||
index={index}
|
||||
form={form}
|
||||
definitionId={definitionId}
|
||||
clearSecretPending={clearSecretMutation.isPending}
|
||||
onUpdate={update}
|
||||
onSetAuthentication={setAuth}
|
||||
onSetSecret={setSecret}
|
||||
onClearSecret={clearSecret}
|
||||
onRemove={removeSource}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Accordion variant="contained">
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const [pendingImport, setPendingImport] = useState<HomarrCustomWidgetV2 | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<>
|
||||
<MobileAffixButton
|
||||
variant="default"
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title={t("action.pasteImportHint")}
|
||||
>
|
||||
{t("action.import")}
|
||||
</MobileAffixButton>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.md,text/markdown,application/json"
|
||||
hidden
|
||||
onChange={handleImport}
|
||||
aria-label={t("importReview.fileLabel")}
|
||||
/>
|
||||
<CustomWidgetImportDialog
|
||||
opened={reviewOpened}
|
||||
widget={pendingImport}
|
||||
onClose={() => {
|
||||
closeReview();
|
||||
setPendingImport(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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<HTMLAnchorElement>("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);
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
} | 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 }]
|
||||
: [];
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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<typeof configurationRequestBodySchema> } | { 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" };
|
||||
}
|
||||
}
|
||||
@@ -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: "<Text>Status</Text>" }],
|
||||
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<string, unknown>;
|
||||
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<string, unknown> = {}, 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: "<Text>Status</Text>" },
|
||||
],
|
||||
["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) }));
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
interface PublishableWorkshopDefinition {
|
||||
sources: Record<string, { name?: string; networkScope: string }>;
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
@@ -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<WorkshopUser | null>(null);
|
||||
const [title, setTitle] = useState(widget.name);
|
||||
const [description, setDescription] = useState("");
|
||||
@@ -31,10 +35,9 @@ export function WorkshopPublishModal({
|
||||
const [error, setError] = useState<string | null>(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 && (
|
||||
<Alert color="yellow">
|
||||
{t("publish.privateSourceWarning", {
|
||||
sources: privateSources.map(([sourceId, source]) => source.name ?? sourceId).join(", "),
|
||||
sources: privateSourceNames.join(", "),
|
||||
})}
|
||||
<Checkbox
|
||||
mt="sm"
|
||||
@@ -126,10 +143,17 @@ export function WorkshopPublishModal({
|
||||
/>
|
||||
</Alert>
|
||||
)}
|
||||
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
<Button
|
||||
loading={createSubmission.isPending}
|
||||
disabled={!user || title.trim().length < 3 || (privateSources.length > 0 && !sourceUrlsReviewed)}
|
||||
loading={createSubmission.isPending || definition.isFetching}
|
||||
disabled={
|
||||
!user ||
|
||||
!definition.data ||
|
||||
definition.isError ||
|
||||
title.trim().length < 3 ||
|
||||
(privateSourceNames.length > 0 && !sourceUrlsReviewed)
|
||||
}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
{t("publish.action")}
|
||||
|
||||
@@ -43,6 +43,9 @@ and SMTP transport in PocketBase. PocketBase emails the submission author about
|
||||
Workshop administrator about a new report, and emails the author after an administrator deletes their submission. Mail
|
||||
failures are logged and never reject a comment, report, or deletion.
|
||||
|
||||
GitHub OAuth settings are synchronized from the environment on every PocketBase startup. Restart the Workshop after
|
||||
rotating either credential; removing one or both values disables the provider instead of retaining stale credentials.
|
||||
|
||||
Run the disposable service integration test from the repository root:
|
||||
|
||||
```sh
|
||||
@@ -52,8 +55,9 @@ pnpm test:workshop
|
||||
The test uses a separate Compose project and deletes its named volume on exit. It never writes fixture users or admin
|
||||
roles into the normal `homarr-workshop_pb_data` volume.
|
||||
|
||||
The Workshop does not maintain a second Custom Widget schema. Its frontend validates submissions with
|
||||
`customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr validates canonical content again before installation.
|
||||
The Workshop frontend validates submissions with `customWidgetImportSchema` from `@homarr/custom-widgets`, and Homarr
|
||||
validates canonical content again before installation. PocketBase independently enforces publication invariants for
|
||||
direct API requests, owns schema/revision fields, and rejects stale updates using the submitted expected revision.
|
||||
The validated `$schema` is also stored as `widgetSchema` for listing compatibility badges; it must match the downloaded
|
||||
manifest before Homarr enables installation.
|
||||
PocketBase API rules enforce submission and comment ownership, one vote/report per user and submission, and
|
||||
|
||||
@@ -1,5 +1,101 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
|
||||
const CUSTOM_CSS_SCHEMA = "homarr-custom-css-v1";
|
||||
const MAX_CSS_LENGTH = 16_384;
|
||||
const MAX_CONTENT_LENGTH = 1_000_000;
|
||||
|
||||
const rejectRequest = (message) => {
|
||||
throw new BadRequestError(message);
|
||||
};
|
||||
|
||||
const requestBodyValue = (event, name) => {
|
||||
const body = event.requestInfo().body;
|
||||
return body && typeof body.get === "function" ? body.get(name) : body ? body[name] : undefined;
|
||||
};
|
||||
|
||||
const validateWidgetManifest = (content) => {
|
||||
let widget;
|
||||
try {
|
||||
widget = JSON.parse(content);
|
||||
} catch {
|
||||
rejectRequest("Widget content must be valid JSON");
|
||||
}
|
||||
if (!widget || Array.isArray(widget) || typeof widget !== "object") rejectRequest("Widget must be a JSON object");
|
||||
if (widget.$schema !== CUSTOM_WIDGET_SCHEMA) rejectRequest("Widget schema is not supported");
|
||||
if (typeof widget.name !== "string" || !widget.name.trim() || widget.name.length > 100)
|
||||
rejectRequest("Widget name is invalid");
|
||||
if (!widget.sources || Array.isArray(widget.sources) || typeof widget.sources !== "object" || !widget.sources.default)
|
||||
rejectRequest("Widget must define a default source");
|
||||
if (Object.keys(widget.sources).length > 8) rejectRequest("Widget has too many sources");
|
||||
for (const source of Object.values(widget.sources)) {
|
||||
if (!source || Array.isArray(source) || typeof source !== "object") rejectRequest("Widget source is invalid");
|
||||
if (typeof source.baseUrl !== "string" || !/^https?:\/\//.test(source.baseUrl))
|
||||
rejectRequest("Widget source URL is invalid");
|
||||
if (!["public", "private", "loopback"].includes(source.networkScope))
|
||||
rejectRequest("Widget source network scope is invalid");
|
||||
}
|
||||
if (!widget.requests || Array.isArray(widget.requests) || typeof widget.requests !== "object")
|
||||
rejectRequest("Widget requests are invalid");
|
||||
if (!widget.options || Array.isArray(widget.options) || typeof widget.options !== "object")
|
||||
rejectRequest("Widget options are invalid");
|
||||
if (typeof widget.template !== "string" || !widget.template.trim()) rejectRequest("Widget template is invalid");
|
||||
return JSON.stringify(widget);
|
||||
};
|
||||
|
||||
const validateAndNormalizeSubmission = (record) => {
|
||||
const type = record.getString("type");
|
||||
const content = record.getString("content");
|
||||
if (!content || content.length > MAX_CONTENT_LENGTH) rejectRequest("Submission content is invalid");
|
||||
record.set("title", record.getString("title").trim());
|
||||
record.set("description", record.getString("description").trim());
|
||||
record.set("changelog", record.getString("changelog").trim());
|
||||
if (type === "customCss") {
|
||||
if (!content.trim() || content.length > MAX_CSS_LENGTH) rejectRequest("Custom CSS is empty or too large");
|
||||
record.set("widgetSchema", CUSTOM_CSS_SCHEMA);
|
||||
return;
|
||||
}
|
||||
if (type !== "customWidget") rejectRequest("Submission type is invalid");
|
||||
record.set("content", validateWidgetManifest(content));
|
||||
record.set("widgetSchema", CUSTOM_WIDGET_SCHEMA);
|
||||
};
|
||||
|
||||
onBootstrap((event) => {
|
||||
event.next();
|
||||
const users = event.app.findCollectionByNameOrId("users");
|
||||
const clientId = String($os.getenv("GITHUB_CLIENT_ID") || "").trim();
|
||||
const clientSecret = String($os.getenv("GITHUB_CLIENT_SECRET") || "").trim();
|
||||
const configured = Boolean(clientId && clientSecret);
|
||||
users.oauth2.enabled = configured;
|
||||
users.oauth2.providers = configured ? [{ name: "github", clientId, clientSecret }] : [];
|
||||
event.app.save(users);
|
||||
});
|
||||
|
||||
onRecordCreateRequest((event) => {
|
||||
validateAndNormalizeSubmission(event.record);
|
||||
event.record.set("revision", 1);
|
||||
event.record.set("changelog", "");
|
||||
event.record.set("outdated", false);
|
||||
event.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordUpdateRequest((event) => {
|
||||
const original = event.record.original();
|
||||
const currentRevision = original.getInt("revision");
|
||||
const expectedRevision = Number(requestBodyValue(event, "expectedRevision"));
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== currentRevision) {
|
||||
rejectRequest("Submission changed since it was read");
|
||||
}
|
||||
validateAndNormalizeSubmission(event.record);
|
||||
event.record.set("revision", currentRevision + 1);
|
||||
event.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordCreateRequest((event) => {
|
||||
event.record.set("status", "open");
|
||||
event.next();
|
||||
}, "reports");
|
||||
|
||||
onRecordAfterCreateSuccess((event) => {
|
||||
try {
|
||||
const votes = event.app.findCollectionByNameOrId("votes");
|
||||
@@ -81,7 +177,7 @@ onRecordAfterCreateSuccess((event) => {
|
||||
const submission = event.app.findRecordById("submissions", event.record.get("submission"));
|
||||
const reporter = event.app.findRecordById("users", event.record.get("reporter"));
|
||||
const publicOrigin = $os.getenv("WORKSHOP_PUBLIC_ORIGIN").replace(/\/$/, "");
|
||||
const admins = event.app.findAllRecords("users").filter((user) => user.getBool("isAdmin") && Boolean(user.email()));
|
||||
const admins = event.app.findRecordsByFilter("users", "isAdmin = true && email != ''", "", 100, 0);
|
||||
|
||||
if (!publicOrigin || admins.length === 0) {
|
||||
event.next();
|
||||
|
||||
@@ -1,38 +1,66 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
let usersExisted = true;
|
||||
let users;
|
||||
try {
|
||||
users = app.findCollectionByNameOrId("users");
|
||||
} catch {
|
||||
usersExisted = false;
|
||||
users = new Collection({ type: "auth", name: "users" });
|
||||
}
|
||||
|
||||
users.passwordAuth = { enabled: false };
|
||||
if (!users.fields.getByName("displayName"))
|
||||
users.fields.add(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
|
||||
if (!users.fields.getByName("avatarUrl")) users.fields.add(new URLField({ name: "avatarUrl" }));
|
||||
if (!users.fields.getByName("avatar"))
|
||||
users.fields.add(
|
||||
new FileField({
|
||||
name: "avatar",
|
||||
maxSelect: 1,
|
||||
maxSize: 2_097_152,
|
||||
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
|
||||
}),
|
||||
);
|
||||
if (!users.fields.getByName("githubUsername"))
|
||||
users.fields.add(new TextField({ name: "githubUsername", max: 100 }));
|
||||
if (!users.fields.getByName("githubProfileUrl")) users.fields.add(new URLField({ name: "githubProfileUrl" }));
|
||||
if (!users.fields.getByName("isAdmin")) users.fields.add(new BoolField({ name: "isAdmin" }));
|
||||
const settings = app.settings();
|
||||
const stateCollection = new Collection({
|
||||
type: "base",
|
||||
name: "workshop_migration_state",
|
||||
listRule: null,
|
||||
viewRule: null,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [{ type: "text", name: "snapshot", required: true, max: 1_000_000 }],
|
||||
});
|
||||
app.save(stateCollection);
|
||||
const state = {
|
||||
usersExisted,
|
||||
users: usersExisted
|
||||
? {
|
||||
passwordAuth: cloneJson(users.passwordAuth),
|
||||
oauth2: cloneJson(users.oauth2),
|
||||
listRule: users.listRule,
|
||||
viewRule: users.viewRule,
|
||||
createRule: users.createRule,
|
||||
updateRule: users.updateRule,
|
||||
deleteRule: users.deleteRule,
|
||||
}
|
||||
: null,
|
||||
addedUserFields: [],
|
||||
rateLimits: cloneJson(settings.rateLimits),
|
||||
};
|
||||
const addUserField = (field) => {
|
||||
if (users.fields.getByName(field.name)) return;
|
||||
users.fields.add(field);
|
||||
state.addedUserFields.push(field.name);
|
||||
};
|
||||
|
||||
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
|
||||
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
|
||||
if (githubClientId && githubClientSecret) {
|
||||
users.oauth2.enabled = true;
|
||||
users.oauth2.providers = [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }];
|
||||
}
|
||||
users.passwordAuth = { enabled: false };
|
||||
addUserField(new TextField({ name: "displayName", required: true, min: 1, max: 100 }));
|
||||
addUserField(new URLField({ name: "avatarUrl" }));
|
||||
addUserField(
|
||||
new FileField({
|
||||
name: "avatar",
|
||||
maxSelect: 1,
|
||||
maxSize: 2_097_152,
|
||||
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
|
||||
}),
|
||||
);
|
||||
addUserField(new TextField({ name: "githubUsername", max: 100 }));
|
||||
addUserField(new URLField({ name: "githubProfileUrl" }));
|
||||
addUserField(new BoolField({ name: "isAdmin" }));
|
||||
|
||||
const adminRule = "@request.auth.isAdmin = true";
|
||||
users.listRule = "";
|
||||
@@ -41,6 +69,9 @@ migrate(
|
||||
users.updateRule = "id = @request.auth.id && @request.body.isAdmin:isset = false";
|
||||
users.deleteRule = null;
|
||||
app.save(users);
|
||||
const stateRecord = new Record(stateCollection);
|
||||
stateRecord.set("snapshot", JSON.stringify(state));
|
||||
app.save(stateRecord);
|
||||
|
||||
const submissions = new Collection({
|
||||
type: "base",
|
||||
@@ -195,7 +226,6 @@ migrate(
|
||||
});
|
||||
app.save(listings);
|
||||
|
||||
const settings = app.settings();
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
...settings.rateLimits.rules.filter(
|
||||
@@ -209,10 +239,43 @@ migrate(
|
||||
app.save(settings);
|
||||
},
|
||||
(app) => {
|
||||
let stateCollection;
|
||||
let stateRecord;
|
||||
try {
|
||||
stateCollection = app.findCollectionByNameOrId("workshop_migration_state");
|
||||
stateRecord = app.findFirstRecordByFilter(stateCollection.id, "");
|
||||
} catch {
|
||||
throw new Error("Workshop migration cannot be rolled back safely because its state snapshot is missing");
|
||||
}
|
||||
const state = JSON.parse(stateRecord.getString("snapshot"));
|
||||
for (const name of ["workshop_listings", "reports", "comments", "votes", "submissions"]) {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId(name));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const settings = app.settings();
|
||||
settings.rateLimits.enabled = state.rateLimits.enabled;
|
||||
settings.rateLimits.rules = state.rateLimits.rules;
|
||||
app.save(settings);
|
||||
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
if (state.usersExisted) {
|
||||
users.passwordAuth = state.users.passwordAuth;
|
||||
users.oauth2 = state.users.oauth2;
|
||||
users.listRule = state.users.listRule;
|
||||
users.viewRule = state.users.viewRule;
|
||||
users.createRule = state.users.createRule;
|
||||
users.updateRule = state.users.updateRule;
|
||||
users.deleteRule = state.users.deleteRule;
|
||||
for (const name of state.addedUserFields) {
|
||||
const field = users.fields.getByName(name);
|
||||
if (field) users.fields.removeById(field.id);
|
||||
}
|
||||
app.save(users);
|
||||
} else {
|
||||
app.delete(users);
|
||||
}
|
||||
app.delete(stateCollection);
|
||||
},
|
||||
);
|
||||
@@ -4,6 +4,9 @@ set -eu
|
||||
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
|
||||
WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
|
||||
|
||||
node apps/workshop/tests/workshop-contracts.mjs
|
||||
node apps/workshop/tests/workshop-migration.mjs
|
||||
|
||||
cleanup() {
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
@@ -30,3 +33,28 @@ docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml e
|
||||
workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data
|
||||
|
||||
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" node apps/workshop/tests/workshop.integration.mjs
|
||||
|
||||
for client_id in workshop-test-client workshop-rotated-client; do
|
||||
GITHUB_CLIENT_ID="$client_id" GITHUB_CLIENT_SECRET="workshop-test-secret" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" \
|
||||
docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml up -d --force-recreate workshop
|
||||
for attempt in $(seq 1 60); do
|
||||
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_TEST_PORT/api/health" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 60 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" EXPECTED_GITHUB_CLIENT_ID="$client_id" \
|
||||
node apps/workshop/tests/oauth-sync.integration.mjs
|
||||
done
|
||||
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml stop workshop
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint pocketbase workshop \
|
||||
migrate down 1 --dir=/pb_data --migrationsDir=/pb_migrations
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint pocketbase workshop \
|
||||
migrate up --dir=/pb_data --migrationsDir=/pb_migrations
|
||||
@@ -0,0 +1,27 @@
|
||||
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18090";
|
||||
const expectedClientId = process.env.EXPECTED_GITHUB_CLIENT_ID;
|
||||
if (!expectedClientId) throw new Error("EXPECTED_GITHUB_CLIENT_ID is required");
|
||||
|
||||
const request = async (path, init = {}) => {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init.headers },
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`);
|
||||
return body;
|
||||
};
|
||||
|
||||
const root = await request("/api/collections/_superusers/auth-with-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identity: "workshop-test@example.invalid", password: "WorkshopLocalTest123!" }),
|
||||
});
|
||||
const users = await request("/api/collections/users", {
|
||||
headers: { authorization: `Bearer ${root.token}` },
|
||||
});
|
||||
const github = users.oauth2?.providers?.find((provider) => provider.name === "github");
|
||||
if (!users.oauth2?.enabled || github?.clientId !== expectedClientId) {
|
||||
throw new Error(`GitHub OAuth was not synchronized for ${expectedClientId}`);
|
||||
}
|
||||
|
||||
console.log(`Workshop OAuth synchronization passed for ${expectedClientId}`);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const read = (path) => readFile(resolve(path), "utf8");
|
||||
|
||||
const packageJson = JSON.parse(await read("packages/workshop/package.json"));
|
||||
const clientExport = packageJson.exports?.["./client"];
|
||||
if (typeof clientExport !== "string") throw new Error("Workshop client export is missing");
|
||||
await access(resolve("packages/workshop", clientExport));
|
||||
|
||||
const hook = await read("apps/workshop/pb_hooks/workshop.pb.js");
|
||||
if (!hook.includes("onBootstrap") || !hook.includes("users.oauth2.providers = configured")) {
|
||||
throw new Error("Workshop OAuth settings must be synchronized at every bootstrap");
|
||||
}
|
||||
if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) {
|
||||
throw new Error("Workshop report notifications must use a filtered, bounded administrator query");
|
||||
}
|
||||
|
||||
const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js");
|
||||
for (const required of [
|
||||
"workshop_migration_state",
|
||||
"addedUserFields",
|
||||
"state.rateLimits.enabled",
|
||||
"users.passwordAuth = state.users.passwordAuth",
|
||||
"users.oauth2 = state.users.oauth2",
|
||||
]) {
|
||||
if (!migration.includes(required)) throw new Error(`Workshop rollback is missing state restoration: ${required}`);
|
||||
}
|
||||
|
||||
const workflow = await read(".github/workflows/workshop.yml");
|
||||
if (/\n\s+paths:/u.test(workflow)) throw new Error("Workshop workflow must not filter out Docker build inputs");
|
||||
|
||||
console.log("Workshop static contracts passed");
|
||||
@@ -0,0 +1,133 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import vm from "node:vm";
|
||||
|
||||
// oxlint-disable-next-line typescript/no-extraneous-class -- Mirrors the PocketBase Field constructor API.
|
||||
class Field {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
this.id = `field-${options.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
class Fields {
|
||||
constructor(fields = []) {
|
||||
this.values = fields.map((field) => (field instanceof Field ? field : new Field(field)));
|
||||
}
|
||||
getByName(name) {
|
||||
return this.values.find((field) => field.name === name);
|
||||
}
|
||||
add(field) {
|
||||
this.values.push(field);
|
||||
}
|
||||
removeById(id) {
|
||||
this.values = this.values.filter((field) => field.id !== id);
|
||||
}
|
||||
}
|
||||
|
||||
class Collection {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
this.id = options.id ?? options.name;
|
||||
this.fields = new Fields(options.fields);
|
||||
}
|
||||
addIndex() {}
|
||||
}
|
||||
|
||||
let recordId = 0;
|
||||
class Record {
|
||||
constructor(collection) {
|
||||
this.collection = collection;
|
||||
this.id = `record-${++recordId}`;
|
||||
this.data = new Map();
|
||||
}
|
||||
set(name, value) {
|
||||
this.data.set(name, value);
|
||||
}
|
||||
getString(name) {
|
||||
return String(this.data.get(name) ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
const original = {
|
||||
passwordAuth: { enabled: true, identityFields: ["email"] },
|
||||
oauth2: { enabled: true, providers: [{ name: "original", clientId: "before" }] },
|
||||
rules: {
|
||||
listRule: "original-list",
|
||||
viewRule: "original-view",
|
||||
createRule: "original-create",
|
||||
updateRule: "original-update",
|
||||
deleteRule: "original-delete",
|
||||
},
|
||||
rateLimits: { enabled: false, rules: [{ label: "original", audience: "", duration: 1, maxRequests: 1 }] },
|
||||
};
|
||||
const users = new Collection({ type: "auth", name: "users", fields: [{ type: "text", name: "existing" }] });
|
||||
users.passwordAuth = structuredClone(original.passwordAuth);
|
||||
users.oauth2 = structuredClone(original.oauth2);
|
||||
Object.assign(users, original.rules);
|
||||
const settings = { rateLimits: structuredClone(original.rateLimits) };
|
||||
const collections = new Map([[users.id, users]]);
|
||||
const records = new Map();
|
||||
const app = {
|
||||
findCollectionByNameOrId(name) {
|
||||
const collection = collections.get(name);
|
||||
if (!collection) throw new Error(`Missing collection ${name}`);
|
||||
return collection;
|
||||
},
|
||||
findFirstRecordByFilter(collectionId) {
|
||||
const record = records.get(collectionId)?.[0];
|
||||
if (!record) throw new Error(`Missing state record ${collectionId}`);
|
||||
return record;
|
||||
},
|
||||
save(value) {
|
||||
if (value instanceof Collection) collections.set(value.id, value);
|
||||
else if (value instanceof Record)
|
||||
records.set(value.collection.id, [...(records.get(value.collection.id) ?? []), value]);
|
||||
},
|
||||
delete(collection) {
|
||||
collections.delete(collection.id);
|
||||
records.delete(collection.id);
|
||||
},
|
||||
settings() {
|
||||
return settings;
|
||||
},
|
||||
};
|
||||
|
||||
let migration;
|
||||
const sandbox = {
|
||||
Collection,
|
||||
Record,
|
||||
TextField: Field,
|
||||
URLField: Field,
|
||||
FileField: Field,
|
||||
BoolField: Field,
|
||||
console,
|
||||
migrate: (up, down) => {
|
||||
migration = { up, down };
|
||||
},
|
||||
};
|
||||
const source = await readFile("apps/workshop/pb_migrations/1784240000_workshop_widgets.js", "utf8");
|
||||
vm.runInNewContext(source, sandbox);
|
||||
if (!migration) throw new Error("Workshop migration did not register");
|
||||
|
||||
migration.up(app);
|
||||
if (!collections.has("workshop_migration_state") || settings.rateLimits.enabled !== true) {
|
||||
throw new Error("Workshop migration did not capture state before applying changes");
|
||||
}
|
||||
migration.down(app);
|
||||
|
||||
if (JSON.stringify(users.passwordAuth) !== JSON.stringify(original.passwordAuth))
|
||||
throw new Error("Password authentication was not restored");
|
||||
if (JSON.stringify(users.oauth2) !== JSON.stringify(original.oauth2))
|
||||
throw new Error("OAuth settings were not restored");
|
||||
for (const [name, value] of Object.entries(original.rules)) {
|
||||
if (users[name] !== value) throw new Error(`User rule ${name} was not restored`);
|
||||
}
|
||||
if (users.fields.values.map((field) => field.name).join(",") !== "existing")
|
||||
throw new Error("Workshop user fields were not removed on rollback");
|
||||
if (JSON.stringify(settings.rateLimits) !== JSON.stringify(original.rateLimits))
|
||||
throw new Error("Rate limits were not restored");
|
||||
for (const name of ["workshop_migration_state", "workshop_listings", "reports", "comments", "votes", "submissions"]) {
|
||||
if (collections.has(name)) throw new Error(`Rollback left collection ${name}`);
|
||||
}
|
||||
|
||||
console.log("Workshop migration up/down restoration passed");
|
||||
@@ -105,21 +105,54 @@ const submission = await request("/api/collections/submissions/records", {
|
||||
type: "customWidget",
|
||||
title: "Workshop runtime probe",
|
||||
description: "PocketBase integration test",
|
||||
widgetSchema: widget.$schema,
|
||||
widgetSchema: "homarr-custom-css-v1",
|
||||
content: JSON.stringify(widget),
|
||||
author: author.id,
|
||||
revision: 1,
|
||||
changelog: "Initial publication",
|
||||
outdated: false,
|
||||
revision: 999,
|
||||
changelog: "Forged publication metadata",
|
||||
outdated: true,
|
||||
}),
|
||||
});
|
||||
if (
|
||||
submission.author !== author.id ||
|
||||
submission.title !== "Workshop runtime probe" ||
|
||||
submission.widgetSchema !== widget.$schema
|
||||
submission.widgetSchema !== widget.$schema ||
|
||||
submission.revision !== 1 ||
|
||||
submission.changelog !== "" ||
|
||||
submission.outdated !== false
|
||||
) {
|
||||
throw new Error("Submission publication failed");
|
||||
throw new Error("Submission publication was not validated and normalized server-side");
|
||||
}
|
||||
await expectStatus(
|
||||
"/api/collections/submissions/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({
|
||||
type: "customCss",
|
||||
title: "Oversized stylesheet",
|
||||
content: "x".repeat(16_385),
|
||||
widgetSchema: "homarr-custom-widget-v2",
|
||||
author: visitor.id,
|
||||
}),
|
||||
},
|
||||
400,
|
||||
);
|
||||
await expectStatus(
|
||||
"/api/collections/submissions/records",
|
||||
{
|
||||
method: "POST",
|
||||
headers: visitorSession.headers,
|
||||
body: JSON.stringify({
|
||||
type: "customWidget",
|
||||
title: "Invalid widget payload",
|
||||
content: ".dashboard { color: red; }",
|
||||
widgetSchema: "homarr-custom-widget-v2",
|
||||
author: visitor.id,
|
||||
}),
|
||||
},
|
||||
400,
|
||||
);
|
||||
const ownerVotes = await request("/api/collections/votes/records", { headers: authorSession.headers });
|
||||
if (
|
||||
ownerVotes.items.length !== 1 ||
|
||||
@@ -132,17 +165,30 @@ if (
|
||||
const updatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
|
||||
method: "PATCH",
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({ title: "Updated runtime probe" }),
|
||||
body: JSON.stringify({ title: "Updated runtime probe", expectedRevision: 1, revision: 500 }),
|
||||
});
|
||||
if (updatedSubmission.title !== "Updated runtime probe")
|
||||
if (updatedSubmission.title !== "Updated runtime probe" || updatedSubmission.revision !== 2)
|
||||
throw new Error("Authors must be able to edit their submission");
|
||||
const outdatedSubmission = await request(`/api/collections/submissions/records/${submission.id}`, {
|
||||
method: "PATCH",
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({ outdated: true, revision: 2, changelog: "Needs a newer API" }),
|
||||
body: JSON.stringify({ outdated: true, expectedRevision: 2, revision: 900, changelog: "Needs a newer API" }),
|
||||
});
|
||||
if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 2) {
|
||||
throw new Error("Authors must be able to mark submissions outdated and increment revisions");
|
||||
if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 3) {
|
||||
throw new Error("PocketBase must own submission revision increments");
|
||||
}
|
||||
|
||||
const concurrentUpdates = await Promise.all(
|
||||
["Concurrent first", "Concurrent second"].map((title) =>
|
||||
fetch(`${baseUrl}/api/collections/submissions/records/${submission.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json", ...authorSession.headers },
|
||||
body: JSON.stringify({ title, expectedRevision: 3 }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (concurrentUpdates.filter((response) => response.ok).length !== 1) {
|
||||
throw new Error("Exactly one update may consume an expected submission revision");
|
||||
}
|
||||
|
||||
await expectStatus(
|
||||
@@ -240,7 +286,7 @@ const report = await request("/api/collections/reports/records", {
|
||||
reporter: visitor.id,
|
||||
category: "other",
|
||||
explanation: "Runtime moderation test",
|
||||
status: "open",
|
||||
status: "dismissed",
|
||||
}),
|
||||
});
|
||||
await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers }, 404);
|
||||
@@ -267,7 +313,7 @@ await expectStatus(
|
||||
);
|
||||
|
||||
const reports = await request("/api/collections/reports/records", { headers: authorSession.headers });
|
||||
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id) {
|
||||
if (reports.items.length !== 1 || reports.items[0].reporter !== visitor.id || reports.items[0].status !== "open") {
|
||||
throw new Error("Workshop administrators must be able to review reports");
|
||||
}
|
||||
const dismissedReport = await request(`/api/collections/reports/records/${report.id}`, {
|
||||
|
||||
Reference in new issue
Block a user