Organization renaming, deleting and one-click email unsubscribe (#885)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / e2e (push) Failing after 4s
🚀 Publish Trigger.dev Docker / units (push) Failing after 10s
🚀 Publish Trigger.dev Docker / publish (push) Has been skipped

* 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
This commit is contained in:
Matt Aitken
2024-02-07 10:52:50 +00:00
committed by GitHub
parent 29edcd3df9
commit 336029b842
13 changed files with 538 additions and 11 deletions
@@ -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"
/>
<SideMenuItem
name="Organization settings"
icon="settings"
iconColor="text-teal-500"
to={organizationSettingsPath(organization)}
data-action="organization-settings"
/>
</div>
</div>
<div className="flex flex-col gap-1 border-t border-border p-1">
+3
View File
@@ -130,6 +130,9 @@ export async function getUsersInvites({ email }: { email: string }) {
return await prisma.orgMemberInvite.findMany({
where: {
email,
organization: {
deletedAt: null,
},
},
include: {
organization: true,
@@ -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 } },
},
@@ -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 (
<PageContainer>
<PageHeader>
@@ -188,7 +202,12 @@ export default function Page() {
</InputGroup>
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"}>
<Button
type="submit"
variant={"primary/small"}
disabled={isRenameLoading}
LeadingIcon={isRenameLoading ? "spinner-white" : undefined}
>
Rename project
</Button>
}
@@ -227,8 +246,9 @@ export default function Page() {
<Button
type="submit"
variant={"danger/small"}
LeadingIcon="trash-can"
LeadingIcon={isDeleteLoading ? "spinner-white" : "trash-can"}
leadingIconClassName="text-white"
disabled={isDeleteLoading}
>
Delete project
</Button>
@@ -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 (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title={`${organization.title} organization settings`} />
</PageTitleRow>
</PageHeader>
<PageBody>
<div className="flex flex-col gap-4">
<div>
<Form method="post" {...renameForm.props} className="max-w-md">
<input type="hidden" name="action" value="rename" />
<Fieldset>
<InputGroup>
<Label htmlFor={organizationName.id}>Rename your organization</Label>
<Input
{...conform.input(organizationName, { type: "text" })}
defaultValue={organization.title}
placeholder="Your organization name"
icon="folder"
autoFocus
/>
<FormError id={organizationName.errorId}>{organizationName.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant={"primary/small"}
disabled={isRenameLoading}
LeadingIcon={isRenameLoading ? "spinner-white" : undefined}
>
Rename organization
</Button>
}
/>
</Fieldset>
</Form>
</div>
<div>
<Header2 spacing>Danger zone</Header2>
<Form
method="post"
{...deleteForm.props}
className="max-w-md rounded-sm border border-rose-500/40"
>
<input type="hidden" name="action" value="delete" />
<Fieldset className="p-4">
<InputGroup>
<Label htmlFor={organizationSlug.id}>Delete organization</Label>
<Input
{...conform.input(organizationSlug, { type: "text" })}
placeholder="Your organization slug"
icon="warning"
autoFocus
/>
<FormError id={organizationSlug.errorId}>{organizationSlug.error}</FormError>
<FormError>{deleteForm.error}</FormError>
<Hint>
This change is irreversible, so please be certain. Type in the Organization slug
<InlineCode variant="extra-small">{organization.slug}</InlineCode> and then
press Delete.
</Hint>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant={"danger/small"}
LeadingIcon={isDeleteLoading ? "spinner-white" : "trash-can"}
leadingIconClassName="text-white"
disabled={isDeleteLoading}
>
Delete organization
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
</PageBody>
</PageContainer>
);
}
@@ -171,7 +171,7 @@ export default function Page() {
<Checkbox
id="marketingEmails"
{...conform.input(marketingEmails, { type: "checkbox" })}
label="Receive product updates"
label="Receive onboarding emails"
variant="simple/small"
defaultChecked={user.marketingEmails}
/>
@@ -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<typeof loader>();
return (
<AppContainer>
<MainCenteredContainer className="max-w-[22rem]">
{result.success ? (
<div>
<FormTitle LeadingIcon="envelope" title="Unsubscribed" />
<Paragraph spacing>
You have unsubscribed from onboarding emails, {result.email}.
</Paragraph>
<LinkButton variant="primary/medium" to={rootPath()}>
Dashboard
</LinkButton>
</div>
) : (
<div>
<FormTitle LeadingIcon="envelope" title="Unsubscribe failed" />
<Paragraph spacing>{result.message}</Paragraph>
<Paragraph spacing>
If you believe this is a bug, please{" "}
<TextLink href="https://trigger.dev/contact">contact support</TextLink>.
</Paragraph>
<LinkButton variant="primary/medium" to={rootPath()}>
Dashboard
</LinkButton>
</div>
)}
</MainCenteredContainer>
</AppContainer>
);
}
@@ -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(),
},
});
}
}
@@ -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;
}
}
+4
View File
@@ -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`;
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "deletedAt" TIMESTAMP(3);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "IntegrationConnection" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;
+6 -2
View File
@@ -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[]
}