Skip to content

Commit fb3b26d

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
feat(cli,webapp): bootstrap new accounts from the CLI
Mono-RevId: bd637f60d90b7f86a549f9c7d038d4b948f6c057
1 parent 0fe9c5e commit fb3b26d

22 files changed

Lines changed: 1012 additions & 119 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
Set up a new Trigger.dev account from the CLI. Login can prefill an email address, save the user's full name after authorization, and resume authorization later, while `init` can create the first organization, activate its Free plan, and create the first project before scaffolding the app.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { json } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { prisma } from "~/db.server";
4+
import { env } from "~/env.server";
5+
import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server";
6+
import { activateFreePlan } from "~/services/platform.v3.server";
7+
import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
8+
import { engine } from "~/v3/runEngine.server";
9+
10+
const ParamsSchema = z.object({
11+
orgParam: z.string(),
12+
});
13+
14+
export const action = createActionPATApiRoute(
15+
{
16+
method: "POST",
17+
params: ParamsSchema,
18+
context: async ({ orgParam }) => {
19+
const organization = await prisma.organization.findFirst({
20+
where: { OR: [{ id: orgParam }, { slug: orgParam }], deletedAt: null },
21+
select: { id: true },
22+
});
23+
return organization ? { organizationId: organization.id } : {};
24+
},
25+
authorization: { action: "manage", resource: () => ({ type: "billing" }) },
26+
},
27+
async ({ params, authentication }) => {
28+
if (env.ORG_CREATION_API_ENABLED !== "1") {
29+
return json({ error: "Not found" }, { status: 404 });
30+
}
31+
32+
const organization = await resolveOrganizationForApiUser({
33+
orgParam: params.orgParam,
34+
userId: authentication.userId,
35+
});
36+
37+
if (!organization) {
38+
return json({ error: "Organization not found" }, { status: 404 });
39+
}
40+
41+
if (organization.isActivated) {
42+
return json({ error: "Organization is already activated" }, { status: 409 });
43+
}
44+
45+
const result = await activateFreePlan(organization.id, authentication.userId, {
46+
invalidateBillingCache: engine.invalidateBillingCache.bind(engine),
47+
});
48+
49+
if (!result.success) {
50+
return json({ error: "Failed to activate Free plan" }, { status: 502 });
51+
}
52+
53+
return json({ plan: "free" as const });
54+
}
55+
);

‎apps/webapp/app/routes/api.v1.orgs.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
} from "~/services/routeBuilders/apiBuilder.server";
1111
import { extractDomain, faviconUrl } from "~/utils/favicon";
1212

13+
const API_ORGANIZATION_LIMIT = 25;
14+
1315
// Identity-only: lists the caller's own orgs, so no authorization gate.
1416
export const loader = createLoaderPATApiRoute(
1517
{ identityOnly: true },
@@ -67,6 +69,24 @@ export const action = createActionPATApiRoute(
6769
);
6870
}
6971

72+
const organizationMemberships = await prisma.orgMember.findMany({
73+
where: {
74+
userId: authentication.userId,
75+
organization: { deletedAt: null },
76+
},
77+
select: { id: true },
78+
take: API_ORGANIZATION_LIMIT,
79+
});
80+
81+
if (organizationMemberships.length >= API_ORGANIZATION_LIMIT) {
82+
return json(
83+
{
84+
error: `Organization creation through the API is limited to ${API_ORGANIZATION_LIMIT} organizations. Create additional organizations in the dashboard.`,
85+
},
86+
{ status: 403 }
87+
);
88+
}
89+
7090
// Mirror the dashboard: stash companyUrl/companySize as onboarding data and
7191
// derive the org avatar from the company domain's favicon.
7292
const onboardingData: Record<string, string> = {};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { json } from "@remix-run/server-runtime";
2+
import { CompleteProfileRequestBody } from "@trigger.dev/core/v3";
3+
import { prisma } from "~/db.server";
4+
import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
5+
6+
export const action = createActionPATApiRoute(
7+
{
8+
method: "POST",
9+
body: CompleteProfileRequestBody,
10+
},
11+
async ({ body, authentication }) => {
12+
if (authentication.userActor) {
13+
return json(
14+
{
15+
error: "Unauthorized",
16+
code: "unauthorized",
17+
param: "access_token",
18+
type: "authorization",
19+
},
20+
{ status: 403 }
21+
);
22+
}
23+
24+
const result = await prisma.user.updateMany({
25+
where: {
26+
id: authentication.userId,
27+
confirmedBasicDetails: false,
28+
},
29+
data: {
30+
name: body.name,
31+
confirmedBasicDetails: true,
32+
},
33+
});
34+
35+
return json({ updated: result.count > 0 });
36+
}
37+
);

‎apps/webapp/app/routes/login._index/route.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
9595

9696
const url = requestUrl(request);
9797
const redirectTo = url.searchParams.get("redirectTo");
98+
const prefilledEmail = getAuthorizationEmail(redirectTo);
9899
const lastAuthMethod = await getLastAuthMethod(request);
99100

100101
const notice =
@@ -152,6 +153,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
152153
lastAuthMethod,
153154
authError,
154155
notice,
156+
prefilledEmail,
155157
isVercelMarketplace: redirectTo.startsWith("/vercel/callback"),
156158
},
157159
{ headers }
@@ -166,13 +168,29 @@ export async function loader({ request }: LoaderFunctionArgs) {
166168
lastAuthMethod,
167169
authError,
168170
notice,
171+
prefilledEmail,
169172
isVercelMarketplace: false,
170173
},
171174
{ headers }
172175
);
173176
}
174177
}
175178

179+
export function getAuthorizationEmail(redirectTo: string | null) {
180+
if (!redirectTo) return null;
181+
182+
try {
183+
const redirectUrl = new URL(redirectTo, "https://trigger.dev");
184+
if (!redirectUrl.pathname.startsWith("/account/authorization-code/")) return null;
185+
186+
const email = redirectUrl.searchParams.get("email");
187+
const parsed = z.string().email().safeParse(email);
188+
return parsed.success ? parsed.data : null;
189+
} catch {
190+
return null;
191+
}
192+
}
193+
176194
export default function LoginPage() {
177195
const data = useTypedLoaderData<typeof loader>();
178196
const navigation = useNavigation();
@@ -185,6 +203,9 @@ export default function LoginPage() {
185203

186204
const [emailForm, emailFields] = useForm({
187205
id: "login-email",
206+
defaultValue: {
207+
email: data.prefilledEmail ?? "",
208+
},
188209
onValidate({ formData }) {
189210
return parseWithZod(formData, { schema: emailSchema });
190211
},

‎apps/webapp/app/services/organizationApiAccess.server.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ export async function resolveOrganizationForApiUser({
1212
}: {
1313
orgParam: string;
1414
userId: string;
15-
}): Promise<{ id: string; slug: string } | null> {
15+
}): Promise<{ id: string; slug: string; isActivated: boolean } | null> {
1616
return prisma.organization.findFirst({
1717
where: {
1818
OR: [{ id: orgParam }, { slug: orgParam }],
1919
deletedAt: null,
2020
members: { some: { userId } },
2121
},
22-
select: { id: true, slug: true },
22+
select: { id: true, slug: true, isActivated: true },
2323
});
2424
}

‎apps/webapp/app/services/platform.v3.server.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,36 @@ export async function getPlans() {
548548
}
549549
}
550550

551+
export async function activateFreePlan(
552+
organizationId: string,
553+
userId: string,
554+
opts?: { invalidateBillingCache?: (orgId: string) => void }
555+
): Promise<{ success: true } | { success: false; error: string }> {
556+
if (!client) {
557+
return { success: false, error: "Billing is not configured" };
558+
}
559+
560+
const [error, result] = await tryCatch(client.setPlan(organizationId, { type: "free", userId }));
561+
562+
if (error) {
563+
recordPlatformFailure("activateFreePlan", "caught");
564+
return { success: false, error: error.message };
565+
}
566+
567+
if (!result.success) {
568+
recordPlatformFailure("activateFreePlan", "no_success");
569+
return { success: false, error: result.error };
570+
}
571+
572+
if (result.action !== "free_connect_required" && result.action !== "free_connected") {
573+
return { success: false, error: "Unable to activate the Free plan" };
574+
}
575+
576+
opts?.invalidateBillingCache?.(organizationId);
577+
invalidatePlanDerivedCaches(organizationId);
578+
return { success: true };
579+
}
580+
551581
export async function setPlan(
552582
organization: { id: string; slug: string },
553583
request: Request,

‎apps/webapp/scripts/fk-cascade-index-baseline.json‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,6 @@
126126
"onDelete": "Cascade",
127127
"fkColumns": ["organizationIntegrationId"]
128128
},
129-
{
130-
"key": "control-plane:OrgMember.user",
131-
"onDelete": "Cascade",
132-
"fkColumns": ["userId"]
133-
},
134129
{
135130
"key": "control-plane:OrgMemberInvite.inviter",
136131
"onDelete": "Cascade",

‎apps/webapp/test/contextlessPatRoutes.test.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
1212
authenticateUserActor: vi.fn(),
1313
authenticatePat: vi.fn(),
1414
createOrganization: vi.fn(),
15+
findManyOrgMembers: vi.fn(),
1516
findManyProjects: vi.fn(),
1617
env: { SESSION_SECRET: "test-session-secret", ORG_CREATION_API_ENABLED: "1" } as {
1718
SESSION_SECRET: string;
@@ -26,7 +27,10 @@ vi.mock("~/services/rbac.server", () => ({
2627
},
2728
}));
2829
vi.mock("~/db.server", () => ({
29-
prisma: { project: { findMany: mocks.findManyProjects } },
30+
prisma: {
31+
orgMember: { findMany: mocks.findManyOrgMembers },
32+
project: { findMany: mocks.findManyProjects },
33+
},
3034
$replica: {},
3135
}));
3236
vi.mock("~/env.server", () => ({ env: mocks.env }));
@@ -188,6 +192,8 @@ describe("creating an organization over the API", () => {
188192
// Creation always succeeds if it is reached, so a refusal is the gate and nothing else.
189193
beforeEach(() => {
190194
mocks.createOrganization.mockReset();
195+
mocks.findManyOrgMembers.mockReset();
196+
mocks.findManyOrgMembers.mockResolvedValue([]);
191197
mocks.createOrganization.mockResolvedValue({
192198
id: "org_new",
193199
title: "New Org",
@@ -210,6 +216,38 @@ describe("creating an organization over the API", () => {
210216
expect(result.status).toBe(201);
211217
expect(result.body.slug).toBe("new-org");
212218
expect(result.canCalls).toBe(0);
219+
expect(mocks.findManyOrgMembers).toHaveBeenCalledWith({
220+
where: {
221+
userId: USER_ID,
222+
organization: { deletedAt: null },
223+
},
224+
select: { id: true },
225+
take: 25,
226+
});
227+
});
228+
229+
it("admits users with fewer than 25 organizations", async () => {
230+
mocks.findManyOrgMembers.mockResolvedValue(
231+
Array.from({ length: 24 }, (_, index) => ({ id: `member_${index}` }))
232+
);
233+
234+
const result = await createOrgWithPat();
235+
236+
expect(result.status).toBe(201);
237+
expect(mocks.createOrganization).toHaveBeenCalled();
238+
});
239+
240+
it("directs users with 25 organizations to the dashboard", async () => {
241+
mocks.findManyOrgMembers.mockResolvedValue(
242+
Array.from({ length: 25 }, (_, index) => ({ id: `member_${index}` }))
243+
);
244+
245+
const result = await createOrgWithPat();
246+
247+
expect(result.status).toBe(403);
248+
expect(result.body.error).toContain("limited to 25 organizations");
249+
expect(result.body.error).toContain("dashboard");
250+
expect(mocks.createOrganization).not.toHaveBeenCalled();
213251
});
214252

215253
// The env gate runs before the capability gate, so an install with the API disabled tells
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
CREATE INDEX CONCURRENTLY IF NOT EXISTS "OrgMember_userId_idx" ON "OrgMember"("userId");

0 commit comments

Comments
 (0)