Implement the cloud private beta invite form
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<HTMLInputElement> & {
|
||||
variant?: keyof typeof variants;
|
||||
icon?: IconNames;
|
||||
icon?: IconNamesOrString;
|
||||
shortcut?: string;
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<HTMLAnchorElement>;
|
||||
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<boolean>;
|
||||
} = {}
|
||||
) {
|
||||
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 (
|
||||
<AppContainer showBackgroundGradient={true}>
|
||||
<NavBar />
|
||||
<MainCenteredContainer>
|
||||
<div>Enter your invitation code to get access to the cloud.</div>
|
||||
<FormTitle
|
||||
LeadingIcon="qr-code"
|
||||
title="Trigger.dev Cloud Beta"
|
||||
description={
|
||||
<>
|
||||
Enter your code to get access. If you don't have one you can
|
||||
always{" "}
|
||||
<TextLink
|
||||
target="_blank"
|
||||
href="https://trigger.dev/docs/documentation/guides/self-hosting"
|
||||
trailingIcon="external-link"
|
||||
trailingIconClassName="h-3 w-3 text-indigo-500 transition group-hover:text-indigo-400"
|
||||
>
|
||||
self-host
|
||||
</TextLink>{" "}
|
||||
Trigger.dev
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Form method="post" {...form.props}>
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Input
|
||||
{...conform.input(code, { type: "text" })}
|
||||
placeholder="Your super secret invite code"
|
||||
icon="qr-code"
|
||||
autoFocus={Boolean(code.initialError)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<FormError id={code.errorId}>{code.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary/small"}
|
||||
TrailingIcon={"arrow-right"}
|
||||
>
|
||||
Get access
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</MainCenteredContainer>
|
||||
</AppContainer>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user