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,12 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { serverOnly$ } from "vite-env-only/macros";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
import { loadAdminDashboardRouteData, type AdminDashboardLoaderData } from "~/utils/data/.server/adminDashboardRouteData";
|
||||
|
||||
export type { AdminDashboardLoaderData };
|
||||
|
||||
export const loader = serverOnly$(async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireAuth({ request, params });
|
||||
const { data, headers } = await loadAdminDashboardRouteData({ request, params });
|
||||
return Response.json(data, { headers });
|
||||
})!;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Form, Link, useActionData, useLoaderData, useNavigation } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ADMIN_MFA_ENROLLMENT_PATH } from "~/utils/auth/mfa/paths";
|
||||
import { useRootData } from "~/utils/data/useRootData";
|
||||
import type { ActionData, LoaderData } from "~/routeModules/admin/mfa-challenge.impl";
|
||||
|
||||
export default function AdminMfaChallengeRoute() {
|
||||
const { t } = useTranslation();
|
||||
const data = useLoaderData<LoaderData>();
|
||||
const actionData = useActionData<ActionData>();
|
||||
const navigation = useNavigation();
|
||||
const rootData = useRootData();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-6 py-12">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold text-gray-900">{t("admin.mfaChallenge.title")}</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">{t("admin.mfaChallenge.description")}</p>
|
||||
{actionData?.error ? <p className="mt-4 rounded-md bg-red-50 p-3 text-sm text-red-700">{actionData.error}</p> : null}
|
||||
<Form method="post" className="mt-6 space-y-4">
|
||||
{rootData.csrf && <input type="hidden" name="csrf" value={rootData.csrf} />}
|
||||
<input type="hidden" name="redirectTo" value={actionData?.redirectTo ?? data.redirectTo} />
|
||||
<div>
|
||||
<label htmlFor="code" className="block text-sm font-medium text-gray-700">
|
||||
{t("admin.mfaChallenge.fields.authenticatorCode")}
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
name="code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 shadow-sm focus:border-gray-900 focus:outline-none focus:ring-1 focus:ring-gray-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="recoveryCode" className="block text-sm font-medium text-gray-700">
|
||||
{t("admin.mfaChallenge.fields.recoveryCode")}
|
||||
</label>
|
||||
<input
|
||||
id="recoveryCode"
|
||||
name="recoveryCode"
|
||||
autoComplete="off"
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 uppercase text-gray-900 shadow-sm focus:border-gray-900 focus:outline-none focus:ring-1 focus:ring-gray-900"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !data.totpEnabled}
|
||||
className="w-full rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isSubmitting ? t("admin.mfaChallenge.actions.verifying") : t("admin.mfaChallenge.actions.verifyAndContinue")}
|
||||
</button>
|
||||
</Form>
|
||||
<Link to={data.enrollmentPath || ADMIN_MFA_ENROLLMENT_PATH} className="mt-4 inline-block text-sm text-gray-600 hover:text-gray-900">
|
||||
{t("admin.mfaChallenge.actions.manageSettings")}
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "react-router";
|
||||
import { serverOnly$ } from "vite-env-only/macros";
|
||||
import { loadRequiredWebConfig } from "@toolctrl/config";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { ADMIN_MFA_DEFAULT_REDIRECT_PATH, ADMIN_MFA_ENROLLMENT_PATH } from "~/utils/auth/mfa/paths";
|
||||
import { setMfaStepUpState, type MfaStepUpMethod } from "~/utils/auth/mfa/step-up.server";
|
||||
import { verifyTotpCode } from "~/services/auth/totp.server";
|
||||
import { checkMfaChallengeRateLimit, mfaChallengeRateLimitKey, recordFailedMfaChallengeAttempt, resetMfaChallengeRateLimit } from "~/utils/auth/mfa/challenge-rate-limit.server";
|
||||
import { absoluteAdminPath } from "~/utils/app/CrossHostUrls";
|
||||
import { getHostRoutingContextFromRequest } from "~/utils/app/HostRouting.server";
|
||||
import { safeRedirectPath } from "~/utils/app/RedirectSafety.server";
|
||||
import { consumeUserMfaRecoveryCode, getUser, getUserTotpStatus } from "~/utils/db/users.db.server";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { commitSession, getUserSession, validateCSRFToken } from "~/utils/session.server";
|
||||
|
||||
export type LoaderData = {
|
||||
redirectTo: string;
|
||||
totpEnabled: boolean;
|
||||
enrollmentPath: string;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
error?: string;
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
function isAdminHostRequest(request: Request) {
|
||||
return getHostRoutingContextFromRequest(request) === "admin";
|
||||
}
|
||||
|
||||
function getCanonicalAdminChallengeUrl(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
return absoluteAdminPath(loadRequiredWebConfig().routing, `mfa-challenge${url.search}`);
|
||||
}
|
||||
|
||||
function safeRedirectTo(value: FormDataEntryValue | string | null | undefined) {
|
||||
return safeRedirectPath(typeof value === "string" ? value : null, { fallbackPath: ADMIN_MFA_DEFAULT_REDIRECT_PATH });
|
||||
}
|
||||
|
||||
function isSixDigitTotpCode(code: FormDataEntryValue | null): code is string {
|
||||
return typeof code === "string" && /^\d{6}$/.test(code);
|
||||
}
|
||||
|
||||
function normalizeRecoveryCode(code: FormDataEntryValue | null) {
|
||||
if (typeof code !== "string") {
|
||||
return "";
|
||||
}
|
||||
return code.trim().toUpperCase();
|
||||
}
|
||||
|
||||
async function getActiveTotpSecret(userId: string) {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { totpSecretEncrypted: true, totpEnabledAt: true },
|
||||
});
|
||||
|
||||
if (!user?.totpSecretEncrypted || !user.totpEnabledAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user.totpSecretEncrypted;
|
||||
}
|
||||
|
||||
export const loader = serverOnly$(async ({ request }: LoaderFunctionArgs) => {
|
||||
if (!isAdminHostRequest(request)) {
|
||||
throw redirect(getCanonicalAdminChallengeUrl(request));
|
||||
}
|
||||
|
||||
const session = await getUserSession(request);
|
||||
const userId = session.get("userId");
|
||||
if (typeof userId !== "string" || userId.length === 0) {
|
||||
throw redirect("/login");
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const { t } = await getTranslations(request);
|
||||
const redirectTo = safeRedirectTo(url.searchParams.get("redirectTo"));
|
||||
const user = await getUser(userId);
|
||||
if (!user?.admin) {
|
||||
throw Response.json({ error: t("admin.mfaChallenge.errors.adminAccessRequired"), redirectTo } satisfies ActionData, { status: 403 });
|
||||
}
|
||||
|
||||
const totpStatus = await getUserTotpStatus(userId);
|
||||
if (!totpStatus?.enabled) {
|
||||
throw redirect(ADMIN_MFA_ENROLLMENT_PATH);
|
||||
}
|
||||
|
||||
return Response.json({ redirectTo, totpEnabled: true, enrollmentPath: ADMIN_MFA_ENROLLMENT_PATH } satisfies LoaderData);
|
||||
})!;
|
||||
|
||||
export const action = serverOnly$(async ({ request }: ActionFunctionArgs) => {
|
||||
const { t } = await getTranslations(request);
|
||||
if (!isAdminHostRequest(request)) {
|
||||
return Response.json(
|
||||
{ error: t("admin.mfaChallenge.errors.adminHostRequired"), redirectTo: getCanonicalAdminChallengeUrl(request) } satisfies ActionData,
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const session = await getUserSession(request);
|
||||
const userId = session.get("userId");
|
||||
if (typeof userId !== "string" || userId.length === 0) {
|
||||
throw redirect("/login");
|
||||
}
|
||||
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
const redirectTo = safeRedirectTo(form.get("redirectTo") ?? new URL(request.url).searchParams.get("redirectTo"));
|
||||
const user = await getUser(userId);
|
||||
if (!user?.admin) {
|
||||
return Response.json({ error: t("admin.mfaChallenge.errors.adminAccessRequired"), redirectTo } satisfies ActionData, { status: 403 });
|
||||
}
|
||||
|
||||
const totpStatus = await getUserTotpStatus(user.id);
|
||||
if (!totpStatus?.enabled) {
|
||||
throw redirect(ADMIN_MFA_ENROLLMENT_PATH);
|
||||
}
|
||||
|
||||
const rateLimitKey = mfaChallengeRateLimitKey(request, user.id);
|
||||
const rateLimit = await checkMfaChallengeRateLimit(rateLimitKey);
|
||||
if (!rateLimit.allowed) {
|
||||
return Response.json({ error: t("admin.mfaChallenge.errors.tooManyAttempts"), redirectTo } satisfies ActionData, {
|
||||
status: 429,
|
||||
headers: { "Retry-After": String(rateLimit.retryAfterSeconds) },
|
||||
});
|
||||
}
|
||||
|
||||
const code = form.get("code");
|
||||
let method: MfaStepUpMethod | null = null;
|
||||
|
||||
if (isSixDigitTotpCode(code)) {
|
||||
const encryptedSecret = await getActiveTotpSecret(user.id);
|
||||
let totpMatches = false;
|
||||
try {
|
||||
totpMatches = Boolean(encryptedSecret && verifyTotpCode(encryptedSecret, code));
|
||||
} catch {
|
||||
totpMatches = false;
|
||||
}
|
||||
if (totpMatches) {
|
||||
method = "totp";
|
||||
}
|
||||
}
|
||||
|
||||
if (!method) {
|
||||
const recoveryCode = normalizeRecoveryCode(form.get("recoveryCode"));
|
||||
if (recoveryCode.length > 0) {
|
||||
const result = await consumeUserMfaRecoveryCode(user.id, recoveryCode);
|
||||
if (result?.consumed) {
|
||||
method = "recovery-code";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!method) {
|
||||
await recordFailedMfaChallengeAttempt(rateLimitKey);
|
||||
return Response.json({ error: t("admin.mfaChallenge.errors.invalidCode"), redirectTo } satisfies ActionData, { status: 400 });
|
||||
}
|
||||
|
||||
await resetMfaChallengeRateLimit(rateLimitKey);
|
||||
setMfaStepUpState(session, { userId: user.id, method, redirectTo });
|
||||
throw redirect(redirectTo, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
})!;
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Form, useActionData, useLoaderData, useNavigation } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRootData } from "~/utils/data/useRootData";
|
||||
import { AuthShell } from "~/components/auth/AuthShell";
|
||||
import { Button } from "~/ui/components/Button";
|
||||
import { Checkbox } from "~/ui/components/Checkbox";
|
||||
import { InputOtp } from "~/ui/components/InputOtp";
|
||||
import { LinkButton } from "~/ui/components/LinkButton";
|
||||
import { TextInput } from "~/ui/components/TextInput";
|
||||
import type { ActionData, LoaderData } from "~/routeModules/auth/login-mfa.impl";
|
||||
|
||||
// D-D0a — 2FA-Code-Eingabe nach Davids Subframe-Design ("_2FA _ Code Entry"): Logo + Card
|
||||
// (Bestaetigung in zwei Schritten) + 6-stellige InputOtp + "Geraet vertrauen"-Checkbox + Bestaetigen +
|
||||
// Recovery-Code-Toggle. An die bestehende login-mfa-Action verdrahtet (code/recoveryCode/rememberDevice/csrf).
|
||||
// Die 6 OTP-Boxen werden zu einem hidden "code"-Feld kombiniert (Auto-Advance, Paste, Backspace).
|
||||
|
||||
const OTP_LENGTH = 6;
|
||||
|
||||
export default function LoginMfaRoute() {
|
||||
const { t } = useTranslation();
|
||||
const { csrf } = useRootData();
|
||||
const data = useLoaderData<LoaderData>();
|
||||
const actionData = useActionData<ActionData>();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state !== "idle";
|
||||
|
||||
const [digits, setDigits] = useState<string[]>(Array(OTP_LENGTH).fill(""));
|
||||
const [trustDevice, setTrustDevice] = useState(false);
|
||||
const [showRecovery, setShowRecovery] = useState(false);
|
||||
const inputs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const code = digits.join("");
|
||||
|
||||
function handleChange(index: number, raw: string) {
|
||||
const clean = raw.replace(/\D/g, "");
|
||||
setDigits((prev) => {
|
||||
const next = [...prev];
|
||||
if (!clean) {
|
||||
next[index] = "";
|
||||
return next;
|
||||
}
|
||||
// Einzelzeichen ODER Paste (z.B. "123456") ab dieser Box vorwaerts fuellen.
|
||||
for (let k = 0; k < clean.length && index + k < OTP_LENGTH; k++) {
|
||||
next[index + k] = clean[k];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (clean) {
|
||||
inputs.current[Math.min(index + clean.length, OTP_LENGTH - 1)]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(index: number, event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "Backspace" && !digits[index] && index > 0) {
|
||||
inputs.current[index - 1]?.focus();
|
||||
setDigits((prev) => {
|
||||
const next = [...prev];
|
||||
next[index - 1] = "";
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const errorState = Boolean(actionData?.error && navigation.state === "idle");
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
title={t("account.loginMfa.twoStepTitle")}
|
||||
subtitle={t("account.loginMfa.codeSubtitle")}
|
||||
logoIconOnly
|
||||
subtitleStrong
|
||||
dataUiId="ui.auth.loginMfa.page"
|
||||
>
|
||||
<Form method="post" noValidate className="flex w-full flex-col gap-4" data-ui-id="ui.auth.loginMfa.form">
|
||||
{csrf ? <input type="hidden" name="csrf" value={csrf} readOnly /> : null}
|
||||
<input type="hidden" name="redirectTo" value={data.redirectTo} readOnly />
|
||||
<input type="hidden" name="code" value={code} readOnly />
|
||||
<input type="hidden" name="rememberDevice" value={trustDevice ? "on" : ""} readOnly />
|
||||
|
||||
{!showRecovery ? (
|
||||
<div className="flex w-full flex-col items-center py-2">
|
||||
<InputOtp state={errorState ? "error" : "default"} helperText={t("account.loginMfa.codeHelper")}>
|
||||
{Array.from({ length: OTP_LENGTH }).map((_, i) => (
|
||||
<InputOtp.OtpInputItem key={i} error={errorState}>
|
||||
<InputOtp.Input
|
||||
className="h-auto w-12 flex-none self-stretch"
|
||||
ref={(el) => {
|
||||
inputs.current[i] = el;
|
||||
}}
|
||||
value={digits[i]}
|
||||
onChange={(event) => handleChange(i, event.target.value)}
|
||||
onKeyDown={(event) => handleKeyDown(i, event)}
|
||||
inputMode="numeric"
|
||||
autoComplete={i === 0 ? "one-time-code" : "off"}
|
||||
maxLength={OTP_LENGTH}
|
||||
aria-label={`${t("account.loginMfa.fields.authenticatorCode")} ${i + 1}`}
|
||||
data-ui-id={i === 0 ? "ui.auth.loginMfa.code" : undefined}
|
||||
/>
|
||||
</InputOtp.OtpInputItem>
|
||||
))}
|
||||
</InputOtp>
|
||||
</div>
|
||||
) : (
|
||||
<TextInput className="h-auto w-full flex-none" fillContainer showLabel label={t("account.loginMfa.fields.recoveryCode")}>
|
||||
<TextInput.Input
|
||||
name="recoveryCode"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
placeholder={t("account.loginMfa.fields.recoveryCode")}
|
||||
disabled={isSubmitting}
|
||||
data-ui-id="ui.auth.loginMfa.recoveryCode"
|
||||
/>
|
||||
</TextInput>
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
label={t("account.loginMfa.trustDevice")}
|
||||
checked={trustDevice}
|
||||
onCheckedChange={setTrustDevice}
|
||||
data-ui-id="ui.auth.loginMfa.rememberDevice"
|
||||
/>
|
||||
|
||||
<Button type="submit" loading={isSubmitting} disabled={isSubmitting} data-ui-id="ui.auth.loginMfa.submit">
|
||||
{t("account.loginMfa.confirm")}
|
||||
</Button>
|
||||
|
||||
{errorState ? (
|
||||
<p className="text-center text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative" role="alert">
|
||||
{actionData?.error}
|
||||
</p>
|
||||
) : null}
|
||||
</Form>
|
||||
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<LinkButton
|
||||
size="small"
|
||||
hideIcon
|
||||
onClick={() => setShowRecovery((v) => !v)}
|
||||
data-ui-id="ui.auth.loginMfa.useRecoveryCode"
|
||||
>
|
||||
{showRecovery ? t("account.loginMfa.useAuthenticator") : t("account.loginMfa.useRecoveryCode")}
|
||||
</LinkButton>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "react-router";
|
||||
import { serverOnly$ } from "vite-env-only/macros";
|
||||
import AuthService from "~/modules/users/services/AuthService";
|
||||
import { getUserInfo } from "~/utils/session.server";
|
||||
|
||||
// D1 — tenant-faehige Verify-Route fuer das 2FA-Login-Gate. Der Login (AuthService.loginFromRequest)
|
||||
// schreibt nach Passwort-OK den Pending-Zustand (`pendingMfaUserId`) ins Cookie und schickt den
|
||||
// Client hierher. Diese Route liest den Pending-Zustand, prueft den zweiten Faktor und erstellt
|
||||
// erst dann die echte Session. Route wird per Ordner-Konvention autoregistriert (login-mfa.tsx).
|
||||
|
||||
export type LoaderData = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const loader = serverOnly$(async ({ request }: LoaderFunctionArgs) => {
|
||||
const userInfo = await getUserInfo(request);
|
||||
// Bereits voll eingeloggt -> hier nichts zu tun.
|
||||
if (userInfo.userId) {
|
||||
throw redirect("/");
|
||||
}
|
||||
// Kein laufender 2FA-Login -> zurueck zur Anmeldung.
|
||||
if (!userInfo.pendingMfaUserId) {
|
||||
throw redirect("/login");
|
||||
}
|
||||
return Response.json({ redirectTo: userInfo.pendingMfaRedirectTo ?? "" } satisfies LoaderData);
|
||||
})!;
|
||||
|
||||
export const action = serverOnly$(async ({ request }: ActionFunctionArgs) => {
|
||||
const form = await request.formData();
|
||||
return AuthService.verifyMfaLoginFromRequest(request, form);
|
||||
})!;
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Form, useActionData, useLoaderData, useNavigate, useNavigation } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FeatherChevronRight,
|
||||
FeatherPlus,
|
||||
FeatherUserPlus,
|
||||
FeatherUsers,
|
||||
} from "@subframe/core";
|
||||
import * as SubframeCore from "@subframe/core";
|
||||
import { useRootData } from "~/utils/data/useRootData";
|
||||
import { Avatar } from "~/ui/components/Avatar";
|
||||
import { Button } from "~/ui/components/Button";
|
||||
import { Card } from "~/ui/components/Card";
|
||||
import { DropdownMenu } from "~/ui/components/DropdownMenu";
|
||||
import { ErrorState } from "~/ui/components/ErrorState";
|
||||
import { Logo } from "~/ui/components/Logo";
|
||||
import { SearchInput } from "~/ui/components/SearchInput";
|
||||
import { Tag_Label } from "~/ui/components/Tag_Label";
|
||||
import type { ActionData, LoaderData } from "~/routeModules/auth/select-tenant.impl";
|
||||
|
||||
// D-D0a — Flow "Login Tenantauswahl (Admin)". 1:1 an Davids Subframe-Page "Tenantauswahl": Logo +
|
||||
// Karten-Box (Such-Feld immer sichtbar, Profil-Karten, Divider, "Profil hinzufuegen"-Dropdown). An die
|
||||
// echten Tenants des Users (Loader-VM) verdrahtet: Karte = Form-Submit -> Action (CSRF + Membership-
|
||||
// Check serverseitig) -> Tenant-Space. SearchInput filtert client-seitig. Fehler/Empty via ~/ui ErrorState.
|
||||
// data-ui-id-Marker bleiben beim spaeteren Subframe-Swap erhalten.
|
||||
|
||||
export default function SelectTenantRoute() {
|
||||
const { t } = useTranslation();
|
||||
const { csrf } = useRootData();
|
||||
const { vm } = useLoaderData<LoaderData>();
|
||||
const actionData = useActionData<ActionData>();
|
||||
const navigate = useNavigate();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state !== "idle";
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const tenants = vm.status === "ready" ? vm.data.tenants : [];
|
||||
const visibleTenants = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return tenants;
|
||||
return tenants.filter((tenant) => tenant.name.toLowerCase().includes(q));
|
||||
}, [tenants, query]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen w-full flex-col items-center justify-center gap-8 bg-surface-neutral-primary px-4 py-12"
|
||||
data-ui-id="ui.auth.selectTenant.page"
|
||||
>
|
||||
<Logo size="64" />
|
||||
<div className="flex w-full max-w-[36rem] flex-col items-start gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-8 py-8 shadow-lg">
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<h1 className="text-heading- font-heading- text-text-neutral-primary">{t("account.selectTenant.title")}</h1>
|
||||
<p className="w-full text-center text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
|
||||
{t("account.selectTenant.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{actionData?.error ? (
|
||||
<ErrorState
|
||||
variant="full-width"
|
||||
hideCancel
|
||||
title={t(actionData.error)}
|
||||
data-ui-id="ui.auth.selectTenant.error"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{vm.status === "ready" ? (
|
||||
<SearchInput
|
||||
className="h-auto w-full min-w-[288px] flex-none"
|
||||
fillContainer
|
||||
hideShortcut
|
||||
placeholder={t("account.selectTenant.searchPlaceholder")}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
aria-label={t("account.selectTenant.searchPlaceholder")}
|
||||
data-ui-id="ui.auth.selectTenant.search"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{vm.status === "empty" ? (
|
||||
<div className="flex w-full flex-col items-center gap-4 py-4 text-center" data-ui-id="ui.auth.selectTenant.empty">
|
||||
<p className="text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-secondary">
|
||||
{t("account.selectTenant.empty")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-start gap-2" data-ui-id="ui.auth.selectTenant.list">
|
||||
{visibleTenants.length === 0 ? (
|
||||
<p className="w-full py-4 text-center text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
|
||||
{t("account.selectTenant.noResults")}
|
||||
</p>
|
||||
) : (
|
||||
visibleTenants.map((tenant) => (
|
||||
<Form method="post" key={tenant.id} className="w-full">
|
||||
<input type="hidden" name="csrf" value={csrf ?? ""} readOnly />
|
||||
<input type="hidden" name="tenantId" value={tenant.id} readOnly />
|
||||
<input type="hidden" name="slug" value={tenant.slug} readOnly />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full text-left transition disabled:cursor-default disabled:opacity-60"
|
||||
data-ui-id="ui.auth.selectTenant.card"
|
||||
>
|
||||
<Card className="group/card transition hover:border-border-strong hover:bg-surface-neutral-tertiary">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<Avatar size="medium" color={tenant.avatarColor} image={tenant.avatarImage}>
|
||||
{tenant.avatarInitials}
|
||||
</Avatar>
|
||||
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">
|
||||
{tenant.name}
|
||||
</span>
|
||||
{tenant.role && tenant.roleTone ? (
|
||||
<Tag_Label tone={tenant.roleTone} noAction size="small">
|
||||
{t(tenant.roleLabelKey ?? "")}
|
||||
</Tag_Label>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">
|
||||
{[tenant.subtitle, tenant.isCurrent ? t("account.selectTenant.lastUsed") : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
<FeatherChevronRight className="text-body-large font-body-large text-icon-neutral-tertiary group-hover/card:text-icon-neutral-primary" />
|
||||
</div>
|
||||
</Card>
|
||||
</button>
|
||||
</Form>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex h-px w-full flex-none flex-col items-center bg-border-default" />
|
||||
|
||||
<SubframeCore.DropdownMenu.Root>
|
||||
<SubframeCore.DropdownMenu.Trigger asChild>
|
||||
<Button
|
||||
className="h-auto w-full flex-none"
|
||||
icon={<FeatherPlus />}
|
||||
data-ui-id="ui.auth.selectTenant.addProfile"
|
||||
>
|
||||
{t("account.selectTenant.addProfile")}
|
||||
</Button>
|
||||
</SubframeCore.DropdownMenu.Trigger>
|
||||
<SubframeCore.DropdownMenu.Portal>
|
||||
<SubframeCore.DropdownMenu.Content side="bottom" align="center" sideOffset={4} asChild>
|
||||
<DropdownMenu className="h-auto w-auto flex-none">
|
||||
<DropdownMenu.DropdownItem
|
||||
icon={<FeatherUsers />}
|
||||
onClick={() => navigate("/login")}
|
||||
data-ui-id="ui.auth.selectTenant.addExisting"
|
||||
>
|
||||
{t("account.selectTenant.addExisting")}
|
||||
</DropdownMenu.DropdownItem>
|
||||
<DropdownMenu.DropdownItem
|
||||
icon={<FeatherUserPlus />}
|
||||
onClick={() => navigate("/new-account")}
|
||||
data-ui-id="ui.auth.selectTenant.addNew"
|
||||
>
|
||||
{t("account.selectTenant.addNew")}
|
||||
</DropdownMenu.DropdownItem>
|
||||
</DropdownMenu>
|
||||
</SubframeCore.DropdownMenu.Content>
|
||||
</SubframeCore.DropdownMenu.Portal>
|
||||
</SubframeCore.DropdownMenu.Root>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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" }));
|
||||
})!;
|
||||
Reference in new issue
Block a user