84 lines
3.3 KiB
TypeScript
84 lines
3.3 KiB
TypeScript
import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "react-router";
|
|
import { serverOnly$ } from "vite-env-only/macros";
|
|
import { buildSelectTenantVM, type SelectTenantData, type TenantRole } from "@toolctrl/view-models";
|
|
import type { VMState } from "@toolctrl/view-models";
|
|
import { TenantUserType } from "~/application/enums/tenants/TenantUserType";
|
|
import { appPath } from "~/utils/app/CanonicalUrlUtils";
|
|
import { getMyTenants, getTenantUserType } from "~/utils/db/tenants.db.server";
|
|
import { getUser } from "~/utils/db/users.db.server";
|
|
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
|
|
|
|
// D-D0a — Flow "Login Tenantauswahl (Admin)". Tenant-/Profil-Auswahl nach dem Login.
|
|
// Der Loader baut serverseitig das VM (M013) aus getMyTenants + der Rolle des Users je Tenant; die
|
|
// Subframe-Karten-UI (select-tenant.component) rendert nur. Klick auf eine Karte postet hierher,
|
|
// die Action verifiziert die Mitgliedschaft serverseitig und leitet in den Tenant-Space.
|
|
// Route wird per Ordner-Konvention autoregistriert (select-tenant.tsx).
|
|
|
|
export type SelectTenantVM = VMState<SelectTenantData>;
|
|
|
|
export type LoaderData = {
|
|
vm: SelectTenantVM;
|
|
};
|
|
|
|
export type ActionData = {
|
|
error?: string;
|
|
};
|
|
|
|
function roleFromType(type: TenantUserType | null | undefined): TenantRole | null {
|
|
switch (type) {
|
|
case TenantUserType.OWNER:
|
|
return "owner";
|
|
case TenantUserType.ADMIN:
|
|
return "admin";
|
|
case TenantUserType.MEMBER:
|
|
return "member";
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export const loader = serverOnly$(async ({ request }: LoaderFunctionArgs) => {
|
|
const userInfo = await getUserInfo(request);
|
|
if (!userInfo.userId) {
|
|
throw redirect("/login");
|
|
}
|
|
const user = await getUser(userInfo.userId);
|
|
if (!user) {
|
|
throw redirect("/login");
|
|
}
|
|
const tenants = await getMyTenants(userInfo.userId);
|
|
const memberships = await Promise.all(tenants.map((tenant) => getTenantUserType(userInfo.userId, tenant.id)));
|
|
const vm = buildSelectTenantVM({
|
|
tenants: tenants.map((tenant, index) => ({
|
|
id: tenant.id,
|
|
name: tenant.name,
|
|
slug: tenant.slug,
|
|
image: tenant.icon ?? tenant.logo ?? null,
|
|
role: roleFromType(memberships[index]?.type),
|
|
subtitle: user.email,
|
|
})),
|
|
currentTenantId: user.defaultTenantId,
|
|
});
|
|
return Response.json({ vm } satisfies LoaderData);
|
|
})!;
|
|
|
|
export const action = serverOnly$(async ({ request }: ActionFunctionArgs) => {
|
|
const form = await request.formData();
|
|
await validateCSRFToken(request, { formData: form });
|
|
const userInfo = await getUserInfo(request);
|
|
if (!userInfo.userId) {
|
|
throw redirect("/login");
|
|
}
|
|
const tenantId = String(form.get("tenantId") ?? "");
|
|
const slug = String(form.get("slug") ?? "");
|
|
if (!tenantId || !slug) {
|
|
return Response.json({ error: "account.selectTenant.errors.invalidSelection" } satisfies ActionData, { status: 400 });
|
|
}
|
|
// Mitgliedschaft serverseitig verifizieren — der Client-Form nicht vertrauen.
|
|
const membership = await getTenantUserType(userInfo.userId, tenantId);
|
|
if (!membership) {
|
|
return Response.json({ error: "account.selectTenant.errors.notMember" } satisfies ActionData, { status: 403 });
|
|
}
|
|
throw redirect(appPath({ tenantSlug: slug, path: "dashboard", mode: "host-aware" }));
|
|
})!;
|