diff --git a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx index 7ff0c88474..706433a21b 100644 --- a/apps/web/app/(org)/dashboard/_components/MobileTab.tsx +++ b/apps/web/app/(org)/dashboard/_components/MobileTab.tsx @@ -43,11 +43,13 @@ const MobileTab = () => { } }); return ( -
- - {open && } - - +
+
+ + {open && } + + +
{Tabs.filter((i) => !i.ownerOnly || isOwner).map((tab) => ( @@ -73,7 +75,7 @@ const Orgs = ({
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" > -

+

{activeOrg?.organization.name}

} 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) => { @@ -142,7 +144,7 @@ const OrgsMenu = ({ />

diff --git a/apps/web/app/(org)/dashboard/dashboard-data.ts b/apps/web/app/(org)/dashboard/dashboard-data.ts index b1e2f0a5bc..e2a45473a0 100644 --- a/apps/web/app/(org)/dashboard/dashboard-data.ts +++ b/apps/web/app/(org)/dashboard/dashboard-data.ts @@ -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), ), ); @@ -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; diff --git a/apps/web/app/(org)/dashboard/settings/organization/Organization.tsx b/apps/web/app/(org)/dashboard/settings/organization/Organization.tsx index f7a760e671..8d6fa89ee4 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/Organization.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/Organization.tsx @@ -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"; @@ -90,6 +90,8 @@ export const Organization = () => { showOwnerToast={showOwnerToast} handleManageBilling={() => handleManageBilling(setLoading)} /> + + ); }; diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx index edb9410652..561df22df6 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/CapSettingsCard.tsx @@ -160,7 +160,7 @@ const CapSettingsCard = () => { {options.map((option) => (

{ + const [toggleDeleteDialog, setToggleDeleteDialog] = useState(false); + const { organizationData, user } = useDashboardContext(); + + return ( + <> + + + + Delete Organization + + Delete your organization and all associated data.{" "} + + + + + + ); +}; + +export default DeleteOrg; diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/DeleteOrgDialog.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/DeleteOrgDialog.tsx new file mode 100644 index 0000000000..2c287bf361 --- /dev/null +++ b/apps/web/app/(org)/dashboard/settings/organization/components/DeleteOrgDialog.tsx @@ -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 ( + + + } + description="Removing your organization will delete all associated data, including videos, and cannot be undone." + > + Delete Organization + +
+ setOrganizationName(e.target.value)} + placeholder="Organization name" + /> +
+ + + + +
+
+ ); +}; + +export default DeleteOrgDialog; diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx index 98dc6500eb..400320cafd 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/OrganizationIcon.tsx @@ -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 = () => { diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 6d697d9c64..18294c8417 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -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"; @@ -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); diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index b9e06a99fb..8107277e59 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -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"; @@ -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); diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 6a94501f2b..ab14efa62f 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -180,6 +180,7 @@ export const organizations = mysqlTable( name: varchar("name", { length: 255 }).notNull(), ownerId: nanoId("ownerId").notNull().$type(), metadata: json("metadata"), + tombstoneAt: timestamp("tombstoneAt"), allowedEmailDomain: varchar("allowedEmailDomain", { length: 255 }), customDomain: varchar("customDomain", { length: 255 }), domainVerified: timestamp("domainVerified"), diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index 3e64fc3c3b..bee04c830a 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -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: diff --git a/packages/web-backend/src/Organisations/OrganisationsRpcs.ts b/packages/web-backend/src/Organisations/OrganisationsRpcs.ts index 330f74e641..611a60a883 100644 --- a/packages/web-backend/src/Organisations/OrganisationsRpcs.ts +++ b/packages/web-backend/src/Organisations/OrganisationsRpcs.ts @@ -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" }), + }), + ), }; }), ); diff --git a/packages/web-backend/src/Organisations/index.ts b/packages/web-backend/src/Organisations/index.ts index 6022ec1a7b..fb7490e484 100644 --- a/packages/web-backend/src/Organisations/index.ts +++ b/packages/web-backend/src/Organisations/index.ts @@ -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"; @@ -49,7 +48,48 @@ export class Organisations extends Effect.Service()( } }); - return { update }; + const deleteOrg = Effect.fn("Organisations.deleteOrg")(function* ( + 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, diff --git a/packages/web-domain/src/Organisation.ts b/packages/web-domain/src/Organisation.ts index 6818a69979..bc6dd0a185 100644 --- a/packages/web-domain/src/Organisation.ts +++ b/packages/web-domain/src/Organisation.ts @@ -23,6 +23,11 @@ export class Organisation extends Schema.Class("Organisation")({ name: Schema.String, }) {} +export const OrganisationDelete = Schema.Struct({ + id: OrganisationId, +}); +export type OrganisationDelete = Schema.Schema.Type; + export const OrganisationUpdate = Schema.Struct({ id: OrganisationId, image: Schema.optional(ImageUpdatePayload), @@ -34,4 +39,8 @@ export class OrganisationRpcs extends RpcGroup.make( payload: OrganisationUpdate, error: Schema.Union(InternalError, PolicyDeniedError, NotFoundError), }).middleware(RpcAuthMiddleware), + Rpc.make("OrganisationDelete", { + payload: OrganisationDelete, + error: Schema.Union(InternalError, PolicyDeniedError, NotFoundError), + }).middleware(RpcAuthMiddleware), ) {}