diff --git a/apps/webapp/app/components/helpContent/HelpContentText.tsx b/apps/webapp/app/components/helpContent/HelpContentText.tsx index cf8dee099..bd6baf940 100644 --- a/apps/webapp/app/components/helpContent/HelpContentText.tsx +++ b/apps/webapp/app/components/helpContent/HelpContentText.tsx @@ -1,8 +1,17 @@ +import { Link } from "@remix-run/react"; import { Paragraph, TextLink } from "~/components/primitives/Paragraph"; import { StepNumber } from "~/components/primitives/StepNumber"; import { useAppOrigin } from "~/hooks/useAppOrigin"; import { useDevEnvironment } from "~/hooks/useEnvironments"; +import { useJob } from "~/hooks/useJob"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; import { IntegrationIcon } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations/route"; +import { jobTestPath } from "~/utils/pathBuilder"; +import { InlineCode } from "../code/InlineCode"; +import { HelpPanelProps } from "../integrations/ApiKeyHelp"; +import { HelpInstall } from "../integrations/HelpInstall"; +import { HelpSamples } from "../integrations/HelpSamples"; import { Button, LinkButton } from "../primitives/Buttons"; import { Callout, variantClasses } from "../primitives/Callout"; import { @@ -15,17 +24,6 @@ import { ClipboardField } from "../primitives/ClipboardField"; import integrationButton from "./integration-button.png"; import selectEnvironment from "./select-environment.png"; import selectExample from "./select-example.png"; -import { InlineCode } from "../code/InlineCode"; -import { ApiKeyHelp, HelpPanelProps } from "../integrations/ApiKeyHelp"; -import { CodeBlock } from "../code/CodeBlock"; -import { HelpInstall } from "../integrations/HelpInstall"; -import { HelpSamples } from "../integrations/HelpSamples"; -import { jobTestPath } from "~/utils/pathBuilder"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { useProject } from "~/hooks/useProject"; -import { useJob } from "~/hooks/useJob"; -import { Link } from "@remix-run/react"; -import { OrderedList, OrderedListItem } from "../primitives/Lists"; export function HowToSetupYourProject() { const devEnvironment = useDevEnvironment(); diff --git a/apps/webapp/app/components/primitives/Input.tsx b/apps/webapp/app/components/primitives/Input.tsx index e2007557d..ea90b65bc 100644 --- a/apps/webapp/app/components/primitives/Input.tsx +++ b/apps/webapp/app/components/primitives/Input.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { cn } from "~/utils/cn"; -import type { IconNames } from "./NamedIcon"; +import type { IconNamesOrString } from "./NamedIcon"; import { NamedIcon } from "./NamedIcon"; const variants = { @@ -44,7 +44,7 @@ const variants = { export type InputProps = React.InputHTMLAttributes & { variant?: keyof typeof variants; - icon?: IconNames; + icon?: IconNamesOrString; shortcut?: string; fullWidth?: boolean; }; diff --git a/apps/webapp/app/components/primitives/NamedIcon.tsx b/apps/webapp/app/components/primitives/NamedIcon.tsx index 11877c65b..8f15ee13c 100644 --- a/apps/webapp/app/components/primitives/NamedIcon.tsx +++ b/apps/webapp/app/components/primitives/NamedIcon.tsx @@ -228,6 +228,7 @@ const icons = { }; export type IconNames = keyof typeof icons; +export type IconNamesOrString = IconNames | (string & {}); export const iconNames = Object.keys(icons) as IconNames[]; export function NamedIcon({ diff --git a/apps/webapp/app/components/primitives/Paragraph.tsx b/apps/webapp/app/components/primitives/Paragraph.tsx index 1b7901a60..ab4b616ff 100644 --- a/apps/webapp/app/components/primitives/Paragraph.tsx +++ b/apps/webapp/app/components/primitives/Paragraph.tsx @@ -1,6 +1,6 @@ import { Link } from "@remix-run/react"; import { cn } from "~/utils/cn"; -import { IconNames, NamedIcon } from "./NamedIcon"; +import { IconNamesOrString, NamedIcon } from "./NamedIcon"; const paragraphVariants = { base: { @@ -95,13 +95,13 @@ type TextLinkProps = { href?: string; to?: string; className?: string; - trailingIcon?: IconNames; + trailingIcon?: IconNamesOrString; trailingIconClassName?: string; children: React.ReactNode; } & React.AnchorHTMLAttributes; const classes = - "text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center"; + "text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center group"; export function TextLink({ href, diff --git a/apps/webapp/app/features.server.ts b/apps/webapp/app/features.server.ts index 0093fd55e..f02751095 100644 --- a/apps/webapp/app/features.server.ts +++ b/apps/webapp/app/features.server.ts @@ -12,7 +12,7 @@ export function featuresForRequest(request: Request): TriggerFeatures { const isManagedCloud = url.host === "cloud.trigger.dev" || url.host === "test-cloud.trigger.dev" || - process.env.NODE_ENV === "development"; + process.env.CLOUD_ENV === "development"; return { isManagedCloud, diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts index ac98bf5bc..a4b361a99 100644 --- a/apps/webapp/app/models/user.server.ts +++ b/apps/webapp/app/models/user.server.ts @@ -128,3 +128,22 @@ export function updateUser({ data: { name, email, marketingEmails, confirmedBasicDetails: true }, }); } + +export async function grantUserCloudAccess({ + id, + inviteCode, +}: { + id: string; + inviteCode: string; +}) { + return prisma.user.update({ + where: { id }, + data: { + invitationCode: { + connect: { + code: inviteCode, + }, + }, + }, + }); +} diff --git a/apps/webapp/app/routes/invitation-code.tsx b/apps/webapp/app/routes/invitation-code.tsx index 235a3e466..99206f971 100644 --- a/apps/webapp/app/routes/invitation-code.tsx +++ b/apps/webapp/app/routes/invitation-code.tsx @@ -1,15 +1,159 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { Form, useActionData } from "@remix-run/react"; +import { ActionArgs, json } from "@remix-run/server-runtime"; +import { z } from "zod"; import { AppContainer, MainCenteredContainer, } from "~/components/layout/AppLayout"; import { NavBar } from "~/components/navigation/NavBar"; +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 { FormTitle } from "~/components/primitives/FormTitle"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { TextLink } from "~/components/primitives/Paragraph"; +import { prisma } from "~/db.server"; +import { redirectWithSuccessMessage } from "~/models/message.server"; +import { grantUserCloudAccess } from "~/models/user.server"; +import { requireUserId } from "~/services/session.server"; +import { organizationsPath } from "~/utils/pathBuilder"; + +function createSchema( + constraints: { + isValidCode?: (code: string) => Promise; + } = {} +) { + return z.object({ + code: z + .string() + .min(1, "Invite code missing") + .superRefine((code, ctx) => { + if (constraints.isValidCode === undefined) { + //client-side validation skips this + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: conform.VALIDATION_UNDEFINED, + }); + } else { + // Tell zod this is an async validation by returning the promise + return constraints.isValidCode(code).then((isValid) => { + if (isValid) { + return; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid invitation code", + }); + }); + } + }), + }); +} + +export async function action({ request }: ActionArgs) { + const userId = await requireUserId(request); + const formData = await request.formData(); + + const formSchema = createSchema({ + isValidCode: async (code) => { + const invitationCode = await prisma.invitationCode.findUnique({ + where: { + code, + }, + }); + + return invitationCode !== undefined && invitationCode !== null; + }, + }); + + const submission = await parse(formData, { schema: formSchema, async: true }); + + if (!submission.value) { + return json(submission); + } + + try { + await grantUserCloudAccess({ + id: userId, + inviteCode: submission.value.code, + }); + + return redirectWithSuccessMessage( + organizationsPath(), + request, + "🚀 Welcome to the Trigger.dev Cloud private beta" + ); + } catch (error: any) { + return json({ errors: { body: error.message } }, { status: 400 }); + } +} export default function Page() { + const lastSubmission = useActionData(); + + const [form, { code }] = useForm({ + id: "invitation-code", + lastSubmission, + shouldRevalidate: "onSubmit", + onValidate({ formData }) { + return parse(formData, { schema: createSchema() }); + }, + }); + return ( -
Enter your invitation code to get access to the cloud.
+ + Enter your code to get access. If you don't have one you can + always{" "} + + self-host + {" "} + Trigger.dev + + } + /> +
+
+ + + {code.error} + + + + Get access + + } + /> +
+
);