mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
feat(auth): add desktop sign-in and cloud upload foundations
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -11,6 +11,12 @@
|
||||
"electron/native/bin/**"
|
||||
],
|
||||
"productName": "Recordly",
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Recordly authentication",
|
||||
"schemes": ["recordly"]
|
||||
}
|
||||
],
|
||||
"npmRebuild": true,
|
||||
"buildDependenciesFromSource": true,
|
||||
"compression": "normal",
|
||||
|
||||
@@ -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(
|
||||
'<!doctype html><meta charset="utf-8"><title>Signed into Recordly</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#08090a;color:#ededef;font:16px "Helvetica Neue",Helvetica,Arial,sans-serif}.card{max-width:420px;padding:32px;text-align:center}h1{font-size:22px;font-weight:500}p{color:#8b8b8e;line-height:1.6}</style><main class="card"><h1>Signed into Recordly</h1><p>You can close this tab and return to the app.</p></main>',
|
||||
);
|
||||
});
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export type CloudShareTicket = {
|
||||
uploadUrl: string;
|
||||
shareUrl: string;
|
||||
shareCode?: string;
|
||||
method: "PUT";
|
||||
headers: Record<string, string>;
|
||||
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<string, unknown>;
|
||||
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<string, string> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, AbortController>();
|
||||
|
||||
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<void>((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<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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 };
|
||||
});
|
||||
}
|
||||
Generated
+103
-1
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string>();
|
||||
const [message, setMessage] = useState<string>();
|
||||
|
||||
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<unknown>) => {
|
||||
setBusy(label);
|
||||
setMessage(undefined);
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
setMessage(friendlyAuthError(error, label));
|
||||
} finally {
|
||||
setBusy(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const submitEmail = (event: FormEvent<HTMLFormElement>) => {
|
||||
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 (
|
||||
<Modal isOpen={open} onOpenChange={onOpenChange}>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container size="sm" placement="center">
|
||||
<Modal.Dialog>
|
||||
<Modal.CloseTrigger aria-label="Close" />
|
||||
<Modal.Header>
|
||||
<Modal.Heading>
|
||||
{user ? "Your Recordly account" : "Sign into Recordly"}
|
||||
</Modal.Heading>
|
||||
<Description>
|
||||
{user
|
||||
? user.email
|
||||
: reason === "share"
|
||||
? "Sign in to publish this video and manage its shared link."
|
||||
: "Access your recordings and shared links."}
|
||||
</Description>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-4">
|
||||
{user ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
isDisabled={Boolean(busy)}
|
||||
onPress={() => void run("signout", signOutRecordly)}
|
||||
>
|
||||
<SignOut className="size-4" />
|
||||
{busy === "signout" ? "Signing out…" : "Sign out"}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
isDisabled={disabled}
|
||||
onPress={() =>
|
||||
void run("google", () => signInWithSocial("google"))
|
||||
}
|
||||
>
|
||||
<GoogleLogo className="size-4" />
|
||||
Google
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
isDisabled={disabled}
|
||||
onPress={() =>
|
||||
void run("x", () => signInWithSocial("twitter"))
|
||||
}
|
||||
>
|
||||
<XLogo className="size-4" />X
|
||||
</Button>
|
||||
</div>
|
||||
<div className="my-1 flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-xs text-muted">or</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
<Form className="flex flex-col gap-4" onSubmit={submitEmail}>
|
||||
<TextField
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
isRequired
|
||||
isDisabled={Boolean(busy)}
|
||||
>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
<FieldError />
|
||||
</TextField>
|
||||
<TextField
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
isRequired
|
||||
isDisabled={Boolean(busy)}
|
||||
>
|
||||
<Label>Password</Label>
|
||||
<Input autoComplete="current-password" />
|
||||
<FieldError />
|
||||
</TextField>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-mt-2 self-end"
|
||||
isDisabled={disabled}
|
||||
onPress={forgotPassword}
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isDisabled={disabled}
|
||||
>
|
||||
{busy === "email" ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
{message || callbackError ? (
|
||||
<Alert
|
||||
status={
|
||||
message === "Password reset email sent."
|
||||
? "success"
|
||||
: "danger"
|
||||
}
|
||||
>
|
||||
<Alert.Indicator />
|
||||
<Alert.Content>
|
||||
<Alert.Description>
|
||||
{message || callbackError}
|
||||
</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert>
|
||||
) : null}
|
||||
</Modal.Body>
|
||||
{!configured && !user && (
|
||||
<Modal.Footer>
|
||||
<Description role="status" className="min-w-0 flex-1">
|
||||
Cloud sign-in isn’t available in this build yet. You can still
|
||||
save videos to your computer.
|
||||
</Description>
|
||||
</Modal.Footer>
|
||||
)}
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<User | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string>();
|
||||
const [loading, setLoading] = useState(recordlyAuthConfigured);
|
||||
const [callbackError, setCallbackError] = useState<string>();
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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<User> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const client = requireAuth();
|
||||
const { error } = await client.auth.signOut();
|
||||
if (error) throw error;
|
||||
}
|
||||
Reference in New Issue
Block a user