// Client-safe shim for the dashboard route builder. The actual server // implementation lives in dashboardBuilder.server.ts; the wrappers here // just return closures that lazily import that impl on first invocation. // // Why split: routes use `export const loader = dashboardLoader(...)` at // module top-level. Remix's dev build preserves the top-level call when // resolving the loader export, so the import target needs to exist on // the client even though the closure body never executes there. A // `.server.ts` file is excluded from the client bundle, which would // resolve `dashboardLoader` to undefined and crash with // "dashboardLoader is not a function" on first navigation. Keeping this // file non-`.server` puts the wrappers in the client bundle as // effectively no-op closures (they're never called there), and the // closure body's dynamic import only resolves at server runtime. import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import type { RbacAbility, RbacResource } from "@trigger.dev/rbac"; import type { z } from "zod"; type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion; type InferZod = T extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion ? z.infer : undefined; export type SessionUser = { id: string; email: string; name: string | null; displayName: string | null; avatarUrl: string | null; admin: boolean; confirmedBasicDetails: boolean; isImpersonating: boolean; }; // `requireSuper: true` enforces ability.canSuper(). Otherwise an explicit // action + resource pair is checked via ability.can(...). On failure the // builder throws a permission-denied 403, rendered as the permission panel by // the nearest route ErrorBoundary (RouteErrorDisplay), unless // `unauthorizedRedirect` is set; `message` customizes the panel copy. export type AuthorizationOption = | { requireSuper: true; message?: string } | { action: string; resource: RbacResource | RbacResource[]; message?: string; }; // Plugin-side scope: whatever the route's `context` returns must include // these (or just be `{}` when the route doesn't scope by org/project). // rbac.authenticateSession reads them off the value to filter UserRole. type AuthScope = { organizationId?: string; projectId?: string }; export type DashboardLoaderOptions = { params?: TParams; searchParams?: TSearchParams; // Resolves any per-request data the handler + auth check both need // (typically org/project lookups from URL params). The returned object // is fed to `rbac.authenticateSession` as the auth scope AND passed // through to the handler in `args.context`, so the route does each // lookup once. context?: (params: InferZod, request: Request) => TContext | Promise; authorization?: AuthorizationOption; // Where to send unauthenticated requests. Defaults to /login with a // redirectTo back to the original path. loginRedirect?: string; // Where to send users who pass auth but fail the ability check. Defaults // to "/" (the home page). unauthorizedRedirect?: string; }; export type DashboardLoaderHandlerArgs = { params: InferZod; searchParams: InferZod; user: SessionUser; ability: RbacAbility; context: TContext; request: Request; }; export function dashboardLoader< TParams extends AnyZodSchema | undefined = undefined, TSearchParams extends AnyZodSchema | undefined = undefined, TContext extends AuthScope = AuthScope, TReturn extends Response = Response, >( options: DashboardLoaderOptions, handler: (args: DashboardLoaderHandlerArgs) => Promise ) { return async function loader({ request, params }: LoaderFunctionArgs): Promise { // Server-only — see comment at top. Node caches the module after the // first call, so the dynamic import is effectively free past warmup. const { authenticateAndAuthorize } = await import("./dashboardBuilder.server"); const result = await authenticateAndAuthorize(request, params, options); if (!result.ok) throw result.response; return handler({ params: result.params as InferZod, searchParams: result.searchParams as InferZod, user: result.user, ability: result.ability, context: result.context as TContext, request, }); }; } export type DashboardActionOptions< TParams, TSearchParams, TContext extends AuthScope, > = DashboardLoaderOptions; export type DashboardActionHandlerArgs = DashboardLoaderHandlerArgs; export function dashboardAction< TParams extends AnyZodSchema | undefined = undefined, TSearchParams extends AnyZodSchema | undefined = undefined, TContext extends AuthScope = AuthScope, TReturn extends Response = Response, >( options: DashboardActionOptions, handler: (args: DashboardActionHandlerArgs) => Promise ) { return async function action({ request, params }: ActionFunctionArgs): Promise { const { authenticateAndAuthorize } = await import("./dashboardBuilder.server"); const result = await authenticateAndAuthorize(request, params, options); if (!result.ok) throw result.response; return handler({ params: result.params as InferZod, searchParams: result.searchParams as InferZod, user: result.user, ability: result.ability, context: result.context as TContext, request, }); }; }