Organization renaming, deleting and one-click email unsubscribe (#885)
* 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:
@@ -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">
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
|
||||
+26
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "IntegrationConnection" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user