Skip to content

Commit ca0beba

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
feat(webapp,database): let orgs choose when Trigger.dev support can open their dashboard
Organization Owners and Admins can now choose, in Settings under Support Access, whether Trigger.dev support can open their dashboard directly or only after an Owner or Admin approves a request. Each approval lasts 7 days. Mono-RevId: 5fe206017b19c5073bb886c831b43f1288b7224a
1 parent e82eca1 commit ca0beba

39 files changed

Lines changed: 2675 additions & 91 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Organization Owners and Admins can now choose, in Settings under Support Access, whether Trigger.dev support can open their dashboard directly or only after an Owner or Admin approves a request, with each approval lasting 7 days.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { useFetcher } from "@remix-run/react";
2+
import { useEffect } from "react";
3+
import { Button } from "~/components/primitives/Buttons";
4+
import { Callout } from "~/components/primitives/Callout";
5+
import { ClipboardField } from "~/components/primitives/ClipboardField";
6+
import { RelativeDateTime } from "~/components/primitives/DateTime";
7+
import {
8+
Dialog,
9+
DialogContent,
10+
DialogDescription,
11+
DialogFooter,
12+
DialogHeader,
13+
} from "~/components/primitives/Dialog";
14+
import { FormError } from "~/components/primitives/FormError";
15+
import { Hint } from "~/components/primitives/Hint";
16+
import { Label } from "~/components/primitives/Label";
17+
import { TextArea } from "~/components/primitives/TextArea";
18+
import type {
19+
SupportAccessDialogActionData,
20+
SupportAccessDialogData,
21+
} from "~/routes/admin.api.v2.orgs.$organizationId.support-access";
22+
23+
type SupportAccessRequestDialogProps = {
24+
org: { id: string; title: string } | null;
25+
open: boolean;
26+
onOpenChange: (open: boolean) => void;
27+
};
28+
29+
export function SupportAccessRequestDialog({
30+
org,
31+
open,
32+
onOpenChange,
33+
}: SupportAccessRequestDialogProps) {
34+
const loadFetcher = useFetcher<SupportAccessDialogData>();
35+
const submitFetcher = useFetcher<SupportAccessDialogActionData>();
36+
const load = loadFetcher.load;
37+
const endpoint = org ? `/admin/api/v2/orgs/${org.id}/support-access` : undefined;
38+
39+
useEffect(() => {
40+
if (open && endpoint) {
41+
load(endpoint);
42+
}
43+
}, [load, open, endpoint]);
44+
45+
if (!org) return null;
46+
47+
const result = submitFetcher.data;
48+
const created = result?.success ? result : undefined;
49+
const error = result && !result.success ? result.error : undefined;
50+
const isSubmitting = submitFetcher.state !== "idle";
51+
52+
if (created) {
53+
return (
54+
<Dialog open={open} onOpenChange={onOpenChange}>
55+
<DialogContent className="sm:max-w-lg">
56+
<DialogHeader>Request created - {org.title}</DialogHeader>
57+
<DialogDescription>
58+
We don't email the org. Send this link to an Owner or Admin of {org.title} so they can
59+
approve it. Once approved, click Support Access again. Access lasts 7 days.
60+
</DialogDescription>
61+
<ClipboardField value={created.link} variant="secondary/medium" />
62+
<DialogFooter className="justify-end">
63+
<Button variant="primary/small" onClick={() => onOpenChange(false)}>
64+
Done
65+
</Button>
66+
</DialogFooter>
67+
</DialogContent>
68+
</Dialog>
69+
);
70+
}
71+
72+
const pending = loadFetcher.data?.pending ?? [];
73+
74+
return (
75+
<Dialog open={open} onOpenChange={onOpenChange}>
76+
<DialogContent className="sm:max-w-lg">
77+
<DialogHeader>Support Access - {org.title}</DialogHeader>
78+
<DialogDescription>
79+
This organization requires approval before Trigger.dev staff can access its dashboard.
80+
</DialogDescription>
81+
<submitFetcher.Form method="post" action={endpoint} className="flex flex-col gap-4">
82+
<Callout variant="warning">
83+
No active approval. An Owner or Admin of this org has to approve a request first.
84+
Approval lasts 7 days and covers any Trigger.dev staff member.
85+
</Callout>
86+
<div>
87+
<Label htmlFor="support-access-reason" required>
88+
Reason
89+
</Label>
90+
<TextArea
91+
id="support-access-reason"
92+
name="reason"
93+
rows={4}
94+
required
95+
placeholder="e.g. Investigating stuck runs in production"
96+
/>
97+
<Hint>Shown to the org's admins on their Support Access page.</Hint>
98+
{error && <FormError>{error}</FormError>}
99+
</div>
100+
{pending.length > 0 && (
101+
<div className="flex flex-col gap-1.5">
102+
<Label>Pending requests</Label>
103+
{pending.map((request) => (
104+
<div key={request.id} className="rounded-md bg-background-hover px-3 py-2.5">
105+
<div className="truncate text-sm text-text-bright">{request.reason}</div>
106+
<div className="text-xs text-text-dimmed">
107+
{request.requestedBy} · <RelativeDateTime date={new Date(request.createdAt)} />
108+
</div>
109+
</div>
110+
))}
111+
</div>
112+
)}
113+
<DialogFooter>
114+
<Button type="button" variant="tertiary/small" onClick={() => onOpenChange(false)}>
115+
Cancel
116+
</Button>
117+
<Button type="submit" variant="primary/small" disabled={isSubmitting}>
118+
{isSubmitting ? "Creating..." : "Create request"}
119+
</Button>
120+
</DialogFooter>
121+
</submitFetcher.Form>
122+
</DialogContent>
123+
</Dialog>
124+
);
125+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { SupportAccessMode } from "@trigger.dev/database";
2+
3+
export type SupportAccessState = "allow" | "approved" | "request";
4+
5+
export function supportAccessState(org: {
6+
supportAccessMode: SupportAccessMode;
7+
supportAccessRequests: unknown[];
8+
}): SupportAccessState {
9+
if (org.supportAccessMode === "ALLOW") return "allow";
10+
return org.supportAccessRequests.length > 0 ? "approved" : "request";
11+
}

‎apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon";
44
import { CreditCardIcon } from "~/assets/icons/CreditCardIcon";
55
import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon";
66
import { PadlockIcon } from "~/assets/icons/PadlockIcon";
7+
import { ShieldIcon } from "~/assets/icons/ShieldIcon";
78
import { UsageIcon } from "~/assets/icons/UsageIcon";
89
import { RolesIcon } from "~/assets/icons/RolesIcon";
910
import { SlackIcon } from "~/assets/icons/SlackIcon";
@@ -21,6 +22,7 @@ import {
2122
organizationSettingsPath,
2223
organizationSlackIntegrationPath,
2324
organizationSsoPath,
25+
organizationSupportAccessPath,
2426
organizationTeamPath,
2527
organizationVercelIntegrationPath,
2628
v3BillingLimitsPath,
@@ -185,6 +187,19 @@ export function OrganizationSettingsSideMenu({
185187
data-action="sso"
186188
/>
187189
)}
190+
{/* Same rule as the page: flag on, or the org already requires requests. */}
191+
{isManagedCloud &&
192+
(featureFlags.supportAccessSettingsEnabled === true ||
193+
organization.supportAccessMode === "REQUIRES_REQUEST") && (
194+
<SideMenuItem
195+
name="Support Access"
196+
icon={ShieldIcon}
197+
activeIconColor="text-text-bright"
198+
inactiveIconColor="text-text-dimmed"
199+
to={organizationSupportAccessPath(organization)}
200+
data-action="support-access"
201+
/>
202+
)}
188203
</div>
189204
<div className="flex flex-col">
190205
<div className="mb-1">

‎apps/webapp/app/components/navigation/SideMenu.tsx‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ function getSectionCollapsed(
160160
return sideMenu?.collapsedSections?.[sectionId] ?? false;
161161
}
162162

163-
// Impersonation accent (menu border + "Stop impersonating"). Full class strings so Tailwind's
163+
// Impersonation accent (menu border + "Exit Support Access"). Full class strings so Tailwind's
164164
// static scanner picks them up.
165165
const IMPERSONATION_ACCENT = {
166166
border: "border-yellow-500/80",
@@ -824,7 +824,7 @@ export function SideMenu({
824824
"relative h-full border-r bg-background-bright",
825825
// The accent is the loudest "you are not this user" tell, so "view as user" drops it too —
826826
// the point of the mode is a dashboard that looks exactly like the user's. The account
827-
// menu's "Stop impersonating" and the toggle itself stay on raw impersonation, so there is
827+
// menu's "Exit Support Access" and the toggle itself stay on raw impersonation, so there is
828828
// still a way back out (as does the ⌘⌥A shortcut in <GlobalShortcuts>).
829829
user.isImpersonating && !isViewingAsUser
830830
? IMPERSONATION_ACCENT.border
@@ -1584,7 +1584,24 @@ function OrgSelector({
15841584
<Integrations organization={organization} />
15851585
</div>
15861586
<div className="border-t border-grid-bright p-1">
1587-
{organizations.length > 1 ? (
1587+
{isImpersonating ? (
1588+
<SimpleTooltip
1589+
asChild
1590+
side="right"
1591+
content="Not available in a Support Access session"
1592+
button={
1593+
<div>
1594+
<PopoverMenuItem
1595+
title="Switch organization"
1596+
icon={ArrowLeftRightIcon}
1597+
leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
1598+
className={cn(SIDE_MENU_POPOVER_ITEM_LABEL, "cursor-not-allowed opacity-50!")}
1599+
disabled
1600+
/>
1601+
</div>
1602+
}
1603+
/>
1604+
) : organizations.length > 1 ? (
15881605
<SwitchOrganizations organizations={organizations} organization={organization} />
15891606
) : (
15901607
<PopoverMenuItem
@@ -1627,7 +1644,7 @@ function AccountMenuItems({
16271644

16281645
return (
16291646
<>
1630-
{/* "Stop impersonating" and the view-as-user toggle key off raw impersonation, not `isAdmin`:
1647+
{/* "Exit Support Access" and the view-as-user toggle key off raw impersonation, not `isAdmin`:
16311648
with "view as user" on, `isAdmin` is false and these are the only ways back out. */}
16321649
{(isImpersonating || isAdmin) && (
16331650
<div className="flex flex-col gap-1 border-b border-grid-bright p-1">
@@ -1636,7 +1653,7 @@ function AccountMenuItems({
16361653
<PopoverMenuItem
16371654
title={
16381655
<div className="flex w-full items-center justify-between">
1639-
<span className={IMPERSONATION_ACCENT.text}>Stop impersonating</span>
1656+
<span className={IMPERSONATION_ACCENT.text}>Exit Support Access</span>
16401657
<ShortcutKey
16411658
shortcut={{ modifiers: ["mod", "alt"], key: "a" }}
16421659
variant="medium/bright"

‎apps/webapp/app/components/primitives/Buttons.tsx‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,28 @@ export const LinkButton = ({
502502
);
503503
}
504504

505-
if (to.toString().startsWith("http") || to.toString().startsWith("/resources")) {
505+
if (to.toString().startsWith("/resources")) {
506+
return (
507+
<Link
508+
to={to}
509+
ref={innerRef}
510+
reloadDocument
511+
target="_blank"
512+
rel={newTabRel(to.toString())}
513+
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
514+
onClick={onClick}
515+
onMouseDown={onMouseDown}
516+
onMouseEnter={onMouseEnter}
517+
onMouseLeave={onMouseLeave}
518+
download={download}
519+
aria-label={ariaLabel}
520+
>
521+
<ButtonContent {...props} />
522+
</Link>
523+
);
524+
}
525+
526+
if (to.toString().startsWith("http")) {
506527
return (
507528
<ExtLink
508529
href={to.toString()}
@@ -544,6 +565,11 @@ type ExtLinkProps = JSX.IntrinsicElements["a"] & {
544565
href: string;
545566
};
546567

568+
// Same-origin links keep the Referer, which Support Access sessions use to scope ID-keyed routes.
569+
export function newTabRel(href: string) {
570+
return href.startsWith("/") && !href.startsWith("//") ? "noopener" : "noopener noreferrer";
571+
}
572+
547573
const ExtLink = forwardRef<HTMLAnchorElement, ExtLinkProps>(
548574
({ className, href, children, ...props }, ref) => {
549575
return (

‎apps/webapp/app/components/primitives/Popover.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Link } from "@remix-run/react";
77
import * as React from "react";
88
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
99
import { cn } from "~/utils/cn";
10-
import { ButtonContent, type ButtonContentPropsType } from "./Buttons";
10+
import { ButtonContent, type ButtonContentPropsType, newTabRel } from "./Buttons";
1111
import { type RenderIcon } from "./Icon";
1212
import { Paragraph, type ParagraphVariant } from "./Paragraph";
1313

@@ -116,7 +116,7 @@ const PopoverMenuItem = React.forwardRef<
116116
className={cn("group/button focus-custom", contentProps.fullWidth ? "w-full" : "")}
117117
onClick={onClick as any}
118118
target={openInNewTab ? "_blank" : undefined}
119-
rel={openInNewTab ? "noopener noreferrer" : undefined}
119+
rel={openInNewTab ? newTabRel(to) : undefined}
120120
>
121121
<ButtonContent {...contentProps}>{title}</ButtonContent>
122122
</Link>

0 commit comments

Comments
 (0)