Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17df4839d7 | |||
| e3f78178f7 | |||
| da90ee13c3 | |||
| 583da458ec | |||
| dcf95c4eb2 | |||
| 32cf5790cb | |||
| c272e38de2 | |||
| ca78ddc2c2 | |||
| 07ed8c346a | |||
| d4391f2e2d | |||
| 2c328ba93d | |||
| 68dbc07ee8 | |||
| 58448243af | |||
| 6c4047cf21 | |||
| 831860eace | |||
| db46f2a69a | |||
| 4dd6cf18dc | |||
| adf66d23b8 | |||
| 5c42831a60 | |||
| 26f5e7774d | |||
| f209a3b364 | |||
| a93b554f8b | |||
| 0f342cd1be | |||
| 7df2f85a1f | |||
| f14180d13c | |||
| ab6b9514cd | |||
| 98345d67d9 | |||
| 38f5a90399 | |||
| 1b2635ae4a | |||
| 129f023d11 | |||
| 795e637dec | |||
| 5238c424fc | |||
| 1bbd7e6dc3 | |||
| 3bb82ed9a5 | |||
| ff4ff869ab | |||
| 1dcee2b338 | |||
| 0a798446c8 | |||
| 209942a63d | |||
| 57a9b35870 | |||
| a16b65f666 |
@@ -13,8 +13,15 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb
|
||||
["#22C55E", "#22C55E", "#F59E0B", "#F43F5E", "#F43F5E"]
|
||||
);
|
||||
|
||||
const hasHitLimit = cappedPercentage >= 1;
|
||||
|
||||
return (
|
||||
<div className="rounded border border-slate-900 bg-[#101722] p-2.5">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border border-slate-900 bg-[#101722] p-2.5",
|
||||
hasHitLimit && "border-rose-800/60"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowUpCircleIcon className="h-5 w-5 text-dimmed" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { formatNumberCompact } from "~/utils/numberFormatter";
|
||||
import { plansPath } from "~/utils/pathBuilder";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
|
||||
type UpgradePromptProps = {
|
||||
organization: MatchedOrganization;
|
||||
@@ -18,19 +19,25 @@ export function UpgradePrompt({ organization }: UpgradePromptProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center gap-4 bg-gradient-to-r from-transparent to-indigo-900/50 pr-1.5">
|
||||
<Paragraph variant="extra-small" className="text-rose-500">
|
||||
You have exceeded the monthly {formatNumberCompact(currentPlan.usage.runCountCap)} runs
|
||||
limit
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
variant={"primary/small"}
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="px-0"
|
||||
to={plansPath(organization)}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Callout variant="error" className="flex h-full items-center rounded-none px-1 py-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Paragraph variant="extra-small" className="text-white">
|
||||
{organization.runsEnabled
|
||||
? `You have exceeded the monthly ${formatNumberCompact(
|
||||
currentPlan.usage.runCountCap
|
||||
)} runs
|
||||
limit`
|
||||
: `No runs are executing because you have exceeded the free limit`}
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
variant={"primary/small"}
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="px-0"
|
||||
to={plansPath(organization)}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</div>
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,4 +5,12 @@ export const EventListSearchSchema = z.object({
|
||||
cursor: z.string().optional(),
|
||||
direction: DirectionSchema.optional(),
|
||||
environment: FilterableEnvironment.optional(),
|
||||
from: z
|
||||
.string()
|
||||
.transform((value) => parseInt(value))
|
||||
.optional(),
|
||||
to: z
|
||||
.string()
|
||||
.transform((value) => parseInt(value))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -12,14 +12,19 @@ import {
|
||||
} from "../primitives/Select";
|
||||
import { EventListSearchSchema } from "./EventStatuses";
|
||||
import { environmentKeys, FilterableEnvironment } from "~/components/runs/RunStatuses";
|
||||
import { TimeFrameFilter } from "../runs/TimeFrameFilter";
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
|
||||
export function EventsFilters() {
|
||||
const navigate = useNavigate();
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const { environment } = EventListSearchSchema.parse(Object.fromEntries(searchParams.entries()));
|
||||
const { environment, from, to } = EventListSearchSchema.parse(
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const handleFilterChange = (filterType: string, value: string | undefined) => {
|
||||
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
|
||||
if (value) {
|
||||
searchParams.set(filterType, value);
|
||||
} else {
|
||||
@@ -28,12 +33,38 @@ export function EventsFilters() {
|
||||
searchParams.delete("cursor");
|
||||
searchParams.delete("direction");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTimeFrameChange = useCallback((range: { from?: number; to?: number }) => {
|
||||
if (range.from) {
|
||||
searchParams.set("from", range.from.toString());
|
||||
} else {
|
||||
searchParams.delete("from");
|
||||
}
|
||||
|
||||
if (range.to) {
|
||||
searchParams.set("to", range.to.toString());
|
||||
} else {
|
||||
searchParams.delete("to");
|
||||
}
|
||||
|
||||
searchParams.delete("cursor");
|
||||
searchParams.delete("direction");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
const handleEnvironmentChange = (value: FilterableEnvironment | "ALL") => {
|
||||
handleFilterChange("environment", value === "ALL" ? undefined : value);
|
||||
};
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
searchParams.delete("status");
|
||||
searchParams.delete("environment");
|
||||
searchParams.delete("from");
|
||||
searchParams.delete("to");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-between gap-x-2">
|
||||
<SelectGroup>
|
||||
@@ -62,6 +93,12 @@ export function EventsFilters() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
|
||||
|
||||
<Button variant="tertiary/small" onClick={() => clearFilters()} LeadingIcon={"close"}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,13 +49,13 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
<TableRow key={job.id} className="group">
|
||||
<TableCell to={path}>
|
||||
<span className="flex items-center gap-2">
|
||||
<NamedIcon name={job.event.icon} className="h-8 w-8" />
|
||||
<NamedIcon name={job.event.icon} className="w-8 h-8" />
|
||||
<LabelValueStack
|
||||
label={job.title}
|
||||
value={
|
||||
job.dynamic ? (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<NamedIcon name="dynamic" className="h-4 w-4" />{" "}
|
||||
<NamedIcon name="dynamic" className="w-4 h-4" />{" "}
|
||||
<span className="uppercase">Dynamic:</span> {job.event.title}
|
||||
</span>
|
||||
) : (
|
||||
@@ -75,9 +75,9 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
key={integration.key}
|
||||
button={
|
||||
<div className="relative">
|
||||
<NamedIcon name={integration.icon} className="h-6 w-6" />
|
||||
<NamedIcon name={integration.icon} className="w-6 h-6" />
|
||||
{integration.setupStatus === "MISSING_FIELDS" && (
|
||||
<NamedIcon name="error" className="absolute -left-1 -top-1 h-4 w-4" />
|
||||
<NamedIcon name="error" className="absolute w-4 h-4 -left-1 -top-1" />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
{job.properties && (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="flex max-w-[200px] items-start justify-start gap-5 truncate">
|
||||
<div className="flex max-w-[300px] items-start justify-start gap-5 truncate">
|
||||
{job.properties.map((property, index) => (
|
||||
<LabelValueStack
|
||||
key={index}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { User } from "@trigger.dev/database";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { accountPath, personalAccessTokensPath, rootPath } from "~/utils/pathBuilder";
|
||||
import { Header3 } from "../primitives/Headers";
|
||||
import { ArrowLeftIcon, ChevronLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { DiscordIcon } from "@trigger.dev/companyicons";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { Button, LinkButton } from "../primitives/Buttons";
|
||||
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
|
||||
import { useV3Enabled } from "~/root";
|
||||
|
||||
export function AccountSideMenu({ user }: { user: User }) {
|
||||
const v3Enabled = useV3Enabled();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-ui-border transition"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={cn("flex items-center justify-between border-b bg-background p-px transition")}
|
||||
>
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={ArrowLeftIcon}
|
||||
to={rootPath()}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Account
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="h-full overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<div className="mb-6 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={user.name ?? user.displayName ?? user.email} />
|
||||
|
||||
<SideMenuItem
|
||||
name="Your profile"
|
||||
icon="account"
|
||||
iconColor="text-indigo-500"
|
||||
to={accountPath()}
|
||||
data-action="account"
|
||||
/>
|
||||
</div>
|
||||
{v3Enabled && (
|
||||
<div className="mb-1 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title="Security" />
|
||||
<SideMenuItem
|
||||
name="Personal Access Tokens"
|
||||
icon={ShieldCheckIcon}
|
||||
iconColor="text-emerald-500"
|
||||
to={personalAccessTokensPath()}
|
||||
data-action="tokens"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-border p-1">
|
||||
<SideMenuItem
|
||||
name="Join our Discord"
|
||||
icon={DiscordIcon}
|
||||
to="https://trigger.dev/discord"
|
||||
data-action="join our discord"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
name="Documentation"
|
||||
icon="docs"
|
||||
to="https://trigger.dev/docs"
|
||||
data-action="documentation"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Changelog"
|
||||
icon="star"
|
||||
to="https://trigger.dev/changelog"
|
||||
data-action="changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon="log"
|
||||
data-action="help & feedback"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Help & Feedback
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,15 +5,14 @@ import {
|
||||
ChartBarIcon,
|
||||
CursorArrowRaysIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { AnchorHTMLAttributes, Fragment, useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { usePathName } from "~/hooks/usePathName";
|
||||
import { MatchedProject } from "~/hooks/useProject";
|
||||
import { User } from "~/models/user.server";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
@@ -28,11 +27,12 @@ import {
|
||||
organizationIntegrationsPath,
|
||||
organizationPath,
|
||||
organizationTeamPath,
|
||||
personalAccessTokensPath,
|
||||
projectEnvironmentsPath,
|
||||
projectEventsPath,
|
||||
projectHttpEndpointsPath,
|
||||
projectPath,
|
||||
projectRunsPath,
|
||||
projectEventsPath,
|
||||
projectSetupPath,
|
||||
projectTriggersPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
@@ -42,11 +42,10 @@ import { LogoIcon } from "../LogoIcon";
|
||||
import { StepContentContainer } from "../StepContentContainer";
|
||||
import { UserProfilePhoto } from "../UserProfilePhoto";
|
||||
import { FreePlanUsage } from "../billing/FreePlanUsage";
|
||||
import { Button, LinkButton } from "../primitives/Buttons";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { ClipboardField } from "../primitives/ClipboardField";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { type IconNames } from "../primitives/NamedIcon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
@@ -57,7 +56,9 @@ import {
|
||||
PopoverSectionHeader,
|
||||
} from "../primitives/Popover";
|
||||
import { StepNumber } from "../primitives/StepNumber";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { MenuCount, SideMenuItem } from "./SideMenuItem";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { useV3Enabled } from "~/root";
|
||||
|
||||
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
|
||||
type SideMenuProject = Pick<
|
||||
@@ -380,6 +381,7 @@ function ProjectSelector({
|
||||
function UserMenu({ user }: { user: SideMenuUser }) {
|
||||
const [isProfileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
const v3Enabled = useV3Enabled();
|
||||
|
||||
useEffect(() => {
|
||||
setProfileMenuOpen(false);
|
||||
@@ -417,6 +419,14 @@ function UserMenu({ user }: { user: SideMenuUser }) {
|
||||
icon={UserProfilePhoto}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
/>
|
||||
{v3Enabled && (
|
||||
<PopoverMenuItem
|
||||
to={personalAccessTokensPath()}
|
||||
title="Personal Access Tokens"
|
||||
icon={ShieldCheckIcon}
|
||||
leadingIconClassName="text-emerald-500"
|
||||
/>
|
||||
)}
|
||||
<PopoverMenuItem
|
||||
to={logoutPath()}
|
||||
title="Log out"
|
||||
@@ -429,99 +439,3 @@ function UserMenu({ user }: { user: SideMenuUser }) {
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function SideMenuHeader({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
return (
|
||||
<div className="group flex items-center justify-between pl-1.5">
|
||||
<Paragraph
|
||||
variant="extra-extra-small/caps"
|
||||
className="cursor-default truncate text-slate-500"
|
||||
>
|
||||
{title}
|
||||
</Paragraph>
|
||||
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
|
||||
<PopoverCustomTrigger className="p-1">
|
||||
<EllipsisHorizontalIcon className="h-4 w-4 text-slate-500 transition group-hover:text-bright" />
|
||||
</PopoverCustomTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="start"
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">{children}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SideMenuItem({
|
||||
icon,
|
||||
iconColor,
|
||||
name,
|
||||
to,
|
||||
hasWarning,
|
||||
count,
|
||||
target,
|
||||
subItem = false,
|
||||
}: {
|
||||
icon?: IconNames | React.ComponentType<any>;
|
||||
iconColor?: string;
|
||||
name: string;
|
||||
to: string;
|
||||
hasWarning?: string | boolean;
|
||||
count?: number;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
subItem?: boolean;
|
||||
}) {
|
||||
const pathName = usePathName();
|
||||
const isActive = pathName === to;
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={isActive ? iconColor : "text-dimmed"}
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"text-bright group-hover:bg-slate-850",
|
||||
subItem ? "text-dimmed" : "",
|
||||
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
<div className="flex items-center gap-1">
|
||||
{count !== undefined && count > 0 && <MenuCount count={count} />}
|
||||
{typeof hasWarning === "string" ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-1 border border-rose-500 bg-rose-500/20 backdrop-blur-xl">
|
||||
{hasWarning}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuCount({ count }: { count: number | string }) {
|
||||
return <div className="rounded-full bg-slate-900 px-2 py-1 text-xxs text-dimmed">{count}</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function SideMenuHeader({ title, children }: { title: string; children?: React.ReactNode }) {
|
||||
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
return (
|
||||
<div className="group flex items-center justify-between pl-1.5">
|
||||
<Paragraph
|
||||
variant="extra-extra-small/caps"
|
||||
className="cursor-default truncate text-slate-500"
|
||||
>
|
||||
{title}
|
||||
</Paragraph>
|
||||
{children !== undefined ? (
|
||||
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
|
||||
<PopoverCustomTrigger className="p-1">
|
||||
<EllipsisHorizontalIcon className="h-4 w-4 text-slate-500 transition group-hover:text-bright" />
|
||||
</PopoverCustomTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="start"
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">{children}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { AnchorHTMLAttributes } from "react";
|
||||
import { usePathName } from "~/hooks/usePathName";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { IconNames } from "../primitives/NamedIcon";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
|
||||
export function SideMenuItem({
|
||||
icon,
|
||||
iconColor,
|
||||
name,
|
||||
to,
|
||||
hasWarning,
|
||||
count,
|
||||
target,
|
||||
subItem = false,
|
||||
}: {
|
||||
icon?: IconNames | React.ComponentType<any>;
|
||||
iconColor?: string;
|
||||
name: string;
|
||||
to: string;
|
||||
hasWarning?: string | boolean;
|
||||
count?: number;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
subItem?: boolean;
|
||||
}) {
|
||||
const pathName = usePathName();
|
||||
const isActive = pathName === to;
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={isActive ? iconColor : "text-dimmed"}
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"text-bright group-hover:bg-slate-850",
|
||||
subItem ? "text-dimmed" : "",
|
||||
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
<div className="flex items-center gap-1">
|
||||
{count !== undefined && count > 0 && <MenuCount count={count} />}
|
||||
{typeof hasWarning === "string" ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-1 border border-rose-500 bg-rose-500/20 backdrop-blur-xl">
|
||||
{hasWarning}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuCount({ count }: { count: number | string }) {
|
||||
return <div className="rounded-full bg-slate-900 px-2 py-1 text-xxs text-dimmed">{count}</div>;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const ClientTabs = TabsPrimitive.Root;
|
||||
|
||||
@@ -48,4 +49,47 @@ const ClientTabsContent = React.forwardRef<
|
||||
));
|
||||
ClientTabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export type TabsProps = {
|
||||
tabs: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
currentValue: string;
|
||||
className?: string;
|
||||
layoutId: string;
|
||||
};
|
||||
|
||||
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(`flex flex-row gap-x-6 border-b border-slate-700`, className)}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = currentValue === tab.value;
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(`group flex flex-col items-center`, className)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-indigo-500" : "text-slate-200"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-slate-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
);
|
||||
}
|
||||
|
||||
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { CalendarDateTime, createCalendar } from "@internationalized/date";
|
||||
import { useDateField, useDateSegment } from "@react-aria/datepicker";
|
||||
import type { DateFieldState, DateSegment } from "@react-stately/datepicker";
|
||||
import { useDateFieldState } from "@react-stately/datepicker";
|
||||
import { Granularity } from "@react-types/datepicker";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { useLocales } from "./LocaleProvider";
|
||||
import { Button } from "./Buttons";
|
||||
|
||||
type DateFieldProps = {
|
||||
label?: string;
|
||||
defaultValue?: Date;
|
||||
minValue?: Date;
|
||||
maxValue?: Date;
|
||||
className?: string;
|
||||
fieldClassName?: string;
|
||||
granularity: Granularity;
|
||||
showGuide?: boolean;
|
||||
showNowButton?: boolean;
|
||||
showClearButton?: boolean;
|
||||
onValueChange?: (value: Date | undefined) => void;
|
||||
};
|
||||
|
||||
export function DateField({
|
||||
label,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
minValue,
|
||||
maxValue,
|
||||
granularity,
|
||||
className,
|
||||
fieldClassName,
|
||||
showGuide = false,
|
||||
showNowButton = false,
|
||||
showClearButton = false,
|
||||
}: DateFieldProps) {
|
||||
const [value, setValue] = useState<undefined | CalendarDateTime>(
|
||||
utcDateToCalendarDate(defaultValue)
|
||||
);
|
||||
|
||||
const state = useDateFieldState({
|
||||
value: value,
|
||||
onChange: (value) => {
|
||||
if (value) {
|
||||
setValue(value);
|
||||
onValueChange?.(value.toDate("utc"));
|
||||
}
|
||||
},
|
||||
minValue: utcDateToCalendarDate(minValue),
|
||||
maxValue: utcDateToCalendarDate(maxValue),
|
||||
shouldForceLeadingZeros: true,
|
||||
granularity,
|
||||
locale: "en-US",
|
||||
createCalendar: (name: string) => {
|
||||
return createCalendar(name);
|
||||
},
|
||||
});
|
||||
|
||||
//if the passed in value changes, we should update the date
|
||||
useEffect(() => {
|
||||
if (state.value === undefined && defaultValue === undefined) return;
|
||||
|
||||
const calendarDate = utcDateToCalendarDate(defaultValue);
|
||||
//unchanged
|
||||
if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(calendarDate);
|
||||
}, [defaultValue]);
|
||||
|
||||
const ref = useRef<null | HTMLDivElement>(null);
|
||||
const { labelProps, fieldProps } = useDateField(
|
||||
{
|
||||
label,
|
||||
},
|
||||
state,
|
||||
ref
|
||||
);
|
||||
|
||||
//render if reverse date order
|
||||
const yearSegment = state.segments.find((s) => s.type === "year")!;
|
||||
const monthSegment = state.segments.find((s) => s.type === "month")!;
|
||||
const daySegment = state.segments.find((s) => s.type === "day")!;
|
||||
const hourSegment = state.segments.find((s) => s.type === "hour")!;
|
||||
const minuteSegment = state.segments.find((s) => s.type === "minute")!;
|
||||
const secondSegment = state.segments.find((s) => s.type === "second")!;
|
||||
const dayPeriodSegment = state.segments.find((s) => s.type === "dayPeriod")!;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-start ${className || ""}`}>
|
||||
<span {...labelProps} className="mb-1 ml-0.5 text-xs text-slate-300">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div
|
||||
{...fieldProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex rounded-sm border border-slate-800 bg-midnight-900 p-0.5 px-1.5 transition-colors focus-within:border-slate-500 hover:border-slate-700 focus-within:hover:border-slate-500",
|
||||
fieldClassName
|
||||
)}
|
||||
>
|
||||
<DateSegment segment={yearSegment} state={state} />
|
||||
<DateSegment segment={literalSegment("/")} state={state} />
|
||||
<DateSegment segment={monthSegment} state={state} />
|
||||
<DateSegment segment={literalSegment("/")} state={state} />
|
||||
<DateSegment segment={daySegment} state={state} />
|
||||
<DateSegment segment={literalSegment(", ")} state={state} />
|
||||
<DateSegment segment={hourSegment} state={state} />
|
||||
<DateSegment segment={literalSegment(":")} state={state} />
|
||||
<DateSegment segment={minuteSegment} state={state} />
|
||||
<DateSegment segment={literalSegment(":")} state={state} />
|
||||
<DateSegment segment={secondSegment} state={state} />
|
||||
<DateSegment segment={literalSegment(" ")} state={state} />
|
||||
<DateSegment segment={dayPeriodSegment} state={state} />
|
||||
</div>
|
||||
{showNowButton && (
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
setValue(utcDateToCalendarDate(new Date()));
|
||||
onValueChange?.(now);
|
||||
}}
|
||||
>
|
||||
Now
|
||||
</Button>
|
||||
)}
|
||||
{showClearButton && (
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
LeadingIcon={"close"}
|
||||
onClick={() => {
|
||||
setValue(undefined);
|
||||
onValueChange?.(undefined);
|
||||
state.clearSegment("year");
|
||||
state.clearSegment("month");
|
||||
state.clearSegment("day");
|
||||
state.clearSegment("hour");
|
||||
state.clearSegment("minute");
|
||||
state.clearSegment("second");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showGuide && (
|
||||
<div className="mt-1 flex px-2">
|
||||
{state.segments.map((segment, i) => (
|
||||
<DateSegmentGuide key={i} segment={segment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function utcDateToCalendarDate(date?: Date) {
|
||||
return date
|
||||
? new CalendarDateTime(
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth(),
|
||||
date.getUTCDate(),
|
||||
date.getUTCHours(),
|
||||
date.getUTCMinutes(),
|
||||
date.getUTCSeconds()
|
||||
)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
type DateSegmentProps = {
|
||||
segment: DateSegment;
|
||||
state: DateFieldState;
|
||||
};
|
||||
|
||||
function DateSegment({ segment, state }: DateSegmentProps) {
|
||||
const ref = useRef<null | HTMLDivElement>(null);
|
||||
const { segmentProps } = useDateSegment(segment, state, ref);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...segmentProps}
|
||||
ref={ref}
|
||||
style={{
|
||||
...segmentProps.style,
|
||||
minWidth: minWidthForSegment(segment),
|
||||
}}
|
||||
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none focus:bg-indigo-500 focus:text-white ${
|
||||
!segment.isEditable ? "text-slate-500" : "text-bright"
|
||||
}`}
|
||||
>
|
||||
{/* Always reserve space for the placeholder, to prevent layout shift when editing. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="block text-center italic text-slate-500 group-focus:text-white"
|
||||
style={{
|
||||
visibility: segment.isPlaceholder ? undefined : "hidden",
|
||||
height: segment.isPlaceholder ? "" : 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{segment.placeholder}
|
||||
</span>
|
||||
{segment.isPlaceholder ? "" : segment.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function literalSegment(text: string): DateSegment {
|
||||
return {
|
||||
type: "literal",
|
||||
text,
|
||||
isPlaceholder: false,
|
||||
isEditable: false,
|
||||
placeholder: "",
|
||||
};
|
||||
}
|
||||
|
||||
function minWidthForSegment(segment: DateSegment) {
|
||||
if (segment.type === "literal") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return String(`${segment.maxValue}`).length + "ch";
|
||||
}
|
||||
|
||||
function DateSegmentGuide({ segment }: { segment: DateSegment }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minWidth: minWidthForSegment(segment),
|
||||
}}
|
||||
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none ${
|
||||
!segment.isEditable ? "text-slate-500" : "text-bright"
|
||||
}`}
|
||||
>
|
||||
<span className="block text-center italic text-slate-500">
|
||||
{segment.type !== "literal" ? segment.placeholder : segment.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import type { IconNamesOrString } from "./NamedIcon";
|
||||
import { NamedIcon } from "./NamedIcon";
|
||||
import { Icon, RenderIcon } from "./Icon";
|
||||
|
||||
const variants = {
|
||||
large: {
|
||||
@@ -44,7 +45,7 @@ const variants = {
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
variant?: keyof typeof variants;
|
||||
icon?: IconNamesOrString;
|
||||
icon?: RenderIcon;
|
||||
shortcut?: string;
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
@@ -59,7 +60,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<div className={cn("relative", fullWidth ? "w-full" : "max-w-max")}>
|
||||
{icon && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center">
|
||||
<NamedIcon name={icon} className={cn(iconClassName, "text-dimmed")} />
|
||||
<Icon icon={icon} className={cn(iconClassName, "text-dimmed")} />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
|
||||
@@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
|
||||
sideOffset={sideOffset}
|
||||
avoidCollisions={true}
|
||||
className={cn(
|
||||
"z-50 min-w-max rounded-md border bg-midnight-850 p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-max rounded-md border border-slate-700 bg-midnight-850 p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -56,7 +56,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-max overflow-hidden rounded-md bg-popover text-bright shadow-md animate-in fade-in-40",
|
||||
"relative z-50 min-w-max overflow-hidden rounded-md border border-slate-700 bg-popover text-bright shadow-md animate-in fade-in-40",
|
||||
position === "popper" && "translate-y-1",
|
||||
className
|
||||
)}
|
||||
@@ -65,7 +65,7 @@ const SelectContent = React.forwardRef<
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"border border-slate-800 px-1 py-0",
|
||||
"px-1 py-0",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
|
||||
@@ -122,10 +122,26 @@ type TableCellProps = TableCellBasicProps & {
|
||||
to?: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
hasAction?: boolean;
|
||||
isSticky?: boolean;
|
||||
};
|
||||
|
||||
const stickyStyles =
|
||||
"sticky right-0 z-10 w-[2.8rem] min-w-[2.8rem] bg-background before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem] before:bg-gradient-to-r before:from-transparent before:to-background before:content-[''] group-hover:before:to-slate-900";
|
||||
|
||||
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
({ className, alignment = "left", children, colSpan, to, onClick, hasAction = false }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
alignment = "left",
|
||||
children,
|
||||
colSpan,
|
||||
to,
|
||||
onClick,
|
||||
hasAction = false,
|
||||
isSticky = false,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
let alignmentClassName = "text-left";
|
||||
switch (alignment) {
|
||||
case "center":
|
||||
@@ -154,6 +170,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
? "cursor-pointer group-hover:bg-slate-900"
|
||||
: "px-4 py-3 align-middle",
|
||||
!to && !onClick && alignmentClassName,
|
||||
isSticky && stickyStyles,
|
||||
className
|
||||
)}
|
||||
colSpan={colSpan}
|
||||
@@ -174,9 +191,6 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
}
|
||||
);
|
||||
|
||||
const stickyStyles =
|
||||
"sticky right-0 z-10 w-[2.8rem] min-w-[2.8rem] bg-background before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem] before:bg-gradient-to-r before:from-transparent before:to-background before:content-[''] group-hover:before:to-slate-900";
|
||||
|
||||
export const TableCellChevron = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
{
|
||||
@@ -189,7 +203,8 @@ export const TableCellChevron = forwardRef<
|
||||
>(({ className, to, children, isSticky, onClick }, ref) => {
|
||||
return (
|
||||
<TableCell
|
||||
className={cn(isSticky && stickyStyles, className)}
|
||||
className={className}
|
||||
isSticky={isSticky}
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
@@ -213,7 +228,8 @@ export const TableCellMenu = forwardRef<
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<TableCell
|
||||
className={cn(isSticky && stickyStyles, className)}
|
||||
className={className}
|
||||
isSticky={isSticky}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
alignment="right"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NoSymbolIcon,
|
||||
PauseCircleIcon,
|
||||
XCircleIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
@@ -27,16 +28,19 @@ import {
|
||||
environmentKeys,
|
||||
statusKeys,
|
||||
} from "./RunStatuses";
|
||||
import { TimeFrameFilter } from "./TimeFrameFilter";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function RunsFilters() {
|
||||
const navigate = useNavigate();
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const { environment, status } = RunListSearchSchema.parse(
|
||||
const { environment, status, from, to } = RunListSearchSchema.parse(
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const handleFilterChange = (filterType: string, value: string | undefined) => {
|
||||
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
|
||||
if (value) {
|
||||
searchParams.set(filterType, value);
|
||||
} else {
|
||||
@@ -45,15 +49,41 @@ export function RunsFilters() {
|
||||
searchParams.delete("cursor");
|
||||
searchParams.delete("direction");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleStatusChange = (value: FilterableStatus | "ALL") => {
|
||||
const handleStatusChange = useCallback((value: FilterableStatus | "ALL") => {
|
||||
handleFilterChange("status", value === "ALL" ? undefined : value);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleEnvironmentChange = (value: FilterableEnvironment | "ALL") => {
|
||||
const handleEnvironmentChange = useCallback((value: FilterableEnvironment | "ALL") => {
|
||||
handleFilterChange("environment", value === "ALL" ? undefined : value);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTimeFrameChange = useCallback((range: { from?: number; to?: number }) => {
|
||||
if (range.from) {
|
||||
searchParams.set("from", range.from.toString());
|
||||
} else {
|
||||
searchParams.delete("from");
|
||||
}
|
||||
|
||||
if (range.to) {
|
||||
searchParams.set("to", range.to.toString());
|
||||
} else {
|
||||
searchParams.delete("to");
|
||||
}
|
||||
|
||||
searchParams.delete("cursor");
|
||||
searchParams.delete("direction");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
searchParams.delete("status");
|
||||
searchParams.delete("environment");
|
||||
searchParams.delete("from");
|
||||
searchParams.delete("to");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-between gap-x-2">
|
||||
@@ -108,6 +138,12 @@ export function RunsFilters() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
|
||||
|
||||
<Button variant="tertiary/small" onClick={() => clearFilters()} LeadingIcon={"close"}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,6 +156,14 @@ export const RunListSearchSchema = z.object({
|
||||
direction: DirectionSchema.optional(),
|
||||
status: FilterableStatus.optional(),
|
||||
environment: FilterableEnvironment.optional(),
|
||||
from: z
|
||||
.string()
|
||||
.transform((value) => parseInt(value))
|
||||
.optional(),
|
||||
to: z
|
||||
.string()
|
||||
.transform((value) => parseInt(value))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const filterableStatuses: Record<FilterableStatus, JobRunStatus[]> = {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Calendar,
|
||||
CalendarDateTime,
|
||||
DateValue,
|
||||
getLocalTimeZone,
|
||||
today,
|
||||
} from "@internationalized/date";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { ClientTabs, ClientTabsContent, ClientTabsWithUnderline } from "../primitives/ClientTabs";
|
||||
import { formatDateTime } from "../primitives/DateTime";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import { DateField } from "../primitives/DateField";
|
||||
import { useLocales } from "../primitives/LocaleProvider";
|
||||
import { createCalendar } from "@internationalized/date";
|
||||
|
||||
type RunTimeFrameFilterProps = {
|
||||
from?: number;
|
||||
to?: number;
|
||||
onRangeChanged: (range: { from?: number; to?: number }) => void;
|
||||
};
|
||||
|
||||
type Mode = "absolute" | "relative";
|
||||
|
||||
export function TimeFrameFilter({ from, to, onRangeChanged }: RunTimeFrameFilterProps) {
|
||||
const [activeTab, setActiveTab] = useState<Mode>("absolute");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [relativeTimeSeconds, setRelativeTimeSeconds] = useState<number | undefined>();
|
||||
|
||||
const fromDate = from ? new Date(from) : undefined;
|
||||
const toDate = to ? new Date(to) : undefined;
|
||||
|
||||
const relativeTimeFrameChanged = useCallback((value: number) => {
|
||||
const to = new Date().getTime();
|
||||
const from = to - value;
|
||||
onRangeChanged({ from, to });
|
||||
setRelativeTimeSeconds(value);
|
||||
}, []);
|
||||
|
||||
const absoluteTimeFrameChanged = useCallback(({ from, to }: { from?: Date; to?: Date }) => {
|
||||
setRelativeTimeSeconds(undefined);
|
||||
const fromTime = from?.getTime();
|
||||
const toTime = to?.getTime();
|
||||
onRangeChanged({ from: fromTime, to: toTime });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setIsOpen(open)} open={isOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
className="bg-slate-800 group-hover:bg-tertiary-foreground"
|
||||
>
|
||||
<Paragraph variant="extra-small" className="mr-2">
|
||||
{title(from, to, relativeTimeSeconds)}
|
||||
</Paragraph>
|
||||
<ChevronDownIcon className="h-4 w-4 text-bright" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="bg-popover p-2">
|
||||
<ClientTabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as Mode)}
|
||||
className="p-1"
|
||||
>
|
||||
<ClientTabsWithUnderline
|
||||
tabs={[
|
||||
{ label: "Absolute", value: "absolute" },
|
||||
{ label: "Relative", value: "relative" },
|
||||
]}
|
||||
currentValue={activeTab}
|
||||
layoutId={"time-tabs"}
|
||||
/>
|
||||
<ClientTabsContent value={"absolute"}>
|
||||
<AbsoluteTimeFrame
|
||||
from={fromDate}
|
||||
to={toDate}
|
||||
onValueChange={absoluteTimeFrameChanged}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"relative"}>
|
||||
<RelativeTimeFrame
|
||||
value={relativeTimeSeconds}
|
||||
onValueChange={relativeTimeFrameChanged}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function title(
|
||||
from: number | undefined,
|
||||
to: number | undefined,
|
||||
relativeTimeSeconds: number | undefined
|
||||
): string {
|
||||
if (!from && !to) {
|
||||
return "All time periods";
|
||||
}
|
||||
|
||||
if (relativeTimeSeconds !== undefined) {
|
||||
return timeFrameValues.find((t) => t.value === relativeTimeSeconds)?.label ?? "Timeframe";
|
||||
}
|
||||
|
||||
let fromString = from ? formatDateTime(new Date(from), "UTC", ["en-US"], false, true) : undefined;
|
||||
let toString = to ? formatDateTime(new Date(to), "UTC", ["en-US"], false, true) : undefined;
|
||||
if (from && !to) {
|
||||
return `From ${fromString} (UTC)`;
|
||||
}
|
||||
|
||||
if (!from && to) {
|
||||
return `To ${toString} (UTC)`;
|
||||
}
|
||||
|
||||
return `${fromString} - ${toString} (UTC)`;
|
||||
}
|
||||
|
||||
function RelativeTimeFrame({
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
value?: number;
|
||||
onValueChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-1 pt-2">
|
||||
{timeFrameValues.map((timeframe) => (
|
||||
<Button
|
||||
key={timeframe.value}
|
||||
variant={value === timeframe.value ? "primary/small" : "tertiary/small"}
|
||||
className={cn(
|
||||
"w-full",
|
||||
value !== timeframe.value && "border border-slate-700 group-hover:bg-slate-700"
|
||||
)}
|
||||
onClick={() => {
|
||||
onValueChange(timeframe.value);
|
||||
}}
|
||||
>
|
||||
<Paragraph variant="extra-small">{timeframe.label}</Paragraph>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const timeFrameValues = [
|
||||
{
|
||||
label: "5 mins",
|
||||
value: 5 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "15 mins",
|
||||
value: 15 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "30 mins",
|
||||
value: 30 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "1 hour",
|
||||
value: 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "3 hours",
|
||||
value: 3 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "6 hours",
|
||||
value: 6 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "1 day",
|
||||
value: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "3 days",
|
||||
value: 3 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "7 days",
|
||||
value: 7 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "10 days",
|
||||
value: 10 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "14 days",
|
||||
value: 14 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "30 days",
|
||||
value: 30 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
];
|
||||
|
||||
export type RelativeTimeFrameItem = (typeof timeFrameValues)[number];
|
||||
|
||||
function AbsoluteTimeFrame({
|
||||
from,
|
||||
to,
|
||||
onValueChange,
|
||||
}: {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
onValueChange: (value: { from?: Date; to?: Date }) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<div className="flex flex-col justify-start gap-2">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<DateField
|
||||
label="From (UTC)"
|
||||
defaultValue={from}
|
||||
onValueChange={(value) => {
|
||||
onValueChange({ from: value, to: to });
|
||||
}}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<DateField
|
||||
label="To (UTC)"
|
||||
defaultValue={to}
|
||||
onValueChange={(value) => {
|
||||
onValueChange({ from: from, to: value });
|
||||
}}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const mockOrganization: MatchedOrganization = {
|
||||
],
|
||||
hasUnconfiguredIntegrations: false,
|
||||
memberCount: 1,
|
||||
runsEnabled: true,
|
||||
};
|
||||
|
||||
export const ProgressBar: Story = {
|
||||
|
||||
@@ -67,6 +67,9 @@ const EnvironmentSchema = z.object({
|
||||
|
||||
TUNNEL_HOST: z.string().optional(),
|
||||
TUNNEL_SECRET_KEY: z.string().optional(),
|
||||
|
||||
//v3
|
||||
V3_ENABLED: z.string().default("false"),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -10,6 +10,8 @@ type EventListOptions = {
|
||||
filterEnvironment?: FilterableEnvironment;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
from?: number;
|
||||
to?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
@@ -31,6 +33,8 @@ export class EventListPresenter {
|
||||
direction = "forward",
|
||||
cursor,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
from,
|
||||
to,
|
||||
}: EventListOptions) {
|
||||
const directionMultiplier = direction === "forward" ? 1 : -1;
|
||||
|
||||
@@ -91,12 +95,19 @@ export class EventListPresenter {
|
||||
},
|
||||
where: {
|
||||
internal: false,
|
||||
name: {
|
||||
notIn: ["trigger.scheduled", "dev.trigger.scheduled"],
|
||||
},
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
environmentId: {
|
||||
in: environments.map((environment) => environment.id),
|
||||
},
|
||||
environment: filterEnvironment ? { type: filterEnvironment } : undefined,
|
||||
createdAt: {
|
||||
gte: from ? new Date(from).toISOString() : undefined,
|
||||
lte: to ? new Date(to).toISOString() : undefined,
|
||||
},
|
||||
},
|
||||
orderBy: [{ id: "desc" }],
|
||||
//take an extra record to tell if there are more
|
||||
|
||||
@@ -94,6 +94,7 @@ export class OrganizationsPresenter {
|
||||
})),
|
||||
hasUnconfiguredIntegrations: org._count.integrations > 0,
|
||||
memberCount: org._count.members,
|
||||
runsEnabled: org.runsEnabled,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ type RunListOptions = {
|
||||
filterEnvironment?: FilterableEnvironment;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
from?: number;
|
||||
to?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
@@ -43,6 +45,8 @@ export class RunListPresenter {
|
||||
direction = "forward",
|
||||
cursor,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
from,
|
||||
to,
|
||||
}: RunListOptions) {
|
||||
const filterStatuses = filterStatus ? filterableStatuses[filterStatus] : undefined;
|
||||
|
||||
@@ -133,6 +137,10 @@ export class RunListPresenter {
|
||||
},
|
||||
status: filterStatuses ? { in: filterStatuses } : undefined,
|
||||
environment: filterEnvironment ? { type: filterEnvironment } : undefined,
|
||||
startedAt: {
|
||||
gte: from ? new Date(from).toISOString() : undefined,
|
||||
lte: to ? new Date(to).toISOString() : undefined,
|
||||
},
|
||||
},
|
||||
orderBy: [{ id: "desc" }],
|
||||
//take an extra record to tell if there are more
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { ErrorBoundary as HighlightErrorBoundary } from "@highlight-run/react";
|
||||
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import type { ShouldRevalidateFunction } from "@remix-run/react";
|
||||
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
|
||||
import {
|
||||
Links,
|
||||
LiveReload,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useMatches,
|
||||
} from "@remix-run/react";
|
||||
import { metaV1 } from "@remix-run/v1-meta";
|
||||
import { TypedMetaFunction, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ExternalScripts } from "remix-utils/external-scripts";
|
||||
@@ -16,6 +24,7 @@ import { env } from "./env.server";
|
||||
import { featuresForRequest } from "./features.server";
|
||||
import { useHighlight } from "./hooks/useHighlight";
|
||||
import { usePostHog } from "./hooks/usePostHog";
|
||||
import { useTypedMatchesData } from "./hooks/useTypedMatchData";
|
||||
import { getUser } from "./services/session.server";
|
||||
import { appEnvTitleTag } from "./utils";
|
||||
|
||||
@@ -28,14 +37,24 @@ export const meta: TypedMetaFunction<typeof loader> = (args) => {
|
||||
title: `Trigger.dev${appEnvTitleTag(args.data?.appEnv)}`,
|
||||
charset: "utf-8",
|
||||
viewport: "width=1024, initial-scale=1",
|
||||
robots: args.data.features.isManagedCloud ? "index, follow" : "noindex, nofollow",
|
||||
});
|
||||
};
|
||||
|
||||
export function useV3Enabled() {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: "root",
|
||||
});
|
||||
|
||||
return routeMatch?.v3Enabled ?? false;
|
||||
}
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
const toastMessage = session.get("toastMessage") as ToastMessage;
|
||||
const posthogProjectKey = env.POSTHOG_PROJECT_KEY;
|
||||
const highlightProjectId = env.HIGHLIGHT_PROJECT_ID;
|
||||
const v3Enabled = env.V3_ENABLED === "true";
|
||||
const features = featuresForRequest(request);
|
||||
|
||||
return typedjson(
|
||||
@@ -47,6 +66,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
features,
|
||||
appEnv: env.APP_ENV,
|
||||
appOrigin: env.APP_ORIGIN,
|
||||
v3Enabled,
|
||||
},
|
||||
{ headers: { "Set-Cookie": await commitSession(session) } }
|
||||
);
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function Page() {
|
||||
<div className="flex flex-col gap-5 rounded border border-border p-6">
|
||||
{hitsRunLimit && (
|
||||
<Callout
|
||||
variant={"pricing"}
|
||||
variant={"error"}
|
||||
cta={
|
||||
<LinkButton
|
||||
variant="primary/small"
|
||||
@@ -108,7 +108,7 @@ export default function Page() {
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
<Paragraph variant="small" className="text-white">
|
||||
You have exceeded the monthly{" "}
|
||||
{formatNumberCompact(currentPlan?.subscription?.limits.runs ?? 0)} runs limit.
|
||||
Upgrade to a paid plan before{" "}
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function Page() {
|
||||
</Callout>
|
||||
)}
|
||||
{hitRunLimit && (
|
||||
<Callout variant={"pricing"}>
|
||||
<Callout variant={"error"}>
|
||||
{`You have exceeded the monthly
|
||||
${formatNumberCompact(currentPlan!.subscription!.limits.runs!)} runs limit. Upgrade so you
|
||||
can continue to perform runs.`}
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function Integrations() {
|
||||
</PageHeader>
|
||||
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid h-full max-w-full grid-cols-[2fr_3fr] gap-4 divide-x divide-slate-900 overflow-hidden">
|
||||
<div className="grid h-full max-w-full grid-cols-[2fr_3fr] divide-x divide-slate-900 overflow-hidden">
|
||||
<PossibleIntegrationsList
|
||||
options={options}
|
||||
organizationId={organization.id}
|
||||
@@ -142,7 +142,7 @@ function PossibleIntegrationsList({
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<div className="py-4 pl-4">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Header2 className="mb-2">Connect an API</Header2>
|
||||
<Switch
|
||||
|
||||
+2
@@ -52,6 +52,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
organizationSlug,
|
||||
direction: searchParams.direction,
|
||||
cursor: searchParams.cursor,
|
||||
from: searchParams.from,
|
||||
to: searchParams.to,
|
||||
});
|
||||
|
||||
return typedjson({ event, list });
|
||||
|
||||
+2
@@ -37,6 +37,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
organizationSlug,
|
||||
direction: searchParams.direction,
|
||||
cursor: searchParams.cursor,
|
||||
from: searchParams.from,
|
||||
to: searchParams.to,
|
||||
pageSize: 25,
|
||||
});
|
||||
|
||||
|
||||
+2
@@ -41,6 +41,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
organizationSlug,
|
||||
direction: searchParams.direction,
|
||||
cursor: searchParams.cursor,
|
||||
from: searchParams.from,
|
||||
to: searchParams.to,
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
|
||||
+2
@@ -40,6 +40,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
direction: searchParams.direction,
|
||||
cursor: searchParams.cursor,
|
||||
pageSize: 25,
|
||||
from: searchParams.from,
|
||||
to: searchParams.to,
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { Form, useActionData } from "@remix-run/react";
|
||||
import { ActionFunction, json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Checkbox } from "~/components/primitives/Checkbox";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
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 { useUser } from "~/hooks/useUser";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { updateUser } from "~/models/user.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import { accountPath } from "~/utils/pathBuilder";
|
||||
|
||||
function createSchema(
|
||||
constraints: {
|
||||
isEmailUnique?: (email: string) => Promise<boolean>;
|
||||
} = {}
|
||||
) {
|
||||
return z.object({
|
||||
name: z
|
||||
.string({ required_error: "You must enter a name" })
|
||||
.min(2, "Your name must be at least 2 characters long")
|
||||
.max(50),
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.superRefine((email, ctx) => {
|
||||
if (constraints.isEmailUnique === 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.isEmailUnique(email).then((isUnique) => {
|
||||
if (isUnique) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Email is already being used by a different account",
|
||||
});
|
||||
});
|
||||
}
|
||||
}),
|
||||
marketingEmails: z.preprocess((value) => value === "on", z.boolean()),
|
||||
});
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const formSchema = createSchema({
|
||||
isEmailUnique: async (email) => {
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (existingUser.id === userId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const submission = await parse(formData, { schema: formSchema, async: true });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await updateUser({
|
||||
id: userId,
|
||||
name: submission.value.name,
|
||||
email: submission.value.email,
|
||||
marketingEmails: submission.value.marketingEmails,
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
accountPath(),
|
||||
request,
|
||||
"Your account profile has been updated."
|
||||
);
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
export const handle: Handle = {
|
||||
breadcrumb: (match) => {
|
||||
return <BreadcrumbLink to={match.pathname} title={"Profile"} />;
|
||||
},
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const user = useUser();
|
||||
const lastSubmission = useActionData();
|
||||
|
||||
const [form, { name, email, marketingEmails }] = useForm({
|
||||
id: "account",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: createSchema() });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitleRow>
|
||||
<PageTitle title="Your profile" />
|
||||
</PageTitleRow>
|
||||
</PageHeader>
|
||||
|
||||
<PageBody>
|
||||
<Form method="post" {...form.props} className="max-w-md">
|
||||
<InputGroup className="mb-4">
|
||||
<Label htmlFor={name.id}>Profile picture</Label>
|
||||
<UserProfilePhoto className="h-24 w-24" />
|
||||
</InputGroup>
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={name.id}>Full name</Label>
|
||||
<Input
|
||||
{...conform.input(name, { type: "text" })}
|
||||
placeholder="Your full name"
|
||||
defaultValue={user?.name ?? ""}
|
||||
icon="account"
|
||||
/>
|
||||
<Hint>Your teammates will see this</Hint>
|
||||
<FormError id={name.errorId}>{name.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label htmlFor={email.id}>Email address</Label>
|
||||
<Input
|
||||
{...conform.input(email, { type: "text" })}
|
||||
placeholder="Your email"
|
||||
defaultValue={user?.email ?? ""}
|
||||
icon="envelope"
|
||||
/>
|
||||
<FormError id={email.errorId}>{email.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label>Notifications</Label>
|
||||
<Checkbox
|
||||
id="marketingEmails"
|
||||
{...conform.input(marketingEmails, { type: "checkbox" })}
|
||||
label="Receive product updates"
|
||||
variant="simple/small"
|
||||
defaultChecked={user.marketingEmails}
|
||||
/>
|
||||
<FormError id={marketingEmails.errorId}>{marketingEmails.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"}>
|
||||
Update
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { title } from "process";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ErrorIcon } from "~/assets/icons/ErrorIcon";
|
||||
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Icon } from "~/components/primitives/Icon";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { createPersonalAccessTokenFromAuthorizationCode } from "~/services/personalAccessToken.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { rootPath } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
authorizationCode: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
logger.info("Invalid params", { params });
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Invalid params",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const personalAccessToken = await createPersonalAccessTokenFromAuthorizationCode(
|
||||
parsedParams.data.authorizationCode,
|
||||
userId
|
||||
);
|
||||
return typedjson({
|
||||
success: true as const,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return typedjson({
|
||||
success: false as const,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.error(JSON.stringify(error));
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const result = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<MainCenteredContainer className="max-w-[22rem]">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
{result.success ? (
|
||||
<div>
|
||||
<Header1 className="mb-2 flex items-center gap-1">
|
||||
<Icon icon={CheckCircleIcon} className="h-6 w-6 text-emerald-500" /> Successfully
|
||||
authenticated
|
||||
</Header1>
|
||||
<Paragraph>Return to your terminal to continue.</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Header1 className="mb-2">Authentication failed</Header1>
|
||||
<Callout variant="error" className="my-2">
|
||||
{result.error}
|
||||
</Callout>
|
||||
<Paragraph spacing>
|
||||
There was a problem authenticating you, please try logging in with your CLI again.
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MainCenteredContainer>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
|
||||
import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
|
||||
import { Form, useActionData, useFetcher } from "@remix-run/react";
|
||||
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
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 {
|
||||
PageButtons,
|
||||
PageDescription,
|
||||
PageHeader,
|
||||
PageTitle,
|
||||
PageTitleRow,
|
||||
} from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import {
|
||||
CreatedPersonalAccessToken,
|
||||
ObfuscatedPersonalAccessToken,
|
||||
createPersonalAccessToken,
|
||||
getValidPersonalAccessTokens,
|
||||
revokePersonalAccessToken,
|
||||
} from "~/services/personalAccessToken.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import { personalAccessTokensPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
try {
|
||||
const personalAccessTokens = await getValidPersonalAccessTokens(userId);
|
||||
|
||||
return typedjson({
|
||||
personalAccessTokens,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const CreateTokenSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("create"),
|
||||
tokenName: z
|
||||
.string({ required_error: "You must enter a name" })
|
||||
.min(2, "Your name must be at least 2 characters long")
|
||||
.max(50),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("revoke"),
|
||||
tokenId: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: CreateTokenSchema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "create": {
|
||||
try {
|
||||
const tokenResult = await createPersonalAccessToken({
|
||||
name: submission.value.tokenName,
|
||||
userId,
|
||||
});
|
||||
|
||||
return json({ ...submission, payload: { token: tokenResult } });
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
case "revoke": {
|
||||
try {
|
||||
await revokePersonalAccessToken(submission.value.tokenId);
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
personalAccessTokensPath(),
|
||||
request,
|
||||
"Personal Access Token revoked"
|
||||
);
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return json({ errors: { body: "Invalid action" } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const handle: Handle = {
|
||||
breadcrumb: (match) => {
|
||||
return <BreadcrumbLink to={match.pathname} title={"Personal Access Tokens"} />;
|
||||
},
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { personalAccessTokens } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitleRow>
|
||||
<PageTitle title="Personal Access Tokens" />
|
||||
<PageButtons>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="primary/small">Create new token</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Create a Personal Access Token</DialogHeader>
|
||||
<CreatePersonalAccessToken />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageButtons>
|
||||
</PageTitleRow>
|
||||
<PageDescription>Personal Access Tokens can be used with our CLI and API.</PageDescription>
|
||||
</PageHeader>
|
||||
|
||||
<PageBody>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Token</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Last accessed</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Delete</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{personalAccessTokens.length > 0 ? (
|
||||
personalAccessTokens.map((personalAccessToken) => {
|
||||
return (
|
||||
<TableRow key={personalAccessToken.id} className="group">
|
||||
<TableCell>{personalAccessToken.name}</TableCell>
|
||||
<TableCell>{personalAccessToken.obfuscatedToken}</TableCell>
|
||||
<TableCell>
|
||||
<DateTime date={personalAccessToken.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{personalAccessToken.lastAccessedAt ? (
|
||||
<DateTime date={personalAccessToken.lastAccessedAt} />
|
||||
) : (
|
||||
"Never"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<RevokePersonalAccessToken token={personalAccessToken} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={5}>
|
||||
<Paragraph variant="small" className="flex items-center justify-center">
|
||||
You have no Personal Access Tokens (that haven't been revoked).
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function CreatePersonalAccessToken() {
|
||||
const fetcher = useFetcher<typeof action>();
|
||||
const lastSubmission = fetcher.data as any;
|
||||
|
||||
const [form, { tokenName }] = useForm({
|
||||
id: "create-personal-access-token",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: CreateTokenSchema });
|
||||
},
|
||||
});
|
||||
|
||||
const token = lastSubmission?.payload?.token
|
||||
? (lastSubmission?.payload?.token as CreatedPersonalAccessToken)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="max-w-full overflow-x-hidden">
|
||||
{token ? (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<Header2>Successfully generated a new token</Header2>
|
||||
<Callout variant="success">
|
||||
Copy this access token and store it in a secure place - you will not be able to see it
|
||||
again.
|
||||
</Callout>
|
||||
<ClipboardField
|
||||
secure
|
||||
value={token.token}
|
||||
variant={"secondary/medium"}
|
||||
icon={<ShieldExclamationIcon className="h-5 w-5 text-emerald-500" />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<fetcher.Form method="post" {...form.props}>
|
||||
<input type="hidden" name="action" value="create" />
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={tokenName.id}>Name</Label>
|
||||
<Input
|
||||
{...conform.input(tokenName, { type: "text" })}
|
||||
placeholder="The name of your Personal Access Token"
|
||||
defaultValue=""
|
||||
icon={ShieldCheckIcon}
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
/>
|
||||
<Hint>
|
||||
This will help you to identify your token. Tokens called "cli" are automatically
|
||||
generated when you login with our CLI.
|
||||
</Hint>
|
||||
<FormError id={tokenName.errorId}>{tokenName.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"}>
|
||||
Update
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</fetcher.Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RevokePersonalAccessToken({ token }: { token: ObfuscatedPersonalAccessToken }) {
|
||||
const lastSubmission = useActionData();
|
||||
|
||||
const [form, { tokenId }] = useForm({
|
||||
id: "revoke-personal-access-token",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: CreateTokenSchema });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="small-menu-item" LeadingIcon="trash-can" className="text-xs" />
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Revoke Personal Access Token</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph>
|
||||
Are you sure you want to revoke "{token.name}"? This can't be reversed.
|
||||
</Paragraph>
|
||||
<Form method="post" {...form.props}>
|
||||
<input type="hidden" name="action" value="revoke" />
|
||||
<input type="hidden" name="tokenId" value={token.id} />
|
||||
<Button type="submit" variant="danger/medium" fullWidth>
|
||||
Revoke token
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +1,35 @@
|
||||
import { Form, useActionData } from "@remix-run/react";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
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 { Outlet } from "@remix-run/react";
|
||||
import { AppContainer } from "~/components/layout/AppLayout";
|
||||
import { AccountSideMenu } from "~/components/navigation/AccountSideMenu";
|
||||
import { Breadcrumb, BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { PageNavigationIndicator } from "~/components/navigation/PageNavigationIndicator";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { z } from "zod";
|
||||
import { ActionFunction, json, redirect } from "@remix-run/server-runtime";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { accountPath, rootPath } from "~/utils/pathBuilder";
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
|
||||
import { Checkbox } from "~/components/primitives/Checkbox";
|
||||
import { updateUser } from "~/models/user.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { Handle } from "~/utils/handle";
|
||||
|
||||
function createSchema(
|
||||
constraints: {
|
||||
isEmailUnique?: (email: string) => Promise<boolean>;
|
||||
} = {}
|
||||
) {
|
||||
return z.object({
|
||||
name: z
|
||||
.string({ required_error: "You must enter a name" })
|
||||
.min(2, "Your name must be at least 2 characters long")
|
||||
.max(50),
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.superRefine((email, ctx) => {
|
||||
if (constraints.isEmailUnique === 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.isEmailUnique(email).then((isUnique) => {
|
||||
if (isUnique) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Email is already being used by a different account",
|
||||
});
|
||||
});
|
||||
}
|
||||
}),
|
||||
marketingEmails: z.preprocess((value) => value === "on", z.boolean()),
|
||||
});
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const formSchema = createSchema({
|
||||
isEmailUnique: async (email) => {
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (existingUser.id === userId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const submission = await parse(formData, { schema: formSchema, async: true });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await updateUser({
|
||||
id: userId,
|
||||
name: submission.value.name,
|
||||
email: submission.value.email,
|
||||
marketingEmails: submission.value.marketingEmails,
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
accountPath(),
|
||||
request,
|
||||
"Your account profile has been updated."
|
||||
);
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
}
|
||||
export const handle: Handle = {
|
||||
breadcrumb: (match) => {
|
||||
return <BreadcrumbLink to={match.pathname} title={"Account"} />;
|
||||
},
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const user = useUser();
|
||||
const lastSubmission = useActionData();
|
||||
|
||||
const [form, { name, email, marketingEmails }] = useForm({
|
||||
id: "account",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: createSchema() });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<MainCenteredContainer>
|
||||
<FormTitle LeadingIcon="user" title="Profile" />
|
||||
<Form method="post" {...form.props} className="max-w-md">
|
||||
<InputGroup className="mb-4">
|
||||
<Label htmlFor={name.id}>Profile picture</Label>
|
||||
<UserProfilePhoto className="h-24 w-24" />
|
||||
</InputGroup>
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={name.id}>Full name</Label>
|
||||
<Input
|
||||
{...conform.input(name, { type: "text" })}
|
||||
placeholder="Your full name"
|
||||
defaultValue={user?.name ?? ""}
|
||||
icon="account"
|
||||
/>
|
||||
<Hint>Your teammates will see this</Hint>
|
||||
<FormError id={name.errorId}>{name.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label htmlFor={email.id}>Email address</Label>
|
||||
<Input
|
||||
{...conform.input(email, { type: "text" })}
|
||||
placeholder="Your email"
|
||||
defaultValue={user?.email ?? ""}
|
||||
icon="envelope"
|
||||
/>
|
||||
<FormError id={email.errorId}>{email.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label>Notifications</Label>
|
||||
<Checkbox
|
||||
id="marketingEmails"
|
||||
{...conform.input(marketingEmails, { type: "checkbox" })}
|
||||
label="Receive product updates"
|
||||
variant="simple/small"
|
||||
defaultChecked={user.marketingEmails}
|
||||
/>
|
||||
<FormError id={marketingEmails.errorId}>{marketingEmails.error}</FormError>
|
||||
</InputGroup>
|
||||
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
|
||||
<AccountSideMenu user={user} />
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"}>
|
||||
Update
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<LinkButton to={rootPath()} variant={"secondary/small"}>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</MainCenteredContainer>
|
||||
<div className="grid grid-rows-[2.25rem_1fr] overflow-hidden">
|
||||
<div className="flex w-full items-center justify-between border-b border-ui-border">
|
||||
<Breadcrumb />
|
||||
<div className="flex h-full items-center gap-4">
|
||||
<PageNavigationIndicator className="mr-2" />
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { CreateAuthorizationCodeResponse } from "@trigger.dev/core";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { createAuthorizationCode } from "~/services/personalAccessToken.server";
|
||||
|
||||
/** Used to create an AuthorizationCode, that can then be used to obtain a Personal Access Token by logging in with the provided URL */
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
logger.info("Creating AuthorizationCode", { url: request.url });
|
||||
|
||||
// Ensure this is a POST request
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
//there is no authentication on this endpoint, anyone can create an AuthorizationCode.
|
||||
//they're only used to allow a user to login, when they'll then receive a Personal Access Token
|
||||
|
||||
try {
|
||||
const authorizationCode = await createAuthorizationCode();
|
||||
const responseJson: CreateAuthorizationCodeResponse = {
|
||||
authorizationCode: authorizationCode.code,
|
||||
url: `${env.APP_ORIGIN}/account/authorization-code/${authorizationCode.code}`,
|
||||
};
|
||||
|
||||
return json(responseJson);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Error creating AuthorizationCode", {
|
||||
url: request.url,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
);
|
||||
|
||||
if (!run) {
|
||||
return json({ error: "Job count not be invoked" }, { status: 500 });
|
||||
return json({ error: "Job could not be invoked" }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ id: run.id });
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
GetPersonalAccessTokenRequestSchema,
|
||||
GetPersonalAccessTokenResponse,
|
||||
} from "@trigger.dev/core";
|
||||
import { generateErrorMessage } from "zod-error";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getPersonalAccessTokenFromAuthorizationCode } from "~/services/personalAccessToken.server";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
logger.info("Getting PersonalAccessToken from AuthorizationCode", { url: request.url });
|
||||
|
||||
// Ensure this is a POST request
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
//There is no authentication on this endpoint, anyone can create an AuthorizationCode.
|
||||
//But only a logged in user can create a PersonalAccessToken, so for a user who can't login to the app this will always fail.
|
||||
|
||||
// Now parse the request body
|
||||
const anyBody = await request.json();
|
||||
const body = GetPersonalAccessTokenRequestSchema.safeParse(anyBody);
|
||||
if (!body.success) {
|
||||
return json({ message: generateErrorMessage(body.error.issues) }, { status: 422 });
|
||||
}
|
||||
|
||||
try {
|
||||
const personalAccessToken = await getPersonalAccessTokenFromAuthorizationCode(
|
||||
body.data.authorizationCode
|
||||
);
|
||||
|
||||
const responseJson: GetPersonalAccessTokenResponse = {
|
||||
token: personalAccessToken.token,
|
||||
};
|
||||
return json(responseJson);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Error getting PersonalAccessToken from AuthorizationCode", {
|
||||
url: request.url,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { WhoAmIResponse } from "@trigger.dev/core";
|
||||
import { prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
logger.info("whoami v2", { url: request.url });
|
||||
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
where: {
|
||||
id: authenticationResult.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const result: WhoAmIResponse = {
|
||||
userId: authenticationResult.userId,
|
||||
email: user.email,
|
||||
};
|
||||
return json(result);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { customAlphabet, nanoid } from "nanoid";
|
||||
import nodeCrypto from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "./logger.server";
|
||||
import { PersonalAccessToken } from "@trigger.dev/database";
|
||||
|
||||
const tokenValueLength = 40;
|
||||
//lowercase only, removed 0 and l to avoid confusion
|
||||
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
|
||||
|
||||
type CreatePersonalAccessTokenOptions = {
|
||||
name: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
/** Returns obfuscated access tokens that aren't revoked */
|
||||
export async function getValidPersonalAccessTokens(userId: string) {
|
||||
const personalAccessTokens = await prisma.personalAccessToken.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
obfuscatedToken: true,
|
||||
createdAt: true,
|
||||
lastAccessedAt: true,
|
||||
},
|
||||
where: {
|
||||
userId,
|
||||
revokedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
return personalAccessTokens.map((pat) => ({
|
||||
id: pat.id,
|
||||
name: pat.name,
|
||||
obfuscatedToken: pat.obfuscatedToken,
|
||||
createdAt: pat.createdAt,
|
||||
lastAccessedAt: pat.lastAccessedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export type ObfuscatedPersonalAccessToken = Awaited<
|
||||
ReturnType<typeof getValidPersonalAccessTokens>
|
||||
>[number];
|
||||
|
||||
/** Gets a PersonalAccessToken from an Auth Code, this only works within 10 mins of the auth code being created */
|
||||
export async function getPersonalAccessTokenFromAuthorizationCode(authorizationCode: string) {
|
||||
//only allow authorization codes that were created less than 10 mins ago
|
||||
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
|
||||
const code = await prisma.authorizationCode.findUnique({
|
||||
select: {
|
||||
personalAccessToken: true,
|
||||
},
|
||||
where: {
|
||||
code: authorizationCode,
|
||||
|
||||
createdAt: {
|
||||
gte: tenMinutesAgo,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!code) {
|
||||
throw new Error("Invalid authorization code, or code expired");
|
||||
}
|
||||
|
||||
//there's no PersonalAccessToken associated with this code
|
||||
if (!code.personalAccessToken) {
|
||||
return {
|
||||
token: null,
|
||||
};
|
||||
}
|
||||
|
||||
const decryptedToken = decryptPersonalAccessToken(code.personalAccessToken);
|
||||
return {
|
||||
token: {
|
||||
token: decryptedToken,
|
||||
obfuscatedToken: code.personalAccessToken.obfuscatedToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function revokePersonalAccessToken(tokenId: string) {
|
||||
await prisma.personalAccessToken.update({
|
||||
where: {
|
||||
id: tokenId,
|
||||
},
|
||||
data: {
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type PersonalAccessTokenAuthenticationResult = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const EncryptedSecretValueSchema = z.object({
|
||||
nonce: z.string(),
|
||||
ciphertext: z.string(),
|
||||
tag: z.string(),
|
||||
});
|
||||
|
||||
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
|
||||
|
||||
export async function authenticateApiRequestWithPersonalAccessToken(
|
||||
request: Request
|
||||
): Promise<PersonalAccessTokenAuthenticationResult | undefined> {
|
||||
const token = getPersonalAccessTokenFromRequest(request);
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
return authenticatePersonalAccessToken(token);
|
||||
}
|
||||
|
||||
function getPersonalAccessTokenFromRequest(request: Request) {
|
||||
const rawAuthorization = request.headers.get("Authorization");
|
||||
|
||||
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
|
||||
if (!authorization.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const personalAccessToken = authorization.data.replace(/^Bearer /, "");
|
||||
return personalAccessToken;
|
||||
}
|
||||
|
||||
export async function authenticatePersonalAccessToken(
|
||||
token: string
|
||||
): Promise<PersonalAccessTokenAuthenticationResult | undefined> {
|
||||
if (!token.startsWith(tokenPrefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hashedToken = hashToken(token);
|
||||
|
||||
const personalAccessToken = await prisma.personalAccessToken.update({
|
||||
where: {
|
||||
hashedToken,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: {
|
||||
lastAccessedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!personalAccessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decryptedToken = decryptPersonalAccessToken(personalAccessToken);
|
||||
|
||||
if (decryptedToken !== token) {
|
||||
logger.error(
|
||||
`PersonalAccessToken with id: ${personalAccessToken.id} was found in the database with hash ${hashedToken}, but the decrypted token did not match the provided token.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: personalAccessToken.userId,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAuthorizationCode() {
|
||||
return prisma.authorizationCode.create({
|
||||
data: {
|
||||
code: nanoid(64),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates a PersonalAccessToken from an Auth Code, and return the token. We only ever return the unencrypted token once. */
|
||||
export async function createPersonalAccessTokenFromAuthorizationCode(
|
||||
authorizationCode: string,
|
||||
userId: string
|
||||
) {
|
||||
//only allow authorization codes that were created less than 10 mins ago
|
||||
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
|
||||
const code = await prisma.authorizationCode.findUnique({
|
||||
where: {
|
||||
code: authorizationCode,
|
||||
personalAccessTokenId: null,
|
||||
createdAt: {
|
||||
gte: tenMinutesAgo,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!code) {
|
||||
throw new Error("Invalid authorization code, code already used, or code expired");
|
||||
}
|
||||
|
||||
const existingCliPersonalAccessToken = await prisma.personalAccessToken.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
name: "cli",
|
||||
},
|
||||
});
|
||||
|
||||
//we only allow you to have one CLI PAT at a time
|
||||
if (existingCliPersonalAccessToken) {
|
||||
await prisma.personalAccessToken.delete({
|
||||
where: {
|
||||
id: existingCliPersonalAccessToken.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const token = await createPersonalAccessToken({
|
||||
name: "cli",
|
||||
userId,
|
||||
});
|
||||
|
||||
await prisma.authorizationCode.update({
|
||||
where: {
|
||||
code: authorizationCode,
|
||||
},
|
||||
data: {
|
||||
personalAccessTokenId: token.id,
|
||||
},
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Created a new PersonalAccessToken, and return the token. We only ever return the unencrypted token once. */
|
||||
export async function createPersonalAccessToken({
|
||||
name,
|
||||
userId,
|
||||
}: CreatePersonalAccessTokenOptions) {
|
||||
const token = createToken();
|
||||
const encryptedToken = encryptToken(token);
|
||||
|
||||
const personalAccessToken = await prisma.personalAccessToken.create({
|
||||
data: {
|
||||
name,
|
||||
userId,
|
||||
encryptedToken,
|
||||
obfuscatedToken: obfuscateToken(token),
|
||||
hashedToken: hashToken(token),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: personalAccessToken.id,
|
||||
name,
|
||||
userId,
|
||||
token,
|
||||
obfuscatedToken: personalAccessToken.obfuscatedToken,
|
||||
};
|
||||
}
|
||||
|
||||
export type CreatedPersonalAccessToken = Awaited<ReturnType<typeof createPersonalAccessToken>>;
|
||||
|
||||
const tokenPrefix = "tr_pat_";
|
||||
|
||||
/** Creates a PersonalAccessToken that starts with tr_pat_ */
|
||||
function createToken() {
|
||||
return `${tokenPrefix}${tokenGenerator()}`;
|
||||
}
|
||||
|
||||
/** Obfuscates all but the first and last 4 characters of the token, so it looks like tr_pat_bhbd•••••••••••••••••••fd4a */
|
||||
function obfuscateToken(token: string) {
|
||||
const withoutPrefix = token.replace(tokenPrefix, "");
|
||||
const obfuscated = `${withoutPrefix.slice(0, 4)}${"•".repeat(18)}${withoutPrefix.slice(-4)}`;
|
||||
return `${tokenPrefix}${obfuscated}`;
|
||||
}
|
||||
|
||||
function encryptToken(value: string) {
|
||||
const nonce = nodeCrypto.randomBytes(12);
|
||||
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", env.ENCRYPTION_KEY, nonce);
|
||||
|
||||
let encrypted = cipher.update(value, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
|
||||
return {
|
||||
nonce: nonce.toString("hex"),
|
||||
ciphertext: encrypted,
|
||||
tag,
|
||||
};
|
||||
}
|
||||
|
||||
function decryptPersonalAccessToken(personalAccessToken: PersonalAccessToken) {
|
||||
const encryptedData = EncryptedSecretValueSchema.safeParse(personalAccessToken.encryptedToken);
|
||||
if (!encryptedData.success) {
|
||||
throw new Error(
|
||||
`Unable to parse encrypted PersonalAccessToken with id: ${personalAccessToken.id}: ${encryptedData.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
const decryptedToken = decryptToken(
|
||||
encryptedData.data.nonce,
|
||||
encryptedData.data.ciphertext,
|
||||
encryptedData.data.tag
|
||||
);
|
||||
return decryptedToken;
|
||||
}
|
||||
|
||||
function decryptToken(nonce: string, ciphertext: string, tag: string): string {
|
||||
const decipher = nodeCrypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
env.ENCRYPTION_KEY,
|
||||
Buffer.from(nonce, "hex")
|
||||
);
|
||||
|
||||
decipher.setAuthTag(Buffer.from(tag, "hex"));
|
||||
|
||||
let decrypted = decipher.update(ciphertext, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
const hash = nodeCrypto.createHash("sha256");
|
||||
hash.update(token);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { $transaction, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import type { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { logger } from "../logger.server";
|
||||
|
||||
export class CreateRunService {
|
||||
#prismaClient: PrismaClientOrTransaction;
|
||||
@@ -25,6 +26,11 @@ export class CreateRunService {
|
||||
},
|
||||
options: { callbackUrl?: string } = {}
|
||||
) {
|
||||
if (!environment.organization.runsEnabled) {
|
||||
logger.debug("Runs are disabled for this organization", environment);
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = await this.#prismaClient.endpoint.findUniqueOrThrow({
|
||||
where: {
|
||||
id: version.endpointId,
|
||||
|
||||
@@ -103,11 +103,11 @@ function validateSchedule(schedule: ScheduleMetadata): ScheduleMetadata {
|
||||
}
|
||||
|
||||
function validateInterval(schedule: IntervalMetadata): ScheduleMetadata {
|
||||
if (schedule.options.seconds < 60) {
|
||||
if (schedule.options.seconds < 20) {
|
||||
return {
|
||||
type: "interval",
|
||||
options: {
|
||||
seconds: 60,
|
||||
seconds: 20,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ export function accountPath() {
|
||||
return `/account`;
|
||||
}
|
||||
|
||||
export function personalAccessTokensPath() {
|
||||
return `/account/tokens`;
|
||||
}
|
||||
|
||||
export function invitesPath() {
|
||||
return `/invites`;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"@heroicons/react": "^2.0.12",
|
||||
"@highlight-run/node": "^3.1.0",
|
||||
"@highlight-run/react": "^3.2.0",
|
||||
"@internationalized/date": "^3.5.1",
|
||||
"@lezer/highlight": "^1.1.6",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.3",
|
||||
@@ -57,6 +58,9 @@
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.5",
|
||||
"@react-aria/datepicker": "^3.9.1",
|
||||
"@react-stately/datepicker": "^3.9.1",
|
||||
"@react-types/datepicker": "^3.7.1",
|
||||
"@remix-run/express": "2.1.0",
|
||||
"@remix-run/node": "2.1.0",
|
||||
"@remix-run/react": "2.1.0",
|
||||
@@ -66,8 +70,8 @@
|
||||
"@tabler/icons-react": "^2.39.0",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@team-plain/typescript-sdk": "^3.5.0",
|
||||
"@trigger.dev/companyicons": "^1.5.35",
|
||||
"@trigger.dev/billing": "^1.0.10",
|
||||
"@trigger.dev/companyicons": "^1.5.35",
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"@trigger.dev/core-backend": "workspace:*",
|
||||
"@trigger.dev/database": "workspace:*",
|
||||
@@ -105,8 +109,10 @@
|
||||
"prismjs": "^1.29.0",
|
||||
"random-words": "^2.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-aria": "^3.31.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-stately": "^3.29.1",
|
||||
"react-use": "^17.4.0",
|
||||
"recharts": "^2.8.0",
|
||||
"remix-auth": "^3.6.0",
|
||||
@@ -172,6 +178,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.6",
|
||||
"@typescript-eslint/parser": "^5.59.6",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"datepicker": "link:@types/@react-aria/datepicker",
|
||||
"esbuild": "^0.15.10",
|
||||
"eslint": "^8.24.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
@@ -193,4 +200,4 @@
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ You can add a step to the GitHub Action that deploys your app.
|
||||
```yaml .github/workflows/release.yml
|
||||
- name: 🚀 Refresh Trigger.dev Jobs
|
||||
env:
|
||||
DEPLOY_TEST_HOOK: ${{ secrets.TRIGGER_ENDPOINT_HOOK }}
|
||||
TRIGGER_ENDPOINT_HOOK: ${{ secrets.TRIGGER_ENDPOINT_HOOK }}
|
||||
run: |
|
||||
curl -X POST $TRIGGER_ENDPOINT_HOOK
|
||||
```
|
||||
|
||||
@@ -64,7 +64,7 @@ client.defineJob({
|
||||
name: "Example Job",
|
||||
version: "0.1.0",
|
||||
trigger: eventTrigger({ name: "example.event" }),
|
||||
enabled: process.env.TRIGGER_JOBS_DISABLED === "true",
|
||||
enabled: process.env.TRIGGER_JOBS_DISABLED !== "true",
|
||||
run: async (payload, io, ctx) => {
|
||||
// your Job code here
|
||||
},
|
||||
|
||||
@@ -26,13 +26,22 @@ Send an email to a recipient with a text payload. [Official Resend Docs](https:/
|
||||
|
||||
## Tasks
|
||||
|
||||
| Function Name | Description |
|
||||
| --------------- | -------------------------------- |
|
||||
| `emails.send` | Send an email |
|
||||
| `emails.create` | Create an email |
|
||||
| `emails.get` | Get an email |
|
||||
| `batch.send` | Send a batch of emails at once |
|
||||
| `batch.create` | Create a batch of emails at once |
|
||||
| Function Name | Description |
|
||||
| ------------------ | -------------------------------- |
|
||||
| `emails.send` | Send an email |
|
||||
| `emails.create` | Create an email |
|
||||
| `emails.get` | Get an email |
|
||||
| `batch.send` | Send a batch of emails at once |
|
||||
| `batch.create` | Create a batch of emails at once |
|
||||
| `audiences.create` | Create an audience |
|
||||
| `audiences.get` | Get an audience |
|
||||
| `audiences.remove` | Remove an audience |
|
||||
| `audiences.list` | List audiences |
|
||||
| `contacts.create` | Create a contact |
|
||||
| `contacts.get` | Get a contact |
|
||||
| `contacts.update` | Update a contact |
|
||||
| `contacts.remove` | Remove a contact |
|
||||
| `contacts.list` | List contacts |
|
||||
|
||||
## Example
|
||||
|
||||
|
||||
+1
-1
@@ -436,7 +436,7 @@
|
||||
},
|
||||
"analytics": {
|
||||
"posthog": {
|
||||
"apiKey": "phc_hwYmedO564b3Ik8nhA4Csrb5SueY0EwFJWCbseGwWW"
|
||||
"apiKey": "phc_9aSDbJCaDUMdZdHxxMPTvcj7A9fsl3mCgM1RBPmPsl7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ description: "`intervalTrigger()` is set as a [Job's trigger](/sdk/job) to trigg
|
||||
Intervals are set with a number of seconds. There are some important considerations:
|
||||
|
||||
- The Job will first run the specified number of seconds after it has first connected to an [Environment](/documentation/concepts/environments-endpoints). This will happen when you first [deploy](/documentation/guides/deployment) that Job.
|
||||
- The minimum interval is 60 seconds (any input less than this it will default to 60).
|
||||
- The minimum interval is 20 seconds (any input less than this it will default to 20).
|
||||
- The maximum interval is 2_592_000 seconds (30 days), if you pass more than this it will trigger every 30 days.
|
||||
|
||||
If you wish to Run a Job at an exact time or less frequently than once pr day you should use a [cronTrigger()](/sdk/crontrigger) instead.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "logger"
|
||||
description: "Used to send log messages to the [Run log](/documentation/guides/viewing-runs)."
|
||||
description: "Used to send log messages to the [Run log](/docs/documentation/guides/viewing-runs)."
|
||||
---
|
||||
|
||||
There are 5 levels, that you can use to log messages.
|
||||
|
||||
@@ -207,7 +207,7 @@ client.defineJob({
|
||||
}
|
||||
},
|
||||
{ name: "On Error" },
|
||||
onError: (error, task) => {
|
||||
(error, task) => {
|
||||
//retry the task in 5 minutes
|
||||
return {
|
||||
retryAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @trigger.dev/airtable
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c4047cf]
|
||||
- @trigger.dev/sdk@2.3.15
|
||||
- @trigger.dev/integration-kit@2.3.15
|
||||
|
||||
## 2.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.14
|
||||
- @trigger.dev/sdk@2.3.14
|
||||
|
||||
## 2.3.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a93b554f]
|
||||
- Updated dependencies [0f342cd1]
|
||||
- @trigger.dev/sdk@2.3.13
|
||||
- @trigger.dev/integration-kit@2.3.13
|
||||
|
||||
## 2.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [129f023d]
|
||||
- Updated dependencies [38f5a903]
|
||||
- Updated dependencies [ff4ff869]
|
||||
- @trigger.dev/sdk@2.3.12
|
||||
- @trigger.dev/integration-kit@2.3.12
|
||||
|
||||
## 2.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/airtable",
|
||||
"version": "2.3.11",
|
||||
"version": "2.3.16",
|
||||
"description": "Trigger.dev integration for airtable",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -25,8 +25,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.11",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.11",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.16",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.16",
|
||||
"airtable": "^0.12.1",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @trigger.dev/github
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c4047cf]
|
||||
- @trigger.dev/sdk@2.3.15
|
||||
- @trigger.dev/integration-kit@2.3.15
|
||||
|
||||
## 2.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.14
|
||||
- @trigger.dev/sdk@2.3.14
|
||||
|
||||
## 2.3.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a93b554f]
|
||||
- Updated dependencies [0f342cd1]
|
||||
- @trigger.dev/sdk@2.3.13
|
||||
- @trigger.dev/integration-kit@2.3.13
|
||||
|
||||
## 2.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [129f023d]
|
||||
- Updated dependencies [38f5a903]
|
||||
- Updated dependencies [ff4ff869]
|
||||
- @trigger.dev/sdk@2.3.12
|
||||
- @trigger.dev/integration-kit@2.3.12
|
||||
|
||||
## 2.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/github",
|
||||
"version": "2.3.11",
|
||||
"version": "2.3.16",
|
||||
"description": "The official GitHub integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -30,8 +30,8 @@
|
||||
"@octokit/request-error": "^5.0.1",
|
||||
"@octokit/webhooks": "^12.0.10",
|
||||
"octokit": "^3.1.2",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.11",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.11",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.16",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.16",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @trigger.dev/linear
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c4047cf]
|
||||
- @trigger.dev/sdk@2.3.15
|
||||
- @trigger.dev/integration-kit@2.3.15
|
||||
|
||||
## 2.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.14
|
||||
- @trigger.dev/sdk@2.3.14
|
||||
|
||||
## 2.3.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a93b554f]
|
||||
- Updated dependencies [0f342cd1]
|
||||
- @trigger.dev/sdk@2.3.13
|
||||
- @trigger.dev/integration-kit@2.3.13
|
||||
|
||||
## 2.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [129f023d]
|
||||
- Updated dependencies [38f5a903]
|
||||
- Updated dependencies [ff4ff869]
|
||||
- @trigger.dev/sdk@2.3.12
|
||||
- @trigger.dev/integration-kit@2.3.12
|
||||
|
||||
## 2.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/linear",
|
||||
"version": "2.3.11",
|
||||
"version": "2.3.16",
|
||||
"description": "Trigger.dev integration for @linear/sdk",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -26,8 +26,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@linear/sdk": "^8.0.0",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.11",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.11",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.16",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.16",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @trigger.dev/slack
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c4047cf]
|
||||
- @trigger.dev/sdk@2.3.15
|
||||
- @trigger.dev/integration-kit@2.3.15
|
||||
|
||||
## 2.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.14
|
||||
- @trigger.dev/sdk@2.3.14
|
||||
|
||||
## 2.3.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a93b554f]
|
||||
- Updated dependencies [0f342cd1]
|
||||
- @trigger.dev/sdk@2.3.13
|
||||
- @trigger.dev/integration-kit@2.3.13
|
||||
|
||||
## 2.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [129f023d]
|
||||
- Updated dependencies [38f5a903]
|
||||
- Updated dependencies [ff4ff869]
|
||||
- @trigger.dev/sdk@2.3.12
|
||||
- @trigger.dev/integration-kit@2.3.12
|
||||
|
||||
## 2.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/openai",
|
||||
"version": "2.3.11",
|
||||
"version": "2.3.16",
|
||||
"description": "The official OpenAI integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -42,8 +42,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"openai": "^4.16.1",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.11",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.11"
|
||||
"@trigger.dev/sdk": "workspace:^2.3.16",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @trigger.dev/plain
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c4047cf]
|
||||
- @trigger.dev/sdk@2.3.15
|
||||
- @trigger.dev/integration-kit@2.3.15
|
||||
|
||||
## 2.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.14
|
||||
- @trigger.dev/sdk@2.3.14
|
||||
|
||||
## 2.3.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a93b554f]
|
||||
- Updated dependencies [0f342cd1]
|
||||
- @trigger.dev/sdk@2.3.13
|
||||
- @trigger.dev/integration-kit@2.3.13
|
||||
|
||||
## 2.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [129f023d]
|
||||
- Updated dependencies [38f5a903]
|
||||
- Updated dependencies [ff4ff869]
|
||||
- @trigger.dev/sdk@2.3.12
|
||||
- @trigger.dev/integration-kit@2.3.12
|
||||
|
||||
## 2.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/plain",
|
||||
"version": "2.3.11",
|
||||
"version": "2.3.16",
|
||||
"description": "The official Plain.com integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -24,8 +24,8 @@
|
||||
"build:tsup": "tsup"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.11",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.11",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.16",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.16",
|
||||
"@team-plain/typescript-sdk": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @trigger.dev/replicate
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c4047cf]
|
||||
- @trigger.dev/sdk@2.3.15
|
||||
- @trigger.dev/integration-kit@2.3.15
|
||||
|
||||
## 2.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.14
|
||||
- @trigger.dev/sdk@2.3.14
|
||||
|
||||
## 2.3.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a93b554f]
|
||||
- Updated dependencies [0f342cd1]
|
||||
- @trigger.dev/sdk@2.3.13
|
||||
- @trigger.dev/integration-kit@2.3.13
|
||||
|
||||
## 2.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [129f023d]
|
||||
- Updated dependencies [38f5a903]
|
||||
- Updated dependencies [ff4ff869]
|
||||
- @trigger.dev/sdk@2.3.12
|
||||
- @trigger.dev/integration-kit@2.3.12
|
||||
|
||||
## 2.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/replicate",
|
||||
"version": "2.3.11",
|
||||
"version": "2.3.16",
|
||||
"description": "Trigger.dev integration for replicate",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -25,8 +25,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.11",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.11",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.16",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.16",
|
||||
"replicate": "^0.18.1",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
# @trigger.dev/resend
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- da90ee13: Fix resend integration example comment
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c4047cf]
|
||||
- @trigger.dev/sdk@2.3.15
|
||||
- @trigger.dev/integration-kit@2.3.15
|
||||
|
||||
## 2.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4dd6cf18: Add support for the new Resend audience API endpoints
|
||||
- @trigger.dev/integration-kit@2.3.14
|
||||
- @trigger.dev/sdk@2.3.14
|
||||
|
||||
## 2.3.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a93b554f]
|
||||
- Updated dependencies [0f342cd1]
|
||||
- @trigger.dev/sdk@2.3.13
|
||||
- @trigger.dev/integration-kit@2.3.13
|
||||
|
||||
## 2.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [129f023d]
|
||||
- Updated dependencies [38f5a903]
|
||||
- Updated dependencies [ff4ff869]
|
||||
- @trigger.dev/sdk@2.3.12
|
||||
- @trigger.dev/integration-kit@2.3.12
|
||||
|
||||
## 2.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/resend",
|
||||
"version": "2.3.11",
|
||||
"version": "2.3.16",
|
||||
"description": "The official Resend.com integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -24,9 +24,9 @@
|
||||
"build:tsup": "tsup"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.11",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.11",
|
||||
"resend": "^2.0.0"
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.16",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.16",
|
||||
"resend": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { IntegrationTaskKey, retry } from "@trigger.dev/sdk";
|
||||
import type { ResendRunTask } from "./index";
|
||||
import { Resend } from "resend";
|
||||
import { handleResendError } from "./utils";
|
||||
|
||||
type CreateAudienceResult = NonNullable<Awaited<ReturnType<Resend["audiences"]["create"]>>["data"]>;
|
||||
type GetAudienceResult = NonNullable<Awaited<ReturnType<Resend["audiences"]["get"]>>["data"]>;
|
||||
type DeleteAudienceResult = NonNullable<Awaited<ReturnType<Resend["audiences"]["remove"]>>["data"]>;
|
||||
type ListAudiencesResult = NonNullable<Awaited<ReturnType<Resend["audiences"]["list"]>>["data"]>;
|
||||
|
||||
export class Audiences {
|
||||
constructor(private runTask: ResendRunTask) {}
|
||||
|
||||
create(
|
||||
key: IntegrationTaskKey,
|
||||
payload: Parameters<Resend["audiences"]["create"]>[0],
|
||||
options?: Parameters<Resend["audiences"]["create"]>[1]
|
||||
): Promise<CreateAudienceResult> {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
const { error, data } = await client.audiences.create(payload, options);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("No data returned from Resend");
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
name: "Create Audience",
|
||||
params: payload,
|
||||
properties: [
|
||||
{
|
||||
label: "Name",
|
||||
text: payload.name,
|
||||
},
|
||||
],
|
||||
retry: retry.standardBackoff,
|
||||
},
|
||||
handleResendError
|
||||
);
|
||||
}
|
||||
|
||||
get(key: IntegrationTaskKey, payload: string): Promise<GetAudienceResult> {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
const { error, data } = await client.audiences.get(payload);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("No data returned from Resend");
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
name: "Get Audience",
|
||||
params: payload,
|
||||
properties: [
|
||||
{
|
||||
label: "ID",
|
||||
text: payload,
|
||||
},
|
||||
],
|
||||
retry: retry.standardBackoff,
|
||||
},
|
||||
handleResendError
|
||||
);
|
||||
}
|
||||
|
||||
remove(key: IntegrationTaskKey, payload: string): Promise<DeleteAudienceResult> {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
const { error, data } = await client.audiences.remove(payload);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("No data returned from Resend");
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
name: "Remove Audience",
|
||||
params: payload,
|
||||
properties: [
|
||||
{
|
||||
label: "ID",
|
||||
text: payload,
|
||||
},
|
||||
],
|
||||
retry: retry.standardBackoff,
|
||||
},
|
||||
handleResendError
|
||||
);
|
||||
}
|
||||
|
||||
list(key: IntegrationTaskKey): Promise<ListAudiencesResult> {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
const { error, data } = await client.audiences.list();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("No data returned from Resend");
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
name: "List Audiences",
|
||||
retry: retry.standardBackoff,
|
||||
},
|
||||
handleResendError
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { IntegrationTaskKey, retry } from "@trigger.dev/sdk";
|
||||
import type { ResendRunTask } from "./index";
|
||||
import { Resend } from "resend";
|
||||
import { handleResendError } from "./utils";
|
||||
|
||||
type CreateContactResult = NonNullable<Awaited<ReturnType<Resend["contacts"]["create"]>>["data"]>;
|
||||
type GetContactResult = NonNullable<Awaited<ReturnType<Resend["contacts"]["get"]>>["data"]>;
|
||||
type UpdateContactResult = NonNullable<Awaited<ReturnType<Resend["contacts"]["update"]>>["data"]>;
|
||||
type DeleteContactResult = NonNullable<Awaited<ReturnType<Resend["contacts"]["remove"]>>["data"]>;
|
||||
type ListContactsResult = NonNullable<Awaited<ReturnType<Resend["contacts"]["list"]>>["data"]>;
|
||||
|
||||
export class Contacts {
|
||||
constructor(private runTask: ResendRunTask) {}
|
||||
|
||||
create(
|
||||
key: IntegrationTaskKey,
|
||||
payload: Parameters<Resend["contacts"]["create"]>[0],
|
||||
options?: Parameters<Resend["contacts"]["create"]>[1]
|
||||
): Promise<CreateContactResult> {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
const { error, data } = await client.contacts.create(payload, options);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("No data returned from Resend");
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
name: "Create Contact",
|
||||
params: payload,
|
||||
properties: [
|
||||
{
|
||||
label: "Email",
|
||||
text: payload.email,
|
||||
},
|
||||
...(payload.first_name && payload.last_name
|
||||
? [{ label: "Name", text: payload.first_name + " " + payload.last_name }]
|
||||
: []),
|
||||
],
|
||||
retry: retry.standardBackoff,
|
||||
},
|
||||
handleResendError
|
||||
);
|
||||
}
|
||||
|
||||
get(
|
||||
key: IntegrationTaskKey,
|
||||
payload: Parameters<Resend["contacts"]["get"]>[0]
|
||||
): Promise<GetContactResult> {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
const { error, data } = await client.contacts.get(payload);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("No data returned from Resend");
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
name: "Get Contact",
|
||||
params: payload,
|
||||
properties: [
|
||||
{
|
||||
label: "Id",
|
||||
text: payload.id,
|
||||
},
|
||||
],
|
||||
retry: retry.standardBackoff,
|
||||
},
|
||||
handleResendError
|
||||
);
|
||||
}
|
||||
|
||||
update(
|
||||
key: IntegrationTaskKey,
|
||||
payload: Parameters<Resend["contacts"]["update"]>[0]
|
||||
): Promise<UpdateContactResult> {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
const { error, data } = await client.contacts.update(payload);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("No data returned from Resend");
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
name: "Update Contact",
|
||||
params: payload,
|
||||
properties: [
|
||||
{
|
||||
label: "Id",
|
||||
text: payload.id,
|
||||
},
|
||||
],
|
||||
retry: retry.standardBackoff,
|
||||
},
|
||||
handleResendError
|
||||
);
|
||||
}
|
||||
|
||||
remove(
|
||||
key: IntegrationTaskKey,
|
||||
payload: Parameters<Resend["contacts"]["remove"]>[0]
|
||||
): Promise<DeleteContactResult> {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
const { error, data } = await client.contacts.remove(payload);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("No data returned from Resend");
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
name: "Remove Contact",
|
||||
params: payload,
|
||||
properties: [
|
||||
{
|
||||
label: "Id",
|
||||
text: payload.id,
|
||||
},
|
||||
],
|
||||
retry: retry.standardBackoff,
|
||||
},
|
||||
handleResendError
|
||||
);
|
||||
}
|
||||
|
||||
list(
|
||||
key: IntegrationTaskKey,
|
||||
payload: Parameters<Resend["contacts"]["list"]>[0]
|
||||
): Promise<ListContactsResult> {
|
||||
return this.runTask(
|
||||
key,
|
||||
async (client, task) => {
|
||||
const { error, data } = await client.contacts.list(payload);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("No data returned from Resend");
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
name: "List Contacts",
|
||||
params: payload,
|
||||
properties: [
|
||||
{
|
||||
label: "Audience Id",
|
||||
text: payload.audience_id,
|
||||
},
|
||||
],
|
||||
retry: retry.standardBackoff,
|
||||
},
|
||||
handleResendError
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import type { ResendRunTask } from "./index";
|
||||
import { Resend } from "resend";
|
||||
import { handleResendError } from "./utils";
|
||||
|
||||
type SendEmailResult = NonNullable<Awaited<ReturnType<Resend["emails"]["send"]>>["data"]>;
|
||||
type CreateEmailResult = NonNullable<Awaited<ReturnType<Resend["emails"]["create"]>>["data"]>;
|
||||
type GetEmailResult = NonNullable<Awaited<ReturnType<Resend["emails"]["get"]>>["data"]>;
|
||||
export type SendEmailResult = NonNullable<Awaited<ReturnType<Resend["emails"]["send"]>>["data"]>;
|
||||
export type CreateEmailResult = NonNullable<
|
||||
Awaited<ReturnType<Resend["emails"]["create"]>>["data"]
|
||||
>;
|
||||
export type GetEmailResult = NonNullable<Awaited<ReturnType<Resend["emails"]["get"]>>["data"]>;
|
||||
|
||||
export class Emails {
|
||||
constructor(private runTask: ResendRunTask) {}
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
type TriggerIntegration,
|
||||
} from "@trigger.dev/sdk";
|
||||
import { Resend as ResendClient } from "resend";
|
||||
import { Emails } from "./emails";
|
||||
import { Emails, SendEmailResult } from "./emails";
|
||||
import { Batch } from "./batch";
|
||||
import { Contacts } from "./contacts";
|
||||
import { Audiences } from "./audiences";
|
||||
|
||||
type ErrorResponse = {
|
||||
statusCode: number;
|
||||
@@ -154,7 +156,39 @@ export class Resend implements TriggerIntegration {
|
||||
/**
|
||||
* @deprecated Please use resend.emails.send instead
|
||||
*/
|
||||
async sendEmail(...args: Parameters<typeof this.emails.send>) {
|
||||
async sendEmail(...args: Parameters<typeof this.emails.send>): Promise<SendEmailResult> {
|
||||
return this.emails.send(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the Resend Audiences API
|
||||
* @example
|
||||
* ```ts
|
||||
* const response = await io.resend.audiences.create("📧", {
|
||||
* name: payload.name
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
get audiences() {
|
||||
return new Audiences(this.runTask.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the Resend Contacts API
|
||||
* @example
|
||||
* ```ts
|
||||
* const response = await io.resend.contacts.create("📧", {
|
||||
* email: payload.email,
|
||||
* first_name: payload.first_name,
|
||||
* last_name: payload.last_name,
|
||||
* unsubscribed: payload.unsubscribed,
|
||||
* audience_id: payload.audience_id
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
get contacts() {
|
||||
return new Contacts(this.runTask.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @trigger.dev/sendgrid
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c4047cf]
|
||||
- @trigger.dev/sdk@2.3.15
|
||||
- @trigger.dev/integration-kit@2.3.15
|
||||
|
||||
## 2.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.14
|
||||
- @trigger.dev/sdk@2.3.14
|
||||
|
||||
## 2.3.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a93b554f]
|
||||
- Updated dependencies [0f342cd1]
|
||||
- @trigger.dev/sdk@2.3.13
|
||||
- @trigger.dev/integration-kit@2.3.13
|
||||
|
||||
## 2.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [129f023d]
|
||||
- Updated dependencies [38f5a903]
|
||||
- Updated dependencies [ff4ff869]
|
||||
- @trigger.dev/sdk@2.3.12
|
||||
- @trigger.dev/integration-kit@2.3.12
|
||||
|
||||
## 2.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/sendgrid",
|
||||
"version": "2.3.11",
|
||||
"version": "2.3.16",
|
||||
"description": "Trigger.dev integration for @sendgrid/mail",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -26,8 +26,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sendgrid/mail": "^7.7.0",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.11",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.11"
|
||||
"@trigger.dev/sdk": "workspace:^2.3.16",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.8.0"
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @trigger.dev/shopify
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c4047cf]
|
||||
- @trigger.dev/sdk@2.3.15
|
||||
- @trigger.dev/integration-kit@2.3.15
|
||||
|
||||
## 2.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.14
|
||||
- @trigger.dev/sdk@2.3.14
|
||||
|
||||
## 2.3.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a93b554f]
|
||||
- Updated dependencies [0f342cd1]
|
||||
- @trigger.dev/sdk@2.3.13
|
||||
- @trigger.dev/integration-kit@2.3.13
|
||||
|
||||
## 2.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [129f023d]
|
||||
- Updated dependencies [38f5a903]
|
||||
- Updated dependencies [ff4ff869]
|
||||
- @trigger.dev/sdk@2.3.12
|
||||
- @trigger.dev/integration-kit@2.3.12
|
||||
|
||||
## 2.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/shopify",
|
||||
"version": "2.3.11",
|
||||
"version": "2.3.16",
|
||||
"description": "Trigger.dev integration for @shopify/shopify-api",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -26,8 +26,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@shopify/shopify-api": "^8.0.2",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.11",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.11",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.16",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.16",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# @trigger.dev/slack
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c4047cf]
|
||||
- @trigger.dev/sdk@2.3.15
|
||||
|
||||
## 2.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/sdk@2.3.14
|
||||
|
||||
## 2.3.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a93b554f]
|
||||
- Updated dependencies [0f342cd1]
|
||||
- @trigger.dev/sdk@2.3.13
|
||||
|
||||
## 2.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [129f023d]
|
||||
- Updated dependencies [38f5a903]
|
||||
- Updated dependencies [ff4ff869]
|
||||
- @trigger.dev/sdk@2.3.12
|
||||
|
||||
## 2.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/slack",
|
||||
"version": "2.3.11",
|
||||
"version": "2.3.16",
|
||||
"description": "The official Slack integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@slack/web-api": "^6.8.1",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.11",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.16",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @trigger.dev/stripe
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c4047cf]
|
||||
- @trigger.dev/sdk@2.3.15
|
||||
- @trigger.dev/integration-kit@2.3.15
|
||||
|
||||
## 2.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.14
|
||||
- @trigger.dev/sdk@2.3.14
|
||||
|
||||
## 2.3.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a93b554f]
|
||||
- Updated dependencies [0f342cd1]
|
||||
- @trigger.dev/sdk@2.3.13
|
||||
- @trigger.dev/integration-kit@2.3.13
|
||||
|
||||
## 2.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [129f023d]
|
||||
- Updated dependencies [38f5a903]
|
||||
- Updated dependencies [ff4ff869]
|
||||
- @trigger.dev/sdk@2.3.12
|
||||
- @trigger.dev/integration-kit@2.3.12
|
||||
|
||||
## 2.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/stripe",
|
||||
"version": "2.3.11",
|
||||
"version": "2.3.16",
|
||||
"description": "Trigger.dev integration for stripe",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -25,8 +25,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.11",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.11",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.16",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.16",
|
||||
"stripe": "^12.14.0",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @trigger.dev/supabase
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c4047cf]
|
||||
- @trigger.dev/sdk@2.3.15
|
||||
- @trigger.dev/integration-kit@2.3.15
|
||||
|
||||
## 2.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.14
|
||||
- @trigger.dev/sdk@2.3.14
|
||||
|
||||
## 2.3.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a93b554f]
|
||||
- Updated dependencies [0f342cd1]
|
||||
- @trigger.dev/sdk@2.3.13
|
||||
- @trigger.dev/integration-kit@2.3.13
|
||||
|
||||
## 2.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [129f023d]
|
||||
- Updated dependencies [38f5a903]
|
||||
- Updated dependencies [ff4ff869]
|
||||
- @trigger.dev/sdk@2.3.12
|
||||
- @trigger.dev/integration-kit@2.3.12
|
||||
|
||||
## 2.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/supabase",
|
||||
"version": "2.3.11",
|
||||
"version": "2.3.16",
|
||||
"description": "Trigger.dev integration for @supabase/supabase-js",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -26,8 +26,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.26.0",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.11",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.11",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.16",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.16",
|
||||
"supabase-management-js": "^1.0.0",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @trigger.dev/typeform
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c4047cf]
|
||||
- @trigger.dev/sdk@2.3.15
|
||||
- @trigger.dev/integration-kit@2.3.15
|
||||
|
||||
## 2.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.14
|
||||
- @trigger.dev/sdk@2.3.14
|
||||
|
||||
## 2.3.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a93b554f]
|
||||
- Updated dependencies [0f342cd1]
|
||||
- @trigger.dev/sdk@2.3.13
|
||||
- @trigger.dev/integration-kit@2.3.13
|
||||
|
||||
## 2.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [129f023d]
|
||||
- Updated dependencies [38f5a903]
|
||||
- Updated dependencies [ff4ff869]
|
||||
- @trigger.dev/sdk@2.3.12
|
||||
- @trigger.dev/integration-kit@2.3.12
|
||||
|
||||
## 2.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/typeform",
|
||||
"version": "2.3.11",
|
||||
"version": "2.3.16",
|
||||
"description": "The official Typeform integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -24,8 +24,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.11",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.11",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.16",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.16",
|
||||
"@typeform/api-client": "^1.8.0",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# @trigger.dev/astro
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6c4047cf]
|
||||
- @trigger.dev/sdk@2.3.15
|
||||
|
||||
## 2.3.14
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/sdk@2.3.14
|
||||
|
||||
## 2.3.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a93b554f]
|
||||
- Updated dependencies [0f342cd1]
|
||||
- @trigger.dev/sdk@2.3.13
|
||||
|
||||
## 2.3.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [129f023d]
|
||||
- Updated dependencies [38f5a903]
|
||||
- Updated dependencies [ff4ff869]
|
||||
- @trigger.dev/sdk@2.3.12
|
||||
|
||||
## 2.3.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trigger.dev/astro",
|
||||
"description": "An Astro-native integration for Trigger.dev background jobs platform",
|
||||
"version": "2.3.11",
|
||||
"version": "2.3.16",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
@@ -20,7 +20,7 @@
|
||||
"build:tsup": "tsup"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@trigger.dev/sdk": "workspace:^2.3.11"
|
||||
"@trigger.dev/sdk": "workspace:^2.3.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"astro": "^3.0.12",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# trigger.dev
|
||||
|
||||
## 1.0.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [583da458]
|
||||
- @trigger.dev/core@2.3.16
|
||||
|
||||
## 1.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.3.15
|
||||
|
||||
## 1.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.3.14
|
||||
|
||||
## 1.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.3.13
|
||||
|
||||
## 1.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.3.12
|
||||
@@ -0,0 +1,28 @@
|
||||
# Running the CLI from source
|
||||
|
||||
1. Run the CLI and watch for changes
|
||||
|
||||
```sh
|
||||
cd packages/cli-v3
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
2. In the job-catalog folder you can use the CLI
|
||||
|
||||
```sh
|
||||
pnpm i
|
||||
pnpm exec trigger-v3-cli
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
If you want to use it in a new folder, you need to first add it as a dev dependency in package.json:
|
||||
|
||||
```json
|
||||
//...
|
||||
"devDependencies": {
|
||||
"trigger.dev": "workspace:*",
|
||||
//...
|
||||
}
|
||||
//...
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Trigger.dev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Trigger.dev CLI
|
||||
|
||||
A CLI that allows you to create, run locally and deploy Trigger.dev background tasks.
|
||||
|
||||
Note: this only works with Trigger.dev v3 projects and later. For older projects use the [@trigger.dev/cli](https://www.npmjs.com/package/@trigger.dev/cli) package.
|
||||
|
||||
Trigger.dev is an open source platform that makes it easy to create event-driven background tasks directly in your existing project.
|
||||
|
||||
## Usage
|
||||
|
||||
### Login
|
||||
|
||||
Logs that machine into Trigger.dev by creating a new Personal Access Token and storing it on the local machine. Once you're logged in you can perform the other actions below.
|
||||
|
||||
```sh
|
||||
npx trigger.dev@latest login
|
||||
```
|
||||
|
||||
| Option | Short option | Description |
|
||||
| ----------- | ------------ | -------------------------------------------------------------------- |
|
||||
| `--api-url` | `-a` | Set the API URL for Trigger.dev, defaults to https://api.trigger.dev |
|
||||
|
||||
### Update
|
||||
|
||||
Will update all of your @trigger.dev packages in your package.json to the latest version.
|
||||
|
||||
```sh
|
||||
npx trigger.dev@latest update
|
||||
```
|
||||
|
||||
You can pass the path to the folder that your package.json file lives in:
|
||||
|
||||
```sh
|
||||
npx trigger.dev@latest update ./myapp
|
||||
```
|
||||
|
||||
| Option | Short option | Description |
|
||||
| ------ | ------------ | ---------------------------------------------------------- |
|
||||
| `--to` | `-t` | The version to update to (ex: 2.1.4), defaults to "latest" |
|
||||
|
||||
### Who Am I?
|
||||
|
||||
Shows the current user that is logged in.
|
||||
|
||||
```sh
|
||||
npx trigger.dev@latest whoami
|
||||
```
|
||||
|
||||
| Option | Short option | Description |
|
||||
| ----------- | ------------ | -------------------------------------------------------------------- |
|
||||
| `--api-url` | `-a` | Set the API URL for Trigger.dev, defaults to https://api.trigger.dev |
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"name": "trigger.dev",
|
||||
"version": "1.0.5",
|
||||
"description": "A Command-Line Interface for Trigger.dev (v3) projects",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/triggerdotdev/trigger.dev.git",
|
||||
"directory": "packages/cli-v3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"keywords": [
|
||||
"typescript",
|
||||
"trigger.dev",
|
||||
"workflows",
|
||||
"orchestration",
|
||||
"events",
|
||||
"webhooks",
|
||||
"integrations",
|
||||
"apis",
|
||||
"jobs",
|
||||
"background jobs",
|
||||
"nextjs"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
"bin": {
|
||||
"trigger-v3-cli": "./dist/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"@trigger.dev/tsconfig": "workspace:*",
|
||||
"@types/gradient-string": "^1.1.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "16",
|
||||
"@types/node-fetch": "^2.6.2",
|
||||
"@types/ws": "^8.5.3",
|
||||
"open": "^10.0.3",
|
||||
"p-retry": "^6.1.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"tsup": "^6.5.0",
|
||||
"type-fest": "^3.6.0",
|
||||
"typescript": "^4.9.5",
|
||||
"vitest": "^0.34.4",
|
||||
"xdg-app-paths": "^8.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc",
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"clean": "rimraf dist",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.7.0",
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"@types/degit": "^2.8.3",
|
||||
"chalk": "^5.2.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"cli-table3": "^0.6.3",
|
||||
"commander": "^9.4.1",
|
||||
"degit": "^2.8.4",
|
||||
"dotenv": "^16.3.1",
|
||||
"execa": "^7.0.0",
|
||||
"gradient-string": "^2.0.2",
|
||||
"liquidjs": "^10.9.2",
|
||||
"mock-fs": "^5.2.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"node-fetch": "^3.3.0",
|
||||
"npm-check-updates": "^16.12.2",
|
||||
"posthog-node": "^3.1.1",
|
||||
"proxy-agent": "^6.3.0",
|
||||
"simple-git": "^3.19.0",
|
||||
"update-check": "^1.5.4",
|
||||
"url": "^0.11.1",
|
||||
"ws": "^8.11.0",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CreateAuthorizationCodeResponseSchema,
|
||||
GetPersonalAccessTokenResponseSchema,
|
||||
WhoAmIResponseSchema,
|
||||
} from "@trigger.dev/core";
|
||||
|
||||
export class ApiClient {
|
||||
constructor(private readonly apiURL: string) {
|
||||
this.apiURL = apiURL;
|
||||
}
|
||||
|
||||
async createAuthorizationCode() {
|
||||
return zodfetch(
|
||||
CreateAuthorizationCodeResponseSchema,
|
||||
`${this.apiURL}/api/v1/authorization-code`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getPersonalAccessToken(authorizationCode: string) {
|
||||
return zodfetch(GetPersonalAccessTokenResponseSchema, `${this.apiURL}/api/v1/token`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
authorizationCode,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async whoAmI({ accessToken }: { accessToken: string }) {
|
||||
return zodfetch(WhoAmIResponseSchema, `${this.apiURL}/api/v2/whoami`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type ApiResult<TSuccessResult> =
|
||||
| { success: true; data: TSuccessResult }
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
async function zodfetch<TResponseBody extends any>(
|
||||
schema: z.Schema<TResponseBody>,
|
||||
url: string,
|
||||
requestInit?: RequestInit
|
||||
): Promise<ApiResult<TResponseBody>> {
|
||||
try {
|
||||
const response = await fetch(url, requestInit);
|
||||
|
||||
if ((!requestInit || requestInit.method === "GET") && response.status === 404) {
|
||||
return {
|
||||
success: false,
|
||||
error: `404: ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
const body = await response.json();
|
||||
if (!body.error) {
|
||||
return { success: false, error: "Something went wrong" };
|
||||
}
|
||||
|
||||
return { success: false, error: body.error };
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to fetch ${url}, got status code ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const jsonBody = await response.json();
|
||||
const parsedResult = schema.safeParse(jsonBody);
|
||||
|
||||
if (parsedResult.success) {
|
||||
return { success: true, data: parsedResult.data };
|
||||
}
|
||||
|
||||
if ("error" in jsonBody) {
|
||||
return {
|
||||
success: false,
|
||||
error: typeof jsonBody.error === "string" ? jsonBody.error : JSON.stringify(jsonBody.error),
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: parsedResult.error.message };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : JSON.stringify(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Command } from "commander";
|
||||
import { devCommand } from "../commands/dev";
|
||||
import { updateCommand } from "../commands/update";
|
||||
import { whoamiCommand } from "../commands/whoami.js";
|
||||
import { COMMAND_NAME } from "../consts";
|
||||
import { getVersion } from "../utilities/getVersion";
|
||||
import { printInitialBanner } from "../utilities/initialBanner";
|
||||
import { login, loginCommand } from "../commands/login";
|
||||
import { z } from "zod";
|
||||
|
||||
export const program = new Command();
|
||||
|
||||
export const ApiUrlOptionsSchema = z.object({
|
||||
apiUrl: z.string(),
|
||||
});
|
||||
|
||||
program
|
||||
.name(COMMAND_NAME)
|
||||
.description("Create, run locally and deploy Trigger.dev background tasks.")
|
||||
.version(getVersion(), "-v, --version", "Display the version number");
|
||||
|
||||
program
|
||||
.command("login")
|
||||
.description("Login with Trigger.dev so you can perform authenticated actions")
|
||||
.option(
|
||||
"-a, --api-url <value>",
|
||||
"Override the API URL, defaults to https://api.trigger.dev",
|
||||
"https://api.trigger.dev"
|
||||
)
|
||||
.version(getVersion(), "-v, --version", "Display the version number")
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await printInitialBanner(false);
|
||||
await loginCommand(options);
|
||||
//todo login command
|
||||
} catch (e) {
|
||||
//todo error reporting
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
//todo update for the new version
|
||||
//todo add usage instructions to the README
|
||||
program
|
||||
.command("dev")
|
||||
.description("Run your Trigger.dev tasks locally")
|
||||
.argument("[path]", "The path to the project", ".")
|
||||
.option("-p, --port <port>", "Override the local port your server is on")
|
||||
.option("-H, --hostname <hostname>", "Override the hostname on which the application is served")
|
||||
.option("-e, --env-file <name>", "Override the name of the env file to load")
|
||||
.option(
|
||||
"-i, --client-id <name>",
|
||||
"The ID of the client to use for this project. Will use the value from the package.json file if not provided."
|
||||
)
|
||||
.version(getVersion(), "-v, --version", "Display the version number")
|
||||
.action(async (path, options) => {
|
||||
try {
|
||||
await printInitialBanner();
|
||||
await devCommand(path, options);
|
||||
} catch (e) {
|
||||
//todo error reporting
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("update")
|
||||
.description(
|
||||
"Updates all @trigger.dev/* packages to their latest compatible versions or the specified version"
|
||||
)
|
||||
.argument("[path]", "The path to the directory that contains the package.json file", ".")
|
||||
.option("-t, --to <version tag>", "The version to update to (ex: 2.1.4)", "latest")
|
||||
.action(async (path, options) => {
|
||||
await printInitialBanner(false);
|
||||
await updateCommand(path, options);
|
||||
});
|
||||
|
||||
program
|
||||
.command("whoami")
|
||||
.description("display the current logged in user and project details")
|
||||
.option(
|
||||
"-a, --api-url <value>",
|
||||
"Override the API URL, defaults to https://cloud.trigger.dev",
|
||||
"https://cloud.trigger.dev"
|
||||
)
|
||||
.version(getVersion(), "-v, --version", "Display the version number")
|
||||
.action(async (options) => {
|
||||
try {
|
||||
await printInitialBanner();
|
||||
await whoamiCommand(options);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import childProcess from "child_process";
|
||||
import util from "util";
|
||||
import { z } from "zod";
|
||||
import { telemetryClient } from "../telemetry/telemetry";
|
||||
import { logger } from "../utilities/logger";
|
||||
import { resolvePath } from "../utilities/parseNameAndPath";
|
||||
import { RequireKeys } from "../utilities/requiredKeys";
|
||||
|
||||
const asyncExecFile = util.promisify(childProcess.execFile);
|
||||
|
||||
export const DevCommandOptionsSchema = z.object({
|
||||
port: z.coerce.number().optional(),
|
||||
hostname: z.string().optional(),
|
||||
envFile: z.string().optional(),
|
||||
clientId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type DevCommandOptions = z.infer<typeof DevCommandOptionsSchema>;
|
||||
type ResolvedOptions = RequireKeys<DevCommandOptions, "envFile">;
|
||||
|
||||
const formattedDate = new Intl.DateTimeFormat("en", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
});
|
||||
|
||||
export async function devCommand(path: string, anyOptions: any) {
|
||||
telemetryClient.dev.started(path, anyOptions);
|
||||
|
||||
const result = DevCommandOptionsSchema.safeParse(anyOptions);
|
||||
if (!result.success) {
|
||||
logger.error(result.error.message);
|
||||
|
||||
return;
|
||||
}
|
||||
const options = result.data;
|
||||
|
||||
const resolvedPath = resolvePath(path);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { intro, log, outro, select, spinner } from "@clack/prompts";
|
||||
import open from "open";
|
||||
import pRetry, { AbortError } from "p-retry";
|
||||
import { ApiClient } from "../apiClient";
|
||||
import { ApiUrlOptionsSchema } from "../cli";
|
||||
import { chalkLink } from "../utilities/colors";
|
||||
import { readAuthConfigFile, writeAuthConfigFile } from "../utilities/configFiles";
|
||||
import { logger } from "../utilities/logger";
|
||||
import { whoAmI } from "./whoami";
|
||||
|
||||
export async function loginCommand(options: any) {
|
||||
const result = ApiUrlOptionsSchema.safeParse(options);
|
||||
if (!result.success) {
|
||||
logger.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
return login(result.data.apiUrl);
|
||||
}
|
||||
|
||||
export type LoginResult =
|
||||
| {
|
||||
success: true;
|
||||
accessToken: string;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function login(apiUrl: string): Promise<LoginResult> {
|
||||
const apiClient = new ApiClient(apiUrl);
|
||||
|
||||
intro("Logging in to Trigger.dev");
|
||||
|
||||
const existingAccessToken = readAuthConfigFile()?.accessToken;
|
||||
if (existingAccessToken) {
|
||||
const whoAmiI = await whoAmI(apiUrl);
|
||||
|
||||
const continueOption = await select({
|
||||
message: "You are already logged in.",
|
||||
options: [
|
||||
{
|
||||
value: false,
|
||||
label: "Exit",
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
label: "Login with a different account",
|
||||
},
|
||||
],
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (continueOption !== true) {
|
||||
outro("Already logged in");
|
||||
return {
|
||||
success: true,
|
||||
accessToken: existingAccessToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
//generate authorization code
|
||||
const createAuthCodeSpinner = spinner();
|
||||
createAuthCodeSpinner.start("Creating authorition code");
|
||||
const authorizationCodeResult = await apiClient.createAuthorizationCode();
|
||||
if (!authorizationCodeResult.success) {
|
||||
createAuthCodeSpinner.stop(
|
||||
`Failed to create authorization code\n${authorizationCodeResult.error}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: authorizationCodeResult.error,
|
||||
};
|
||||
}
|
||||
createAuthCodeSpinner.stop("Created authorization code");
|
||||
|
||||
//Link the user to the authorization code
|
||||
log.step(
|
||||
`Please visit the following URL to login:\n${chalkLink(authorizationCodeResult.data.url)}`
|
||||
);
|
||||
await open(authorizationCodeResult.data.url);
|
||||
|
||||
//poll for personal access token (we need to poll for it)
|
||||
const getPersonalAccessTokenSpinner = spinner();
|
||||
getPersonalAccessTokenSpinner.start("Waiting for you to login");
|
||||
try {
|
||||
const indexResult = await pRetry(
|
||||
() => getPersonalAccessToken(apiClient, authorizationCodeResult.data.authorizationCode),
|
||||
{
|
||||
//this means we're polling, same distance between each attempt
|
||||
factor: 1,
|
||||
retries: 60,
|
||||
minTimeout: 1000,
|
||||
}
|
||||
);
|
||||
|
||||
getPersonalAccessTokenSpinner.stop(`Logged in with token ${indexResult.obfuscatedToken}`);
|
||||
|
||||
writeAuthConfigFile({ accessToken: indexResult.token });
|
||||
|
||||
outro("Logged in successfully");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accessToken: indexResult.token,
|
||||
};
|
||||
} catch (e) {
|
||||
getPersonalAccessTokenSpinner.stop(`Failed to get access token`);
|
||||
if (e instanceof AbortError) {
|
||||
log.error(e.message);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : JSON.stringify(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getPersonalAccessToken(apiClient: ApiClient, authorizationCode: string) {
|
||||
const token = await apiClient.getPersonalAccessToken(authorizationCode);
|
||||
|
||||
if (!token.success) {
|
||||
throw new AbortError(token.error);
|
||||
}
|
||||
|
||||
if (!token.data.token) {
|
||||
throw new Error("No token found yet");
|
||||
}
|
||||
|
||||
return {
|
||||
token: token.data.token.token,
|
||||
obfuscatedToken: token.data.token.obfuscatedToken,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { spinner, confirm } from "@clack/prompts";
|
||||
import path from "path";
|
||||
import { run, RunOptions } from "npm-check-updates";
|
||||
import { installDependencies } from "../utilities/installDependencies";
|
||||
import { readJSONFileSync, writeJSONFile } from "../utilities/fileSystem.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { z } from "zod";
|
||||
import { chalkError, chalkSuccess } from "../utilities/colors";
|
||||
|
||||
export const UpdateCommandOptionsSchema = z.object({
|
||||
to: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UpdateCommandOptions = z.infer<typeof UpdateCommandOptionsSchema>;
|
||||
|
||||
type NcuRunOptionTarget = "latest" | `@${string}`;
|
||||
|
||||
export async function updateCommand(projectPath: string, anyOptions: any) {
|
||||
const loadingSpinner = spinner();
|
||||
loadingSpinner.start("Checking settings");
|
||||
|
||||
const parseRes = UpdateCommandOptionsSchema.safeParse(anyOptions);
|
||||
if (!parseRes.success) {
|
||||
loadingSpinner.stop(chalkError(parseRes.error.message));
|
||||
return;
|
||||
}
|
||||
const options = parseRes.data;
|
||||
|
||||
const triggerDevPackage = "@trigger.dev";
|
||||
const packageJSONPath = path.join(projectPath, "package.json");
|
||||
const packageData = readJSONFileSync(packageJSONPath);
|
||||
if (!packageData) {
|
||||
loadingSpinner.stop(chalkError("Couldn't load package.json"));
|
||||
return;
|
||||
}
|
||||
|
||||
loadingSpinner.message("Checking for updates");
|
||||
|
||||
const packageMaps: { [k: string]: { type: string; version: string } } = {};
|
||||
const packageDependencies = packageData.dependencies || {};
|
||||
const packageDevDependencies = packageData.devDependencies || {};
|
||||
Object.keys(packageDependencies).forEach((i) => {
|
||||
packageMaps[i] = { type: "dependencies", version: packageDependencies[i] };
|
||||
});
|
||||
Object.keys(packageDevDependencies).forEach((i) => {
|
||||
packageMaps[i] = {
|
||||
type: "devDependencies",
|
||||
version: packageDevDependencies[i],
|
||||
};
|
||||
});
|
||||
|
||||
const targetVersion = getTargetVersion(options.to);
|
||||
|
||||
// Use npm-check-updates to get updated dependency versions
|
||||
const ncuOptions: RunOptions = {
|
||||
packageData,
|
||||
upgrade: true,
|
||||
jsonUpgraded: true,
|
||||
target: targetVersion,
|
||||
};
|
||||
|
||||
// Can either give a json like package.json or just with deps and their new versions
|
||||
const updatedDependencies: { [k: string]: any } | void = await run(ncuOptions);
|
||||
|
||||
if (!updatedDependencies) {
|
||||
loadingSpinner.stop(chalkError("Couldn't update dependencies"));
|
||||
return;
|
||||
}
|
||||
|
||||
const ifUpdatedDependenciesIsPackageJSON =
|
||||
updatedDependencies.hasOwnProperty("dependencies") ||
|
||||
updatedDependencies.hasOwnProperty("devDependencies");
|
||||
|
||||
const dependencies = updatedDependencies.dependencies || {};
|
||||
const devDependencies = updatedDependencies.devDependencies || {};
|
||||
|
||||
const allDependencies = ifUpdatedDependenciesIsPackageJSON
|
||||
? Object.keys({ ...dependencies, ...devDependencies })
|
||||
: Object.keys(updatedDependencies);
|
||||
|
||||
const triggerPackages = allDependencies.filter((pkg) => pkg.startsWith(triggerDevPackage));
|
||||
|
||||
// If there are no @trigger.dev packages
|
||||
if (triggerPackages.length === 0) {
|
||||
loadingSpinner.stop(chalkSuccess(`All @trigger.dev/* packages are already up to date.`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter the packages with null and what don't match what
|
||||
// they are installed with so that they can be updated
|
||||
const packagesToUpdate = triggerPackages.filter((pkg: string) => updatedDependencies[pkg]);
|
||||
|
||||
// If no packages require any updation
|
||||
if (packagesToUpdate.length === 0) {
|
||||
loadingSpinner.stop(chalkSuccess(`All @trigger.dev/* packages are already up to date.`));
|
||||
return;
|
||||
}
|
||||
|
||||
let applyUpdates = targetVersion !== "latest";
|
||||
|
||||
if (targetVersion === "latest") {
|
||||
applyUpdates = await hasUserConfirmed(packagesToUpdate, packageMaps, updatedDependencies);
|
||||
}
|
||||
|
||||
if (applyUpdates) {
|
||||
const newPackageJSON = packageData;
|
||||
packagesToUpdate.forEach((packageName) => {
|
||||
const tmp = packageMaps[packageName];
|
||||
if (tmp) {
|
||||
newPackageJSON[tmp.type][packageName] = updatedDependencies[packageName];
|
||||
}
|
||||
});
|
||||
await writeJSONFile(packageJSONPath, newPackageJSON);
|
||||
await installDependencies(projectPath);
|
||||
}
|
||||
}
|
||||
|
||||
// expects a version number, or latest.
|
||||
// if version number is specified, prepend it with '@' for ncu.
|
||||
function getTargetVersion(toVersion?: string): NcuRunOptionTarget {
|
||||
if (!toVersion) {
|
||||
return "latest";
|
||||
}
|
||||
return toVersion === "latest" ? "latest" : `@${toVersion}`;
|
||||
}
|
||||
|
||||
async function hasUserConfirmed(
|
||||
packagesToUpdate: string[],
|
||||
packageMaps: { [x: string]: { type: string; version: string } },
|
||||
updatedDependencies: { [x: string]: any }
|
||||
): Promise<boolean> {
|
||||
// Inform the user of the dependencies that can be updated
|
||||
console.log("\nNewer versions found for the following packages:");
|
||||
console.table(
|
||||
packagesToUpdate.map((i) => ({
|
||||
name: i,
|
||||
old: packageMaps[i]?.version,
|
||||
new: updatedDependencies[i],
|
||||
}))
|
||||
);
|
||||
|
||||
// Ask the user if they want to update the dependencies
|
||||
const shouldContinue = await confirm({
|
||||
message: "Do you want to update these packages in package.json and re-install dependencies?",
|
||||
});
|
||||
|
||||
return shouldContinue as boolean;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { note, spinner } from "@clack/prompts";
|
||||
import { ApiUrlOptionsSchema } from "../cli";
|
||||
import { logger } from "../utilities/logger";
|
||||
import { resolvePath } from "../utilities/parseNameAndPath";
|
||||
import { readAuthConfigFile } from "../utilities/configFiles";
|
||||
import { login } from "./login";
|
||||
import { ApiClient } from "../apiClient";
|
||||
|
||||
type WhoAmIResult =
|
||||
| {
|
||||
success: true;
|
||||
data: {
|
||||
userId: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export async function whoamiCommand(options: any): Promise<WhoAmIResult> {
|
||||
const result = ApiUrlOptionsSchema.safeParse(options);
|
||||
if (!result.success) {
|
||||
logger.error(result.error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: result.error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return whoAmI(result.data.apiUrl);
|
||||
}
|
||||
|
||||
export async function whoAmI(apiUrl: string): Promise<WhoAmIResult> {
|
||||
const loadingSpinner = spinner();
|
||||
loadingSpinner.start("Checking your account details");
|
||||
|
||||
if (!readAuthConfigFile()?.accessToken) {
|
||||
loadingSpinner.stop("You must login.");
|
||||
const loginResult = await login(apiUrl);
|
||||
if (!loginResult.success) {
|
||||
logger.error(loginResult.error);
|
||||
return {
|
||||
success: false,
|
||||
error: loginResult.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const accessToken = readAuthConfigFile()?.accessToken;
|
||||
if (!accessToken) {
|
||||
logger.error("No access token after login… this should never happen");
|
||||
return {
|
||||
success: false,
|
||||
error: "No access token after login… this should never happen",
|
||||
};
|
||||
}
|
||||
|
||||
const apiClient = new ApiClient(apiUrl);
|
||||
const userData = await apiClient.whoAmI({ accessToken });
|
||||
|
||||
if (!userData.success) {
|
||||
loadingSpinner.stop("Error getting your account details");
|
||||
logger.error(userData.error);
|
||||
return {
|
||||
success: false,
|
||||
error: userData.error,
|
||||
};
|
||||
}
|
||||
|
||||
loadingSpinner.stop("Retrieved your account details");
|
||||
|
||||
note(
|
||||
`User ID: ${userData.data.userId}
|
||||
Email: ${userData.data.email}`,
|
||||
"Account details"
|
||||
);
|
||||
|
||||
return userData;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// With the move to TSUP as a build tool, this keeps path routes in other files (installers, loaders, etc) in check more easily.
|
||||
// Path is in relation to a single index.js file inside ./dist
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const distPath = path.dirname(__filename);
|
||||
|
||||
export const PKG_ROOT = path.join(distPath, "../");
|
||||
export const COMMAND_NAME = "trigger.dev";
|
||||
export const CLOUD_WEB_URL = "https://cloud.trigger.dev";
|
||||
export const CLOUD_API_URL = "https://api.trigger.dev";
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { program } from "./cli/index";
|
||||
import { logger } from "./utilities/logger";
|
||||
|
||||
const main = async () => {
|
||||
await program.parseAsync();
|
||||
};
|
||||
|
||||
main().catch((err) => {
|
||||
if (err instanceof Error) {
|
||||
logger.error(err);
|
||||
} else {
|
||||
logger.error("An unknown error has occurred. Please open an issue on github with the below:");
|
||||
logger.error(err);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { PostHog } from "posthog-node";
|
||||
import { nanoid } from "nanoid";
|
||||
import { getVersion } from "../utilities/getVersion";
|
||||
|
||||
const postHogApiKey = "phc_9aSDbJCaDUMdZdHxxMPTvcj7A9fsl3mCgM1RBPmPsl7";
|
||||
|
||||
export class TelemetryClient {
|
||||
#client: PostHog;
|
||||
#sessionId: string;
|
||||
#version: string;
|
||||
|
||||
constructor() {
|
||||
this.#client = new PostHog(postHogApiKey, {
|
||||
host: "https://eu.posthog.com",
|
||||
flushAt: 1,
|
||||
});
|
||||
this.#sessionId = `cli-${nanoid()}`;
|
||||
this.#version = getVersion();
|
||||
}
|
||||
|
||||
identify(organizationId: string, projectId: string, userId?: string) {
|
||||
if (userId) {
|
||||
this.#client.alias({
|
||||
distinctId: userId,
|
||||
alias: this.#sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
this.#client.groupIdentify({
|
||||
groupType: "organization",
|
||||
groupKey: organizationId,
|
||||
});
|
||||
|
||||
this.#client.groupIdentify({
|
||||
groupType: "project",
|
||||
groupKey: projectId,
|
||||
});
|
||||
}
|
||||
|
||||
dev = {
|
||||
started: (path: string, options: Record<string, string | number | boolean>) => {
|
||||
this.#client.capture({
|
||||
distinctId: this.#sessionId,
|
||||
event: "cli_dev_started",
|
||||
properties: { ...options, path },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const telemetryClient = new TelemetryClient();
|
||||
@@ -0,0 +1,32 @@
|
||||
import chalk from "chalk";
|
||||
|
||||
export const green = "#4FFF54";
|
||||
export const purple = "#735BF3";
|
||||
|
||||
export function chalkGreen(text: string) {
|
||||
return chalk.hex(green)(text);
|
||||
}
|
||||
|
||||
export function chalkPurple(text: string) {
|
||||
return chalk.hex(purple)(text);
|
||||
}
|
||||
|
||||
export function chalkGrey(text: string) {
|
||||
return chalk.hex("#666")(text);
|
||||
}
|
||||
|
||||
export function chalkError(text: string) {
|
||||
return chalk.red(text);
|
||||
}
|
||||
|
||||
export function chalkSuccess(text: string) {
|
||||
return chalk.green(text);
|
||||
}
|
||||
|
||||
export function chalkLink(text: string) {
|
||||
return chalk.underline.blue(text);
|
||||
}
|
||||
|
||||
export function logo() {
|
||||
return `${chalk.hex(green).bold("Trigger")}${chalk.hex(purple).bold(".dev")}`;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import fs, { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import xdgAppPaths from "xdg-app-paths";
|
||||
import { z } from "zod";
|
||||
import { isDirectory, pathExists, readJSONFileSync } from "./fileSystem";
|
||||
|
||||
function getGlobalConfigFolderPath() {
|
||||
const configDir = xdgAppPaths(".trigger").config();
|
||||
const legacyConfigDir = path.join(os.homedir(), ".trigger"); // Legacy config in user's home directory
|
||||
|
||||
// Check for the .trigger directory in root, if it is not there then use the XDG compliant path.
|
||||
if (isDirectory(legacyConfigDir)) {
|
||||
return legacyConfigDir;
|
||||
} else {
|
||||
return configDir;
|
||||
}
|
||||
}
|
||||
|
||||
//auth config file
|
||||
export const UserAuthConfigSchema = z.object({
|
||||
accessToken: z.string().optional(),
|
||||
});
|
||||
export type UserAuthConfig = z.infer<typeof UserAuthConfigSchema>;
|
||||
|
||||
function getAuthConfigFilePath() {
|
||||
return path.join(getGlobalConfigFolderPath(), "config", "default.json");
|
||||
}
|
||||
|
||||
export function writeAuthConfigFile(config: UserAuthConfig) {
|
||||
const authConfigFilePath = getAuthConfigFilePath();
|
||||
mkdirSync(path.dirname(authConfigFilePath), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(path.join(authConfigFilePath), JSON.stringify(config), {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
}
|
||||
|
||||
export function readAuthConfigFile(): UserAuthConfig | undefined {
|
||||
const authConfigFilePath = getAuthConfigFilePath();
|
||||
if (!pathExists(authConfigFilePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const json = readJSONFileSync(authConfigFilePath);
|
||||
const parsed = UserAuthConfigSchema.parse(json);
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import fsSync from "fs";
|
||||
import fsModule, { writeFile } from "fs/promises";
|
||||
import fs from "node:fs";
|
||||
import pathModule from "path";
|
||||
|
||||
// Creates a file at the given path, if the directory doesn't exist it will be created
|
||||
export async function createFile(path: string, contents: string): Promise<string> {
|
||||
await fsModule.mkdir(pathModule.dirname(path), { recursive: true });
|
||||
await fsModule.writeFile(path, contents);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
export function isDirectory(configPath: string) {
|
||||
try {
|
||||
return fs.statSync(configPath).isDirectory();
|
||||
} catch (error) {
|
||||
// ignore error
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await fsModule.access(path);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function someFileExists(directory: string, filenames: string[]): Promise<boolean> {
|
||||
for (let index = 0; index < filenames.length; index++) {
|
||||
const filename = filenames[index];
|
||||
if (!filename) continue;
|
||||
|
||||
const path = pathModule.join(directory, filename);
|
||||
if (await pathExists(path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function removeFile(path: string) {
|
||||
await fsModule.unlink(path);
|
||||
}
|
||||
|
||||
export async function readFile(path: string) {
|
||||
return await fsModule.readFile(path, "utf8");
|
||||
}
|
||||
|
||||
export async function readJSONFile(path: string) {
|
||||
const fileContents = await fsModule.readFile(path, "utf8");
|
||||
|
||||
return JSON.parse(fileContents);
|
||||
}
|
||||
|
||||
export async function writeJSONFile(path: string, json: any) {
|
||||
await writeFile(path, JSON.stringify(json, null, 2));
|
||||
}
|
||||
|
||||
export function readJSONFileSync(path: string) {
|
||||
const fileContents = fsSync.readFileSync(path, "utf8");
|
||||
|
||||
return JSON.parse(fileContents);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user