Simplify Custom JSX v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-21 08:19:22 +02:00
1 parent 1faaf29c3b
commit 05dbad8069
177 files changed
+4775 -43426

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
authWithOAuth2: vi.fn(),
update: vi.fn(),
}));
vi.mock("pocketbase", () => {
class MockPocketBase {
public readonly authStore = {
record: null as Record<string, unknown> | null,
isValid: false,
save: (_token: string, record: Record<string, unknown>) => {
this.authStore.record = record;
},
};
public collection() {
return mocks;
}
}
return {
default: MockPocketBase,
ClientResponseError: class ClientResponseError extends Error {},
};
});
import { WorkshopClient } from "./client";
describe("WorkshopClient OAuth", () => {
beforeEach(() => {
mocks.authWithOAuth2.mockReset();
mocks.update.mockReset();
});
test("lets PocketBase own the complete GitHub popup flow", async () => {
mocks.authWithOAuth2.mockResolvedValue({
token: "token",
record: { id: "user-id" },
meta: { username: "octocat" },
});
mocks.update.mockResolvedValue({ id: "user-id", displayName: "octocat" });
const client = new WorkshopClient("https://workshop.example.com");
await client.signInWithGitHub();
expect(mocks.authWithOAuth2).toHaveBeenCalledWith({
provider: "github",
createData: { displayName: "GitHub user" },
});
expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("requestKey");
expect(mocks.authWithOAuth2.mock.calls[0]?.[0]).not.toHaveProperty("urlCallback");
});
});
+4 -46
View File
@@ -46,8 +46,6 @@ const requestSignal = (signal?: AbortSignal) =>
: [AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)],
);
const OAUTH_TIMEOUT_MS = 2 * 60 * 1000;
function oauthText(...values: unknown[]) {
return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim();
}
@@ -113,57 +111,17 @@ export class WorkshopClient {
}
public async signInWithGitHub() {
if (typeof window === "undefined" || typeof window.open !== "function") {
throw new Error("GitHub sign-in requires a browser popup");
}
const popup = window.open("about:blank", "homarr_workshop_oauth", "width=1024,height=768,resizable,menubar=no");
if (!popup) throw new Error("GitHub sign-in popup was blocked. Allow popups for Homarr and try again.");
const requestKey = `workshop-oauth-${Date.now()}-${Math.random().toString(36).slice(2)}`;
let cancellationError: Error | null = null;
let closePoll: ReturnType<typeof setInterval> | undefined;
let timeout: ReturnType<typeof setTimeout> | undefined;
const cancellation = new Promise<never>((_resolve, reject) => {
closePoll = setInterval(() => {
if (!popup.closed) return;
cancellationError = new Error("GitHub sign-in was cancelled.");
this.pocketBase.cancelRequest(requestKey);
reject(cancellationError);
}, 250);
timeout = setTimeout(() => {
cancellationError = new Error("GitHub sign-in timed out. Try again.");
this.pocketBase.cancelRequest(requestKey);
popup.close();
reject(cancellationError);
}, OAUTH_TIMEOUT_MS);
});
try {
const auth = await Promise.race([
this.pocketBase.collection("users").authWithOAuth2({
provider: "github",
createData: { displayName: "GitHub user" },
requestKey,
urlCallback: (url) => {
if (popup.closed) throw new Error("GitHub sign-in was cancelled.");
popup.location.href = url;
},
}),
cancellation,
]);
const auth = await this.pocketBase.collection("users").authWithOAuth2({
provider: "github",
createData: { displayName: "GitHub user" },
});
const profile = githubProfile(auth.meta, auth.record);
const updated = await this.pocketBase.collection("users").update(auth.record.id, profile);
this.pocketBase.authStore.save(auth.token, updated);
return this.currentUser;
} catch (error) {
if (cancellationError) throw cancellationError;
throw workshopError(error, "GitHub sign-in failed");
} finally {
if (closePoll) clearInterval(closePoll);
if (timeout) clearTimeout(timeout);
this.pocketBase.cancelRequest(requestKey);
popup.close();
}
}
+10 -37
View File
@@ -16,42 +16,28 @@ describe("Workshop widget validation", () => {
).toBe(true);
});
test("uses canonical option-binding validation before publication", () => {
const request = {
id: "data",
sourceId: "default",
kind: "query" as const,
method: "GET" as const,
pathTemplate: "/data/{endpointId}",
parameters: { endpointId: "string" as const, limit: "number" as const },
optionsBinding: { endpointId: { $option: "endpointId" }, limit: 5 },
queryTemplate: { limit: { $param: "limit" } },
auth: "inherit" as const,
minimumBoardPermission: "view" as const,
trigger: "load" as const,
};
test("uses canonical direct-binding validation before publication", () => {
const widget = {
...CUSTOM_WIDGET_STARTER,
requests: [request],
optionsSchema: {
type: "object" as const,
properties: { endpointId: { type: "string" as const } },
required: ["endpointId"],
additionalProperties: false as const,
requests: {
data: {
path: "/data/{option:endpointId}",
query: { limit: 5 },
},
},
defaultOptions: { endpointId: "local" },
options: { endpointId: { label: "Endpoint", control: "text", default: "local" } },
};
expect(validateWorkshopWidget(JSON.stringify(widget)).success).toBe(true);
expect(
validateWorkshopWidget(JSON.stringify({ ...widget, requests: [{ ...request, optionsBinding: { limit: 5 } }] }))
validateWorkshopWidget(JSON.stringify({ ...widget, requests: { data: { path: "/data/{option:missing}" } } }))
.success,
).toBe(false);
expect(
validateWorkshopWidget(
JSON.stringify({
...widget,
requests: [{ ...request, optionsBinding: { endpointId: { $option: "missing" }, limit: 5 } }],
requests: { data: { path: "/data/{param:endpointId}" } },
}),
).success,
).toBe(false);
@@ -78,20 +64,7 @@ describe("Workshop widget validation", () => {
validateWorkshopWidget(
JSON.stringify({
...CUSTOM_WIDGET_STARTER,
requests: [
{
id: "load",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/load",
parameters: {},
staticHeaders: { "X-Custom": "Bearer literal-secret-value" },
auth: "none",
minimumBoardPermission: "view",
trigger: "load",
},
],
requests: { load: { path: "/load", headers: { "X-Api-Key": "literal-secret-value" } } },
}),
).success,
).toBe(false);