Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 11 additions & 9 deletions apps/web/app/(org)/dashboard/_components/MobileTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@ const MobileTab = () => {
}
});
return (
<div className="flex sticky bottom-0 z-50 flex-1 gap-5 justify-between items-center px-5 w-screen h-16 border-t lg:hidden border-gray-5 bg-gray-1">
<AnimatePresence>
{open && <OrgsMenu setOpen={setOpen} menuRef={menuRef} />}
</AnimatePresence>
<Orgs open={open} setOpen={setOpen} containerRef={containerRef} />
<div className="flex sticky bottom-0 z-50 flex-1 gap-10 justify-between items-center px-5 w-screen h-16 border-t lg:hidden border-gray-5 bg-gray-1">
<div className="relative flex-auto w-fit">
<AnimatePresence>
{open && <OrgsMenu setOpen={setOpen} menuRef={menuRef} />}
</AnimatePresence>
<Orgs open={open} setOpen={setOpen} containerRef={containerRef} />
</div>
<div className="flex gap-6 justify-between items-center h-full text-gray-11">
{Tabs.filter((i) => !i.ownerOnly || isOwner).map((tab) => (
<Link href={tab.href} key={tab.href}>
Expand All @@ -73,15 +75,15 @@ const Orgs = ({
<div
onClick={() => setOpen((p) => !p)}
ref={containerRef}
className="flex gap-1.5 items-center p-2 rounded-full border bg-gray-3 border-gray-5"
className="flex gap-1.5 items-center flex-auto max-w-[224px] p-2 rounded-full border bg-gray-3 border-gray-5"
>
<SignedImageUrl
image={activeOrg?.organization.iconUrl}
name={activeOrg?.organization.name ?? "No organization found"}
letterClass="text-xs"
className="relative flex-shrink-0 mx-auto size-6"
/>
<p className="text-sm mr-2 text-gray-12 truncate w-fit max-w-[90px]">
<p className="flex-1 mr-2 text-sm truncate text-gray-12">
{activeOrg?.organization.name}
</p>
<ChevronDown
Expand Down Expand Up @@ -112,7 +114,7 @@ const OrgsMenu = ({
transition={{ duration: 0.15 }}
ref={menuRef as LegacyRef<HTMLDivElement>}
className={
"isolate absolute overscroll-contain bottom-14 p-2 space-y-1.5 w-full rounded-xl h-fit border bg-gray-3 max-h-[325px] custom-scroll max-w-[200px] border-gray-4"
"isolate absolute overscroll-contain bottom-14 p-2 space-y-1.5 flex-auto w-full rounded-xl h-fit border bg-gray-3 max-h-[325px] custom-scroll border-gray-4"
}
>
{orgData?.map((organization) => {
Expand Down Expand Up @@ -142,7 +144,7 @@ const OrgsMenu = ({
/>
<p
className={clsx(
"flex-1 text-sm transition-colors duration-200 group-hover:text-gray-12",
"flex-1 text-sm truncate transition-colors duration-200 group-hover:text-gray-12",
isSelected ? "text-gray-12" : "text-gray-10",
)}
>
Expand Down
16 changes: 12 additions & 4 deletions apps/web/app/(org)/dashboard/dashboard-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,12 @@ export async function getDashboardData(user: typeof userSelectProps) {
)
.leftJoin(users, eq(organizationMembers.userId, users.id))
.where(
or(
eq(organizations.ownerId, user.id),
eq(organizationMembers.userId, user.id),
and(
or(
eq(organizations.ownerId, user.id),
eq(organizationMembers.userId, user.id),
),
isNull(organizations.tombstoneAt),
),
);

Expand Down Expand Up @@ -322,7 +325,12 @@ export async function getDashboardData(user: typeof userSelectProps) {
organizationInvites,
eq(organizations.id, organizationInvites.organizationId),
)
.where(eq(organizations.ownerId, organization.ownerId)),
.where(
and(
eq(organizations.ownerId, organization.ownerId),
isNull(organizations.tombstoneAt),
),
),
);

const totalInvites = totalInvitesResult[0]?.value || 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import {
import { toast } from "sonner";
import { manageBilling } from "@/actions/organization/manage-billing";
import { useDashboardContext } from "@/app/(org)/dashboard/Contexts";

import { BillingCard } from "./components/BillingCard";
import CapSettingsCard from "./components/CapSettingsCard";
import DeleteOrg from "./components/DeleteOrg";
import { InviteDialog } from "./components/InviteDialog";
import { MembersCard } from "./components/MembersCard";
import { OrganizationDetailsCard } from "./components/OrganizationDetailsCard";
Expand Down Expand Up @@ -90,6 +90,8 @@ export const Organization = () => {
showOwnerToast={showOwnerToast}
handleManageBilling={() => handleManageBilling(setLoading)}
/>

<DeleteOrg />
</form>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ const CapSettingsCard = () => {
{options.map((option) => (
<div
key={option.value}
className="flex gap-10 justify-between items-center p-4 text-left rounded-xl border transition-colors min-w-fit border-gray-3 bg-gray-1"
className="flex gap-10 justify-between items-center p-4 text-left rounded-xl border transition-colors bg-gray-2 min-w-fit border-gray-3"
>
<div
className={clsx("flex flex-col flex-1", option.pro && "gap-1")}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Button, Card, CardDescription, CardHeader, CardTitle } from "@cap/ui";
import { useState } from "react";
import { useDashboardContext } from "../../../Contexts";
import DeleteOrgDialog from "./DeleteOrgDialog";

const DeleteOrg = () => {
const [toggleDeleteDialog, setToggleDeleteDialog] = useState(false);
const { organizationData, user } = useDashboardContext();

return (
<>
<DeleteOrgDialog
open={toggleDeleteDialog}
onOpenChange={setToggleDeleteDialog}
/>
<Card className="flex flex-wrap gap-6 justify-between items-center w-full">
<CardHeader>
<CardTitle>Delete Organization</CardTitle>
<CardDescription>
Delete your organization and all associated data.{" "}
</CardDescription>
</CardHeader>
<Button
variant="destructive"
disabled={organizationData?.length === 1}
size="sm"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setToggleDeleteDialog(true);
}}
>
Delete Organization
</Button>
</Card>
</>
);
};

export default DeleteOrg;
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
} from "@cap/ui";
import type { Organisation } from "@cap/web-domain";
import { faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Effect } from "effect";
import { useRouter } from "next/navigation";
import { startTransition, useId, useState } from "react";
import { toast } from "sonner";
import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime";
import { useDashboardContext } from "../../../Contexts";

interface DeleteOrgDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}

const DeleteOrgDialog = ({ open, onOpenChange }: DeleteOrgDialogProps) => {
const { activeOrganization, organizationData } = useDashboardContext();
const [organizationName, setOrganizationName] = useState("");
const rpc = useRpcClient();
const inputId = useId();
const router = useRouter();
const deleteOrg = useEffectMutation({
mutationFn: Effect.fn(function* () {
if (!activeOrganization) return;
yield* rpc.OrganisationDelete({
id: activeOrganization.organization.id,
});
}),
onSuccess: () => {
toast.success("Organization deleted successfully");
onOpenChange(false);
startTransition(() => {
router.push("/dashboard/caps");
router.refresh();
});
},
onError: (error) => {
console.error(error);
toast.error("Failed to delete organization");
},
});

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader
icon={<FontAwesomeIcon className="size-3.5" icon={faTrashCan} />}
description="Removing your organization will delete all associated data, including videos, and cannot be undone."
>
<DialogTitle>Delete Organization</DialogTitle>
</DialogHeader>
<div className="p-5">
<Input
id={inputId}
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder="Organization name"
/>
</div>
<DialogFooter>
<Button size="sm" variant="gray" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => deleteOrg.mutate()}
spinner={deleteOrg.isPending}
disabled={
organizationData?.length === 1 ||
organizationName !== activeOrganization?.organization.name ||
deleteOrg.isPending
}
>
{deleteOrg.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

export default DeleteOrgDialog;
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { useId } from "react";
import { toast } from "sonner";
import { FileInput } from "@/components/FileInput";
import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime";
import { withRpc } from "@/lib/Rpcs";
import { useDashboardContext } from "../../../Contexts";

export const OrganizationIcon = () => {
Expand Down
11 changes: 7 additions & 4 deletions apps/web/app/api/desktop/[...route]/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import { buildEnv, serverEnv } from "@cap/env";
import { stripe, userIsPro } from "@cap/utils";
import { zValidator } from "@hono/zod-validator";
import { eq, or } from "drizzle-orm";
import { and, eq, isNull, or } from "drizzle-orm";
import { Hono } from "hono";
import { PostHog } from "posthog-node";
import type Stripe from "stripe";
Expand Down Expand Up @@ -212,9 +212,12 @@ app.get("/organizations", withAuth, async (c) => {
eq(organizations.id, organizationMembers.organizationId),
)
.where(
or(
eq(organizations.ownerId, user.id),
eq(organizationMembers.userId, user.id),
and(
isNull(organizations.tombstoneAt),
or(
eq(organizations.ownerId, user.id),
eq(organizationMembers.userId, user.id),
),
),
)
.groupBy(organizations.id);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/s/[videoId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
Policy,
type Video,
} from "@cap/web-domain";
import { eq, type InferSelectModel, sql } from "drizzle-orm";
import { and, eq, type InferSelectModel, isNull, sql } from "drizzle-orm";
import { Effect, Option } from "effect";
import type { Metadata } from "next";
import { headers } from "next/headers";
Expand Down Expand Up @@ -301,7 +301,7 @@ export default async function ShareVideoPage(props: PageProps<"/s/[videoId]">) {
.innerJoin(users, eq(videos.ownerId, users.id))
.leftJoin(videoUploads, eq(videos.id, videoUploads.videoId))
.leftJoin(organizations, eq(videos.orgId, organizations.id))
.where(eq(videos.id, videoId)),
.where(and(eq(videos.id, videoId), isNull(organizations.tombstoneAt))),
).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId)));

return Option.fromNullable(video);
Expand Down
1 change: 1 addition & 0 deletions packages/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export const organizations = mysqlTable(
name: varchar("name", { length: 255 }).notNull(),
ownerId: nanoId("ownerId").notNull().$type<User.UserId>(),
metadata: json("metadata"),
tombstoneAt: timestamp("tombstoneAt"),
allowedEmailDomain: varchar("allowedEmailDomain", { length: 255 }),
customDomain: varchar("customDomain", { length: 255 }),
domainVerified: timestamp("domainVerified"),
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const buttonVariants = cva(
"bg-gray-12 dark-button-shadow text-gray-1 disabled:bg-gray-6 disabled:text-gray-9",
blue: "bg-blue-600 text-white disabled:border-gray-8 border border-blue-800 shadow-[0_1.50px_0_0_rgba(255,255,255,0.20)_inset] hover:bg-blue-700 disabled:bg-gray-7 disabled:text-gray-10",
destructive:
"bg-red-500 text-white hover:bg-red-600 disabled:bg-red-200",
"bg-red-500 text-white border-transparent hover:bg-red-600 disabled:bg-gray-7 disabled:border-gray-8 border disabled:text-gray-10",
outline:
"border border-gray-4 hover:border-gray-5 hover:bg-gray-3 text-gray-12 disabled:bg-gray-8",
white:
Expand Down
6 changes: 6 additions & 0 deletions packages/web-backend/src/Organisations/OrganisationsRpcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export const OrganisationsRpcsLive = Organisation.OrganisationRpcs.toLayer(
S3Error: () => new InternalError({ type: "s3" }),
}),
),
OrganisationDelete: (data) =>
orgs.deleteOrg(data.id).pipe(
Effect.catchTags({
DatabaseError: () => new InternalError({ type: "database" }),
}),
),
};
}),
);
46 changes: 43 additions & 3 deletions packages/web-backend/src/Organisations/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import * as Db from "@cap/database/schema";
import { type ImageUpload, Organisation, Policy } from "@cap/web-domain";
import { CurrentUser, Organisation, Policy } from "@cap/web-domain";
import * as Dz from "drizzle-orm";
import { Array, Effect, Option } from "effect";

import { Database } from "../Database";
import { ImageUploads } from "../ImageUploads";
import { S3Buckets } from "../S3Buckets";
Expand Down Expand Up @@ -49,7 +48,48 @@ export class Organisations extends Effect.Service<Organisations>()(
}
});

return { update };
const deleteOrg = Effect.fn("Organisations.deleteOrg")(function* (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would be better named softDelete

id: Organisation.OrganisationId,
) {
const user = yield* CurrentUser;

yield* Policy.withPolicy(policy.isOwner(id))(Effect.void);

// Perform tombstone, find other org, and update user in a single transaction
yield* db.use((db) =>
db.transaction(async (tx) => {
await tx
.update(Db.organizations)
.set({ tombstoneAt: new Date() })
.where(Dz.eq(Db.organizations.id, id));

// Find another active organization owned by the user
const [otherOrg] = await tx
.select({ id: Db.organizations.id })
.from(Db.organizations)
.where(
Dz.and(
Dz.ne(Db.organizations.id, id),
Dz.isNull(Db.organizations.tombstoneAt),
Dz.eq(Db.organizations.ownerId, user.id),
),
)
.orderBy(Dz.asc(Db.organizations.createdAt))
.limit(1);

if (otherOrg) {
await tx
.update(Db.users)
.set({
activeOrganizationId: otherOrg.id,
defaultOrgId: otherOrg.id,
})
.where(Dz.eq(Db.users.id, user.id));
}
}),
);
});
return { update, deleteOrg };
}),
dependencies: [
ImageUploads.Default,
Expand Down
Loading