Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -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" }));
})!;