Simplify Custom JSX v2 authoring
This commit is contained in:
177 files changed
+4775
-43426
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user