From fd919fabfc1f87a1bee738ef8cd0639f3e3a1d6a Mon Sep 17 00:00:00 2001
From: webadderall <131426131+webadderall@users.noreply.github.com>
Date: Sun, 20 Sep 2026 18:52:26 +1000
Subject: [PATCH] feat(auth): add desktop sign-in and cloud upload foundations
---
.env.example | 3 +
docs/authentication.md | 52 +++
electron-builder.json5 | 6 +
electron/authCallback.ts | 121 ++++++
electron/ipc/cloudShareContract.ts | 100 +++++
electron/ipc/register/cloudShare.test.ts | 92 ++++
electron/ipc/register/cloudShare.ts | 433 +++++++++++++++++++
package-lock.json | 104 ++++-
package.json | 2 +
src/components/auth/RecordlySignInDialog.tsx | 244 +++++++++++
src/components/auth/useRecordlyAuth.ts | 65 +++
src/lib/auth/recordlyAuth.ts | 83 ++++
12 files changed, 1304 insertions(+), 1 deletion(-)
create mode 100644 .env.example
create mode 100644 docs/authentication.md
create mode 100644 electron/authCallback.ts
create mode 100644 electron/ipc/cloudShareContract.ts
create mode 100644 electron/ipc/register/cloudShare.test.ts
create mode 100644 electron/ipc/register/cloudShare.ts
create mode 100644 src/components/auth/RecordlySignInDialog.tsx
create mode 100644 src/components/auth/useRecordlyAuth.ts
create mode 100644 src/lib/auth/recordlyAuth.ts
diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..45d02d2a
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,3 @@
+# Public client configuration. Never put a Supabase service-role key here.
+VITE_SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
+VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_YOUR_KEY
diff --git a/docs/authentication.md b/docs/authentication.md
new file mode 100644
index 00000000..fde2b3d4
--- /dev/null
+++ b/docs/authentication.md
@@ -0,0 +1,52 @@
+# Recordly authentication setup
+
+Recordly uses Supabase Auth for one shared session across email/password and Google. The desktop application uses PKCE and returns from the system browser through a loopback callback in development and `recordly://auth/callback` in production. X and SAML are intentionally hidden until those providers are configured.
+
+## 1. Create the project
+
+1. Create a Supabase project.
+2. In **Project settings → API**, copy the project URL and publishable key.
+3. Copy `.env.example` to `.env.local` and fill in both values. Never use the service-role key in the desktop application.
+4. In **Authentication → URL configuration**, add `recordly://auth/callback` and `http://127.0.0.1:43821/auth/callback` to the redirect allow list.
+
+Email/password works once email authentication is enabled and a user has been created. Password-reset emails use the same desktop callback.
+
+## 2. Google
+
+1. Create a Web OAuth client in Google Auth Platform.
+2. Add Supabase's callback URL, `https://YOUR_PROJECT_REF.supabase.co/auth/v1/callback`, as an authorized redirect URI.
+3. Enable Google under **Supabase → Authentication → Sign In / Providers** and paste the Google client ID and secret.
+4. Keep the requested scopes to `openid`, email, and profile unless Recordly genuinely needs more.
+
+## 3. X (currently hidden)
+
+1. Create an OAuth 2.0 app in the X Developer Dashboard and enable requesting the user's email.
+2. Set its callback URL to `https://YOUR_PROJECT_REF.supabase.co/auth/v1/callback`.
+3. Enable X/Twitter OAuth 2.0 in Supabase and paste the client ID and secret.
+
+## 4. SAML SSO (currently hidden)
+
+SAML is configured per customer workspace. Supabase's SAML support requires Pro or above.
+
+1. Enable SAML in the Supabase Auth provider settings.
+2. Obtain the customer's IdP metadata URL or metadata XML file.
+3. Register the connection and its email domain with the Supabase CLI, for example:
+
+ ```sh
+ supabase sso add --type saml --project-ref YOUR_PROJECT_REF \
+ --metadata-url 'https://customer.example/idp/metadata' \
+ --domains customer.example
+ ```
+
+The Recordly modal extracts the domain from the entered email address and starts the matching SAML connection.
+
+## 5. Verify locally
+
+Restart `npm run dev` after creating `.env.local`. Open an editor and verify:
+
+1. The account button opens **Sign into Recordly**.
+2. Email/password creates a persistent Supabase session.
+3. Google opens the system browser and returns to Recordly.
+4. Clicking **Create link** while signed out opens this modal; after successful authentication it continues to the share dialog.
+
+Add the same `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY` values to the share Worker's secrets or variables. The Worker validates the user's access token with Supabase before accepting an upload. `API_SECRET` is server-side only and remains available for library administration and explicitly enabled local integration tests; it is never entered into or exposed by the desktop app.
diff --git a/electron-builder.json5 b/electron-builder.json5
index 73d57fb8..4a6b6aed 100644
--- a/electron-builder.json5
+++ b/electron-builder.json5
@@ -11,6 +11,12 @@
"electron/native/bin/**"
],
"productName": "Recordly",
+ "protocols": [
+ {
+ "name": "Recordly authentication",
+ "schemes": ["recordly"]
+ }
+ ],
"npmRebuild": true,
"buildDependenciesFromSource": true,
"compression": "normal",
diff --git a/electron/authCallback.ts b/electron/authCallback.ts
new file mode 100644
index 00000000..bfbbbea9
--- /dev/null
+++ b/electron/authCallback.ts
@@ -0,0 +1,121 @@
+import { createServer, type Server } from "node:http";
+import { app, BrowserWindow, ipcMain } from "electron";
+
+const DEV_CALLBACK_HOST = "127.0.0.1:43821";
+const DEV_CALLBACK_ORIGIN = `http://${DEV_CALLBACK_HOST}`;
+const CALLBACK_PATH = "/callback";
+const LOOPBACK_CALLBACK_PATH = "/auth/callback";
+const MAX_CALLBACK_URL_LENGTH = 8192;
+const CALLBACK_PARAMETERS = new Set(["code", "error", "error_code", "error_description"]);
+
+type AuthCallbackOptions = {
+ isDev: boolean;
+ focusApp: () => void;
+};
+
+function callbackHeaders(contentType: string) {
+ return {
+ "Cache-Control": "no-store",
+ "Content-Security-Policy":
+ "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'",
+ "Content-Type": contentType,
+ "Referrer-Policy": "no-referrer",
+ "X-Content-Type-Options": "nosniff",
+ };
+}
+
+export function createAuthCallbackController({ isDev, focusApp }: AuthCallbackOptions) {
+ const protocol = isDev ? "recordly-dev" : "recordly";
+ let pendingUrl: string | null = null;
+ let server: Server | null = null;
+
+ function parseCallback(rawUrl: string): URL | null {
+ if (rawUrl.length > MAX_CALLBACK_URL_LENGTH) return null;
+ try {
+ const url = new URL(rawUrl);
+ if (url.protocol !== `${protocol}:` || url.hostname !== "auth") return null;
+ if (url.pathname !== CALLBACK_PATH || url.username || url.password) return null;
+ return url;
+ } catch {
+ return null;
+ }
+ }
+
+ function find(args: readonly string[]) {
+ return args.find((arg) => parseCallback(arg) !== null) ?? null;
+ }
+
+ function dispatch(rawUrl: string) {
+ const url = parseCallback(rawUrl);
+ if (!url) return false;
+ pendingUrl = url.href;
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) window.webContents.send("auth:callback", url.href);
+ }
+ if (app.isReady()) focusApp();
+ return true;
+ }
+
+ function startDevServer() {
+ if (!isDev || server) return;
+ server = createServer((request, response) => {
+ if (
+ request.method !== "GET" ||
+ request.headers.host !== DEV_CALLBACK_HOST ||
+ (request.url?.length ?? 0) > MAX_CALLBACK_URL_LENGTH
+ ) {
+ response.writeHead(404, callbackHeaders("text/plain; charset=utf-8"));
+ response.end("Not found");
+ return;
+ }
+
+ const requestUrl = new URL(request.url ?? "/", DEV_CALLBACK_ORIGIN);
+ if (requestUrl.pathname !== LOOPBACK_CALLBACK_PATH) {
+ response.writeHead(404, callbackHeaders("text/plain; charset=utf-8"));
+ response.end("Not found");
+ return;
+ }
+
+ const appUrl = new URL(`${protocol}://auth${CALLBACK_PATH}`);
+ for (const key of CALLBACK_PARAMETERS) {
+ for (const value of requestUrl.searchParams.getAll(key)) {
+ appUrl.searchParams.append(key, value);
+ }
+ }
+ if (!appUrl.searchParams.has("code") && !appUrl.searchParams.has("error")) {
+ response.writeHead(400, callbackHeaders("text/plain; charset=utf-8"));
+ response.end("Invalid authentication callback");
+ return;
+ }
+
+ dispatch(appUrl.href);
+ response.writeHead(200, callbackHeaders("text/html; charset=utf-8"));
+ response.end(
+ '
Signed into RecordlySigned into Recordly
You can close this tab and return to the app.
',
+ );
+ });
+ server.on("error", (error) => {
+ console.error("[auth] Could not start local callback server", error);
+ server = null;
+ });
+ server.listen(43821, "127.0.0.1");
+ }
+
+ function close() {
+ server?.close();
+ server = null;
+ }
+
+ app.on("open-url", (event, url) => {
+ event.preventDefault();
+ dispatch(url);
+ });
+
+ ipcMain.handle("auth:get-pending-callback", () => {
+ const callback = pendingUrl;
+ pendingUrl = null;
+ return callback;
+ });
+
+ return { close, dispatch, find, protocol, startDevServer };
+}
diff --git a/electron/ipc/cloudShareContract.ts b/electron/ipc/cloudShareContract.ts
new file mode 100644
index 00000000..f85a28a2
--- /dev/null
+++ b/electron/ipc/cloudShareContract.ts
@@ -0,0 +1,100 @@
+export type CloudShareTicket = {
+ uploadUrl: string;
+ shareUrl: string;
+ shareCode?: string;
+ method: "PUT";
+ headers: Record;
+ finalizeUrl?: string;
+};
+
+const TRUSTED_SHARE_ORIGINS = new Set([
+ "https://videos.recordly.dev",
+ "http://localhost:8787",
+ "http://127.0.0.1:8787",
+]);
+
+function parseUrl(value: unknown, label: string): URL {
+ if (typeof value !== "string") throw new Error(`${label} is missing.`);
+ const url = new URL(value);
+ if (url.username || url.password) throw new Error(`${label} cannot contain credentials.`);
+ return url;
+}
+
+export function normalizeCloudEndpoint(value: unknown): string {
+ if (typeof value !== "string" || value.trim().length === 0) {
+ throw new Error("Enter a cloud share endpoint.");
+ }
+ const url = parseUrl(value.trim(), "Cloud share endpoint");
+ if (!TRUSTED_SHARE_ORIGINS.has(url.origin)) {
+ throw new Error("Cloud sharing is only allowed through the Recordly service.");
+ }
+ if (url.pathname !== "/api/upload" || url.search) {
+ throw new Error("The cloud share endpoint is invalid.");
+ }
+ url.hash = "";
+ return url.toString();
+}
+
+function parseHttpUrl(value: unknown, label: string): string {
+ const url = parseUrl(value, `${label} from the server response`);
+ const localHttp =
+ url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
+ if (url.protocol !== "https:" && !localHttp) {
+ throw new Error(`${label} must use HTTPS (HTTP is allowed for localhost).`);
+ }
+ return url.toString();
+}
+
+export function parseCloudShareTicket(value: unknown): CloudShareTicket {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error("The cloud share server returned an invalid response.");
+ }
+ const input = value as Record;
+ const uploadUrl = input.uploadUrl ?? input.uploadURL;
+ const shareUrl = input.shareUrl ?? input.shareURL;
+ const shareCode =
+ typeof input.shareCode === "string" && /^[a-z0-9]+$/.test(input.shareCode)
+ ? input.shareCode
+ : undefined;
+ const method = input.method === undefined ? "PUT" : input.method;
+ if (method !== "PUT") throw new Error("Only presigned PUT uploads are currently supported.");
+ const headers: Record = {};
+ if (input.headers !== undefined) {
+ if (!input.headers || typeof input.headers !== "object" || Array.isArray(input.headers)) {
+ throw new Error("Upload headers must be an object.");
+ }
+ for (const [key, headerValue] of Object.entries(input.headers)) {
+ if (typeof headerValue !== "string")
+ throw new Error("Upload header values must be strings.");
+ if (
+ [
+ "authorization",
+ "cookie",
+ "host",
+ "content-length",
+ "proxy-authorization",
+ ].includes(key.toLowerCase())
+ ) {
+ throw new Error(`The upload header ${key} is not allowed.`);
+ }
+ headers[key] = headerValue;
+ }
+ }
+ const parsedUploadUrl = parseHttpUrl(uploadUrl, "uploadUrl");
+ const parsedShareUrl = parseHttpUrl(shareUrl, "shareUrl");
+ let finalizeUrl: string | undefined;
+ if (typeof input.finalizeUrl === "string") {
+ finalizeUrl = parseHttpUrl(input.finalizeUrl, "finalizeUrl");
+ } else if (typeof input.shareCode === "string" && /^[a-z0-9]+$/.test(input.shareCode)) {
+ // Voom marks a recording ready after metadata is posted.
+ finalizeUrl = new URL(`/api/metadata/${input.shareCode}`, parsedShareUrl).toString();
+ }
+ return {
+ uploadUrl: parsedUploadUrl,
+ shareUrl: parsedShareUrl,
+ shareCode,
+ method,
+ headers,
+ finalizeUrl,
+ };
+}
diff --git a/electron/ipc/register/cloudShare.test.ts b/electron/ipc/register/cloudShare.test.ts
new file mode 100644
index 00000000..bfaa1198
--- /dev/null
+++ b/electron/ipc/register/cloudShare.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+import { normalizeCloudEndpoint, parseCloudShareTicket } from "../cloudShareContract";
+
+describe("cloud share contract", () => {
+ it("accepts only Recordly production and local development endpoints", () => {
+ expect(normalizeCloudEndpoint("https://videos.recordly.dev/api/upload#ignored")).toBe(
+ "https://videos.recordly.dev/api/upload",
+ );
+ expect(normalizeCloudEndpoint("http://localhost:8787/api/upload")).toBe(
+ "http://localhost:8787/api/upload",
+ );
+ });
+
+ it("normalizes a Voom upload ticket and derives its metadata endpoint", () => {
+ expect(
+ parseCloudShareTicket({
+ shareCode: "abc123",
+ uploadURL: "http://localhost:8787/api/upload-data/abc123",
+ shareURL: "http://localhost:8787/s/abc123",
+ }),
+ ).toEqual({
+ uploadUrl: "http://localhost:8787/api/upload-data/abc123",
+ shareUrl: "http://localhost:8787/s/abc123",
+ shareCode: "abc123",
+ method: "PUT",
+ headers: {},
+ finalizeUrl: "http://localhost:8787/api/metadata/abc123",
+ });
+ });
+
+ it("rejects insecure and untrusted remote endpoints", () => {
+ expect(() => normalizeCloudEndpoint("http://share.example.com/api/shares")).toThrow(
+ "only allowed through the Recordly service",
+ );
+ expect(() => normalizeCloudEndpoint("https://share.example.com/api/upload")).toThrow(
+ "only allowed through the Recordly service",
+ );
+ expect(() => normalizeCloudEndpoint("https://videos.recordly.dev/api/other")).toThrow(
+ "endpoint is invalid",
+ );
+ });
+
+ it("normalizes a presigned PUT ticket", () => {
+ expect(
+ parseCloudShareTicket({
+ uploadUrl: "https://storage.example.com/upload",
+ shareUrl: "https://share.example.com/s/123",
+ headers: { "x-test": "value" },
+ }),
+ ).toEqual({
+ uploadUrl: "https://storage.example.com/upload",
+ shareUrl: "https://share.example.com/s/123",
+ shareCode: undefined,
+ method: "PUT",
+ headers: { "x-test": "value" },
+ finalizeUrl: undefined,
+ });
+ });
+
+ it("rejects unsupported methods and malformed headers", () => {
+ expect(() =>
+ parseCloudShareTicket({
+ uploadUrl: "https://storage.example.com/upload",
+ shareUrl: "https://share.example.com/s/123",
+ method: "POST",
+ }),
+ ).toThrow("presigned PUT");
+ expect(() =>
+ parseCloudShareTicket({
+ uploadUrl: "https://storage.example.com/upload",
+ shareUrl: "https://share.example.com/s/123",
+ headers: { invalid: 42 },
+ }),
+ ).toThrow("must be strings");
+ expect(() =>
+ parseCloudShareTicket({
+ uploadUrl: "https://storage.example.com/upload",
+ shareUrl: "https://share.example.com/s/123",
+ headers: { authorization: "Bearer exfiltrate-me" },
+ }),
+ ).toThrow("not allowed");
+ });
+
+ it("rejects an insecure remote upload URL", () => {
+ expect(() =>
+ parseCloudShareTicket({
+ uploadUrl: "http://storage.example.com/upload",
+ shareUrl: "https://share.example.com/s/123",
+ }),
+ ).toThrow("uploadUrl must use HTTPS");
+ });
+});
diff --git a/electron/ipc/register/cloudShare.ts b/electron/ipc/register/cloudShare.ts
new file mode 100644
index 00000000..e23fab3b
--- /dev/null
+++ b/electron/ipc/register/cloudShare.ts
@@ -0,0 +1,433 @@
+import { createReadStream } from "node:fs";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { Transform } from "node:stream";
+import { ipcMain } from "electron";
+import { normalizeCloudEndpoint, parseCloudShareTicket } from "../cloudShareContract";
+import { isOwnedExportPath } from "../export/exportStream";
+import { isAllowedLocalReadPath } from "../project/manager";
+
+const MAX_CONTROL_RESPONSE_BYTES = 1024 * 1024;
+const MAX_AUTH_TOKEN_BYTES = 8192;
+const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
+const MULTIPART_PART_BYTES = 25 * 1024 * 1024;
+const MULTIPART_PART_ATTEMPTS = 3;
+const activeUploads = new Map();
+
+type UploadedPart = { partNumber: number; etag: string };
+
+class NonRetryableUploadError extends Error {}
+
+function contentTypeFor(filePath: string) {
+ switch (path.extname(filePath).toLowerCase()) {
+ case ".mp4":
+ return "video/mp4";
+ case ".webm":
+ return "video/webm";
+ case ".mov":
+ return "video/quicktime";
+ case ".gif":
+ return "image/gif";
+ default:
+ return "application/octet-stream";
+ }
+}
+
+async function readJsonResponse(response: Response) {
+ const text = await response.text();
+ if (Buffer.byteLength(text) > MAX_CONTROL_RESPONSE_BYTES) {
+ throw new Error("The cloud share server response was too large.");
+ }
+ try {
+ return JSON.parse(text) as unknown;
+ } catch {
+ throw new Error(`The cloud share server returned invalid JSON (${response.status}).`);
+ }
+}
+
+async function responseError(response: Response, fallback: string) {
+ try {
+ const body = await readJsonResponse(response);
+ if (body && typeof body === "object" && "error" in body) {
+ return String((body as { error: unknown }).error);
+ }
+ } catch {
+ // Cloudflare may return an HTML/plain-text edge error instead of Worker JSON.
+ }
+ return `${fallback} (${response.status}).`;
+}
+
+function waitForRetry(delayMs: number, signal: AbortSignal) {
+ return new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(signal.reason ?? new Error("Upload aborted"));
+ return;
+ }
+ const handleAbort = () => {
+ clearTimeout(timeout);
+ reject(signal.reason ?? new Error("Upload aborted"));
+ };
+ const timeout = setTimeout(() => {
+ signal.removeEventListener("abort", handleAbort);
+ resolve();
+ }, delayMs);
+ signal.addEventListener("abort", handleAbort, { once: true });
+ });
+}
+
+function isRetryableUploadStatus(status: number) {
+ return status === 408 || status === 425 || status === 429 || status >= 500;
+}
+
+async function uploadMultipart(options: {
+ endpoint: string;
+ shareCode: string;
+ filePath: string;
+ fileSize: number;
+ contentType: string;
+ token: string;
+ signal: AbortSignal;
+ onProgress: (uploadedBytes: number) => void;
+}) {
+ const endpointOrigin = new URL(options.endpoint).origin;
+ const authHeaders = { authorization: `Bearer ${options.token}` };
+ const startResponse = await fetch(
+ new URL(`/api/upload-multipart/${options.shareCode}`, endpointOrigin),
+ {
+ method: "POST",
+ headers: authHeaders,
+ redirect: "error",
+ signal: options.signal,
+ },
+ );
+ if (!startResponse.ok) {
+ throw new Error(await responseError(startResponse, "Could not start video upload"));
+ }
+ const startBody = await readJsonResponse(startResponse);
+ const multipartUploadId =
+ startBody &&
+ typeof startBody === "object" &&
+ "uploadId" in startBody &&
+ typeof (startBody as { uploadId: unknown }).uploadId === "string"
+ ? (startBody as { uploadId: string }).uploadId
+ : "";
+ if (!multipartUploadId || Buffer.byteLength(multipartUploadId) > 2048) {
+ throw new Error("The cloud share server returned an invalid multipart upload ID.");
+ }
+
+ const encodedUploadId = encodeURIComponent(multipartUploadId);
+ const parts: UploadedPart[] = [];
+ let confirmedBytes = 0;
+ let completed = false;
+
+ try {
+ const partCount = Math.ceil(options.fileSize / MULTIPART_PART_BYTES);
+ for (let partIndex = 0; partIndex < partCount; partIndex += 1) {
+ const partNumber = partIndex + 1;
+ const start = partIndex * MULTIPART_PART_BYTES;
+ const end = Math.min(options.fileSize, start + MULTIPART_PART_BYTES) - 1;
+ const partSize = end - start + 1;
+ let uploadedPart: UploadedPart | undefined;
+
+ for (let attempt = 1; attempt <= MULTIPART_PART_ATTEMPTS; attempt += 1) {
+ let attemptBytes = 0;
+ const progress = new Transform({
+ transform(chunk, _encoding, callback) {
+ attemptBytes += chunk.length;
+ options.onProgress(
+ Math.min(options.fileSize, confirmedBytes + attemptBytes),
+ );
+ callback(null, chunk);
+ },
+ });
+ const body = createReadStream(options.filePath, { start, end }).pipe(progress);
+ try {
+ const partResponse = await fetch(
+ new URL(
+ `/api/upload-part/${options.shareCode}/${encodedUploadId}/${partNumber}`,
+ endpointOrigin,
+ ),
+ {
+ method: "PUT",
+ headers: {
+ ...authHeaders,
+ "content-type": options.contentType,
+ "content-length": String(partSize),
+ },
+ body: body as unknown as BodyInit,
+ duplex: "half",
+ redirect: "error",
+ signal: options.signal,
+ } as RequestInit & { duplex: "half" },
+ );
+ if (!partResponse.ok) {
+ const message = await responseError(
+ partResponse,
+ `Video part ${partNumber} failed`,
+ );
+ if (!isRetryableUploadStatus(partResponse.status)) {
+ throw new NonRetryableUploadError(message);
+ }
+ throw new Error(message);
+ } else {
+ const partBody = await readJsonResponse(partResponse);
+ const etag =
+ partBody && typeof partBody === "object" && "etag" in partBody
+ ? (partBody as { etag: unknown }).etag
+ : undefined;
+ const returnedPartNumber =
+ partBody && typeof partBody === "object" && "partNumber" in partBody
+ ? (partBody as { partNumber: unknown }).partNumber
+ : undefined;
+ if (typeof etag !== "string" || returnedPartNumber !== partNumber) {
+ throw new NonRetryableUploadError(
+ `The cloud share server returned invalid data for video part ${partNumber}.`,
+ );
+ }
+ uploadedPart = { partNumber, etag };
+ break;
+ }
+ } catch (error) {
+ if (
+ options.signal.aborted ||
+ error instanceof NonRetryableUploadError ||
+ attempt === MULTIPART_PART_ATTEMPTS
+ ) {
+ throw error;
+ }
+ } finally {
+ body.destroy();
+ }
+
+ options.onProgress(confirmedBytes);
+ await waitForRetry(300 * 3 ** (attempt - 1), options.signal);
+ }
+
+ if (!uploadedPart) throw new Error(`Video part ${partNumber} could not be uploaded.`);
+ parts.push(uploadedPart);
+ confirmedBytes += partSize;
+ options.onProgress(confirmedBytes);
+ }
+
+ const completeResponse = await fetch(
+ new URL(`/api/upload-complete/${options.shareCode}/${encodedUploadId}`, endpointOrigin),
+ {
+ method: "POST",
+ headers: { ...authHeaders, "content-type": "application/json" },
+ body: JSON.stringify({ parts }),
+ redirect: "error",
+ signal: options.signal,
+ },
+ );
+ if (!completeResponse.ok) {
+ throw new Error(await responseError(completeResponse, "Could not finish video upload"));
+ }
+ completed = true;
+ } finally {
+ if (!completed) {
+ try {
+ await fetch(
+ new URL(
+ `/api/upload-abort/${options.shareCode}/${encodedUploadId}`,
+ endpointOrigin,
+ ),
+ {
+ method: "POST",
+ headers: authHeaders,
+ redirect: "error",
+ signal: AbortSignal.timeout(5000),
+ },
+ );
+ } catch {
+ // R2 also reaps abandoned multipart uploads, so cleanup is best-effort.
+ }
+ }
+ }
+}
+
+export function registerCloudShareHandlers() {
+ ipcMain.handle(
+ "cloud-share-upload",
+ async (
+ event,
+ input: {
+ filePath?: unknown;
+ endpoint?: unknown;
+ token?: unknown;
+ title?: unknown;
+ notes?: unknown;
+ uploadId?: unknown;
+ },
+ ) => {
+ let uploadId: string | undefined;
+ try {
+ const endpoint = normalizeCloudEndpoint(input?.endpoint);
+ if (typeof input?.filePath !== "string" || input.filePath.trim().length === 0) {
+ throw new Error("Export a video before sharing it.");
+ }
+ uploadId =
+ typeof input.uploadId === "string" && /^[a-f0-9-]{36}$/i.test(input.uploadId)
+ ? input.uploadId
+ : crypto.randomUUID();
+ const controller = new AbortController();
+ activeUploads.set(uploadId, controller);
+ const requestedPath = path.resolve(input.filePath);
+ const resolvedPath = await fs.realpath(requestedPath);
+ const isRecordlyExport =
+ isOwnedExportPath(requestedPath) || isOwnedExportPath(resolvedPath);
+ if (!isRecordlyExport && !isAllowedLocalReadPath(resolvedPath)) {
+ throw new Error("This file is outside Recordly's approved media locations.");
+ }
+ const stat = await fs.stat(resolvedPath);
+ if (!stat.isFile()) throw new Error("The exported video could not be found.");
+ const title =
+ (typeof input.title === "string" ? input.title.trim().slice(0, 200) : "") ||
+ path.basename(resolvedPath, path.extname(resolvedPath));
+ const notes =
+ typeof input.notes === "string" ? input.notes.trim().slice(0, 2000) : "";
+
+ const token = typeof input.token === "string" ? input.token.trim() : "";
+ if (!token || Buffer.byteLength(token) > MAX_AUTH_TOKEN_BYTES) {
+ throw new Error("Sign in to Recordly before creating a shared link.");
+ }
+ const controlHeaders: Record = {
+ "content-type": "application/json",
+ };
+ if (token) controlHeaders.authorization = `Bearer ${token}`;
+ const ticketResponse = await fetch(endpoint, {
+ method: "POST",
+ headers: controlHeaders,
+ body: JSON.stringify({
+ name: path.basename(resolvedPath),
+ title,
+ fileSize: stat.size,
+ notes,
+ size: stat.size,
+ contentType: contentTypeFor(resolvedPath),
+ }),
+ redirect: "error",
+ signal: controller.signal,
+ });
+ const ticketBody = await readJsonResponse(ticketResponse);
+ if (!ticketResponse.ok) {
+ const message =
+ ticketBody && typeof ticketBody === "object" && "error" in ticketBody
+ ? String((ticketBody as { error: unknown }).error)
+ : `Cloud share request failed (${ticketResponse.status}).`;
+ throw new Error(message);
+ }
+ const ticket = parseCloudShareTicket(ticketBody);
+ const endpointOrigin = new URL(endpoint).origin;
+ if (new URL(ticket.shareUrl).origin !== endpointOrigin) {
+ throw new Error("The share service returned an untrusted viewing URL.");
+ }
+ if (ticket.finalizeUrl && new URL(ticket.finalizeUrl).origin !== endpointOrigin) {
+ throw new Error("The share service returned an untrusted finalization URL.");
+ }
+ const contentType = contentTypeFor(resolvedPath);
+ const sendProgress = (uploadedBytes: number) => {
+ event.sender.send("cloud-share-progress", {
+ uploadId,
+ uploadedBytes,
+ totalBytes: stat.size,
+ });
+ };
+ const canUseMultipart =
+ stat.size > MULTIPART_THRESHOLD_BYTES &&
+ ticket.shareCode !== undefined &&
+ new URL(ticket.uploadUrl).origin === endpointOrigin;
+
+ if (canUseMultipart) {
+ await uploadMultipart({
+ endpoint,
+ shareCode: ticket.shareCode as string,
+ filePath: resolvedPath,
+ fileSize: stat.size,
+ contentType,
+ token,
+ signal: controller.signal,
+ onProgress: sendProgress,
+ });
+ } else {
+ let uploaded = 0;
+ const progress = new Transform({
+ transform(chunk, _encoding, callback) {
+ uploaded += chunk.length;
+ sendProgress(uploaded);
+ callback(null, chunk);
+ },
+ });
+ const body = createReadStream(resolvedPath).pipe(progress);
+ const uploadHeaders: Record = {
+ "content-type": contentType,
+ "content-length": String(stat.size),
+ ...ticket.headers,
+ };
+ if (new URL(ticket.uploadUrl).origin === endpointOrigin) {
+ uploadHeaders.authorization = `Bearer ${token}`;
+ }
+ try {
+ const uploadResponse = await fetch(ticket.uploadUrl, {
+ method: "PUT",
+ headers: uploadHeaders,
+ body: body as unknown as BodyInit,
+ duplex: "half",
+ redirect: "error",
+ signal: controller.signal,
+ } as RequestInit & { duplex: "half" });
+ if (!uploadResponse.ok) {
+ throw new Error(
+ await responseError(uploadResponse, "Video upload failed"),
+ );
+ }
+ } finally {
+ body.destroy();
+ }
+ }
+
+ if (ticket.finalizeUrl) {
+ const finalizeHeaders: Record = {
+ "content-type": "application/json",
+ };
+ if (token && new URL(ticket.finalizeUrl).origin === new URL(endpoint).origin) {
+ finalizeHeaders.authorization = `Bearer ${token}`;
+ }
+ const finalizeResponse = await fetch(ticket.finalizeUrl, {
+ method: "POST",
+ headers: finalizeHeaders,
+ body: JSON.stringify({
+ title,
+ summary: notes,
+ }),
+ redirect: "error",
+ signal: controller.signal,
+ });
+ if (!finalizeResponse.ok) {
+ throw new Error(
+ `Cloud share finalization failed (${finalizeResponse.status}).`,
+ );
+ }
+ }
+ return { success: true, uploadId, shareUrl: ticket.shareUrl };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ return {
+ success: false,
+ uploadId,
+ canceled: message.includes("aborted"),
+ error: message,
+ };
+ } finally {
+ if (uploadId) activeUploads.delete(uploadId);
+ }
+ },
+ );
+
+ ipcMain.handle("cloud-share-cancel", (_event, uploadId: unknown) => {
+ if (typeof uploadId !== "string") return { success: false };
+ const controller = activeUploads.get(uploadId);
+ if (!controller) return { success: false };
+ controller.abort();
+ return { success: true };
+ });
+}
diff --git a/package-lock.json b/package-lock.json
index 1053d5df..6dac10a9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"hasInstallScript": true,
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
+ "@supabase/supabase-js": "^2.116.0",
"capturekit": "^1.0.13",
"electron-updater": "^6.8.3",
"ffmpeg-static": "^5.3.0",
@@ -44,6 +45,7 @@
"pixi.js": "^8.14.0",
"postcss": "^8.5.6",
"react": "^19.3.0",
+ "react-aria": "3.52.1",
"react-dom": "^19.3.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6",
@@ -2436,6 +2438,98 @@
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
+ "node_modules/@supabase/auth-js": {
+ "version": "2.116.0",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.116.0.tgz",
+ "integrity": "sha512-Cmosty12gyKGK9N3bQb+lMmuAFev5nmUzaR1AsmZHqKOAGzqX1VQzmp49CNPwOx/pw0H9Qqk4rs9yhwTlKpfDg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.116.0",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.116.0.tgz",
+ "integrity": "sha512-E+VOc2QDcni/fySqkBFiZhnoB3SGydEdZgFI6/dEAGAHx6yEhB46TN9qb2wXs+E+RSzOBV0R6dasiSlw4xlZAA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/phoenix": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
+ "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
+ "license": "MIT"
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.116.0",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.116.0.tgz",
+ "integrity": "sha512-kGpVZTDHxFTJS3tu+rU0iTAZ+4U0bcLVjxwCk8f3gRhjw3qdCZjTBlgYvc4kGH2XccmAzbkKwXL/mrNHMGSc+A==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.116.0",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.116.0.tgz",
+ "integrity": "sha512-MHAnlXxi2s6yiJsZsQMfs2B3RFxeVfQWxerqYhIMqcCQV/FuY3LIeouPEkXw/ah7wUWMLYwempF9MOCUScyddg==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/phoenix": "0.4.5",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.116.0",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.116.0.tgz",
+ "integrity": "sha512-6/3hR6vccBP6oGM5B6RfbwZcTCKmQOodd/ZWQdsw8yJsU5zO/a//oBL6yLnmgxcjnHSrelW8rsO7hL5DPybyUQ==",
+ "license": "MIT",
+ "dependencies": {
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.116.0",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.116.0.tgz",
+ "integrity": "sha512-YyWmKXt2NspV9iO8FPnlswUFJIRnrLd3oTCb+3ZyYRuKZtBH0xCUDgnUqoyA0fGUxpM/UhfwDjYf/dht/9bp7g==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/auth-js": "2.116.0",
+ "@supabase/functions-js": "2.116.0",
+ "@supabase/postgrest-js": "2.116.0",
+ "@supabase/realtime-js": "2.116.0",
+ "@supabase/storage-js": "2.116.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
@@ -5590,6 +5684,15 @@
"node": ">=10.17.0"
}
},
+ "node_modules/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/iconv-corefoundation": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
@@ -7988,7 +8091,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
"license": "0BSD"
},
"node_modules/tw-animate-css": {
diff --git a/package.json b/package.json
index 13663ca5..e0a6c11b 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
+ "@supabase/supabase-js": "^2.116.0",
"capturekit": "^1.0.13",
"electron-updater": "^6.8.3",
"ffmpeg-static": "^5.3.0",
@@ -84,6 +85,7 @@
"pixi.js": "^8.14.0",
"postcss": "^8.5.6",
"react": "^19.3.0",
+ "react-aria": "3.52.1",
"react-dom": "^19.3.0",
"react-icons": "^5.5.0",
"react-resizable-panels": "^3.0.6",
diff --git a/src/components/auth/RecordlySignInDialog.tsx b/src/components/auth/RecordlySignInDialog.tsx
new file mode 100644
index 00000000..fbe3d12e
--- /dev/null
+++ b/src/components/auth/RecordlySignInDialog.tsx
@@ -0,0 +1,244 @@
+import { GoogleLogo, SignOut, XLogo } from "@phosphor-icons/react";
+import type { User } from "@supabase/supabase-js";
+import { type FormEvent, useEffect, useState } from "react";
+import {
+ Modal,
+ Button,
+ Form,
+ TextField,
+ Input,
+ Label,
+ Description,
+ FieldError,
+ Separator,
+ Alert,
+} from "@heroui/react";
+import {
+ sendPasswordReset,
+ signInWithEmail,
+ signInWithSocial,
+ signOutRecordly,
+} from "@/lib/auth/recordlyAuth";
+
+export type SignInReason = "account" | "share";
+
+type Props = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ reason?: SignInReason;
+ user: User | null;
+ configured: boolean;
+ callbackError?: string;
+ onAuthenticated: () => void;
+};
+
+function friendlyAuthError(error: unknown, action: string): string {
+ const message = error instanceof Error ? error.message : String(error);
+ if (/unsupported provider|provider is not enabled/i.test(message)) {
+ if (action === "google") {
+ return "Google sign-in isn't enabled yet. Use email for now, or ask your Recordly administrator to connect Google.";
+ }
+ if (action === "x") {
+ return "X sign-in isn't enabled yet. Use email for now, or ask your Recordly administrator to connect X.";
+ }
+ return "This sign-in method isn't enabled for Recordly yet. Use email for now.";
+ }
+ return message;
+}
+
+export function RecordlySignInDialog({
+ open,
+ onOpenChange,
+ reason = "account",
+ user,
+ configured,
+ callbackError,
+ onAuthenticated,
+}: Props) {
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [busy, setBusy] = useState();
+ const [message, setMessage] = useState();
+
+ useEffect(() => {
+ if (!open) {
+ setPassword("");
+ setBusy(undefined);
+ setMessage(undefined);
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (open && user && reason === "share") onAuthenticated();
+ }, [onAuthenticated, open, reason, user]);
+
+ const run = async (label: string, action: () => Promise) => {
+ setBusy(label);
+ setMessage(undefined);
+ try {
+ await action();
+ } catch (error) {
+ setMessage(friendlyAuthError(error, label));
+ } finally {
+ setBusy(undefined);
+ }
+ };
+
+ const submitEmail = (event: FormEvent) => {
+ event.preventDefault();
+ if (!configured || busy) return;
+ void run("email", async () => {
+ await signInWithEmail(email.trim(), password);
+ onAuthenticated();
+ });
+ };
+
+ const forgotPassword = () => {
+ if (!email.trim()) {
+ setMessage("Enter your email address first.");
+ return;
+ }
+ void run("reset", async () => {
+ await sendPasswordReset(email.trim());
+ setMessage("Password reset email sent.");
+ });
+ };
+
+ const disabled = !configured || Boolean(busy);
+ return (
+
+
+
+
+
+
+
+ {user ? "Your Recordly account" : "Sign into Recordly"}
+
+
+ {user
+ ? user.email
+ : reason === "share"
+ ? "Sign in to publish this video and manage its shared link."
+ : "Access your recordings and shared links."}
+
+
+
+ {user ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+ or
+
+
+
+ >
+ )}
+ {message || callbackError ? (
+
+
+
+
+ {message || callbackError}
+
+
+
+ ) : null}
+
+ {!configured && !user && (
+
+
+ Cloud sign-in isn’t available in this build yet. You can still
+ save videos to your computer.
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/auth/useRecordlyAuth.ts b/src/components/auth/useRecordlyAuth.ts
new file mode 100644
index 00000000..8a87ebea
--- /dev/null
+++ b/src/components/auth/useRecordlyAuth.ts
@@ -0,0 +1,65 @@
+import type { User } from "@supabase/supabase-js";
+import { useEffect, useState } from "react";
+import {
+ completeAuthCallback,
+ recordlyAuth,
+ recordlyAuthConfigured,
+} from "@/lib/auth/recordlyAuth";
+
+export function useRecordlyAuth() {
+ const [user, setUser] = useState(null);
+ const [accessToken, setAccessToken] = useState();
+ const [loading, setLoading] = useState(recordlyAuthConfigured);
+ const [callbackError, setCallbackError] = useState();
+
+ useEffect(() => {
+ if (!recordlyAuth) {
+ setLoading(false);
+ return;
+ }
+
+ let mounted = true;
+ void recordlyAuth.auth
+ .getSession()
+ .then(({ data }) => {
+ if (mounted) {
+ setUser(data.session?.user ?? null);
+ setAccessToken(data.session?.access_token);
+ setLoading(false);
+ }
+ })
+ .catch((error) => {
+ if (mounted) {
+ setCallbackError(error instanceof Error ? error.message : String(error));
+ setLoading(false);
+ }
+ });
+ const { data: listener } = recordlyAuth.auth.onAuthStateChange((_event, session) => {
+ if (mounted) {
+ setUser(session?.user ?? null);
+ setAccessToken(session?.access_token);
+ }
+ });
+
+ const handleCallback = async (url: string) => {
+ try {
+ setCallbackError(undefined);
+ await completeAuthCallback(url);
+ } catch (error) {
+ setCallbackError(error instanceof Error ? error.message : String(error));
+ }
+ };
+ const unsubscribe = window.electronAPI.onAuthCallbackUrl((url) => void handleCallback(url));
+ void window.electronAPI.getPendingAuthCallbackUrl().then((url) => {
+ if (url) void handleCallback(url);
+ });
+
+ return () => {
+ mounted = false;
+ listener.subscription.unsubscribe();
+ unsubscribe();
+ };
+ }, []);
+
+ return { user, accessToken, loading, configured: recordlyAuthConfigured, callbackError };
+}
diff --git a/src/lib/auth/recordlyAuth.ts b/src/lib/auth/recordlyAuth.ts
new file mode 100644
index 00000000..41d5c153
--- /dev/null
+++ b/src/lib/auth/recordlyAuth.ts
@@ -0,0 +1,83 @@
+import { createClient, type Provider, type User } from "@supabase/supabase-js";
+
+const supabaseUrl = import.meta.env.VITE_SUPABASE_URL?.trim();
+const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY?.trim();
+const callbackUrl = import.meta.env.DEV
+ ? "http://127.0.0.1:43821/auth/callback"
+ : "recordly://auth/callback";
+
+export const recordlyAuthConfigured = Boolean(supabaseUrl && supabasePublishableKey);
+
+export const recordlyAuth = recordlyAuthConfigured
+ ? createClient(supabaseUrl!, supabasePublishableKey!, {
+ auth: {
+ flowType: "pkce",
+ persistSession: true,
+ autoRefreshToken: true,
+ detectSessionInUrl: false,
+ },
+ })
+ : null;
+
+function requireAuth() {
+ if (!recordlyAuth) {
+ throw new Error("Recordly Auth is not configured. Add the Supabase URL and publishable key.");
+ }
+ return recordlyAuth;
+}
+
+export async function signInWithEmail(email: string, password: string): Promise {
+ const client = requireAuth();
+ const { data, error } = await client.auth.signInWithPassword({ email, password });
+ if (error) throw error;
+ if (!data.user) throw new Error("No user was returned after sign-in.");
+ return data.user;
+}
+
+export async function sendPasswordReset(email: string): Promise {
+ const client = requireAuth();
+ const { error } = await client.auth.resetPasswordForEmail(email, { redirectTo: callbackUrl });
+ if (error) throw error;
+}
+
+async function openAuthUrl(url: string | null) {
+ if (!url) throw new Error("The authentication provider did not return a sign-in URL.");
+ const result = await window.electronAPI.openExternalUrl(url);
+ if (!result.success) throw new Error(result.error || "Could not open the sign-in page.");
+}
+
+export async function signInWithSocial(provider: "google" | "twitter"): Promise {
+ const client = requireAuth();
+ const { data, error } = await client.auth.signInWithOAuth({
+ provider: provider as Provider,
+ options: { redirectTo: callbackUrl, skipBrowserRedirect: true },
+ });
+ if (error) throw error;
+ await openAuthUrl(data.url);
+}
+
+export async function signInWithSaml(email: string): Promise {
+ const client = requireAuth();
+ const domain = email.trim().split("@")[1];
+ if (!domain) throw new Error("Enter a valid work email address.");
+ const { data, error } = await client.auth.signInWithSSO({
+ domain,
+ options: { redirectTo: callbackUrl, skipBrowserRedirect: true },
+ });
+ if (error) throw error;
+ await openAuthUrl(data.url);
+}
+
+export async function completeAuthCallback(url: string): Promise {
+ const code = new URL(url).searchParams.get("code");
+ if (!code) throw new Error("The sign-in callback did not include an authorization code.");
+ const client = requireAuth();
+ const { error } = await client.auth.exchangeCodeForSession(code);
+ if (error) throw error;
+}
+
+export async function signOutRecordly(): Promise {
+ const client = requireAuth();
+ const { error } = await client.auth.signOut();
+ if (error) throw error;
+}