Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,51 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { createMetrics } from "~/modules/metrics/services/.server/MetricTracker";
|
||||
import { adminGetAllTenantsWithUsage, type TenantWithUsage } from "~/utils/db/tenants.db.server";
|
||||
import { getPaginationFromCurrentUrl } from "~/utils/helpers/RowPaginationHelper";
|
||||
import PeriodHelper from "~/utils/helpers/PeriodHelper";
|
||||
import { promiseHash } from "~/utils/promises/promiseHash";
|
||||
import { getAdminDashboardStats } from "~/utils/services/adminDashboardService";
|
||||
import { getSetupSteps } from "~/utils/services/setupService";
|
||||
import type { SetupItem } from "~/application/dtos/setup/SetupItem";
|
||||
import type { Stat } from "~/application/dtos/stats/Stat";
|
||||
import type { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
|
||||
export type AdminDashboardLoaderData = {
|
||||
title: string;
|
||||
stats: Stat[];
|
||||
setupSteps: SetupItem[];
|
||||
tenants: {
|
||||
items: TenantWithUsage[];
|
||||
pagination: PaginationDto;
|
||||
};
|
||||
};
|
||||
|
||||
type AdminDashboardDataArgs = Pick<LoaderFunctionArgs, "request" | "params">;
|
||||
|
||||
export async function loadAdminDashboardRouteData({ request, params }: AdminDashboardDataArgs) {
|
||||
const { time, getServerTimingHeader } = await createMetrics({ request, params }, "admin.dashboard");
|
||||
const urlSearchParams = new URL(request.url).searchParams;
|
||||
const currentPagination = getPaginationFromCurrentUrl(urlSearchParams);
|
||||
|
||||
const { t } = await time(getTranslations(request), "getTranslations");
|
||||
const { stats, setupSteps, tenants } = await time(
|
||||
promiseHash({
|
||||
stats: time(
|
||||
getAdminDashboardStats({ t, gte: PeriodHelper.getGreaterThanOrEqualsFromRequest({ request }) }),
|
||||
"admin.dashboard.details.getAdminDashboardStats"
|
||||
),
|
||||
setupSteps: time(getSetupSteps(), "admin.dashboard.details.getSetupSteps"),
|
||||
tenants: time(adminGetAllTenantsWithUsage(undefined, currentPagination), "admin.dashboard.details.adminGetAllTenantsWithUsage"),
|
||||
}),
|
||||
"admin.dashboard.details"
|
||||
);
|
||||
|
||||
const data: AdminDashboardLoaderData = {
|
||||
title: `${t("app.sidebar.dashboard")} | ${process.env.APP_NAME}`,
|
||||
stats,
|
||||
setupSteps,
|
||||
tenants,
|
||||
};
|
||||
return { data, headers: getServerTimingHeader() };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { readRepoFile } from "~/utils/testing/repoPaths";
|
||||
|
||||
function readAdminDataSource() {
|
||||
return readRepoFile("apps/web/app/utils/data/.server/adminData.ts");
|
||||
}
|
||||
|
||||
describe("admin data MFA enforcement boundary", () => {
|
||||
test("does not import MFA secret or recovery-code storage directly", () => {
|
||||
const source = readAdminDataSource();
|
||||
|
||||
expect(source).not.toMatch(/mfaRecoveryCode|totpSecret|encryptedTotpSecret/i);
|
||||
});
|
||||
|
||||
test("routes shared admin loader through the MFA enforcement boundary", () => {
|
||||
const source = readAdminDataSource();
|
||||
|
||||
expect(source).toContain("getUserTotpStatus");
|
||||
expect(source).toContain("checkMfaStepUpState");
|
||||
expect(source).toContain("enforceAdminMfa");
|
||||
expect(source).toMatch(/if \(!mfaEnforcement\.allowed\) \{\s*throw redirect\(mfaEnforcement\.redirectTo\);\s*\}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { DefaultAdminRoles } from "~/application/dtos/shared/DefaultAdminRoles";
|
||||
import { DefaultPermission } from "~/application/dtos/shared/DefaultPermissions";
|
||||
import { TimeFunction } from "~/modules/metrics/services/.server/MetricTracker";
|
||||
import OnboardingService from "~/modules/onboarding/services/OnboardingService";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { getAllEntities } from "~/utils/db/entities/entities.db.server";
|
||||
import { getAllEntityGroups } from "~/utils/db/entities/entityGroups.db.server";
|
||||
import { getMyGroups } from "~/utils/db/permissions/groups.db.server";
|
||||
import { getAllRolesWithoutPermissions } from "~/utils/db/permissions/roles.db.server";
|
||||
import { getPermissionsByUser, getUserRoleInAdmin } from "~/utils/db/permissions/userRoles.db.server";
|
||||
import { getTenantsForUser as getMyTenants } from "@toolctrl/db/auth-boundary.server";
|
||||
import { getAllTenantTypes } from "~/utils/db/tenants/tenantTypes.db.server";
|
||||
import { getUserTotpStatus } from "~/utils/db/users.db.server";
|
||||
import { enforceAdminMfa } from "~/utils/auth/mfa/enforcement.server";
|
||||
import { checkMfaStepUpState } from "~/utils/auth/mfa/step-up.server";
|
||||
import { promiseHash } from "~/utils/promises/promiseHash";
|
||||
import { getUserInfo, getUserSession } from "~/utils/session.server";
|
||||
import { AdminLoaderData } from "../useAdminData";
|
||||
import { getAuthUserWithoutPasswordById as getUser } from "@toolctrl/db/auth-boundary.server";
|
||||
import { TFunction } from "i18next";
|
||||
import { redirect } from "react-router";
|
||||
import { NOT_FOUND_ENVELOPE } from "~/utils/errors.server";
|
||||
|
||||
export async function loadAdminData({ request, t }: { request: Request; t: TFunction }, time: TimeFunction) {
|
||||
const userInfo = await time(getUserInfo(request), "getUserInfo");
|
||||
const url = new URL(request.url);
|
||||
if (UrlUtils.stripTrailingSlash(url.pathname) === `/admin`) {
|
||||
throw Response.json(NOT_FOUND_ENVELOPE, { status: 404 });
|
||||
}
|
||||
const user = await time(getUser(userInfo?.userId), "getUser");
|
||||
const redirectTo = url.pathname + url.search;
|
||||
if (!userInfo || !user) {
|
||||
let searchParams = new URLSearchParams([["redirect", redirectTo]]);
|
||||
throw redirect(`/login?${searchParams}`);
|
||||
}
|
||||
|
||||
if (!user.admin) {
|
||||
throw Response.json({ error: t("admin.errors.adminOnly") }, { status: 401 });
|
||||
}
|
||||
|
||||
const session = await time(getUserSession(request), "getUserSession");
|
||||
const totpStatus = await time(getUserTotpStatus(user.id), "getUserTotpStatus");
|
||||
const stepUp = checkMfaStepUpState(session, user.id);
|
||||
const mfaEnforcement = enforceAdminMfa({
|
||||
requestUrl: url,
|
||||
userId: user.id,
|
||||
isAdmin: Boolean(user.admin),
|
||||
totpStatus,
|
||||
stepUp,
|
||||
redirectMode: "host-aware",
|
||||
});
|
||||
if (!mfaEnforcement.allowed) {
|
||||
throw redirect(mfaEnforcement.redirectTo);
|
||||
}
|
||||
|
||||
const myTenants = await time(getMyTenants(user.id), "getMyTenants");
|
||||
|
||||
const { allPermissions, superAdminRole, entities, entityGroups, allRoles, myGroups, onboardingSession } = await time(
|
||||
promiseHash({
|
||||
allPermissions: getPermissionsByUser(userInfo.userId, null),
|
||||
superAdminRole: getUserRoleInAdmin(userInfo.userId, DefaultAdminRoles.SuperAdmin),
|
||||
entities: getAllEntities({ tenantId: null }),
|
||||
entityGroups: getAllEntityGroups(),
|
||||
allRoles: getAllRolesWithoutPermissions("admin"),
|
||||
myGroups: getMyGroups(user.id, null),
|
||||
onboardingSession: OnboardingService.getUserActiveOnboarding({ userId: user.id, tenantId: null, request }),
|
||||
}),
|
||||
"loadAdminData.getDetails"
|
||||
);
|
||||
const data: AdminLoaderData = {
|
||||
user,
|
||||
myTenants,
|
||||
currentTenant: null,
|
||||
entities,
|
||||
entityGroups,
|
||||
// roles,
|
||||
allRoles,
|
||||
permissions: allPermissions.map((f) => f as DefaultPermission),
|
||||
isSuperUser: !!superAdminRole,
|
||||
isSuperAdmin: !!superAdminRole,
|
||||
myGroups,
|
||||
lng: user?.locale ?? userInfo.lng,
|
||||
onboardingSession,
|
||||
tenantTypes: await getAllTenantTypes(),
|
||||
};
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TenantUserType } from "~/application/enums/tenants/TenantUserType";
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
};
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const postDetails = {
|
||||
tenantUser: deferred<{ type: TenantUserType }>(),
|
||||
tenantTypes: deferred<unknown[]>(),
|
||||
credits: deferred<unknown>(),
|
||||
productAccess: deferred<{ allowed: boolean }>(),
|
||||
};
|
||||
return { postDetails };
|
||||
});
|
||||
|
||||
vi.mock("~/utils/services/.server/urlService", () => ({ getTenantIdFromUrl: vi.fn(async () => "tenant_1") }));
|
||||
vi.mock("~/utils/session.server", () => ({ getUserInfo: vi.fn(async () => ({ userId: "user_1", lng: "en" })) }));
|
||||
vi.mock("@toolctrl/db/auth-boundary.server", () => ({
|
||||
getAuthUserWithoutPasswordById: vi.fn(async () => ({ id: "user_1", admin: false, locale: "en" })),
|
||||
getTenantsForUser: vi.fn(async () => [{ id: "tenant_1", slug: "acme", name: "Acme" }]),
|
||||
}));
|
||||
vi.mock("~/utils/db/tenants.db.server", () => ({
|
||||
adminGetAllActiveTenantSimple: vi.fn(async () => []),
|
||||
getTenantSimple: vi.fn(async () => ({ id: "tenant_1", slug: "acme", name: "Acme", deactivatedReason: null })),
|
||||
getTenantUserType: vi.fn(() => mocks.postDetails.tenantUser.promise),
|
||||
}));
|
||||
vi.mock("@toolctrl/config", () => ({ loadRequiredWebConfig: vi.fn(() => ({ routing: {} })) }));
|
||||
vi.mock("~/utils/db/entities/entities.db.server", () => ({ getAllEntities: vi.fn(async () => []) }));
|
||||
vi.mock("~/utils/db/entities/entityGroups.db.server", () => ({ getAllEntityGroups: vi.fn(async () => []) }));
|
||||
vi.mock("~/utils/db/permissions/groups.db.server", () => ({ getMyGroups: vi.fn(async () => []) }));
|
||||
vi.mock("~/utils/db/permissions/roles.db.server", () => ({ getAllRolesWithoutPermissions: vi.fn(async () => []) }));
|
||||
vi.mock("~/utils/db/permissions/userRoles.db.server", () => ({
|
||||
getPermissionsByUser: vi.fn(async () => []),
|
||||
getUserRoleInAdmin: vi.fn(async () => null),
|
||||
getUserRoleInTenant: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("~/utils/db/tenants/tenantTypes.db.server", () => ({ getAllTenantTypes: vi.fn(() => mocks.postDetails.tenantTypes.promise) }));
|
||||
vi.mock("~/utils/services/.server/subscriptionService", () => ({
|
||||
getActiveTenantSubscriptions: vi.fn(async () => ({ products: [] })),
|
||||
getPlanFeatureUsage: vi.fn(() => mocks.postDetails.credits.promise),
|
||||
}));
|
||||
vi.mock("~/modules/onboarding/services/OnboardingService", () => ({ default: { getUserActiveOnboarding: vi.fn(async () => null) } }));
|
||||
vi.mock("~/utils/entitlements/.server/productAccess.server", () => ({
|
||||
resolveTenantProductAccess: vi.fn(() => mocks.postDetails.productAccess.promise),
|
||||
}));
|
||||
|
||||
import { getTenantUserType } from "~/utils/db/tenants.db.server";
|
||||
import { getAllTenantTypes } from "~/utils/db/tenants/tenantTypes.db.server";
|
||||
import { getPlanFeatureUsage } from "~/utils/services/.server/subscriptionService";
|
||||
import { resolveTenantProductAccess } from "~/utils/entitlements/.server/productAccess.server";
|
||||
import { loadAppData } from "./appData";
|
||||
|
||||
async function flushAsyncWork() {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
describe("loadAppData performance", () => {
|
||||
it("starts post-detail shell lookups in parallel", async () => {
|
||||
const result = loadAppData({
|
||||
request: new Request("https://app.example.test/acme/dashboard"),
|
||||
params: { tenant: "acme" },
|
||||
t: ((key: string) => key) as never,
|
||||
});
|
||||
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(getTenantUserType).toHaveBeenCalledWith("user_1", "tenant_1");
|
||||
expect(getAllTenantTypes).toHaveBeenCalled();
|
||||
expect(getPlanFeatureUsage).toHaveBeenCalledWith("tenant_1", "ai-credits");
|
||||
expect(resolveTenantProductAccess).toHaveBeenCalled();
|
||||
|
||||
mocks.postDetails.tenantUser.resolve({ type: TenantUserType.MEMBER });
|
||||
mocks.postDetails.tenantTypes.resolve([]);
|
||||
mocks.postDetails.credits.resolve(undefined);
|
||||
mocks.postDetails.productAccess.resolve({ allowed: true });
|
||||
|
||||
await expect(result).resolves.toMatchObject({
|
||||
currentRole: TenantUserType.MEMBER,
|
||||
tenantTypes: [],
|
||||
productAccess: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("~/utils/services/.server/urlService", () => ({ getTenantIdFromUrl: vi.fn(async () => "tenant_1") }));
|
||||
vi.mock("~/utils/session.server", () => ({ getUserInfo: vi.fn(async () => ({ userId: "user_1", lng: "en" })) }));
|
||||
vi.mock("@toolctrl/db/auth-boundary.server", () => ({
|
||||
getAuthUserWithoutPasswordById: vi.fn(async () => ({ id: "user_1", admin: false, locale: "en" })),
|
||||
getTenantsForUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/utils/db/tenants.db.server", () => ({
|
||||
getTenantSimple: vi.fn(async () => ({ id: "tenant_1", slug: "acme", name: "Acme", deactivatedReason: null })),
|
||||
getTenantUserType: vi.fn(),
|
||||
}));
|
||||
vi.mock("@toolctrl/config", () => ({ loadRequiredWebConfig: vi.fn(() => ({ routing: {} })) }));
|
||||
vi.mock("~/utils/db/entities/entities.db.server", () => ({ getAllEntities: vi.fn() }));
|
||||
vi.mock("~/utils/db/entities/entityGroups.db.server", () => ({ getAllEntityGroups: vi.fn() }));
|
||||
vi.mock("~/utils/db/permissions/groups.db.server", () => ({ getMyGroups: vi.fn() }));
|
||||
vi.mock("~/utils/db/permissions/roles.db.server", () => ({ getAllRolesWithoutPermissions: vi.fn() }));
|
||||
vi.mock("~/utils/db/permissions/userRoles.db.server", () => ({
|
||||
getPermissionsByUser: vi.fn(),
|
||||
getUserRoleInAdmin: vi.fn(),
|
||||
getUserRoleInTenant: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/utils/db/tenants/tenantTypes.db.server", () => ({ getAllTenantTypes: vi.fn() }));
|
||||
vi.mock("~/utils/services/.server/subscriptionService", () => ({ getActiveTenantSubscriptions: vi.fn(), getPlanFeatureUsage: vi.fn() }));
|
||||
vi.mock("~/modules/onboarding/services/OnboardingService", () => ({ default: { getUserActiveOnboarding: vi.fn() } }));
|
||||
|
||||
import { loadAppData } from "./appData";
|
||||
|
||||
describe("loadAppData tenant canonicalization", () => {
|
||||
it("redirects tenant id aliases to the canonical tenant slug and preserves path/search", async () => {
|
||||
try {
|
||||
await loadAppData({
|
||||
request: new Request("https://app.example.test/tenant_1/settings/profile?tab=security"),
|
||||
params: { tenant: "tenant_1" },
|
||||
t: ((key: string) => key) as never,
|
||||
});
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Response);
|
||||
expect((error as Response).headers.get("Location")).toBe("/acme/settings/profile?tab=security");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { readRepoFile } from "~/utils/testing/repoPaths";
|
||||
|
||||
describe("loadAppData tenant role lookup", () => {
|
||||
test("calls getTenantUserType with user id before tenant id", () => {
|
||||
const source = readRepoFile("apps/web/app/utils/data/.server/appData.ts");
|
||||
|
||||
expect(source).toContain("getTenantUserType(userInfo.userId, tenantId)");
|
||||
expect(source).not.toContain("getTenantUserType(tenantId, userInfo.userId)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Params, redirect } from "react-router";
|
||||
import { DefaultAdminRoles } from "~/application/dtos/shared/DefaultAdminRoles";
|
||||
import { DefaultAppRoles } from "~/application/dtos/shared/DefaultAppRoles";
|
||||
import { DefaultEntityTypes } from "~/application/dtos/shared/DefaultEntityTypes";
|
||||
import { DefaultPermission } from "~/application/dtos/shared/DefaultPermissions";
|
||||
import { TenantUserType } from "~/application/enums/tenants/TenantUserType";
|
||||
import { timeFake, TimeFunction } from "~/modules/metrics/services/.server/MetricTracker";
|
||||
import OnboardingService from "~/modules/onboarding/services/OnboardingService";
|
||||
import { absoluteAppRootPath } from "~/utils/app/CrossHostUrls";
|
||||
import { appPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { loadRequiredWebConfig } from "@toolctrl/config";
|
||||
import { getAllEntities } from "~/utils/db/entities/entities.db.server";
|
||||
import { getAllEntityGroups } from "~/utils/db/entities/entityGroups.db.server";
|
||||
import { getMyGroups } from "~/utils/db/permissions/groups.db.server";
|
||||
import { getAllRolesWithoutPermissions } from "~/utils/db/permissions/roles.db.server";
|
||||
import { getPermissionsByUser, getUserRoleInTenant, getUserRoleInAdmin } from "~/utils/db/permissions/userRoles.db.server";
|
||||
import { adminGetAllActiveTenantSimple, getTenantSimple, getTenantUserType } from "~/utils/db/tenants.db.server";
|
||||
import { getAuthUserWithoutPasswordById as getUser, getTenantsForUser as getMyTenants } from "@toolctrl/db/auth-boundary.server";
|
||||
import { getAllTenantTypes } from "~/utils/db/tenants/tenantTypes.db.server";
|
||||
import { promiseHash } from "~/utils/promises/promiseHash";
|
||||
import { getTenantIdFromUrl } from "~/utils/services/.server/urlService";
|
||||
import { getActiveTenantSubscriptions, getPlanFeatureUsage } from "~/utils/services/.server/subscriptionService";
|
||||
import { getUserInfo } from "~/utils/session.server";
|
||||
import { AppLoaderData } from "../useAppData";
|
||||
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
|
||||
import { CreditTypes } from "~/modules/usage/dtos/CreditType";
|
||||
import { DefaultFeatures } from "~/application/dtos/shared/DefaultFeatures";
|
||||
import { TFunction } from "i18next";
|
||||
import { featureKeys, type FeatureKey } from "@toolctrl/entitlements";
|
||||
import { resolveTenantProductAccess } from "~/utils/entitlements/.server/productAccess.server";
|
||||
import { WALKING_SKELETON_MODULE_KEY } from "~/modules/walkingSkeleton/constants";
|
||||
|
||||
const tenantNavigationFeatureKeys = [
|
||||
featureKeys.crmBasic,
|
||||
featureKeys.emailMarketing,
|
||||
featureKeys.api,
|
||||
featureKeys.workflowEngine,
|
||||
featureKeys.credits,
|
||||
] as const satisfies readonly FeatureKey[];
|
||||
|
||||
// R-P1c: Konnektor-Demo-Modul (kein Entitlement-FeatureKey) — speist das Sidebar-Gate der
|
||||
// Walking-Skeleton-Route. Liefert für reale Tenants "blueprint-disabled" → Eintrag ausgeblendet.
|
||||
const tenantConnectorDemoModuleKeys: readonly string[] = [WALKING_SKELETON_MODULE_KEY];
|
||||
|
||||
export async function loadAppData({
|
||||
request,
|
||||
params,
|
||||
t,
|
||||
time = timeFake,
|
||||
tenantId: preloadedTenantId,
|
||||
userInfo: preloadedUserInfo,
|
||||
user: preloadedUser,
|
||||
}: {
|
||||
request: Request;
|
||||
params: Params;
|
||||
t: TFunction;
|
||||
time?: TimeFunction;
|
||||
tenantId?: string;
|
||||
userInfo?: Awaited<ReturnType<typeof getUserInfo>>;
|
||||
user?: Awaited<ReturnType<typeof getUser>>;
|
||||
}) {
|
||||
const { tenantId, userInfo } = await time(
|
||||
promiseHash({
|
||||
tenantId: preloadedTenantId ? Promise.resolve(preloadedTenantId) : getTenantIdFromUrl(params),
|
||||
userInfo: preloadedUserInfo ? Promise.resolve(preloadedUserInfo) : getUserInfo(request),
|
||||
}),
|
||||
"loadAppData.session"
|
||||
);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const { user, currentTenant } = await time(
|
||||
promiseHash({
|
||||
user: preloadedUser ? Promise.resolve(preloadedUser) : getUser(userInfo?.userId),
|
||||
currentTenant: getTenantSimple(tenantId),
|
||||
}),
|
||||
"loadAppData.getUserAndTenant"
|
||||
);
|
||||
|
||||
const redirectTo = url.pathname + url.search;
|
||||
if (!userInfo || !user) {
|
||||
let searchParams = new URLSearchParams([["redirect", redirectTo]]);
|
||||
throw redirect(`/login?${searchParams}`);
|
||||
}
|
||||
|
||||
if (!currentTenant) {
|
||||
throw redirect(absoluteAppRootPath(loadRequiredWebConfig().routing));
|
||||
}
|
||||
const [, tenantPathSegment = "", ...childPathSegments] = url.pathname.split("/");
|
||||
const childPath = childPathSegments.join("/");
|
||||
if (params.tenant && params.tenant !== currentTenant.slug) {
|
||||
throw redirect(`${appPath({ tenantSlug: currentTenant.slug, path: childPath || "dashboard", mode: "host-aware" })}${url.search}`);
|
||||
}
|
||||
if (UrlUtils.stripTrailingSlash(url.pathname) === UrlUtils.stripTrailingSlash(appPath({ tenantSlug: tenantPathSegment, mode: "host-aware" }))) {
|
||||
throw redirect(`${appPath({ tenantSlug: currentTenant.slug, path: "dashboard", mode: "host-aware" })}${url.search}`);
|
||||
}
|
||||
let { myTenants, mySubscription, allPermissions, superUserRole, superAdminRole, entities, entityGroups, allRoles, myGroups, onboardingSession } = await time(
|
||||
promiseHash({
|
||||
myTenants: time(user.admin ? adminGetAllActiveTenantSimple() : getMyTenants(user.id), "loadAppData.getDetails.getMyTenants"),
|
||||
mySubscription: time(getActiveTenantSubscriptions(tenantId), "loadAppData.getDetails.getActiveTenantSubscriptions"),
|
||||
allPermissions: time(getPermissionsByUser(userInfo.userId, tenantId), "loadAppData.getDetails.getPermissionsByUser"),
|
||||
superUserRole: time(getUserRoleInTenant(userInfo.userId, tenantId, DefaultAppRoles.SuperUser), "loadAppData.getDetails.getUserRoleInTenant"),
|
||||
superAdminRole: time(getUserRoleInAdmin(userInfo.userId, DefaultAdminRoles.SuperAdmin), "loadAppData.getDetails.getUserRoleInAdmin"),
|
||||
entities: time(
|
||||
getAllEntities({ tenantId, active: true, types: [DefaultEntityTypes.All, DefaultEntityTypes.AppOnly] }),
|
||||
"loadAppData.getDetails.getAllEntities"
|
||||
),
|
||||
entityGroups: time(getAllEntityGroups(), "loadAppData.getDetails.getAllEntityGroups()"),
|
||||
allRoles: time(getAllRolesWithoutPermissions("app"), "loadAppData.getDetails.getAllRolesWithoutPermissions"),
|
||||
myGroups: time(getMyGroups(user.id, currentTenant.id), "loadAppData.getDetails.getMyGroups"),
|
||||
onboardingSession: time(
|
||||
OnboardingService.getUserActiveOnboarding({ userId: user.id, tenantId: currentTenant.id, request }),
|
||||
"loadAppData.getDetails.OnboardingService.getUserActiveOnboarding"
|
||||
),
|
||||
}),
|
||||
"loadAppData.getDetails"
|
||||
);
|
||||
|
||||
const { tenantUser, tenantTypes, credits, productAccessEntries } = await time(
|
||||
promiseHash({
|
||||
tenantUser: time(getTenantUserType(userInfo.userId, tenantId), "loadAppData.postDetails.getTenantUserType"),
|
||||
tenantTypes: time(getAllTenantTypes(), "loadAppData.postDetails.getAllTenantTypes"),
|
||||
credits:
|
||||
CreditTypes.length > 0
|
||||
? time(getPlanFeatureUsage(tenantId, DefaultFeatures.Credits), "loadAppData.postDetails.getPlanFeatureUsage")
|
||||
: Promise.resolve(undefined as PlanFeatureUsageDto | undefined),
|
||||
productAccessEntries: time(
|
||||
Promise.all(
|
||||
[...tenantNavigationFeatureKeys, ...tenantConnectorDemoModuleKeys].map(async (featureKey) => [
|
||||
featureKey,
|
||||
await resolveTenantProductAccess({
|
||||
tenantId,
|
||||
userId: userInfo.userId,
|
||||
featureKey,
|
||||
subscription: mySubscription,
|
||||
tenantRolePermissionNames: allPermissions,
|
||||
bypassTenantRolePermission: !!user.admin || !!superAdminRole,
|
||||
}),
|
||||
] as const)
|
||||
),
|
||||
"loadAppData.postDetails.resolveTenantProductAccess"
|
||||
),
|
||||
}),
|
||||
"loadAppData.postDetails"
|
||||
);
|
||||
const currentRole = user.admin ? TenantUserType.ADMIN : tenantUser?.type ?? TenantUserType.MEMBER;
|
||||
const data: AppLoaderData = {
|
||||
// i18n: i18n.translations,
|
||||
user,
|
||||
myTenants,
|
||||
currentTenant,
|
||||
currentRole,
|
||||
mySubscription,
|
||||
entities,
|
||||
entityGroups,
|
||||
// roles,
|
||||
allRoles,
|
||||
permissions: allPermissions.map((f) => f as DefaultPermission),
|
||||
myGroups,
|
||||
isSuperUser: !!superUserRole,
|
||||
isSuperAdmin: !!superAdminRole,
|
||||
lng: user?.locale ?? userInfo.lng,
|
||||
onboardingSession,
|
||||
tenantTypes,
|
||||
credits,
|
||||
productAccess: Object.fromEntries(productAccessEntries),
|
||||
};
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readRepoFile } from "~/utils/testing/repoPaths";
|
||||
|
||||
const dataLoaderPaths = [
|
||||
{
|
||||
path: "apps/web/app/utils/data/.server/rootData.ts",
|
||||
blockedImports: ["~/utils/db/users.db.server", "~/utils/db/tenants.db.server"],
|
||||
},
|
||||
{
|
||||
path: "apps/web/app/utils/data/.server/appData.ts",
|
||||
blockedImports: ["~/utils/db/users.db.server"],
|
||||
},
|
||||
{
|
||||
path: "apps/web/app/utils/data/.server/adminData.ts",
|
||||
blockedImports: ["~/utils/db/tenants.db.server"],
|
||||
},
|
||||
];
|
||||
|
||||
const tenantSelectionRoutePaths = [
|
||||
"apps/web/app/routes/app.tsx",
|
||||
"apps/web/app/routes/login.tsx",
|
||||
];
|
||||
|
||||
function readLoader(relativePath: string) {
|
||||
return readRepoFile(relativePath);
|
||||
}
|
||||
|
||||
describe("auth boundary imports for shared data loaders", () => {
|
||||
it.each(dataLoaderPaths)("imports migrated auth/user/tenant reads from @toolctrl/db in $path", ({ path: relativePath }) => {
|
||||
const source = readLoader(relativePath);
|
||||
|
||||
expect(source).toContain("@toolctrl/db/auth-boundary.server");
|
||||
});
|
||||
|
||||
it.each(dataLoaderPaths)("does not import migrated app DB wrappers in $path", ({ path: relativePath, blockedImports }) => {
|
||||
const source = readLoader(relativePath);
|
||||
|
||||
for (const importPath of blockedImports) {
|
||||
expect(source).not.toContain(`from "${importPath}"`);
|
||||
expect(source).not.toContain(`from '${importPath}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth boundary imports for tenant selection routes", () => {
|
||||
it.each(tenantSelectionRoutePaths)("imports migrated auth/user/tenant reads from @toolctrl/db in %s", (relativePath) => {
|
||||
const source = readLoader(relativePath);
|
||||
|
||||
expect(source).toContain("@toolctrl/db/auth-boundary.server");
|
||||
});
|
||||
|
||||
it.each(tenantSelectionRoutePaths)("does not import migrated app DB wrappers in %s", (relativePath) => {
|
||||
const source = readLoader(relativePath);
|
||||
|
||||
for (const importPath of ["~/utils/db/users.db.server", "~/utils/db/tenants.db.server"]) {
|
||||
expect(source).not.toContain(`from "${importPath}"`);
|
||||
expect(source).not.toContain(`from '${importPath}'`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { loadRequiredWebConfig } from "@toolctrl/config";
|
||||
import { getAuthUserWithoutPasswordById as getUser, getTenantById as getTenant, getTenantByIdOrSlug } from "@toolctrl/db/auth-boundary.server";
|
||||
import { Params } from "react-router";
|
||||
import { AnalyticsInfoDto } from "~/application/dtos/marketing/AnalyticsInfoDto";
|
||||
import { ImpersonatingSessionDto } from "~/application/dtos/session/ImpersonatingSessionDto";
|
||||
import { CookieCategory } from "~/application/cookies/CookieCategory";
|
||||
import { remixI18Next, i18nCookie } from "~/locale/i18next.server";
|
||||
import { getCurrentEnabledFlagNames } from "~/utils/services/.server/featureFlagsRequest.server";
|
||||
import { createMetrics } from "~/modules/metrics/services/.server/MetricTracker";
|
||||
import { getAnalyticsSession, generateAnalyticsUserId, commitAnalyticsSession, destroyAnalyticsSession } from "~/utils/analyticsCookie.server";
|
||||
import { getAppConfiguration } from "~/utils/db/appConfiguration.db.server";
|
||||
import AnalyticsService from "~/utils/helpers/.server/AnalyticsService";
|
||||
import CookieHelper from "~/utils/helpers/CookieHelper";
|
||||
import { promiseHash } from "~/utils/promises/promiseHash";
|
||||
import { getUserInfo, getUserSession, setCSRFToken, createUserSession, commitSession } from "~/utils/session.server";
|
||||
import { getAbsoluteApiUrl, getAbsoluteUrl, getBaseURL, getDomainName } from "~/utils/url.server";
|
||||
import { AppRootData } from "../useRootData";
|
||||
|
||||
export async function loadRootData({ request, params }: { request: Request; params: Params }) {
|
||||
const { time, getServerTimingHeader } = await createMetrics({ request, params }, "root");
|
||||
const { userInfo, session, analyticsSession } = await time(
|
||||
promiseHash({
|
||||
userInfo: getUserInfo(request),
|
||||
session: getUserSession(request),
|
||||
analyticsSession: getAnalyticsSession(request),
|
||||
}),
|
||||
"loadRootData.session"
|
||||
);
|
||||
const user = userInfo.userId ? await getUser(userInfo.userId) : null;
|
||||
const currentTenant = params.tenant ? await getTenantByIdOrSlug(params.tenant) : user?.defaultTenantId ? await getTenant(user.defaultTenantId) : null;
|
||||
|
||||
const existingCsrf = session.get("csrf");
|
||||
const csrf = typeof existingCsrf === "string" && existingCsrf.length > 0 ? existingCsrf : setCSRFToken(session);
|
||||
|
||||
let analytics: AnalyticsInfoDto | undefined = undefined;
|
||||
const appConfiguration = await time(getAppConfiguration({ request }), "getAppConfiguration");
|
||||
const hasAnalyticsConsent = CookieHelper.hasConsent(userInfo, CookieCategory.ANALYTICS);
|
||||
if (appConfiguration.analytics.enabled && hasAnalyticsConsent) {
|
||||
try {
|
||||
analytics = await AnalyticsService.getFromRequest({ appConfiguration, request, userId: userInfo.userId ?? null });
|
||||
} catch (e: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
let userAnalyticsId = analyticsSession.get("userAnalyticsId");
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
if (hasAnalyticsConsent) {
|
||||
if (!userAnalyticsId) {
|
||||
userAnalyticsId = generateAnalyticsUserId();
|
||||
analyticsSession.set("userAnalyticsId", userAnalyticsId);
|
||||
}
|
||||
if (analytics) {
|
||||
analytics = { ...analytics, userAnalyticsId };
|
||||
}
|
||||
headers.append("Set-Cookie", await commitAnalyticsSession(analyticsSession));
|
||||
} else {
|
||||
userAnalyticsId = undefined;
|
||||
headers.append("Set-Cookie", await destroyAnalyticsSession(analyticsSession));
|
||||
}
|
||||
|
||||
let impersonatingSession: ImpersonatingSessionDto | null = null;
|
||||
if (userInfo.impersonatingFromUserId && userInfo.userId?.length > 0) {
|
||||
const fromUser = await getUser(userInfo.impersonatingFromUserId);
|
||||
const toUser = await getUser(userInfo.userId);
|
||||
if (fromUser && toUser) {
|
||||
impersonatingSession = { fromUser, toUser };
|
||||
}
|
||||
}
|
||||
|
||||
const locale = await remixI18Next.getLocale(request);
|
||||
const webConfig = loadRequiredWebConfig();
|
||||
const data: AppRootData = {
|
||||
metatags: [{ title: webConfig.appName }],
|
||||
user,
|
||||
currentTenant,
|
||||
locale,
|
||||
theme: userInfo.theme || appConfiguration.app.theme,
|
||||
serverUrl: getBaseURL(request),
|
||||
publicBaseUrl: getAbsoluteUrl(request, "/"),
|
||||
apiBaseUrl: getAbsoluteApiUrl(request, "/"),
|
||||
routing: webConfig.routing,
|
||||
domainName: getDomainName(request),
|
||||
userSession: userInfo,
|
||||
authenticated: userInfo.userId?.length > 0,
|
||||
debug: webConfig.nodeEnv === "development",
|
||||
isStripeTest: webConfig.stripe.secretKey?.startsWith("sk_test_") ?? true,
|
||||
chatWebsiteId: webConfig.crisp.chatWebsiteId,
|
||||
appConfiguration,
|
||||
csrf,
|
||||
analytics,
|
||||
analyticsToken: appConfiguration.analytics.enabled && hasAnalyticsConsent ? webConfig.analytics.token : undefined,
|
||||
featureFlags: appConfiguration.featureFlags.enabled ? await getCurrentEnabledFlagNames({ request, params }) : [],
|
||||
impersonatingSession,
|
||||
};
|
||||
|
||||
const updateMetrics = userInfo.userId?.length > 0 && appConfiguration.metrics.enabled !== userInfo.metrics?.enabled;
|
||||
const needsToUpdateSession = updateMetrics;
|
||||
if (needsToUpdateSession) {
|
||||
return createUserSession(
|
||||
{
|
||||
...userInfo,
|
||||
metrics: appConfiguration.metrics,
|
||||
},
|
||||
new URL(request.url).pathname + new URL(request.url).search
|
||||
);
|
||||
}
|
||||
|
||||
headers.append("Server-Timing", getServerTimingHeader()["Server-Timing"]);
|
||||
headers.append("Set-Cookie", await i18nCookie.serialize(locale));
|
||||
return Response.json(data, { headers });
|
||||
}
|
||||
Reference in new issue
Block a user