Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ab9e072
wip
oscartbeaumont Sep 25, 2025
c501a42
wip
oscartbeaumont Sep 25, 2025
52729f4
wip
oscartbeaumont Sep 25, 2025
f0b5c6c
Merge branch 'main' into populate-orgId
oscartbeaumont Sep 30, 2025
797fca5
configure default org id
oscartbeaumont Sep 30, 2025
696a6cd
fixes
oscartbeaumont Sep 30, 2025
46b0ff2
fix
oscartbeaumont Sep 30, 2025
29325e2
fix
oscartbeaumont Sep 30, 2025
8e435a1
fix
oscartbeaumont Sep 30, 2025
ecb8f49
permission checks on `orgId` assignment for video upload
oscartbeaumont Oct 1, 2025
0a0372c
cleanup
oscartbeaumont Oct 1, 2025
6204ec4
backfill script
oscartbeaumont Oct 1, 2025
890c794
improve backfill script
oscartbeaumont Oct 1, 2025
17a36e4
filter cluser tables + improve backfill script
oscartbeaumont Oct 1, 2025
fecf7de
format
oscartbeaumont Oct 1, 2025
fc0114e
fix backfill script
oscartbeaumont Oct 1, 2025
2139d55
auth when updating `defaultOrgId`'s
oscartbeaumont Oct 1, 2025
355f3b5
don't navigate with changes
oscartbeaumont Oct 1, 2025
58ca316
format
oscartbeaumont Oct 1, 2025
1ae12d2
fix
oscartbeaumont Oct 1, 2025
ffd86c7
fix
oscartbeaumont Oct 1, 2025
61713b8
fix
oscartbeaumont Oct 1, 2025
0d8587b
assign default properly
oscartbeaumont Oct 1, 2025
a6ff99c
nahhhh
oscartbeaumont Oct 1, 2025
08caacb
Merge branch 'main' into populate-orgId
oscartbeaumont Oct 1, 2025
88bf9df
fix types
oscartbeaumont Oct 1, 2025
932cd33
fix
oscartbeaumont Oct 1, 2025
f910ca7
fix
oscartbeaumont Oct 1, 2025
057da33
update select component
ameer2468 Oct 2, 2025
8aa84dd
Merge branch 'main' into populate-orgId
ameer2468 Oct 2, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/actions/video/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ export async function createVideoAndGetUploadUrl({
isScreenshot = false,
isUpload = false,
folderId,
orgId,
}: {
videoId?: Video.VideoId;
duration?: number;
Expand All @@ -173,6 +174,7 @@ export async function createVideoAndGetUploadUrl({
isScreenshot?: boolean;
isUpload?: boolean;
folderId?: Folder.FolderId;
orgId: string;
}) {
const user = await getCurrentUser();

Expand Down Expand Up @@ -228,6 +230,7 @@ export async function createVideoAndGetUploadUrl({
isScreenshot ? "Screenshot" : isUpload ? "Upload" : "Recording"
} - ${formattedDate}`,
ownerId: user.id,
orgId,
source: { type: "desktopMP4" as const },
isScreenshot,
bucket: customBucket?.id,
Expand Down
12 changes: 11 additions & 1 deletion apps/web/app/(org)/dashboard/caps/components/UploadCapButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const UploadCapButton = ({
grey?: boolean;
folderId?: Folder.FolderId;
}) => {
const { user } = useDashboardContext();
const { user, activeOrganization } = useDashboardContext();
const inputRef = useRef<HTMLInputElement>(null);
const { uploadingStore, setUploadStatus } = useUploadingContext();
const isUploading = useStore(uploadingStore, (s) => !!s.uploadStatus);
Expand All @@ -52,9 +52,16 @@ export const UploadCapButton = ({
const file = e.target.files?.[0];
if (!file || !user) return;

// This should be unreachable.
if (activeOrganization === null) {
alert("No organization active!");
return;
}

const ok = await legacyUploadCap(
file,
folderId,
activeOrganization.organization.id,
setUploadStatus,
queryClient,
);
Expand Down Expand Up @@ -93,6 +100,7 @@ export const UploadCapButton = ({
async function legacyUploadCap(
file: File,
folderId: Folder.FolderId | undefined,
orgId: string,
setUploadStatus: (state: UploadStatus | undefined) => void,
queryClient: QueryClient,
) {
Expand Down Expand Up @@ -127,6 +135,7 @@ async function legacyUploadCap(
isScreenshot: false,
isUpload: true,
folderId,
orgId,
});

const uploadId = videoData.id;
Expand Down Expand Up @@ -451,6 +460,7 @@ async function legacyUploadCap(
videoId: uploadId,
isScreenshot: true,
isUpload: true,
orgId,
});

const screenshotFormData = new FormData();
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/(org)/dashboard/dashboard-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export async function getDashboardData(user: typeof userSelectProps) {
email: users.email,
inviteQuota: users.inviteQuota,
image: users.image,
defaultOrgId: users.defaultOrgId,
},
})
.from(organizations)
Expand Down
77 changes: 62 additions & 15 deletions apps/web/app/(org)/dashboard/settings/account/Settings.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,47 @@
"use client";

import type { users } from "@cap/database/schema";
import { Button, Card, CardDescription, CardTitle, Input } from "@cap/ui";
import {
Button,
Card,
CardDescription,
CardTitle,
Input,
Select,
} from "@cap/ui";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { useDashboardContext } from "../../Contexts";
import { patchAccountSettings } from "./server";

export const Settings = ({
user,
}: {
user?: typeof users.$inferSelect | null;
}) => {
const router = useRouter();
const { organizationData } = useDashboardContext();
const [firstName, setFirstName] = useState(user?.name || "");
const [lastName, setLastName] = useState(user?.lastName || "");
const router = useRouter();
const [defaultOrgId, setDefaultOrgId] = useState<string | undefined>(
user?.defaultOrgId || undefined,
);

// Track if form has unsaved changes
const hasChanges =
firstName !== (user?.name || "") ||
lastName !== (user?.lastName || "") ||
defaultOrgId !== user?.defaultOrgId;

const { mutate: updateName, isPending: updateNamePending } = useMutation({
mutationFn: async () => {
const res = await fetch("/api/settings/user/name", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName: firstName.trim(),
lastName: lastName.trim() ? lastName.trim() : null,
}),
});
if (!res.ok) {
throw new Error("Failed to update name");
}
await patchAccountSettings(
firstName.trim(),
lastName.trim() ? lastName.trim() : undefined,
defaultOrgId,
);
},
onSuccess: () => {
toast.success("Name updated successfully");
Expand All @@ -39,6 +52,19 @@ export const Settings = ({
},
});

// Prevent navigation when there are unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasChanges) {
e.preventDefault();
e.returnValue = "";
}
};

window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [hasChanges]);

return (
<form
onSubmit={(e) => {
Expand Down Expand Up @@ -91,9 +117,30 @@ export const Settings = ({
disabled
/>
</Card>
<Card className="flex flex-col flex-1 gap-4 justify-between items-stretch">
<div className="space-y-1">
<CardTitle>Default organization</CardTitle>
<CardDescription>This is the default organization</CardDescription>
</div>

<Select
placeholder="Default organization"
value={
defaultOrgId ??
user?.defaultOrgId ??
organizationData?.[0]?.organization.id ??
""
}
onValueChange={(value) => setDefaultOrgId(value)}
options={(organizationData || []).map((org) => ({
value: org.organization.id,
label: org.organization.name,
}))}
/>
</Card>
</div>
<Button
disabled={!firstName || updateNamePending}
disabled={!firstName || updateNamePending || !hasChanges}
className="mt-6"
type="submit"
size="sm"
Expand Down
66 changes: 66 additions & 0 deletions apps/web/app/(org)/dashboard/settings/account/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use server";

import { db } from "@cap/database";
import { getCurrentUser } from "@cap/database/auth/session";
import {
organizationMembers,
organizations,
users,
} from "@cap/database/schema";
import { eq, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";

export async function patchAccountSettings(
firstName?: string,
lastName?: string,
defaultOrgId?: string,
) {
const currentUser = await getCurrentUser();
if (!currentUser) throw new Error("Unauthorized");

const updatePayload: Partial<{
name: string;
lastName: string;
defaultOrgId: string;
}> = {};

if (firstName !== undefined) updatePayload.name = firstName;
if (lastName !== undefined) updatePayload.lastName = lastName;
if (defaultOrgId !== undefined) {
const userOrganizations = await db()
.select({
id: organizations.id,
})
.from(organizations)
.leftJoin(
organizationMembers,
eq(organizations.id, organizationMembers.organizationId),
)
.where(
or(
// User owns the organization
eq(organizations.ownerId, currentUser.id),
// User is a member of the organization
eq(organizationMembers.userId, currentUser.id),
),
)
// Remove duplicates if user is both owner and member
.groupBy(organizations.id);

const userOrgIds = userOrganizations.map((org) => org.id);

if (!userOrgIds.includes(defaultOrgId))
throw new Error(
"Forbidden: User does not have access to the specified organization",
);

updatePayload.defaultOrgId = defaultOrgId;
}

await db()
.update(users)
.set(updatePayload)
.where(eq(users.id, currentUser.id));

revalidatePath("/dashboard/settings/account");
}
71 changes: 65 additions & 6 deletions apps/web/app/api/desktop/[...route]/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import { db } from "@cap/database";
import { sendEmail } from "@cap/database/emails/config";
import { FirstShareableLink } from "@cap/database/emails/first-shareable-link";
import { nanoId } from "@cap/database/helpers";
import { s3Buckets, videos, videoUploads } from "@cap/database/schema";
import {
organizationMembers,
organizations,
s3Buckets,
users,
videos,
videoUploads,
} from "@cap/database/schema";
import { buildEnv, NODE_ENV, serverEnv } from "@cap/env";
import { userIsPro } from "@cap/utils";
import { S3Buckets } from "@cap/web-backend";
import { Video } from "@cap/web-domain";
import { zValidator } from "@hono/zod-validator";
import { and, count, eq, gt, gte, lt, lte } from "drizzle-orm";
import { and, count, eq, gt, gte, lt, lte, or } from "drizzle-orm";
import { Effect, Option } from "effect";
import { Hono } from "hono";
import { z } from "zod";
Expand All @@ -34,6 +41,7 @@ app.get(
width: stringOrNumberOptional,
height: stringOrNumberOptional,
fps: stringOrNumberOptional,
orgId: z.string().optional(),
}),
),
async (c) => {
Expand All @@ -47,6 +55,7 @@ app.get(
width,
height,
fps,
orgId,
} = c.req.valid("query");
const user = c.get("user");

Expand All @@ -71,8 +80,6 @@ app.get(
.from(s3Buckets)
.where(eq(s3Buckets.ownerId, user.id));

console.log("User bucket:", customBucket ? "found" : "not found");

const date = new Date();
const formattedDate = `${date.getDate()} ${date.toLocaleString(
"default",
Expand All @@ -85,15 +92,66 @@ app.get(
.from(videos)
.where(eq(videos.id, Video.VideoId.make(videoId)));

if (video) {
if (video)
return c.json({
id: video.id,
// All deprecated
user_id: user.id,
aws_region: "n/a",
aws_bucket: "n/a",
});
}
}

const userOrganizations = await db()
.select({
id: organizations.id,
name: organizations.name,
})
.from(organizations)
.leftJoin(
organizationMembers,
eq(organizations.id, organizationMembers.organizationId),
)
.where(
or(
// User owns the organization
eq(organizations.ownerId, user.id),
// User is a member of the organization
eq(organizationMembers.userId, user.id),
),
)
// Remove duplicates if user is both owner and member
.groupBy(organizations.id, organizations.name)
.orderBy(organizations.createdAt);
const userOrgIds = userOrganizations.map((org) => org.id);

let videoOrgId: string;
if (orgId) {
// Hard error if the user requested org is non-existent or they don't have access.
if (!userOrgIds.includes(orgId))
return c.json({ error: "forbidden_org" }, { status: 403 });
videoOrgId = orgId;
} else if (user.defaultOrgId) {
// User's defaultOrgId is no longer valid, switch to first available org
if (!userOrgIds.includes(user.defaultOrgId)) {
if (!userOrganizations[0])
return c.json({ error: "no_valid_org" }, { status: 403 });

videoOrgId = userOrganizations[0].id;

// Update user's defaultOrgId to the new valid org
await db()
.update(users)
.set({
defaultOrgId: videoOrgId,
})
.where(eq(users.id, user.id));
} else videoOrgId = user.defaultOrgId;
} else {
// No orgId provided and no defaultOrgId, use first available org
if (!userOrganizations[0])
return c.json({ error: "no_valid_org" }, { status: 403 });
videoOrgId = userOrganizations[0].id;
}

const idToUse = Video.VideoId.make(nanoId());
Expand All @@ -108,6 +166,7 @@ app.get(
id: idToUse,
name: videoName,
ownerId: user.id,
orgId: videoOrgId,
source:
recordingMode === "hls"
? { type: "local" as const }
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ span,
input,
label,
button {
@apply tracking-normal text-gray-10 font-normal leading-[1.5rem];
@apply tracking-normal font-normal leading-[1.5rem];
}

a {
Expand Down
Loading