diff --git a/apps/desktop/src-tauri/src/api.rs b/apps/desktop/src-tauri/src/api.rs index c633d5ce91..c9fdeaa1ce 100644 --- a/apps/desktop/src-tauri/src/api.rs +++ b/apps/desktop/src-tauri/src/api.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use serde_json::json; +use specta::Type; use tauri::AppHandle; use tracing::{instrument, trace}; @@ -32,7 +33,7 @@ pub async fn upload_multipart_initiate( .map_err(|err| format!("api/upload_multipart_initiate/request: {err}"))?; if !resp.status().is_success() { - let status = resp.status(); + let status = resp.status().as_u16(); let error_body = resp .text() .await @@ -255,3 +256,31 @@ pub async fn desktop_video_progress( Ok(()) } + +#[derive(Serialize, Deserialize, Type, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Organization { + pub id: String, + pub name: String, + pub owner_id: String, +} + +pub async fn fetch_organizations(app: &AppHandle) -> Result, AuthedApiError> { + let resp = app + .authed_api_request("/api/desktop/organizations", |client, url| client.get(url)) + .await + .map_err(|err| format!("api/fetch_organizations/request: {err}"))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let error_body = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(format!("api/fetch_organizations/{status}: {error_body}").into()); + } + + resp.json() + .await + .map_err(|err| format!("api/fetch_organizations/response: {err}").into()) +} diff --git a/apps/desktop/src-tauri/src/auth.rs b/apps/desktop/src-tauri/src/auth.rs index 698c41f10b..750e88e720 100644 --- a/apps/desktop/src-tauri/src/auth.rs +++ b/apps/desktop/src-tauri/src/auth.rs @@ -6,7 +6,10 @@ use tauri_plugin_store::StoreExt; use web_api::ManagerExt; -use crate::web_api; +use crate::{ + api::{self, Organization}, + web_api, +}; #[derive(Serialize, Deserialize, Type, Debug)] pub struct AuthStore { @@ -14,6 +17,8 @@ pub struct AuthStore { pub user_id: Option, pub plan: Option, pub intercom_hash: Option, + #[serde(default)] + pub organizations: Vec, } #[derive(Serialize, Deserialize, Type, Debug)] @@ -96,6 +101,9 @@ impl AuthStore { manual: auth.plan.as_ref().is_some_and(|p| p.manual), }); auth.intercom_hash = Some(plan_response.intercom_hash.unwrap_or_default()); + auth.organizations = api::fetch_organizations(app) + .await + .map_err(|e| e.to_string())?; Self::set(app, Some(auth))?; diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index 7372da2b9b..dbd90f667f 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -133,9 +133,10 @@ impl DeepLinkAction { }; let inputs = StartRecordingInputs { + mode, capture_target, capture_system_audio, - mode, + organization_id: None, }; crate::recording::start_recording(app.clone(), state, inputs) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c7d9ba563b..aff1b0a33c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -86,7 +86,7 @@ use tauri_plugin_opener::OpenerExt; use tauri_plugin_shell::ShellExt; use tauri_specta::Event; use tokio::sync::{RwLock, oneshot}; -use tracing::{error, instrument, trace, warn}; +use tracing::*; use upload::{create_or_get_video, upload_image, upload_video}; use web_api::AuthedApiError; use web_api::ManagerExt as WebManagerExt; @@ -1081,6 +1081,7 @@ async fn upload_exported_video( path: PathBuf, mode: UploadMode, channel: Channel, + organization_id: Option, ) -> Result { let Ok(Some(auth)) = AuthStore::get(&app) else { AuthStore::set(&app, None).map_err(|e| e.to_string())?; @@ -1127,6 +1128,7 @@ async fn upload_exported_video( video_id, Some(meta.pretty_name.clone()), Some(metadata.clone()), + organization_id, ) .await } @@ -1656,6 +1658,7 @@ async fn check_upgraded_and_update(app: AppHandle) -> Result { manual: auth.plan.map(|p| p.manual).unwrap_or(false), last_checked: chrono::Utc::now().timestamp() as i32, }), + organizations: auth.organizations, }; println!("Updating auth store with new pro status"); AuthStore::set(&app, Some(updated_auth)).map_err(|e| e.to_string())?; @@ -2323,19 +2326,18 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { let _ = set_mic_input(app.state(), settings.mic_name).await; let _ = set_camera_input(app.clone(), app.state(), settings.camera_id).await; - let _ = start_recording( - app.clone(), - app.state(), + let _ = start_recording(app.clone(), app.state(), { recording::StartRecordingInputs { capture_target: settings.target.unwrap_or_else(|| { ScreenCaptureTarget::Display { id: Display::primary().id(), } }), - capture_system_audio: settings.system_audio, mode: event.mode, - }, - ) + capture_system_audio: settings.system_audio, + organization_id: settings.organization_id, + } + }) .await; }); diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 10eab98b01..ab5fd90b32 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -237,6 +237,8 @@ pub struct StartRecordingInputs { #[serde(default)] pub capture_system_audio: bool, pub mode: RecordingMode, + #[serde(default)] + pub organization_id: Option, } #[derive(tauri_specta::Event, specta::Type, Clone, Debug, serde::Serialize)] @@ -313,6 +315,7 @@ pub async fn start_recording( chrono::Local::now().format("%Y-%m-%d %H:%M:%S") )), None, + inputs.organization_id.clone(), ) .await { diff --git a/apps/desktop/src-tauri/src/recording_settings.rs b/apps/desktop/src-tauri/src/recording_settings.rs index 57f22c03e7..9b62c4f57d 100644 --- a/apps/desktop/src-tauri/src/recording_settings.rs +++ b/apps/desktop/src-tauri/src/recording_settings.rs @@ -20,6 +20,7 @@ pub struct RecordingSettingsStore { pub camera_id: Option, pub mode: Option, pub system_audio: bool, + pub organization_id: Option, } impl RecordingSettingsStore { diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index a4688a40f7..f426ea2d9a 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -177,7 +177,7 @@ pub async fn upload_image( .ok_or("Invalid file path")? .to_string(); - let s3_config = create_or_get_video(app, true, None, None, None).await?; + let s3_config = create_or_get_video(app, true, None, None, None, None).await?; let (stream, total_size) = file_reader_stream(file_path).await?; singlepart_uploader( @@ -206,6 +206,7 @@ pub async fn create_or_get_video( video_id: Option, name: Option, meta: Option, + organization_id: Option, ) -> Result { let mut s3_config_url = if let Some(id) = video_id { format!("/api/desktop/video/create?recordingMode=desktopMP4&videoId={id}") @@ -228,6 +229,10 @@ pub async fn create_or_get_video( } } + if let Some(org_id) = organization_id { + s3_config_url.push_str(&format!("&orgId={}", org_id)); + } + let response = app .authed_api_request(s3_config_url, |client, url| client.get(url)) .await?; diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index de21995239..bc38363267 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -440,6 +440,8 @@ function Page() { currentWindow.setSize(new LogicalSize(size.width, size.height)); }); + commands.updateAuthPlan(); + onCleanup(async () => { (await unlistenFocus)?.(); (await unlistenResize)?.(); diff --git a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx index 49a92a4acc..75f24dff38 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/recordings.tsx @@ -350,6 +350,7 @@ function RecordingItem(props: { props.recording.path, "Reupload", new Channel((progress) => {}), + null, ), })); diff --git a/apps/desktop/src/routes/editor/ExportDialog.tsx b/apps/desktop/src/routes/editor/ExportDialog.tsx index 92271f183b..e9a54fd611 100644 --- a/apps/desktop/src/routes/editor/ExportDialog.tsx +++ b/apps/desktop/src/routes/editor/ExportDialog.tsx @@ -7,6 +7,7 @@ import { keepPreviousData, } from "@tanstack/solid-query"; import { Channel } from "@tauri-apps/api/core"; +import { CheckMenuItem, Menu } from "@tauri-apps/api/menu"; import { save as saveDialog } from "@tauri-apps/plugin-dialog"; import { cx } from "cva"; import { @@ -19,6 +20,7 @@ import { mergeProps, on, Show, + Suspense, Switch, type ValidComponent, } from "solid-js"; @@ -30,6 +32,7 @@ import { authStore } from "~/store"; import { trackEvent } from "~/utils/analytics"; import { createSignInMutation } from "~/utils/auth"; import { exportVideo } from "~/utils/export"; +import { createOrganizationsQuery } from "~/utils/queries"; import { commands, type ExportCompression, @@ -107,6 +110,7 @@ interface Settings { exportTo: ExportToOption; resolution: { label: string; value: string; width: number; height: number }; compression: ExportCompression; + organizationId?: string | null; } export function ExportDialog() { const { @@ -120,6 +124,7 @@ export function ExportDialog() { } = useEditorContext(); const auth = authStore.createQuery(); + const organisations = createOrganizationsQuery(); const hasTransparentBackground = () => { const backgroundSource = @@ -146,15 +151,22 @@ export function ExportDialog() { const ret: Partial = {}; if (hasTransparentBackground() && _settings.format === "Mp4") ret.format = "Gif"; + // Ensure GIF is not selected when exportTo is "link" + else if (_settings.format === "Gif" && _settings.exportTo === "link") + ret.format = "Mp4"; + else if (!["Mp4", "Gif"].includes(_settings.format)) ret.format = "Mp4"; - return ret; - }); + Object.defineProperty(ret, "organizationId", { + get() { + if (!_settings.organizationId && organisations().length > 0) + return organisations()[0].id; - if (!["Mp4", "Gif"].includes(settings.format)) setSettings("format", "Mp4"); + return _settings.organizationId; + }, + }); - // Ensure GIF is not selected when exportTo is "link" - if (settings.format === "Gif" && settings.exportTo === "link") - setSettings("format", "Mp4"); + return ret; + }); const exportWithSettings = (onProgress: (progress: FramesRendered) => void) => exportVideo( @@ -365,19 +377,21 @@ export function ExportDialog() { setExportState({ type: "uploading", progress: 0 }); + console.log({ organizationId: settings.organizationId }); + // Now proceed with upload const result = meta().sharing ? await commands.uploadExportedVideo( projectPath, "Reupload", uploadChannel, + settings.organizationId ?? null, ) : await commands.uploadExportedVideo( projectPath, - { - Initial: { pre_created_video: null }, - }, + { Initial: { pre_created_video: null } }, uploadChannel, + settings.organizationId ?? null, ); if (result === "NotAuthenticated") @@ -436,14 +450,14 @@ export function ExportDialog() { ) } leftFooterContent={ -
- - {(est) => ( -
+
+ + + {(est) => (

@@ -502,9 +516,9 @@ export function ExportDialog() { })()}

-
- )} - + )} + +
} > @@ -512,7 +526,49 @@ export function ExportDialog() { {/* Export to */}
-

Export to

+
+

Export to

+ + 0 + } + > +
{ + const menu = await Menu.new({ + items: await Promise.all( + organisations().map((org) => + CheckMenuItem.new({ + text: org.name, + action: () => { + setSettings("organizationId", org.id); + }, + checked: settings.organizationId === org.id, + }), + ), + ), + }); + menu.popup(); + }} + > + Organization: + + { + ( + organisations().find( + (o) => o.id === settings.organizationId, + ) ?? organisations()[0] + )?.name + } + + +
+
+
+
{(option) => ( diff --git a/apps/desktop/src/routes/editor/ShareButton.tsx b/apps/desktop/src/routes/editor/ShareButton.tsx index fbd3883849..03793ff3a4 100644 --- a/apps/desktop/src/routes/editor/ShareButton.tsx +++ b/apps/desktop/src/routes/editor/ShareButton.tsx @@ -100,6 +100,7 @@ function ShareButton() { projectPath, "Reupload", uploadChannel, + null, ) : await commands.uploadExportedVideo( projectPath, @@ -107,6 +108,7 @@ function ShareButton() { Initial: { pre_created_video: null }, }, uploadChannel, + null, ); if (result === "NotAuthenticated") { diff --git a/apps/desktop/src/routes/recordings-overlay.tsx b/apps/desktop/src/routes/recordings-overlay.tsx index 01a7812889..fd20488659 100644 --- a/apps/desktop/src/routes/recordings-overlay.tsx +++ b/apps/desktop/src/routes/recordings-overlay.tsx @@ -772,10 +772,9 @@ function createRecordingMutations( res = await commands.uploadExportedVideo( media.path, - { - Initial: { pre_created_video: null }, - }, + { Initial: { pre_created_video: null } }, uploadChannel, + null, ); } else { setActionState({ diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index aa788f081e..ee540a3dac 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -12,11 +12,14 @@ import { CheckMenuItem, Menu, Submenu } from "@tauri-apps/api/menu"; import { cx } from "cva"; import { type ComponentProps, + createEffect, createMemo, createRoot, createSignal, + For, type JSX, Match, + mergeProps, onCleanup, onMount, Show, @@ -26,12 +29,13 @@ import { import { createStore, reconcile } from "solid-js/store"; import ModeSelect from "~/components/ModeSelect"; import { authStore, generalSettingsStore } from "~/store"; -import { createOptionsQuery } from "~/utils/queries"; +import { createOptionsQuery, createOrganizationsQuery } from "~/utils/queries"; import { handleRecordingResult } from "~/utils/recording"; import { commands, type DisplayId, events, + Organization, type ScreenCaptureTarget, type TargetUnderCursor, } from "~/utils/tauri"; @@ -65,13 +69,34 @@ export default function () { ); } +function useOptions() { + const { rawOptions: _rawOptions, setOptions } = createOptionsQuery(); + + const organizations = createOrganizationsQuery(); + const options = mergeProps(_rawOptions, () => { + const ret: Partial = {}; + + if ( + (!_rawOptions.organizationId && organizations().length > 0) || + (_rawOptions.organizationId && + organizations().every((o) => o.id !== _rawOptions.organizationId) && + organizations().length > 0) + ) + ret.organizationId = organizations()[0]?.id; + + return ret; + }); + + return [options, setOptions] as const; +} + function Inner() { const [params] = useSearchParams<{ displayId: DisplayId; isHoveredDisplay: string; }>(); const isHoveredDisplay = params.isHoveredDisplay === "true"; - const { rawOptions, setOptions } = createOptionsQuery(); + const [options, setOptions] = useOptions(); const [toggleModeSelect, setToggleModeSelect] = createSignal(false); const [targetUnderCursor, setTargetUnderCursor] = @@ -109,8 +134,7 @@ function Inner() { return null; } }, - enabled: - params.displayId !== undefined && rawOptions.targetMode === "display", + enabled: params.displayId !== undefined && options.targetMode === "display", })); const [bounds, setBounds] = createStore( @@ -138,7 +162,7 @@ function Inner() { return ( - + {(_) => (
- +
)}
@@ -247,14 +271,14 @@ function Inner() { Adjust recording area
)} - + {(_) => { const [state, setState] = createSignal< "creating" | "dragging" | undefined @@ -785,7 +809,7 @@ function Inner() { } />
@@ -833,7 +857,7 @@ function RecordingControls(props: { showBackground?: boolean; }) { const auth = authStore.createQuery(); - const { setOptions, rawOptions } = useRecordingOptions(); + const [options, setOptions] = useOptions(); const generalSetings = generalSettingsStore.createQuery(); @@ -851,14 +875,14 @@ function RecordingControls(props: { action: () => { setOptions("mode", "studio"); }, - checked: rawOptions.mode === "studio", + checked: options.mode === "studio", }), await CheckMenuItem.new({ text: "Instant Mode", action: () => { setOptions("mode", "instant"); }, - checked: rawOptions.mode === "instant", + checked: options.mode === "instant", }), ], }); @@ -901,8 +925,9 @@ function RecordingControls(props: { handleRecordingResult( commands.startRecording({ capture_target: props.target, - mode: rawOptions.mode, - capture_system_audio: rawOptions.captureSystemAudio, + mode: options.mode, + capture_system_audio: options.captureSystemAudio, + organization_id: options.organizationId ?? null, }), setOptions, ), @@ -937,12 +962,12 @@ function RecordingControls(props: {
{ - if (rawOptions.mode === "instant" && !auth.data) { + if (options.mode === "instant" && !auth.data) { emit("start-sign-in"); return; } @@ -957,19 +982,19 @@ function RecordingControls(props: { !startRecording.isPending && "hover:bg-blue-10", )} > - {rawOptions.mode === "studio" ? ( + {options.mode === "studio" ? ( ) : ( )}
- {rawOptions.mode === "instant" && !auth.data + {options.mode === "instant" && !auth.data ? "Sign In To Use" : "Start Recording"} - {`${capitalize(rawOptions.mode)} Mode`} + {`${capitalize(options.mode)} Mode`}
@@ -996,20 +1021,69 @@ function RecordingControls(props: { +
+ + +
+ + ); +} + +function OrganizationSelect(props: { showBackground?: boolean }) { + const organisations = createOrganizationsQuery(); + const [options, setOptions] = useOptions(); + + return ( + 1}> ); @@ -1051,9 +1125,3 @@ function ResizeHandle( /> ); } - -function getDisplayId(displayId: string | undefined) { - const id = Number(displayId); - if (Number.isNaN(id)) return 0; - return id; -} diff --git a/apps/desktop/src/utils/queries.ts b/apps/desktop/src/utils/queries.ts index 93ad12177a..c9462c5c98 100644 --- a/apps/desktop/src/utils/queries.ts +++ b/apps/desktop/src/utils/queries.ts @@ -23,7 +23,7 @@ import { type RecordingMode, type ScreenCaptureTarget, } from "./tauri"; -import { orgCustomDomainClient, protectedHeaders } from "./web-api"; +import { apiClient, orgCustomDomainClient, protectedHeaders } from "./web-api"; export const listWindows = queryOptions({ queryKey: ["capture", "windows"] as const, @@ -121,6 +121,7 @@ export function createOptionsQuery() { captureSystemAudio?: boolean; targetMode?: "display" | "window" | "area" | null; cameraID?: DeviceOrModelID | null; + organizationId?: string | null; /** @deprecated */ cameraLabel: string | null; }>({ @@ -128,6 +129,7 @@ export function createOptionsQuery() { micName: null, cameraLabel: null, mode: "studio", + organizationId: null, }); createEventListener(window, "storage", (e) => { @@ -141,6 +143,7 @@ export function createOptionsQuery() { cameraId: _state.cameraID, mode: _state.mode, systemAudio: _state.captureSystemAudio, + organizationId: _state.organizationId, }); }); @@ -238,3 +241,19 @@ export function createCustomDomainQuery() { refetchOnWindowFocus: true, })); } + +export function createOrganizationsQuery() { + const auth = authStore.createQuery(); + + // Refresh organizations if they're missing + createEffect(() => { + if ( + auth.data?.user_id && + (!auth.data?.organizations || auth.data.organizations.length === 0) + ) { + commands.updateAuthPlan().catch(console.error); + } + }); + + return () => auth.data?.organizations ?? []; +} diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 17987f9b9d..96fc21dd78 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -128,8 +128,8 @@ async doPermissionsCheck(initialCheck: boolean) : Promise { async requestPermission(permission: OSPermission) : Promise { await TAURI_INVOKE("request_permission", { permission }); }, -async uploadExportedVideo(path: string, mode: UploadMode, channel: TAURI_CHANNEL) : Promise { - return await TAURI_INVOKE("upload_exported_video", { path, mode, channel }); +async uploadExportedVideo(path: string, mode: UploadMode, channel: TAURI_CHANNEL, organizationId: string | null) : Promise { + return await TAURI_INVOKE("upload_exported_video", { path, mode, channel, organizationId }); }, async uploadScreenshot(screenshotPath: string) : Promise { return await TAURI_INVOKE("upload_screenshot", { screenshotPath }); @@ -352,7 +352,7 @@ export type AudioMeta = { path: string; */ start_time?: number | null } export type AuthSecret = { api_key: string } | { token: string; expires: number } -export type AuthStore = { secret: AuthSecret; user_id: string | null; plan: Plan | null; intercom_hash: string | null } +export type AuthStore = { secret: AuthSecret; user_id: string | null; plan: Plan | null; intercom_hash: string | null; organizations?: Organization[] } export type BackgroundConfiguration = { source: BackgroundSource; blur: number; padding: number; rounding: number; inset: number; crop: Crop | null; shadow?: number; advancedShadow?: ShadowConfiguration | null; border?: BorderConfiguration | null } export type BackgroundSource = { type: "wallpaper"; path: string | null } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number]; alpha?: number } | { type: "gradient"; from: [number, number, number]; to: [number, number, number]; angle?: number } export type BorderConfiguration = { enabled: boolean; width: number; color: [number, number, number]; opacity: number } @@ -430,6 +430,7 @@ export type OSPermission = "screenRecording" | "camera" | "microphone" | "access export type OSPermissionStatus = "notNeeded" | "empty" | "granted" | "denied" export type OSPermissionsCheck = { screenRecording: OSPermissionStatus; microphone: OSPermissionStatus; camera: OSPermissionStatus; accessibility: OSPermissionStatus } export type OnEscapePress = null +export type Organization = { id: string; name: string; ownerId: string } export type PhysicalSize = { width: number; height: number } export type Plan = { upgraded: boolean; manual: boolean; last_checked: number } export type Platform = "MacOS" | "Windows" @@ -446,7 +447,7 @@ export type RecordingMeta = (StudioRecordingMeta | InstantRecordingMeta) & { pla export type RecordingMetaWithMetadata = ((StudioRecordingMeta | InstantRecordingMeta) & { platform?: Platform | null; pretty_name: string; sharing?: SharingMeta | null; upload?: UploadMeta | null }) & { mode: RecordingMode; status: StudioRecordingStatus } export type RecordingMode = "studio" | "instant" export type RecordingOptionsChanged = null -export type RecordingSettingsStore = { target: ScreenCaptureTarget | null; micName: string | null; cameraId: DeviceOrModelID | null; mode: RecordingMode | null; systemAudio: boolean } +export type RecordingSettingsStore = { target: ScreenCaptureTarget | null; micName: string | null; cameraId: DeviceOrModelID | null; mode: RecordingMode | null; systemAudio: boolean; organizationId: string | null } export type RecordingStarted = null export type RecordingStopped = null export type RecordingTargetMode = "display" | "window" | "area" @@ -466,7 +467,7 @@ export type ShadowConfiguration = { size: number; opacity: number; blur: number export type SharingMeta = { id: string; link: string } export type ShowCapWindow = "Setup" | { Main: { init_target_mode: RecordingTargetMode | null } } | { Settings: { page: string | null } } | { Editor: { project_path: string } } | "RecordingsOverlay" | { WindowCaptureOccluder: { screen_id: DisplayId } } | { TargetSelectOverlay: { display_id: DisplayId } } | { CaptureArea: { screen_id: DisplayId } } | "Camera" | { InProgressRecording: { countdown: number | null } } | "Upgrade" | "ModeSelect" export type SingleSegment = { display: VideoMeta; camera?: VideoMeta | null; audio?: AudioMeta | null; cursor?: string | null } -export type StartRecordingInputs = { capture_target: ScreenCaptureTarget; capture_system_audio?: boolean; mode: RecordingMode } +export type StartRecordingInputs = { capture_target: ScreenCaptureTarget; capture_system_audio?: boolean; mode: RecordingMode; organization_id?: string | null } export type StereoMode = "stereo" | "monoL" | "monoR" export type StudioRecordingMeta = { segment: SingleSegment } | { inner: MultipleSegments } export type StudioRecordingStatus = { status: "InProgress" } | { status: "Failed"; error: string } | { status: "Complete" } diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index dca8c34205..6d697d9c64 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -1,10 +1,14 @@ import * as crypto from "node:crypto"; import { db } from "@cap/database"; -import { organizations, users } from "@cap/database/schema"; +import { + organizationMembers, + organizations, + users, +} from "@cap/database/schema"; import { buildEnv, serverEnv } from "@cap/env"; import { stripe, userIsPro } from "@cap/utils"; import { zValidator } from "@hono/zod-validator"; -import { eq } from "drizzle-orm"; +import { eq, or } from "drizzle-orm"; import { Hono } from "hono"; import { PostHog } from "posthog-node"; import type Stripe from "stripe"; @@ -193,6 +197,31 @@ app.get("/plan", withAuth, async (c) => { }); }); +app.get("/organizations", withAuth, async (c) => { + const user = c.get("user"); + + const orgs = await db() + .select({ + id: organizations.id, + name: organizations.name, + ownerId: organizations.ownerId, + }) + .from(organizations) + .leftJoin( + organizationMembers, + eq(organizations.id, organizationMembers.organizationId), + ) + .where( + or( + eq(organizations.ownerId, user.id), + eq(organizationMembers.userId, user.id), + ), + ) + .groupBy(organizations.id); + + return c.json(orgs); +}); + app.post( "/subscribe", withAuth, diff --git a/apps/web/package.json b/apps/web/package.json index a26f85b85a..ae311637ca 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -151,6 +151,6 @@ "typescript": "^5.8.3" }, "engines": { - "node": "20" + "node": ">=20" } } diff --git a/package.json b/package.json index 2c268a1e08..0f7c91b6b0 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,6 @@ "packageManager": "pnpm@10.5.2", "name": "cap", "engines": { - "node": "24" + "node": ">=20" } } diff --git a/packages/database/package.json b/packages/database/package.json index 4b52b473e8..42074bb491 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -53,7 +53,7 @@ "typescript": "^5.8.3" }, "engines": { - "node": "20" + "node": ">=20" }, "exports": { ".": "./index.ts", diff --git a/packages/local-docker/package.json b/packages/local-docker/package.json index f4a274a680..0bbc840a60 100644 --- a/packages/local-docker/package.json +++ b/packages/local-docker/package.json @@ -10,6 +10,6 @@ "docker:clean": "docker compose down -v" }, "engines": { - "node": "20" + "node": ">=20" } } diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 8914e81d16..66d8db55d1 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -65,6 +65,7 @@ declare global { const IconLucideAppWindowMac: typeof import('~icons/lucide/app-window-mac.jsx')['default'] const IconLucideBell: typeof import('~icons/lucide/bell.jsx')['default'] const IconLucideBug: typeof import('~icons/lucide/bug.jsx')['default'] + const IconLucideBuilding2: typeof import('~icons/lucide/building2.jsx')['default'] const IconLucideCheck: typeof import('~icons/lucide/check.jsx')['default'] const IconLucideClock: typeof import('~icons/lucide/clock.jsx')['default'] const IconLucideDatabase: typeof import('~icons/lucide/database.jsx')['default'] diff --git a/packages/ui/package.json b/packages/ui/package.json index 83dd10de52..3e90ae271b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -55,6 +55,6 @@ "zod": "^3" }, "engines": { - "node": "20" + "node": ">=20" } } diff --git a/packages/utils/package.json b/packages/utils/package.json index cc9536c5f7..82f842d4c5 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -28,6 +28,6 @@ "zod": "^3" }, "engines": { - "node": "20" + "node": ">=20" } }