diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx
index 57b3dfb2a..1f660acdf 100644
--- a/apps/webapp/app/components/navigation/SideMenu.tsx
+++ b/apps/webapp/app/components/navigation/SideMenu.tsx
@@ -26,6 +26,7 @@ import {
organizationBillingPath,
organizationIntegrationsPath,
organizationPath,
+ organizationSettingsPath,
organizationTeamPath,
personalAccessTokensPath,
projectEnvironmentsPath,
@@ -209,6 +210,13 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
iconColor="text-green-600"
data-action="usage & billing"
/>
+
diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts
index 5cb07f1a6..716590898 100644
--- a/apps/webapp/app/models/member.server.ts
+++ b/apps/webapp/app/models/member.server.ts
@@ -130,6 +130,9 @@ export async function getUsersInvites({ email }: { email: string }) {
return await prisma.orgMemberInvite.findMany({
where: {
email,
+ organization: {
+ deletedAt: null,
+ },
},
include: {
organization: true,
diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts
index e5bff604d..4e95aa193 100644
--- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts
+++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts
@@ -171,7 +171,7 @@ export class OrganizationsPresenter {
async #getOrganizations(userId: string) {
const orgs = await this.#prismaClient.organization.findMany({
- where: { members: { some: { userId } } },
+ where: { members: { some: { userId } }, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
@@ -245,6 +245,7 @@ export class OrganizationsPresenter {
where: {
deletedAt: null,
organization: {
+ deletedAt: null,
slug: organizationSlug,
members: { some: { userId } },
},
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx
index 03fc9c547..74f8b4a61 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.settings/route.tsx
@@ -1,9 +1,8 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
-import { Form, useActionData } from "@remix-run/react";
+import { Form, useActionData, useNavigation } from "@remix-run/react";
import { ActionFunction, json } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
-import { r } from "tar";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
@@ -28,7 +27,7 @@ import {
import { DeleteProjectService } from "~/services/deleteProject.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
-import { organizationPath, projectPath, projectSettingsPath } from "~/utils/pathBuilder";
+import { organizationPath, projectPath } from "~/utils/pathBuilder";
export function createSchema(
constraints: {
@@ -90,6 +89,13 @@ export const action: ActionFunction = async ({ request, params }) => {
await prisma.project.update({
where: {
slug: projectParam,
+ organization: {
+ members: {
+ some: {
+ userId,
+ },
+ },
+ },
},
data: {
name: submission.value.projectName,
@@ -130,9 +136,9 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function Page() {
- const organization = useOrganization();
const project = useProject();
const lastSubmission = useActionData();
+ const navigation = useNavigation();
const [renameForm, { projectName }] = useForm({
id: "rename-project",
@@ -161,6 +167,14 @@ export default function Page() {
},
});
+ const isRenameLoading =
+ navigation.formData?.get("action") === "rename" &&
+ (navigation.state === "submitting" || navigation.state === "loading");
+
+ const isDeleteLoading =
+ navigation.formData?.get("action") === "delete" &&
+ (navigation.state === "submitting" || navigation.state === "loading");
+
return (
@@ -188,7 +202,12 @@ export default function Page() {
+
}
@@ -227,8 +246,9 @@ export default function Page() {
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx
new file mode 100644
index 000000000..2cb476ba5
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx
@@ -0,0 +1,270 @@
+import { conform, useForm } from "@conform-to/react";
+import { parse } from "@conform-to/zod";
+import { Form, useActionData, useNavigation } from "@remix-run/react";
+import { ActionFunction, json } from "@remix-run/server-runtime";
+import { redirect } from "remix-typedjson";
+import { r } from "tar";
+import { z } from "zod";
+import { InlineCode } from "~/components/code/InlineCode";
+import { PageBody, PageContainer } from "~/components/layout/AppLayout";
+import { Button } from "~/components/primitives/Buttons";
+import { Fieldset } from "~/components/primitives/Fieldset";
+import { FormButtons } from "~/components/primitives/FormButtons";
+import { FormError } from "~/components/primitives/FormError";
+import { Header2 } from "~/components/primitives/Headers";
+import { Hint } from "~/components/primitives/Hint";
+import { Input } from "~/components/primitives/Input";
+import { InputGroup } from "~/components/primitives/InputGroup";
+import { Label } from "~/components/primitives/Label";
+import { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
+import { prisma } from "~/db.server";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
+import {
+ clearCurrentProjectId,
+ commitCurrentProjectSession,
+} from "~/services/currentProject.server";
+import { DeleteOrganizationService } from "~/services/deleteOrganization.server";
+import { logger } from "~/services/logger.server";
+import { requireUserId } from "~/services/session.server";
+import { organizationPath, organizationSettingsPath, rootPath } from "~/utils/pathBuilder";
+
+export function createSchema(
+ constraints: {
+ getSlugMatch?: (slug: string) => { isMatch: boolean; organizationSlug: string };
+ } = {}
+) {
+ return z.discriminatedUnion("action", [
+ z.object({
+ action: z.literal("rename"),
+ organizationName: z
+ .string()
+ .min(3, "Organization name must have at least 3 characters")
+ .max(50),
+ }),
+ z.object({
+ action: z.literal("delete"),
+ organizationSlug: z.string().superRefine((slug, ctx) => {
+ if (constraints.getSlugMatch === undefined) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: conform.VALIDATION_UNDEFINED,
+ });
+ } else {
+ const { isMatch, organizationSlug } = constraints.getSlugMatch(slug);
+ if (isMatch) {
+ return;
+ }
+
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `The slug must match ${organizationSlug}`,
+ });
+ }
+ }),
+ }),
+ ]);
+}
+
+export const action: ActionFunction = async ({ request, params }) => {
+ const userId = await requireUserId(request);
+ const { organizationSlug } = params;
+ if (!organizationSlug) {
+ return json({ errors: { body: "organizationSlug is required" } }, { status: 400 });
+ }
+
+ const formData = await request.formData();
+ const schema = createSchema({
+ getSlugMatch: (slug) => {
+ return { isMatch: slug === organizationSlug, organizationSlug };
+ },
+ });
+ const submission = parse(formData, { schema });
+
+ if (!submission.value || submission.intent !== "submit") {
+ return json(submission);
+ }
+
+ try {
+ switch (submission.value.action) {
+ case "rename": {
+ await prisma.organization.update({
+ where: {
+ slug: organizationSlug,
+ members: {
+ some: {
+ userId,
+ },
+ },
+ },
+ data: {
+ title: submission.value.organizationName,
+ },
+ });
+
+ return redirectWithSuccessMessage(
+ organizationPath({ slug: organizationSlug }),
+ request,
+ `Organization renamed to ${submission.value.organizationName}`
+ );
+ }
+ case "delete": {
+ const deleteOrganizationService = new DeleteOrganizationService();
+ try {
+ await deleteOrganizationService.call({ organizationSlug, userId, request });
+
+ //we need to clear the project from the session
+ const removeProjectIdSession = await clearCurrentProjectId(request);
+ return redirect(rootPath(), {
+ headers: {
+ "Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession),
+ },
+ });
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
+ logger.error("Organization could not be deleted", {
+ error: errorMessage,
+ });
+ return redirectWithErrorMessage(
+ organizationSettingsPath({ slug: organizationSlug }),
+ request,
+ errorMessage
+ );
+ }
+ }
+ }
+ } catch (error: any) {
+ return json({ errors: { body: error.message } }, { status: 400 });
+ }
+};
+
+export default function Page() {
+ const organization = useOrganization();
+ const lastSubmission = useActionData();
+ const navigation = useNavigation();
+
+ const [renameForm, { organizationName }] = useForm({
+ id: "rename-organization",
+ // TODO: type this
+ lastSubmission: lastSubmission as any,
+ shouldRevalidate: "onSubmit",
+ onValidate({ formData }) {
+ return parse(formData, {
+ schema: createSchema(),
+ });
+ },
+ });
+
+ const [deleteForm, { organizationSlug }] = useForm({
+ id: "delete-organization",
+ // TODO: type this
+ lastSubmission: lastSubmission as any,
+ shouldValidate: "onInput",
+ shouldRevalidate: "onSubmit",
+ onValidate({ formData }) {
+ return parse(formData, {
+ schema: createSchema({
+ getSlugMatch: (slug) => ({
+ isMatch: slug === organization.slug,
+ organizationSlug: organization.slug,
+ }),
+ }),
+ });
+ },
+ });
+
+ const isRenameLoading =
+ navigation.formData?.get("action") === "rename" &&
+ (navigation.state === "submitting" || navigation.state === "loading");
+
+ const isDeleteLoading =
+ navigation.formData?.get("action") === "delete" &&
+ (navigation.state === "submitting" || navigation.state === "loading");
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Danger zone
+
+
+
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx
index 17ff666d3..1d026d455 100644
--- a/apps/webapp/app/routes/account._index/route.tsx
+++ b/apps/webapp/app/routes/account._index/route.tsx
@@ -171,7 +171,7 @@ export default function Page() {
diff --git a/apps/webapp/app/routes/unsubscribe.$userId.$token.tsx b/apps/webapp/app/routes/unsubscribe.$userId.$token.tsx
new file mode 100644
index 000000000..e13ee8c2a
--- /dev/null
+++ b/apps/webapp/app/routes/unsubscribe.$userId.$token.tsx
@@ -0,0 +1,97 @@
+import { parse } from "@conform-to/zod";
+import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
+import { PlainClient, uiComponent } from "@team-plain/typescript-sdk";
+import { inspect } from "util";
+import { z } from "zod";
+import { env } from "~/env.server";
+import { redirectWithSuccessMessage } from "~/models/message.server";
+import { requireUser } from "~/services/session.server";
+import crypto from "node:crypto";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { prisma } from "~/db.server";
+import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
+import { Header1 } from "~/components/primitives/Headers";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { TextLink } from "~/components/primitives/TextLink";
+import { LinkButton } from "~/components/primitives/Buttons";
+import { rootPath } from "~/utils/pathBuilder";
+import { FormTitle } from "~/components/primitives/FormTitle";
+import { EnvelopeIcon } from "@heroicons/react/24/solid";
+
+export const ParamsSchema = z.object({
+ userId: z.string(),
+ token: z.string(),
+});
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const { userId, token } = ParamsSchema.parse(params);
+
+ try {
+ const user = await prisma.user.findUnique({
+ where: { id: userId },
+ });
+
+ if (!user) {
+ return typedjson({
+ success: false as const,
+ message: "User not found",
+ });
+ }
+
+ //check that the token is valid for the userId
+ const hashedUserId = crypto
+ .createHash("sha256")
+ .update(`${userId}-${env.MAGIC_LINK_SECRET}`)
+ .digest("hex");
+ if (hashedUserId !== token) {
+ return typedjson({
+ success: false as const,
+ message: "This unsubscribe link was invalid so we can't unsubscribe you.",
+ });
+ }
+
+ await prisma.user.update({
+ where: { id: userId },
+ data: { marketingEmails: false },
+ });
+
+ return typedjson({ success: true as const, email: user.email });
+ } catch (e) {
+ const errorMessage = e instanceof Error ? e.message : JSON.stringify(e);
+ return typedjson({ success: false as const, message: errorMessage });
+ }
+};
+
+export default function Page() {
+ const result = useTypedLoaderData();
+
+ return (
+
+
+ {result.success ? (
+
+
+
+ You have unsubscribed from onboarding emails, {result.email}.
+
+
+ Dashboard
+
+
+ ) : (
+
+
+
{result.message}
+
+ If you believe this is a bug, please{" "}
+ contact support.
+
+
+ Dashboard
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/webapp/app/services/deleteOrganization.server.ts b/apps/webapp/app/services/deleteOrganization.server.ts
new file mode 100644
index 000000000..4887d5d18
--- /dev/null
+++ b/apps/webapp/app/services/deleteOrganization.server.ts
@@ -0,0 +1,103 @@
+import { PrismaClient } from "@trigger.dev/database";
+import { prisma } from "~/db.server";
+import { DisableJobService } from "./jobs/disableJob.server";
+import { AuthenticatedEnvironment } from "./apiAuth.server";
+import { DeleteJobService } from "./jobs/deleteJob.server";
+import { DeleteEndpointService } from "./endpoints/deleteEndpointService";
+import { logger } from "./logger.server";
+import { DisableScheduleSourceService } from "./schedules/disableScheduleSource.server";
+import { featuresForRequest } from "~/features.server";
+import { DeleteProjectService } from "./deleteProject.server";
+import { BillingService } from "./billing.server";
+import { DateFormatter } from "@internationalized/date";
+
+export class DeleteOrganizationService {
+ #prismaClient: PrismaClient;
+
+ constructor(prismaClient: PrismaClient = prisma) {
+ this.#prismaClient = prismaClient;
+ }
+
+ public async call({
+ organizationSlug,
+ userId,
+ request,
+ }: {
+ organizationSlug: string;
+ userId: string;
+ request: Request;
+ }) {
+ const organization = await this.#prismaClient.organization.findFirst({
+ include: {
+ projects: true,
+ members: true,
+ },
+ where: {
+ slug: organizationSlug,
+ members: { some: { userId: userId } },
+ },
+ });
+
+ if (!organization) {
+ throw new Error("Organization not found");
+ }
+
+ if (organization.deletedAt) {
+ throw new Error("Organization already deleted");
+ }
+
+ //check if they have an active subscription
+ const { isManagedCloud } = featuresForRequest(request);
+ const billingPresenter = new BillingService(isManagedCloud);
+ const currentPlan = await billingPresenter.currentPlan(organization.id);
+
+ if (currentPlan && currentPlan.subscription && currentPlan.subscription.isPaying) {
+ //they've cancelled and that date hasn't passed yet
+ if (
+ currentPlan.subscription.canceledAt &&
+ new Date(currentPlan.subscription.canceledAt) > new Date()
+ ) {
+ //a dateformatter that produces results like "Jan 1 2024"
+ const dateFormatter = new DateFormatter("en-us", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ });
+ throw new Error(
+ `This Organization has a canceled subscription. You can delete it when the cancelation date (${dateFormatter.format(
+ new Date(currentPlan.subscription.canceledAt)
+ )}) is in the past.`
+ );
+ }
+
+ throw new Error("You can't delete an Organization that has an active subscription");
+ }
+
+ // loop through the projects and delete them
+ const projectDeleteService = new DeleteProjectService();
+ for (const project of organization.projects) {
+ await projectDeleteService.call({ projectId: project.id, userId });
+ }
+
+ //set all the integrations to disabled
+ await this.#prismaClient.integrationConnection.updateMany({
+ where: {
+ organizationId: organization.id,
+ },
+ data: {
+ enabled: false,
+ },
+ });
+
+ //mark the organization as deleted
+ await this.#prismaClient.organization.update({
+ where: {
+ id: organization.id,
+ },
+ data: {
+ runsEnabled: false,
+ deletedAt: new Date(),
+ },
+ });
+ }
+}
diff --git a/apps/webapp/app/services/externalApis/integrationAuthRepository.server.ts b/apps/webapp/app/services/externalApis/integrationAuthRepository.server.ts
index c36413376..23e4c77ee 100644
--- a/apps/webapp/app/services/externalApis/integrationAuthRepository.server.ts
+++ b/apps/webapp/app/services/externalApis/integrationAuthRepository.server.ts
@@ -577,6 +577,13 @@ export class IntegrationAuthRepository {
throw new Error(`Connection ${connectionId} not found`);
}
+ if (!connection.enabled) {
+ logger.info("Connection is disabled", {
+ connection,
+ });
+ return;
+ }
+
let customOAuthClient: OAuthClient | undefined;
if (connection.integration.customClientReference) {
const secretStore = getSecretStore(env.SECRET_STORE);
@@ -687,9 +694,15 @@ export class IntegrationAuthRepository {
if (connection.expiresAt) {
const refreshBy = new Date(connection.expiresAt.getTime() - tokenRefreshThreshold * 1000);
if (refreshBy < new Date()) {
- connection = await this.refreshConnection({
+ const refreshedConnection = await this.refreshConnection({
connectionId: connection.id,
});
+
+ if (!refreshedConnection) {
+ return;
+ }
+
+ connection = refreshedConnection;
}
}
diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts
index ee60278fe..4a2e6e216 100644
--- a/apps/webapp/app/utils/pathBuilder.ts
+++ b/apps/webapp/app/utils/pathBuilder.ts
@@ -131,6 +131,10 @@ export function organizationBillingPath(organization: OrgForPath) {
return `${organizationPath(organization)}/billing`;
}
+export function organizationSettingsPath(organization: OrgForPath) {
+ return `${organizationPath(organization)}/settings`;
+}
+
export function usagePath(organization: OrgForPath) {
return `${organizationPath(organization)}/billing`;
}
diff --git a/packages/database/prisma/migrations/20240206112723_organization_deleted_at/migration.sql b/packages/database/prisma/migrations/20240206112723_organization_deleted_at/migration.sql
new file mode 100644
index 000000000..852a5f276
--- /dev/null
+++ b/packages/database/prisma/migrations/20240206112723_organization_deleted_at/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Organization" ADD COLUMN "deletedAt" TIMESTAMP(3);
diff --git a/packages/database/prisma/migrations/20240206133516_integration_connections_can_be_disabled/migration.sql b/packages/database/prisma/migrations/20240206133516_integration_connections_can_be_disabled/migration.sql
new file mode 100644
index 000000000..bfdeefea3
--- /dev/null
+++ b/packages/database/prisma/migrations/20240206133516_integration_connections_can_be_disabled/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "IntegrationConnection" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;
diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma
index 065e76c52..86bcd1737 100644
--- a/packages/database/prisma/schema.prisma
+++ b/packages/database/prisma/schema.prisma
@@ -109,8 +109,9 @@ model Organization {
maximumExecutionTimePerRunInMs Int @default(900000) // 15 minutes
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ deletedAt DateTime?
companySize String?
@@ -271,6 +272,9 @@ model IntegrationConnection {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+ /// If enabled is false, OAuth refreshing will not be attempted
+ enabled Boolean @default(true)
+
runConnections RunConnection[]
}