Files
2026-07-13 13:32:57 +08:00

124 lines
4.1 KiB
TypeScript

import { Outlet, useLoaderData } from "@remix-run/react";
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { DashboardAgent } from "~/components/dashboard-agent/DashboardAgent";
import { prisma } from "~/db.server";
import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferences.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { tenantContext } from "~/services/tenantContext.server";
import { EnvironmentParamSchema, v3ProjectPath } from "~/utils/pathBuilder";
import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await prisma.project.findFirst({
where: {
slug: projectParam,
organization: {
slug: organizationSlug,
members: {
some: {
userId: user.id,
},
},
},
deletedAt: null,
},
select: {
id: true,
externalRef: true,
organization: { select: { id: true, featureFlags: true } },
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
});
if (!project) {
logger.error("Project not found", { params, user });
throw new Response("Project not Found", { status: 404, statusText: "Project not found" });
}
const environments = project.environments.filter((env) => env.slug === envParam);
if (environments.length === 0) {
return redirect(v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }));
}
let environmentId: string | undefined = undefined;
let environmentType: "DEVELOPMENT" | "PREVIEW" | "STAGING" | "PRODUCTION" | undefined;
if (environments.length > 1) {
const bestEnvironment = environments.find((env) => env.orgMember?.userId === user.id);
if (!bestEnvironment) {
throw new Response("Environment not Found", {
status: 404,
statusText: "Environment not found",
});
}
environmentId = bestEnvironment.id;
environmentType = bestEnvironment.type;
} else {
environmentId = environments[0].id;
environmentType = environments[0].type;
}
// userId is enriched higher up in `_app/route.tsx`; only stamp tenant fields here.
tenantContext.enrich({
orgId: project.organization.id,
projectId: project.id,
projectRef: project.externalRef,
envId: environmentId,
envType: environmentType,
});
await updateCurrentProjectEnvironmentId({ user: user, projectId: project.id, environmentId });
// Resolve dashboard-agent access here (single source of truth: global env,
// admins/impersonators, then the global/per-org feature flag, default off) so
// the launcher button is hidden when it's not enabled. The org's featureFlags
// came from the membership-checked project query above, so we pass them in to
// avoid a second org lookup.
const hasDashboardAgentAccess = await canAccessDashboardAgent({
userId: user.id,
isAdmin: user.admin,
isImpersonating: user.isImpersonating,
organizationSlug,
orgFeatureFlags: (project.organization.featureFlags as Record<string, unknown>) ?? {},
});
return {
...project,
hasDashboardAgentAccess,
};
};
export default function Page() {
const { hasDashboardAgentAccess } = useLoaderData<typeof loader>();
return (
<DashboardAgent hasAccess={hasDashboardAgentAccess}>
<Outlet />
</DashboardAgent>
);
}
// Caught here (inside the project SideMenu's Outlet) rather than at the project
// layout, so a permission denial or error on any env-scoped page renders in the
// content pane with the SideMenu intact. RouteErrorDisplay renders the
// permission panel for a 403 and the generic error otherwise.
export function ErrorBoundary() {
return <RouteErrorDisplay />;
}