From 336029b8428a826bfc92932a61653979a0e1fb79 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 7 Feb 2024 10:52:50 +0000 Subject: [PATCH] Organization renaming, deleting and one-click email unsubscribe (#885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added the org settings page to the sidebar * Added loading states when renaming/deleting projects * Don’t show deleted orgs in the app * The actual db migration file * The Org settings page with the actions working * Don’t remove org members, just leave them * Don’t show invites from orgs that are deleted * Allow disabling IntegrationConnections * Don’t refresh IntegrationConnections that are disabled * Set all the integrations as disabled * Updated the unsubscribe checkbox text * Unsubscribe route * Use the magic link secret, not the encryption key. Also make the error message more vague * Only members of the org or project can rename them * Don’t throw an error if the connection can’t be refreshed, return undefined instead --- .../app/components/navigation/SideMenu.tsx | 8 + apps/webapp/app/models/member.server.ts | 3 + .../OrganizationsPresenter.server.ts | 3 +- .../route.tsx | 32 ++- .../route.tsx | 270 ++++++++++++++++++ .../app/routes/account._index/route.tsx | 2 +- .../app/routes/unsubscribe.$userId.$token.tsx | 97 +++++++ .../app/services/deleteOrganization.server.ts | 103 +++++++ .../integrationAuthRepository.server.ts | 15 +- apps/webapp/app/utils/pathBuilder.ts | 4 + .../migration.sql | 2 + .../migration.sql | 2 + packages/database/prisma/schema.prisma | 8 +- 13 files changed, 538 insertions(+), 11 deletions(-) create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx create mode 100644 apps/webapp/app/routes/unsubscribe.$userId.$token.tsx create mode 100644 apps/webapp/app/services/deleteOrganization.server.ts create mode 100644 packages/database/prisma/migrations/20240206112723_organization_deleted_at/migration.sql create mode 100644 packages/database/prisma/migrations/20240206133516_integration_connections_can_be_disabled/migration.sql 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 ( + + + + + + + + +
+
+
+ +
+ + + + {organizationName.error} + + + Rename organization + + } + /> +
+
+
+ +
+ Danger zone +
+ +
+ + + + {organizationSlug.error} + {deleteForm.error} + + This change is irreversible, so please be certain. Type in the Organization slug + {organization.slug} and then + press Delete. + + + + Delete organization + + } + /> +
+
+
+
+
+
+ ); +} 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[] }