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,90 @@
import { useState } from "react";
import { Form, useNavigate, useNavigation } from "react-router";
import { useTranslation } from "react-i18next";
import { FeatherMail } from "@subframe/core";
import { useRootData } from "~/utils/data/useRootData";
import { AuthShell, AuthStatus } from "~/components/auth/AuthShell";
import { Button } from "~/ui/components/Button";
import { LinkButton } from "~/ui/components/LinkButton";
import { TextInput } from "~/ui/components/TextInput";
// Forgot-Password-Screen nach Davids Subframe-Design ("Login_Forgot password" + "_ Sent"): Logo + Card
// (Passwort vergessen? + E-Mail + "Link senden") -> action (mode=reset) gibt den Reset-Link aus ->
// Davids "E-Mail gesendet"-Status. Magic-Link-Alternative liegt jetzt im Login-Screen, daher hier nur reset.
export default function ForgotPasswordScreen({
actionData,
}: {
actionData: { success?: string; error?: string } | null | undefined;
}) {
const { t } = useTranslation();
const { csrf } = useRootData();
const navigation = useNavigation();
const navigate = useNavigate();
const isSubmitting = navigation.state !== "idle";
const [email, setEmail] = useState("");
if (actionData?.success) {
return (
<AuthStatus
icon={<FeatherMail />}
tone="positive"
title={t("account.forgot.sentTitle")}
message={t("account.forgot.sentBody", { email })}
hint={t("account.login.magicLinkSentHint")}
dataUiId="ui.auth.forgot.sent"
>
<Form method="post" className="contents">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="mode" value="reset" readOnly />
<input type="hidden" name="email" value={email} readOnly />
<LinkButton type="submit" size="small" hideIcon disabled={isSubmitting} data-ui-id="ui.auth.forgot.resend">
{t("account.login.magicLinkResend")}
</LinkButton>
</Form>
<LinkButton size="small" hideIcon onClick={() => navigate("/login")} data-ui-id="ui.auth.forgot.backToLogin">
{t("account.forgot.backToLogin")}
</LinkButton>
</AuthStatus>
);
}
return (
<AuthShell title={t("account.forgot.title")} subtitle={t("account.forgot.subtitle")} subtitleStrong dataUiId="ui.auth.forgot.page">
<Form method="post" noValidate className="flex w-full flex-col gap-4" data-ui-id="ui.auth.forgot.form">
<input type="hidden" name="csrf" value={csrf} readOnly />
<TextInput className="h-auto w-full flex-none" showLabel label={t("account.shared.email")} type="email">
<TextInput.Input
name="email"
type="email"
required
autoFocus
autoComplete="email"
placeholder={t("account.shared.emailPlaceholder")}
value={email}
onChange={(event) => setEmail(event.target.value)}
disabled={isSubmitting}
data-ui-id="ui.auth.forgot.email"
/>
</TextInput>
<Button type="submit" name="mode" value="reset" loading={isSubmitting} disabled={isSubmitting} data-ui-id="ui.auth.forgot.submit">
{t("account.forgot.sendLink")}
</Button>
{actionData?.error && navigation.state === "idle" ? (
<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={() => navigate("/login")} data-ui-id="ui.auth.forgot.backToLoginLink">
{t("account.forgot.backToLogin")}
</LinkButton>
</div>
</AuthShell>
);
}
@@ -0,0 +1,157 @@
import { useState } from "react";
import { Form, Link, useNavigation } from "react-router";
import { useTranslation } from "react-i18next";
import * as SubframeCore from "@subframe/core";
import { FeatherAlertTriangle, FeatherEdit2, FeatherMoreHorizontal, FeatherPlus, FeatherTrash, FeatherUsers } from "@subframe/core";
import type { GroupWithDetails } from "~/utils/db/permissions/groups.db.server";
import { Button } from "~/ui/components/Button";
import { DropdownMenu } from "~/ui/components/DropdownMenu";
import { IconButton } from "~/ui/components/IconButton";
import { IconWithBackground } from "~/ui/components/IconWithBackground";
import { SkeletonText } from "~/ui/components/SkeletonText";
import { Table } from "~/ui/components/Table";
import { useRootData } from "~/utils/data/useRootData";
// D-P2 — Gruppen-Liste nach Davids "Groups Flow"-Design (Table: Gruppe/Beschreibung/Mitglieder/Aktionen).
// Praesentational: items + csrf injiziert; Anlegen/Bearbeiten via bestehende Routen (new / $id im SlideOver),
// Löschen via Form-POST an den $id-Pfad (action=delete). Empty-/Loading-States nach Davids Vorlage.
export default function GroupsListView({ items, canManage }: { items: GroupWithDetails[]; canManage: boolean }) {
const { t } = useTranslation();
const { csrf = "" } = useRootData();
const navigation = useNavigation();
const loading = navigation.state === "loading";
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string } | null>(null);
return (
<div className="flex w-full flex-col items-start gap-4">
<div className="flex w-full items-start justify-between gap-4 mobile:flex-col">
<div className="flex flex-col items-start gap-1">
<span className="text-title-page font-title-page text-text-neutral-primary mobile:text-subtitle mobile:font-subtitle">
{t("models.group.plural")}
</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-tertiary">
{t("settings.members.groups.subtitle")}
</span>
</div>
{canManage ? (
<Link to="new" className="no-underline mobile:w-full" data-ui-id="ui.settings.groups.new">
<Button variant="primary" icon={<FeatherPlus />} className="mobile:w-full">
{t("settings.members.groups.createCta")}
</Button>
</Link>
) : null}
</div>
<Table
header={
<Table.HeaderRow>
<Table.HeaderCell>{t("models.group.object")}</Table.HeaderCell>
<Table.HeaderCell>{t("models.group.description")}</Table.HeaderCell>
<Table.HeaderCell>{t("settings.members.groups.membersCount")}</Table.HeaderCell>
<Table.HeaderCell> </Table.HeaderCell>
</Table.HeaderRow>
}
>
{loading ? (
[0, 1, 2, 3].map((i) => (
<Table.Row key={`sk-${i}`} clickable={false}>
<Table.Cell><SkeletonText className="h-5 w-32 flex-none" /></Table.Cell>
<Table.Cell><SkeletonText className="h-5 w-48 flex-none" /></Table.Cell>
<Table.Cell><SkeletonText className="h-5 w-8 flex-none" /></Table.Cell>
<Table.Cell>
<div className="flex grow shrink-0 basis-0 items-center justify-end">
<SkeletonText className="h-5 w-6 flex-none" />
</div>
</Table.Cell>
</Table.Row>
))
) : items.length === 0 ? (
<Table.Row clickable={false}>
<Table.Cell colSpan={4}>
<div className="flex grow shrink-0 basis-0 flex-col items-center justify-center gap-6 py-12">
<div className="flex flex-col items-center gap-4">
<IconWithBackground variant="neutral" icon={<FeatherUsers />} />
<div className="flex flex-col items-center gap-2">
<span className="text-body-large-0 font-body-large-0 text-text-neutral-primary text-center">{t("settings.members.groups.empty.title")}</span>
<span className="max-w-[384px] text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary text-center">
{t("settings.members.groups.empty.description")}
</span>
</div>
</div>
{canManage ? (
<Link to="new" className="no-underline">
<Button variant="primary" icon={<FeatherPlus />}>{t("settings.members.groups.createCta")}</Button>
</Link>
) : null}
</div>
</Table.Cell>
</Table.Row>
) : (
items.map((group) => (
<Table.Row key={group.id} data-ui-id="ui.settings.groups.row">
<Table.Cell>
<span className="whitespace-nowrap text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-secondary">{group.name}</span>
</Table.Cell>
<Table.Cell>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{group.description || "—"}</span>
</Table.Cell>
<Table.Cell>
<span className="whitespace-nowrap text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-secondary">{group.users.length}</span>
</Table.Cell>
<Table.Cell>
<div className="flex grow shrink-0 basis-0 items-center justify-end">
{canManage ? (
<SubframeCore.DropdownMenu.Root>
<SubframeCore.DropdownMenu.Trigger asChild>
<IconButton icon={<FeatherMoreHorizontal />} data-ui-id="ui.settings.groups.rowActions" />
</SubframeCore.DropdownMenu.Trigger>
<SubframeCore.DropdownMenu.Portal>
<SubframeCore.DropdownMenu.Content side="bottom" align="end" sideOffset={4} asChild>
<DropdownMenu>
<Link to={group.id} className="no-underline">
<DropdownMenu.DropdownItem icon={<FeatherEdit2 />}>{t("shared.edit")}</DropdownMenu.DropdownItem>
</Link>
<DropdownMenu.DropdownItem icon={<FeatherTrash />} onClick={() => setConfirmDelete({ id: group.id, name: group.name })}>
{t("shared.delete")}
</DropdownMenu.DropdownItem>
</DropdownMenu>
</SubframeCore.DropdownMenu.Content>
</SubframeCore.DropdownMenu.Portal>
</SubframeCore.DropdownMenu.Root>
) : null}
</div>
</Table.Cell>
</Table.Row>
))
)}
</Table>
{confirmDelete ? (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-2 bg-surface-neutral-overlay px-4 py-4" data-ui-id="ui.settings.groups.deleteConfirm">
<div className="flex w-96 max-w-full flex-col items-start gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-modal px-6 py-6 shadow-lg mobile:w-full mobile:flex-none">
<div className="flex h-12 w-12 flex-none items-center justify-center gap-2 rounded-round bg-surface-feedback-subtle-negative">
<FeatherAlertTriangle className="text-heading-0 font-heading-0 text-icon-feedback-negative" />
</div>
<div className="flex w-full flex-col items-start gap-2">
<span className="w-full text-heading-0 font-heading-0 text-text-neutral-primary">{t("settings.members.groups.delete.title")}</span>
<span className="w-full text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">
{t("settings.members.groups.delete.description", { name: confirmDelete.name })}
</span>
</div>
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button variant="tertiary" onClick={() => setConfirmDelete(null)}>{t("shared.cancel")}</Button>
<Form method="post" action={confirmDelete.id} className="contents">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="delete" readOnly />
<Button variant="destructive" type="submit" loading={navigation.state !== "idle"} data-ui-id="ui.settings.groups.deleteSubmit">
{t("shared.delete")}
</Button>
</Form>
</div>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,158 @@
import { Form, Link, useNavigate, useNavigation, useSearchParams } from "react-router";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import ExclamationTriangleIcon from "~/components/ui/icons/ExclamationTriangleIcon";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import { useRootData } from "~/utils/data/useRootData";
export type LoginActionData = {
error?: string;
fieldErrors?: {
email: string | undefined;
password: string | undefined;
};
fields?: {
email: string;
password: string;
};
/**
* D1 — gesetzt, wenn das Passwort stimmt, der Nutzer aber 2FA aktiviert hat. Das Session-Cookie
* traegt dann nur den Pending-Zustand; der Client wird zur Verify-Route (`mfaVerifyPath`) geleitet.
*/
requiresMfa?: boolean;
mfaVerifyPath?: string;
/** D-D0a — Magic-Link-Login: gesetzt, wenn der Anmeldelink ausgegeben wurde (zeigt den "Link gesendet"-Screen). */
magicLinkSent?: boolean;
magicLinkEmail?: string;
};
export default function LoginForm({ actionData, redirectTo }: { actionData: LoginActionData | null | undefined; redirectTo?: string }) {
const { t } = useTranslation();
const { appConfiguration, csrf } = useRootData();
const navigation = useNavigation();
const [searchParams] = useSearchParams();
const emailErrorId = actionData?.fieldErrors?.email ? "email-error" : undefined;
const passwordErrorId = actionData?.fieldErrors?.password ? "password-error" : undefined;
const navigate = useNavigate();
const mfaVerifyPath = actionData?.mfaVerifyPath ?? "/login-mfa";
const emailInput = useRef<RefInputText>(null);
useEffect(() => {
if (appConfiguration.auth.authMethods.emailPassword.enabled) {
setTimeout(() => {
emailInput.current?.input.current?.focus();
}, 300);
}
}, [appConfiguration.auth.authMethods.emailPassword.enabled]);
// D1 — Passwort ok, aber 2FA noetig: das Pending-Cookie ist bereits gesetzt, jetzt zur Verify-Route.
useEffect(() => {
if (actionData?.requiresMfa) {
navigate(mfaVerifyPath);
}
}, [actionData?.requiresMfa, mfaVerifyPath, navigate]);
if (actionData?.requiresMfa) {
return (
<div className="space-y-4 text-center" data-ui-id="ui.auth.login.mfaRedirect">
<p className="text-sm">{t("account.loginMfa.redirecting")}</p>
<Link to={mfaVerifyPath} className="text-primary font-medium hover:underline" data-ui-id="ui.auth.login.mfaContinue">
{t("account.loginMfa.continue")}
</Link>
</div>
);
}
return (
<div className="space-y-5">
<div className="flex flex-col items-center">
<h1 className="text-left text-2xl font-extrabold">{t("account.login.headline")}</h1>
{appConfiguration.subscription.allowSignUpBeforeSubscribe && (
<p className="mt-1 text-left">
<Link to="/register" className="text-primary font-medium hover:underline">
{t("account.login.orRegister")}
</Link>
</p>
)}
</div>
<div className="border-border mx-auto flex flex-col items-center space-y-6 rounded-lg border p-6">
{appConfiguration.auth.authMethods.emailPassword.enabled && (
<Form method="post" noValidate className="w-full space-y-3" data-ui-id="ui.auth.login.form">
<input type="hidden" name="action" value="login" hidden readOnly />
<input type="hidden" name="csrf" value={csrf} hidden readOnly />
<input type="hidden" name="redirectTo" value={redirectTo ?? searchParams.get("redirect") ?? searchParams.get("redirectTo") ?? undefined} />
<div>
{/* <label className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground" htmlFor="email">
{t("account.shared.email")}
</label> */}
<InputText
ref={emailInput}
title={t("account.shared.email")}
autoFocus
id="email"
name="email"
dataUiId="ui.auth.login.email"
type="email"
required
ariaDescribedBy={emailErrorId}
ariaInvalid={emailErrorId ? true : undefined}
// className="focus:border-theme-500 focus:ring-ring relative mt-1 block w-full appearance-none rounded-md border border-border px-3 py-2 text-sm text-foreground placeholder-gray-500 focus:z-10 focus:outline-hidden sm:text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-slate-200"
placeholder={t("account.shared.emailPlaceholder")}
disabled={navigation.state !== "idle"}
defaultValue={actionData?.fields?.email}
/>
{actionData?.fieldErrors?.email ? (
<p className="text-destructive-foreground py-2 text-xs" role="alert" id="email-error">
{actionData.fieldErrors.email}
</p>
) : null}
</div>
<div>
{/* <label className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground" htmlFor="password">
{t("account.shared.password")}
</label> */}
<InputText
id="password"
title={t("account.shared.password")}
name="password"
dataUiId="ui.auth.login.password"
type="password"
required
ariaDescribedBy={passwordErrorId}
ariaInvalid={passwordErrorId ? true : undefined}
// className="focus:border-theme-500 focus:ring-ring relative mt-1 block w-full appearance-none rounded-md border border-border px-3 py-2 text-sm text-foreground placeholder-gray-500 focus:z-10 focus:outline-hidden sm:text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-slate-200"
placeholder="************"
disabled={navigation.state !== "idle"}
defaultValue={actionData?.fields?.password}
/>
{actionData?.fieldErrors?.password ? (
<p className="text-destructive-foreground py-2 text-xs" role="alert" id="password-error">
{actionData.fieldErrors.password}
</p>
) : null}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Link to="/forgot-password" className="text-foreground inline-block align-baseline text-xs font-medium hover:underline" data-ui-id="ui.auth.login.forgot">
{t("account.login.forgot")}
</Link>
<LoadingButton disabled={navigation.state !== "idle"} className="block w-full sm:w-auto" type="submit" actionName="login" dataUiId="ui.auth.login.submit">
{t("account.login.button")}
</LoadingButton>
</div>
<div id="form-error-message">
{actionData?.error && navigation.state === "idle" ? (
<div className="flex items-center justify-center space-x-2 text-sm text-red-600 dark:text-red-300" role="alert">
<ExclamationTriangleIcon className="h-4 w-4" />
<div>{actionData.error}</div>
</div>
) : null}
</div>
</Form>
)}
</div>
</div>
);
}
@@ -0,0 +1,188 @@
import { useEffect } from "react";
import { Form, Link, useNavigate, useNavigation, useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
import { FeatherArrowLeft, FeatherMail, FeatherRotateCw, FeatherWand2 } from "@subframe/core";
import { Button } from "~/ui/components/Button";
import { LinkButton } from "~/ui/components/LinkButton";
import { TextInput } from "~/ui/components/TextInput";
import { useRootData } from "~/utils/data/useRootData";
import { AuthShell, AuthStatus, AuthDivider } from "~/components/auth/AuthShell";
import type { LoginActionData } from "~/modules/users/components/LoginForm";
// Login-Screen nach Davids Subframe-Design ("Login"): Logo + Card (Anmelden/Willkommen zurueck) +
// E-Mail/Passwort + "Passwort vergessen?" + Anmelden + "oder" + Magic-Link + Footer "Registrieren".
// Voll an die bestehende Login-Action verdrahtet (action=login, csrf, redirectTo, Feldfehler, 2FA-Redirect).
// Reines ~/ui (TextInput/Button/LinkButton) + AuthShell. Wird vom Login-Route fuer den NICHT-Admin-Pfad
// gerendert; der Admin-/SSO-Pfad bleibt in login.tsx unveraendert.
export default function LoginScreen({
actionData,
redirectTo,
}: {
actionData: LoginActionData | null | undefined;
redirectTo?: string;
}) {
const { t } = useTranslation();
const { csrf } = useRootData();
const navigation = useNavigation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const isSubmitting = navigation.state !== "idle";
const mfaVerifyPath = actionData?.mfaVerifyPath ?? "/login-mfa";
const emailError = actionData?.fieldErrors?.email;
const passwordError = actionData?.fieldErrors?.password;
// D1 — Passwort ok, aber 2FA noetig: Pending-Cookie ist gesetzt, jetzt zur Verify-Route.
useEffect(() => {
if (actionData?.requiresMfa) {
navigate(mfaVerifyPath);
}
}, [actionData?.requiresMfa, mfaVerifyPath, navigate]);
if (actionData?.requiresMfa) {
return (
<AuthShell title={t("account.loginMfa.title")} dataUiId="ui.auth.login.page">
<div className="flex flex-col items-center gap-3 text-center" data-ui-id="ui.auth.login.mfaRedirect">
<p className="text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">
{t("account.loginMfa.redirecting")}
</p>
<Link to={mfaVerifyPath} className="no-underline" data-ui-id="ui.auth.login.mfaContinue">
<LinkButton hideIcon>{t("account.loginMfa.continue")}</LinkButton>
</Link>
</div>
</AuthShell>
);
}
// D-D0a — Magic-Link ausgegeben -> Davids "Link gesendet"-Screen (AuthStatus).
if (actionData?.magicLinkSent) {
return (
<AuthStatus
icon={<FeatherMail />}
tone="positive"
title={t("account.login.magicLinkSentTitle")}
message={t("account.login.magicLinkSentBody", { email: actionData.magicLinkEmail ?? "" })}
hint={t("account.login.magicLinkSentHint")}
dataUiId="ui.auth.login.magicLinkSent"
>
<Form method="post" className="contents">
<input type="hidden" name="intent" value="magicLink" readOnly />
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="email" value={actionData.magicLinkEmail ?? ""} readOnly />
<LinkButton type="submit" size="small" icon={<FeatherRotateCw />} disabled={isSubmitting} data-ui-id="ui.auth.login.magicLinkResend">
{t("account.login.magicLinkResend")}
</LinkButton>
</Form>
<LinkButton size="small" icon={<FeatherArrowLeft />} onClick={() => navigate("/login")} data-ui-id="ui.auth.login.backToLogin">
{t("account.login.backToLogin")}
</LinkButton>
</AuthStatus>
);
}
return (
<AuthShell
title={t("account.login.title")}
subtitle={t("account.login.headline")}
titleAlign="center"
dataUiId="ui.auth.login.page"
footer={
<>
<span className="text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">
{t("account.login.noAccount")}
</span>
<Link to="/register" className="no-underline" data-ui-id="ui.auth.login.register">
<LinkButton size="small" hideIcon>
{t("account.login.register")}
</LinkButton>
</Link>
</>
}
>
<Form id="loginForm" method="post" noValidate className="flex w-full flex-col gap-4" data-ui-id="ui.auth.login.form">
<input type="hidden" name="action" value="login" readOnly />
<input type="hidden" name="csrf" value={csrf} readOnly />
<input
type="hidden"
name="redirectTo"
value={redirectTo ?? searchParams.get("redirect") ?? searchParams.get("redirectTo") ?? ""}
readOnly
/>
<TextInput
className="h-auto w-full flex-none"
showLabel
label={t("account.shared.email")}
helpText={emailError ?? ""}
error={!!emailError}
type="email"
>
<TextInput.Input
name="email"
type="email"
required
autoFocus
autoComplete="email"
placeholder={t("account.shared.emailPlaceholder")}
defaultValue={actionData?.fields?.email}
disabled={isSubmitting}
data-ui-id="ui.auth.login.email"
/>
</TextInput>
<TextInput
className="h-auto w-full flex-none"
showLabel
label={t("account.shared.password")}
helpText={passwordError ?? ""}
error={!!passwordError}
type="password"
>
<TextInput.Input
name="password"
type="password"
required
autoComplete="current-password"
placeholder=""
defaultValue={actionData?.fields?.password}
disabled={isSubmitting}
data-ui-id="ui.auth.login.password"
/>
</TextInput>
<Link to="/forgot-password" className="no-underline" data-ui-id="ui.auth.login.forgot">
<LinkButton size="small" hideIcon>
{t("account.login.forgot")}
</LinkButton>
</Link>
<Button type="submit" loading={isSubmitting} disabled={isSubmitting} data-ui-id="ui.auth.login.submit">
{t("account.login.button")}
</Button>
{actionData?.error && navigation.state === "idle" ? (
<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>
<AuthDivider label={t("account.login.divider")} />
{/* Magic-Link-Login: submitet die Login-Form (via form="loginForm") mit intent=magicLink ->
login-Action gibt den Login-Magic-Link aus -> "Link gesendet"-Screen. */}
<Button
type="submit"
form="loginForm"
name="intent"
value="magicLink"
variant="secondary"
icon={<FeatherWand2 />}
loading={isSubmitting}
data-ui-id="ui.auth.login.magicLink"
>
{t("account.login.magicLink")}
</Button>
</AuthShell>
);
}
@@ -0,0 +1,120 @@
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import * as SubframeCore from "@subframe/core";
import { FeatherMoreHorizontal, FeatherShield, FeatherTrash } from "@subframe/core";
import type { TenantUserWithUser } from "~/utils/db/tenants.db.server";
import { roleDisplayName } from "~/components/core/roles/roleDisplay";
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
import { Avatar } from "~/ui/components/Avatar";
import { BadgeFeedback } from "~/ui/components/BadgeFeedback";
import { DropdownMenu } from "~/ui/components/DropdownMenu";
import { IconButton } from "~/ui/components/IconButton";
import { Table } from "~/ui/components/Table";
// D-P2 — Mitarbeiter-Liste nach Davids "User Management"-Design (Table: Name/Rolle/Status/2FA/Aktionen).
// Rollen-Management + Einladen laufen ueber die bestehenden Routen (edit-SlideOver, members/new). Die fruehere
// Inline-Rollen-Matrix + Permission-Matrix-Modals entfallen hier (Davids Liste ist schlank) -> Rollen werden im
// Editor bzw. auf dem Rollen-Screen verwaltet. 2FA-Spalte aus mfaEnabledUserIds (Loader liefert sie).
function statusBadge(t: ReturnType<typeof useTranslation>["t"], status: number) {
if (status === TenantUserStatus.ACTIVE) return { farbe: "subtle-positive" as const, label: t("settings.members.status.active") };
if (status === TenantUserStatus.INACTIVE) return { farbe: "subtle-neutral" as const, label: t("settings.members.status.deactivated") };
return { farbe: "subtle-warning" as const, label: t("settings.members.status.invited") };
}
export default function MembersListView({
items,
mfaEnabledUserIds,
tenantId,
canEdit,
editRouteFor,
rolesUrl,
}: {
items: TenantUserWithUser[];
mfaEnabledUserIds: string[];
tenantId: string;
canEdit: boolean;
editRouteFor: (tenantUserId: string) => string;
rolesUrl: string;
}) {
const { t } = useTranslation();
const mfaSet = new Set(mfaEnabledUserIds);
return (
<Table
header={
<Table.HeaderRow>
<Table.HeaderCell>{t("models.user.object")}</Table.HeaderCell>
<Table.HeaderCell>{t("models.role.object")}</Table.HeaderCell>
<Table.HeaderCell>{t("settings.members.columns.status")}</Table.HeaderCell>
<Table.HeaderCell>{t("settings.members.columns.twoFactor")}</Table.HeaderCell>
<Table.HeaderCell> </Table.HeaderCell>
</Table.HeaderRow>
}
>
{items.map((member) => {
const user = member.user;
const name = [user.firstName, user.lastName].filter(Boolean).join(" ") || user.email;
const initials = `${(user.firstName ?? "").charAt(0)}${(user.lastName ?? "").charAt(0)}`.toUpperCase() || user.email.charAt(0).toUpperCase();
const roleNames = user.roles
.filter((r) => r.tenantId === tenantId)
.map((r) => roleDisplayName(r.role, t))
.join(", ");
const status = statusBadge(t, member.status);
const mfaOn = mfaSet.has(user.id);
const isInvited = member.status === TenantUserStatus.PENDING_INVITATION || member.status === TenantUserStatus.PENDING_ACCEPTANCE;
return (
<Table.Row key={member.id} data-ui-id="ui.settings.members.row">
<Table.Cell>
<div className="flex items-center gap-3">
<Avatar size="small">{initials}</Avatar>
<div className="flex flex-col items-start">
<span className="whitespace-nowrap text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{name}</span>
<span className="whitespace-nowrap text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">{user.email}</span>
</div>
</div>
</Table.Cell>
<Table.Cell>
<span className="whitespace-nowrap text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-secondary">{roleNames || "—"}</span>
</Table.Cell>
<Table.Cell>
<BadgeFeedback farbe={status.farbe}>{status.label}</BadgeFeedback>
</Table.Cell>
<Table.Cell>
{isInvited ? (
<span className="whitespace-nowrap text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-subtle">—</span>
) : (
<BadgeFeedback farbe={mfaOn ? "subtle-positive" : "subtle-neutral"}>
{mfaOn ? t("settings.members.twoFactor.on") : t("settings.members.twoFactor.off")}
</BadgeFeedback>
)}
</Table.Cell>
<Table.Cell>
<div className="flex grow shrink-0 basis-0 items-center justify-end">
{canEdit ? (
<SubframeCore.DropdownMenu.Root>
<SubframeCore.DropdownMenu.Trigger asChild>
<IconButton icon={<FeatherMoreHorizontal />} data-ui-id="ui.settings.members.rowActions" />
</SubframeCore.DropdownMenu.Trigger>
<SubframeCore.DropdownMenu.Portal>
<SubframeCore.DropdownMenu.Content side="bottom" align="end" sideOffset={4} asChild>
<DropdownMenu>
<Link to={rolesUrl} className="no-underline">
<DropdownMenu.DropdownItem icon={<FeatherShield />}>{t("settings.members.menu.manageRoles")}</DropdownMenu.DropdownItem>
</Link>
<Link to={editRouteFor(member.id)} className="no-underline">
<DropdownMenu.DropdownItem icon={<FeatherTrash />}>{t("settings.members.menu.remove")}</DropdownMenu.DropdownItem>
</Link>
</DropdownMenu>
</SubframeCore.DropdownMenu.Content>
</SubframeCore.DropdownMenu.Portal>
</SubframeCore.DropdownMenu.Root>
) : null}
</div>
</Table.Cell>
</Table.Row>
);
})}
</Table>
);
}
@@ -0,0 +1,480 @@
import { useEffect, useRef, useState } from "react";
import { Form, useActionData, useNavigation, useSubmit } from "react-router";
import { useTranslation } from "react-i18next";
import { QRCodeSVG } from "qrcode.react";
import {
FeatherCheck,
FeatherCheckCircle,
FeatherKey,
FeatherShieldCheck,
FeatherUpload,
FeatherX,
} from "@subframe/core";
import { Avatar } from "~/ui/components/Avatar";
import { BadgeFeedback } from "~/ui/components/BadgeFeedback";
import { Button } from "~/ui/components/Button";
import { CopyToClipboardButton } from "~/ui/components/CopyToClipboardButton";
import { IconButton } from "~/ui/components/IconButton";
import { InputOtp } from "~/ui/components/InputOtp";
import { Progress } from "~/ui/components/Progress";
import { TextInput } from "~/ui/components/TextInput";
import { useRootData } from "~/utils/data/useRootData";
// D-P2 — Settings > Profil nach Davids Subframe-"My Profile"-Design: Profil-Card (Avatar + Name inline-edit +
// E-Mail read-only) + Sicherheits-Card (Passwort ändern -> Modal, 2FA -> Setup/Disable-Modal). Praesentational,
// an die bestehende profile-Action verdrahtet (action=profile / password / startTotpEnrollment /
// confirmTotpEnrollment / disableTotpEnrollment). QR via qrcode.react. Position/Abteilung/Telefon sind in Davids
// Mock enthalten, haben aber kein Backing-Feld -> bewusst weggelassen (CR), statt Mock-Daten zu erfinden.
type ProfileUser = { email: string; firstName?: string | null; lastName?: string | null; avatar?: string | null };
type MfaStatus = { enabled?: boolean; pending?: boolean } | null;
function passwordStrength(pw: string): number {
if (!pw) return 0;
const checks = [pw.length >= 8, /[A-Z]/.test(pw), /[a-z]/.test(pw), /\d/.test(pw), /[^A-Za-z0-9]/.test(pw)];
return Math.round((checks.filter(Boolean).length / checks.length) * 100);
}
function initials(user: ProfileUser): string {
const a = (user.firstName ?? "").trim();
const b = (user.lastName ?? "").trim();
return `${a.charAt(0)}${b.charAt(0)}`.toUpperCase() || user.email.charAt(0).toUpperCase();
}
export default function MyProfileScreen({
user,
mfaStatus,
otpauthUri,
recoveryCodes,
mfaError,
}: {
user: ProfileUser;
mfaStatus: MfaStatus;
otpauthUri?: string;
recoveryCodes?: string[];
mfaError?: string;
}) {
const { t } = useTranslation();
const { csrf = "" } = useRootData();
const navigation = useNavigation();
const actionData = useActionData<{ success?: string; passwordSuccess?: string; error?: string }>();
const submit = useSubmit();
const submitting = navigation.state !== "idle";
const saved = actionData?.success ?? actionData?.passwordSuccess;
const [editingName, setEditingName] = useState(false);
const [pwOpen, setPwOpen] = useState(false);
// 2FA-Modal: null | "password" (Setup-Gate, Action verlangt aktuelles PW) | "qr" | "verify" | "success" | "disable"
const [twoFa, setTwoFa] = useState<null | "password" | "qr" | "verify" | "success" | "disable">(null);
const fileInput = useRef<HTMLInputElement | null>(null);
const avatarForm = useRef<HTMLFormElement | null>(null);
const [avatarData, setAvatarData] = useState<string>("");
// Schritt-Übergänge des 2FA-Flows aus der Action-Antwort ableiten.
useEffect(() => {
if (otpauthUri && twoFa === "password") setTwoFa("qr");
}, [otpauthUri]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (recoveryCodes && recoveryCodes.length > 0) setTwoFa("success");
}, [recoveryCodes]);
// Modal/Inline-Edit erst bei bestätigtem Erfolg schließen (nicht optimistisch beim Submit) -> Fehler bleiben sichtbar.
useEffect(() => {
if (saved) {
setPwOpen(false);
setEditingName(false);
}
}, [saved]);
// Disable-Modal schließen, sobald 2FA tatsächlich deaktiviert ist.
useEffect(() => {
if (!mfaStatus?.enabled && twoFa === "disable") setTwoFa(null);
}, [mfaStatus?.enabled, twoFa]);
function onPickAvatar(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setAvatarData(String(reader.result ?? ""));
requestAnimationFrame(() => avatarForm.current && submit(avatarForm.current));
};
reader.readAsDataURL(file);
}
const mfaEnabled = Boolean(mfaStatus?.enabled);
return (
<div className="flex w-full flex-col items-center" data-ui-id="ui.settings.profile.page">
<div className="flex w-full max-w-[768px] flex-col items-start gap-8">
{/* Header */}
<div className="flex w-full flex-col items-start gap-2">
<span className="text-title-page font-title-page text-text-neutral-primary mobile:text-subtitle mobile:font-subtitle">
{t("settings.profile.pageTitle")}
</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-tertiary">
{t("settings.profile.pageSubtitle")}
</span>
</div>
{/* Profil-Card */}
<section className="flex w-full flex-col items-start gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-6 py-6 shadow-sm">
<div className="flex w-full flex-col items-start gap-1">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.section.profile.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{t("settings.profile.section.profile.subtitle")}
</span>
</div>
{/* Avatar-Reihe */}
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start">
<Avatar size="large" image={user.avatar ?? undefined}>{initials(user)}</Avatar>
<div className="flex flex-col items-start gap-1">
<Form ref={avatarForm} method="post" replace>
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="profile" readOnly />
<input type="hidden" name="firstName" value={user.firstName ?? ""} readOnly />
<input type="hidden" name="lastName" value={user.lastName ?? ""} readOnly />
<input type="hidden" name="avatar" value={avatarData} readOnly />
<input ref={fileInput} type="file" accept="image/png,image/jpeg" className="hidden" onChange={onPickAvatar} data-ui-id="ui.settings.profile.avatarInput" />
<Button
type="button"
variant="secondary"
size="small"
icon={<FeatherUpload />}
loading={submitting && Boolean(avatarData)}
onClick={() => fileInput.current?.click()}
data-ui-id="ui.settings.profile.avatarUpload"
>
{t("settings.profile.avatar.upload")}
</Button>
</Form>
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">
{t("settings.profile.avatar.hint")}
</span>
</div>
</div>
<div className="flex h-px w-full flex-none flex-col items-center bg-border-default" />
{/* Name (inline editierbar) */}
{editingName ? (
<Form method="post" replace className="flex w-full items-end gap-2">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="profile" readOnly />
<TextInput className="h-auto grow shrink-0 basis-0" variant="filled" showLabel label={t("models.user.firstName")}>
<TextInput.Input name="firstName" required defaultValue={user.firstName ?? ""} data-ui-id="ui.settings.profile.firstName" />
</TextInput>
<TextInput className="h-auto grow shrink-0 basis-0" variant="filled" showLabel label={t("models.user.lastName")}>
<TextInput.Input name="lastName" defaultValue={user.lastName ?? ""} data-ui-id="ui.settings.profile.lastName" />
</TextInput>
<IconButton type="submit" variant="primary" icon={<FeatherCheck />} loading={submitting} data-ui-id="ui.settings.profile.nameSave" />
<IconButton type="button" icon={<FeatherX />} onClick={() => setEditingName(false)} data-ui-id="ui.settings.profile.nameCancel" />
</Form>
) : (
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start mobile:gap-2">
<span className="w-40 flex-none text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-secondary mobile:w-full">
{t("settings.profile.field.name")}
</span>
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-primary">
{[user.firstName, user.lastName].filter(Boolean).join(" ") || "—"}
</span>
<Button variant="tertiary" size="small" onClick={() => setEditingName(true)} data-ui-id="ui.settings.profile.nameEdit">
{t("shared.edit")}
</Button>
</div>
)}
{/* E-Mail (read-only) */}
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start mobile:gap-2">
<span className="w-40 flex-none text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-secondary mobile:w-full">
{t("settings.profile.field.email")}
</span>
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-primary">{user.email}</span>
<BadgeFeedback farbe="subtle-neutral" size="small">{t("settings.profile.field.notEditable")}</BadgeFeedback>
</div>
</section>
{/* Sicherheits-Card */}
<section className="flex w-full flex-col items-start gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-6 py-6 shadow-sm">
<div className="flex w-full flex-col items-start gap-1">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.section.security.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{t("settings.profile.section.security.subtitle")}
</span>
</div>
{/* Passwort */}
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start mobile:gap-2">
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1">
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{t("settings.profile.password")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{t("settings.profile.passwordHint")}</span>
</div>
<Button variant="tertiary" icon={<FeatherKey />} className="mobile:w-full" onClick={() => setPwOpen(true)} data-ui-id="ui.settings.profile.password.open">
{t("settings.profile.changePassword")}
</Button>
</div>
<div className="flex h-px w-full flex-none flex-col items-center bg-border-default" />
{/* 2FA */}
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start mobile:gap-2">
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1">
<div className="flex items-center gap-2">
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{t("settings.profile.mfa.title")}</span>
<BadgeFeedback farbe={mfaEnabled ? "subtle-positive" : "subtle-neutral"} size="small">
{mfaEnabled ? t("settings.profile.mfa.badge.active") : t("settings.profile.mfa.badge.inactive")}
</BadgeFeedback>
</div>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{t("settings.profile.mfa.subtitle")}</span>
</div>
<Button
variant="tertiary"
icon={<FeatherShieldCheck />}
className="mobile:w-full"
onClick={() => setTwoFa(mfaEnabled ? "disable" : "password")}
data-ui-id="ui.settings.profile.mfa.manage"
>
{mfaEnabled ? t("settings.profile.mfa.disable.cta") : t("settings.profile.mfa.setup.cta")}
</Button>
</div>
</section>
</div>
{pwOpen ? <PasswordModal csrf={csrf} submitting={submitting} error={actionData?.error} onClose={() => setPwOpen(false)} /> : null}
{twoFa ? (
<TwoFaModal
step={twoFa}
csrf={csrf}
submitting={submitting}
otpauthUri={otpauthUri}
recoveryCodes={recoveryCodes}
mfaError={mfaError}
onClose={() => setTwoFa(null)}
/>
) : null}
</div>
);
}
function ModalShell({ children, dataUiId, center = false }: { children: React.ReactNode; dataUiId: string; center?: boolean }) {
return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-2 bg-surface-neutral-overlay px-4 py-4" data-ui-id={dataUiId}>
<div
className={`flex w-full max-w-[480px] flex-col gap-6 rounded-md border border-solid border-border-default bg-surface-neutral-modal px-6 py-6 shadow-lg mobile:max-w-full ${
center ? "items-center text-center" : "items-start"
}`}
>
{children}
</div>
</div>
);
}
function PasswordModal({ csrf, submitting, error, onClose }: { csrf: string; submitting: boolean; error?: string; onClose: () => void }) {
const { t } = useTranslation();
const [pw, setPw] = useState("");
const strength = passwordStrength(pw);
return (
<ModalShell dataUiId="ui.settings.profile.password.modal">
<div className="flex w-full flex-col items-start gap-1">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.passwordDialog.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{t("settings.profile.passwordDialog.subtitle")}</span>
</div>
<Form method="post" replace className="flex w-full flex-col items-start gap-4">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="password" readOnly />
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.current")} type="password">
<TextInput.Input name="passwordCurrent" type="password" required autoComplete="current-password" data-ui-id="ui.settings.profile.password.current" />
</TextInput>
<div className="flex w-full flex-col items-start gap-2">
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.new")} type="password">
<TextInput.Input
name="passwordNew"
type="password"
required
autoComplete="new-password"
value={pw}
onChange={(event) => setPw(event.target.value)}
data-ui-id="ui.settings.profile.password.new"
/>
</TextInput>
<Progress className="h-auto w-full flex-none" hideLabel value={strength} state={strength >= 80 ? "success" : undefined} />
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">{t("settings.profile.passwordDialog.hint")}</span>
</div>
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.confirm")} type="password">
<TextInput.Input name="passwordNewConfirm" type="password" required autoComplete="new-password" data-ui-id="ui.settings.profile.password.confirm" />
</TextInput>
{error ? <span className="text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative">{error}</span> : null}
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button type="button" variant="tertiary" onClick={onClose} data-ui-id="ui.settings.profile.password.cancel">{t("shared.cancel")}</Button>
<Button type="submit" variant="primary" icon={<FeatherKey />} loading={submitting} data-ui-id="ui.settings.profile.password.submit">
{t("settings.profile.changePassword")}
</Button>
</div>
</Form>
</ModalShell>
);
}
function TwoFaModal({
step,
csrf,
submitting,
otpauthUri,
recoveryCodes,
mfaError,
onClose,
}: {
step: "password" | "qr" | "verify" | "success" | "disable";
csrf: string;
submitting: boolean;
otpauthUri?: string;
recoveryCodes?: string[];
mfaError?: string;
onClose: () => void;
}) {
const { t } = useTranslation();
const secret = otpauthUri ? new URLSearchParams(otpauthUri.split("?")[1] ?? "").get("secret") ?? "" : "";
const [showVerify, setShowVerify] = useState(false);
const [digits, setDigits] = useState<string[]>(Array(6).fill(""));
const code = digits.join("");
if (step === "success") {
return (
<ModalShell dataUiId="ui.settings.profile.mfa.success" center>
<FeatherCheckCircle className="text-[48px] font-[400] text-icon-feedback-positive" />
<div className="flex w-full flex-col items-center gap-2">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.mfa.success.title")}</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">{t("settings.profile.mfa.success.subtitle")}</span>
</div>
{recoveryCodes && recoveryCodes.length > 0 ? (
<div className="flex w-full flex-col items-start gap-2 rounded-md border border-solid border-border-default bg-surface-neutral-primary px-4 py-4">
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-secondary">{t("settings.profile.mfa.recoveryCodes")}</span>
<div className="grid w-full grid-cols-2 gap-1">
{recoveryCodes.map((code) => (
<span key={code} className="font-mono text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-primary">{code}</span>
))}
</div>
</div>
) : null}
<Button className="h-10 w-full flex-none" variant="primary" onClick={onClose} data-ui-id="ui.settings.profile.mfa.done">{t("settings.profile.mfa.success.done")}</Button>
</ModalShell>
);
}
if (step === "qr") {
return (
<ModalShell dataUiId="ui.settings.profile.mfa.setup">
<div className="flex w-full flex-col items-start gap-2">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.mfa.setup.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-secondary">{t("settings.profile.mfa.setup.subtitle")}</span>
</div>
<div className="flex w-full flex-col items-center gap-4">
<div className="flex h-40 w-40 flex-none items-center justify-center rounded-md border border-solid border-border-default bg-surface-neutral-secondary">
{otpauthUri ? <QRCodeSVG value={otpauthUri} size={144} /> : null}
</div>
{secret ? (
<>
<div className="flex w-full items-center justify-center gap-3">
<div className="h-px grow shrink-0 basis-0 bg-border-default" />
<span className="text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">{t("settings.profile.mfa.setup.manualKey")}</span>
<div className="h-px grow shrink-0 basis-0 bg-border-default" />
</div>
<div className="flex w-full items-center justify-center gap-2">
<span className="font-mono tracking-widest text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{secret}</span>
<CopyToClipboardButton clipboardText={secret} />
</div>
</>
) : null}
</div>
{!showVerify ? (
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button variant="tertiary" onClick={onClose} data-ui-id="ui.settings.profile.mfa.setup.cancel">{t("shared.cancel")}</Button>
<Button variant="primary" onClick={() => setShowVerify(true)} data-ui-id="ui.settings.profile.mfa.next">
{t("settings.profile.mfa.setup.next")}
</Button>
</div>
) : (
<div className="flex w-full flex-col items-start gap-4 border-t border-solid border-border-default pt-4">
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-secondary">{t("settings.profile.mfa.verify.subtitle")}</span>
<Form method="post" replace className="flex w-full flex-col items-center gap-4">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="confirmTotpEnrollment" readOnly />
<input type="hidden" name="code" value={code} readOnly />
<InputOtp state={mfaError ? "error" : "default"}>
{Array.from({ length: 6 }).map((_, i) => (
<InputOtp.OtpInputItem key={i} error={Boolean(mfaError)}>
<InputOtp.Input
className="h-auto w-12 flex-none self-stretch"
value={digits[i]}
onChange={(event) => {
const clean = event.target.value.replace(/\D/g, "").slice(0, 1);
setDigits((prev) => prev.map((d, idx) => (idx === i ? clean : d)));
}}
inputMode="numeric"
aria-label={`${t("account.loginMfa.fields.authenticatorCode")} ${i + 1}`}
data-ui-id={i === 0 ? "ui.settings.profile.mfa.code" : undefined}
/>
</InputOtp.OtpInputItem>
))}
</InputOtp>
{mfaError ? <span className="text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative">{mfaError}</span> : null}
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button type="button" variant="tertiary" onClick={() => setShowVerify(false)} data-ui-id="ui.settings.profile.mfa.back">
{t("settings.profile.mfa.setup.back")}
</Button>
<Button type="submit" variant="primary" icon={<FeatherShieldCheck />} loading={submitting} disabled={code.length !== 6} data-ui-id="ui.settings.profile.mfa.confirm">
{t("settings.profile.mfa.verify.confirm")}
</Button>
</div>
</Form>
</div>
)}
</ModalShell>
);
}
if (step === "disable") {
return (
<ModalShell dataUiId="ui.settings.profile.mfa.disable">
<div className="flex w-full flex-col items-start gap-2">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.mfa.disable.title")}</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">{t("settings.profile.mfa.disable.subtitle")}</span>
</div>
<Form method="post" replace className="flex w-full flex-col items-start gap-4">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="disableTotpEnrollment" readOnly />
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.current")} type="password">
<TextInput.Input name="passwordCurrent" type="password" required autoComplete="current-password" data-ui-id="ui.settings.profile.mfa.disablePassword" />
</TextInput>
{mfaError ? <span className="text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative">{mfaError}</span> : null}
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button type="button" variant="tertiary" onClick={onClose}>{t("shared.cancel")}</Button>
<Button type="submit" variant="destructive" loading={submitting} data-ui-id="ui.settings.profile.mfa.disableSubmit">{t("settings.profile.mfa.disable.cta")}</Button>
</div>
</Form>
</ModalShell>
);
}
// step === "password" — Setup-Gate (Action verlangt aktuelles Passwort, bevor der QR erzeugt wird)
return (
<ModalShell dataUiId="ui.settings.profile.mfa.gate">
<div className="flex w-full flex-col items-start gap-2">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.mfa.setup.title")}</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">{t("settings.profile.mfa.gate.subtitle")}</span>
</div>
<Form method="post" replace className="flex w-full flex-col items-start gap-4">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="startTotpEnrollment" readOnly />
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.current")} type="password">
<TextInput.Input name="passwordCurrent" type="password" required autoFocus autoComplete="current-password" data-ui-id="ui.settings.profile.mfa.gatePassword" />
</TextInput>
{mfaError ? <span className="text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative">{mfaError}</span> : null}
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button type="button" variant="tertiary" onClick={onClose}>{t("shared.cancel")}</Button>
<Button type="submit" variant="primary" loading={submitting} data-ui-id="ui.settings.profile.mfa.gateSubmit">{t("settings.profile.mfa.setup.next")}</Button>
</div>
</Form>
</ModalShell>
);
}
@@ -0,0 +1,127 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen, within } from "@testing-library/react";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { RegisterForm } from "./RegisterForm";
const routerState = vi.hoisted(() => ({
searchParams: new URLSearchParams(),
}));
vi.mock("react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-router")>();
return {
...actual,
Form: ({ children, ...props }: React.FormHTMLAttributes<HTMLFormElement>) => <form {...props}>{children}</form>,
useLocation: () => ({ pathname: "/register" }),
useMatches: () => [],
useNavigation: () => ({ state: "idle" }),
useSearchParams: () => [routerState.searchParams],
};
});
vi.mock("react-i18next", () => ({
useTranslation: () => ({
i18n: { exists: () => true },
t: (key: string) => key,
}),
}));
vi.mock("~/utils/data/useRootData", () => ({
useRootData: () => ({
csrf: "csrf-token",
appConfiguration: {
auth: {
requireEmailVerification: false,
requireName: false,
requireOrganization: false,
authMethods: {
emailPassword: { enabled: true },
},
captcha: {
enabled: false,
instanceUrl: undefined,
loginSiteKey: undefined,
},
},
},
}),
}));
describe("RegisterForm", () => {
it("posts redirectTo from inside the email-password form", () => {
routerState.searchParams = new URLSearchParams({ redirect: "/app/acme/dashboard" });
render(<RegisterForm error={undefined} />);
const form = document.querySelector('[data-ui-id="ui.auth.register.form"]');
expect(form).toBeInTheDocument();
expect(form?.querySelector('input[name="redirectTo"]')).toHaveValue("/app/acme/dashboard");
});
it("renders registration copy and localized password requirement labels", () => {
routerState.searchParams = new URLSearchParams();
render(
<RegisterForm
error={undefined}
passwordPolicy={{
minLength: 8,
requireUpper: true,
requireLower: true,
requireDigit: true,
requireSpecial: true,
}}
/>
);
expect(document.querySelector('[data-ui-id="ui.auth.register.form"]')).toBeInTheDocument();
expect(screen.getByRole("button", { name: "account.register.prompts.register.title" })).toBeInTheDocument();
expect(screen.getByText("account.register.bySigningUp", { exact: false })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "account.register.termsAndConditions" })).toHaveAttribute("href", "/terms-and-conditions");
expect(screen.getByRole("link", { name: "account.register.privacyPolicy" })).toHaveAttribute("href", "/privacy-policy");
const requirements = screen.getByRole("list");
expect(requirements).toHaveAccessibleName("account.register.passwordRequirements.label");
const items = within(requirements).getAllByRole("listitem");
expect(items.map((item) => item.textContent)).toEqual([
"[ ] account.register.passwordRequirements.minLength",
"[ ] account.register.passwordRequirements.upper",
"[ ] account.register.passwordRequirements.lower",
"[ ] account.register.passwordRequirements.digit",
"[ ] account.register.passwordRequirements.special",
]);
expect(items[0]).toHaveAccessibleName("account.register.passwordRequirements.notMet: account.register.passwordRequirements.minLength");
fireEvent.change(screen.getByLabelText(/account\.shared\.password/), { target: { value: "Abcdef1!" } });
expect(items.map((item) => item.textContent)).toEqual([
"[x] account.register.passwordRequirements.minLength",
"[x] account.register.passwordRequirements.upper",
"[x] account.register.passwordRequirements.lower",
"[x] account.register.passwordRequirements.digit",
"[x] account.register.passwordRequirements.special",
]);
expect(items[0]).toHaveAccessibleName("account.register.passwordRequirements.met: account.register.passwordRequirements.minLength");
});
it("keeps lead-conversion email locked while preserving hidden conversion fields", () => {
routerState.searchParams = new URLSearchParams();
render(
<RegisterForm
error={undefined}
data={{ email: "lead@example.com" }}
leadConversion={{ leadId: "lead_123", token: "signed-token", planId: "plan_pro" }}
passwordPolicy={{ minLength: 8, requireUpper: false, requireLower: false, requireDigit: false, requireSpecial: false }}
/>
);
expect(screen.getByLabelText(/account\.shared\.email/)).toHaveAttribute("readonly");
expect(screen.getByLabelText(/account\.shared\.email/)).toHaveAttribute("type", "email");
expect(document.querySelector('input[name="leadId"]')).toHaveValue("lead_123");
expect(document.querySelector('input[name="leadToken"]')).toHaveValue("signed-token");
expect(document.querySelector('input[name="leadPlanId"]')).toHaveValue("plan_pro");
});
});
@@ -0,0 +1,260 @@
import { Form, useNavigation, useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
import { useMemo, useState } from "react";
import { useRootData } from "~/utils/data/useRootData";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import ExclamationTriangleIcon from "~/components/ui/icons/ExclamationTriangleIcon";
import Captcha from "~/components/captcha/Captcha";
import InputText from "~/components/ui/input/InputText";
export type PasswordPolicy = {
minLength: number;
requireUpper: boolean;
requireLower: boolean;
requireDigit: boolean;
requireSpecial: boolean;
};
interface Props {
requireCaptcha?: boolean;
isVerifyingEmail?: boolean;
isSettingUpAccount?: boolean;
data?: { company?: string; firstName?: string; lastName?: string; email?: string; slug?: string };
error: string | undefined;
/**
* Marketing-funnel Phase 2 (Phase 05 #79). When present, hidden form
* inputs are emitted so the register action can route the submission
* through `convertLeadToTenant` (atomic Tenant + Lead-status update +
* TenantAcquisition + outbox event). Email is rendered read-only to
* prevent lead-hijacking ("I use your token but with my email").
*/
leadConversion?: { leadId: string; token: string; planId?: string | null };
passwordPolicy?: PasswordPolicy;
}
export const RegisterForm = ({
requireCaptcha = false,
isVerifyingEmail = false,
isSettingUpAccount = false,
data = {},
error,
leadConversion,
passwordPolicy,
}: Props) => {
const { t } = useTranslation();
const { appConfiguration, csrf } = useRootData();
const navigation = useNavigation();
const [password, setPassword] = useState("");
const [searchParams] = useSearchParams();
const redirect = searchParams.get("redirect") ?? undefined;
const showPasswordInput = !appConfiguration.auth.requireEmailVerification || isVerifyingEmail || isSettingUpAccount;
const passwordChecks = useMemo(() => getPasswordChecks(password, passwordPolicy), [password, passwordPolicy]);
return (
<div className="border-border mx-auto flex flex-col items-center space-y-6 rounded-lg border p-6">
{appConfiguration.auth.authMethods.emailPassword.enabled && (
<Form method="post" className="w-full space-y-3" data-ui-id="ui.auth.register.form">
<input type="hidden" name="redirectTo" value={redirect} hidden readOnly />
<input type="hidden" name="csrf" value={csrf} hidden readOnly />
{leadConversion ? (
<>
<input type="hidden" name="leadId" value={leadConversion.leadId} hidden readOnly />
<input type="hidden" name="leadToken" value={leadConversion.token} hidden readOnly />
{leadConversion.planId ? (
<input type="hidden" name="leadPlanId" value={leadConversion.planId} hidden readOnly />
) : null}
</>
) : null}
{/* Tenant */}
{appConfiguration.auth.requireOrganization && (
<div>
{/* <label className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground" htmlFor="company">
{t("models.tenant.object")}
</label> */}
<InputText
title={t("models.tenant.object")}
disabled={navigation.state !== "idle"}
autoFocus
type="text"
name="company"
dataUiId="ui.auth.register.company"
id="company"
placeholder={t("account.shared.companyPlaceholder")}
required
defaultValue={data.company}
// className="focus:border-theme-500 focus:ring-ring relative mt-1 block w-full appearance-none rounded-md border border-border px-3 py-2 text-sm text-foreground placeholder-gray-500 focus:z-10 focus:outline-hidden dark:border-gray-700 dark:bg-gray-900 dark:text-slate-200 sm:text-sm"
/>
</div>
)}
{/* Tenant: End */}
{/* Personal Info */}
{appConfiguration.auth.requireName && (
<div className="grid gap-2 sm:grid-cols-2">
<div>
{/* <label htmlFor="first-name" className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground">
{t("account.shared.name")}
</label> */}
<InputText
title={t("account.shared.name")}
type="text"
name="first-name"
dataUiId="ui.auth.register.firstName"
id="first-name"
required
defaultValue={data.firstName}
className="appearance-none rounded-md"
placeholder={t("account.shared.firstNamePlaceholder")}
disabled={navigation.state !== "idle"}
/>
</div>
<div>
{/* <label htmlFor="last-name" className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground">
<span className="sr-only">{t("models.user.lastName")}</span>
</label> */}
<InputText
title=""
type="text"
name="last-name"
dataUiId="ui.auth.register.lastName"
id="last-name"
defaultValue={data.lastName}
required
className="appearance-none rounded-md"
placeholder={t("account.shared.lastNamePlaceholder")}
disabled={navigation.state !== "idle"}
/>
</div>
</div>
)}
<div>
{/* <label className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground" htmlFor="email">
{t("account.shared.email")}
</label> */}
<InputText
type="email"
title={t("account.shared.email")}
id="email"
name="email"
dataUiId="ui.auth.register.email"
autoComplete="email"
required
readOnly={isVerifyingEmail || Boolean(leadConversion)}
defaultValue={data.email}
// className={clsx(
// "focus:border-theme-500 focus:ring-ring relative mt-1 block w-full appearance-none rounded-md border border-border px-3 py-2 text-sm text-foreground placeholder-gray-500 focus:z-10 focus:outline-hidden dark:border-gray-700 dark:bg-gray-900 dark:text-slate-200 sm:text-sm",
// isVerifyingEmail && "cursor-not-allowed bg-secondary/90 dark:bg-slate-800"
// )}
placeholder={t("account.shared.emailPlaceholder")}
disabled={navigation.state !== "idle"}
/>
</div>
{showPasswordInput && (
<div>
{/* <label className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground" htmlFor="password">
{t("account.shared.password")}
</label> */}
<InputText
title={t("account.shared.password")}
type="password"
id="password"
name="password"
dataUiId="ui.auth.register.password"
required
placeholder="************"
// className="focus:border-theme-500 focus:ring-ring relative mt-1 block w-full appearance-none rounded-md border border-border px-3 py-2 text-sm text-foreground placeholder-gray-500 focus:z-10 focus:outline-hidden dark:border-gray-700 dark:bg-gray-900 dark:text-slate-200 sm:text-sm"
disabled={navigation.state !== "idle"}
value={password}
setValue={setPassword}
/>
{passwordPolicy ? (
<ul
className="mt-2 space-y-1 text-xs"
aria-live="polite"
aria-label={t("account.register.passwordRequirements.label")}
data-ui-id="ui.auth.register.passwordRequirements"
>
{passwordChecks.map((check) => {
const label = t(check.label, check.args);
const status = check.valid ? t("account.register.passwordRequirements.met") : t("account.register.passwordRequirements.notMet");
return (
<li key={check.key} className={check.valid ? "text-emerald-700 dark:text-emerald-300" : "text-muted-foreground"} aria-label={`${status}: ${label}`}>
<span aria-hidden="true">{check.valid ? "[x]" : "[ ]"}</span> {label}
</li>
);
})}
</ul>
) : null}
</div>
)}
{/* Personal Info: End */}
<Captcha
enabled={requireCaptcha && appConfiguration.auth.captcha.enabled}
instanceUrl={appConfiguration.auth.captcha.instanceUrl}
sitekey={appConfiguration.auth.captcha.loginSiteKey}
/>
<div>
<LoadingButton disabled={navigation.state !== "idle"} className="w-full" type="submit" dataUiId="ui.auth.register.submit">
{t("account.register.prompts.register.title")}
</LoadingButton>
</div>
<div id="form-error-message">
{error && navigation.state === "idle" ? (
<div className="flex items-center justify-center space-x-2 text-sm text-red-600 dark:text-red-300" role="alert">
<ExclamationTriangleIcon className="h-4 w-4" />
<div>{error}</div>
</div>
) : null}
</div>
</Form>
)}
<p className="border-border mt-3 border-t py-2 text-center text-sm">
{t("account.register.bySigningUp")}{" "}
<a target="_blank" href="/terms-and-conditions" className="text-primary underline">
{t("account.register.termsAndConditions")}
</a>{" "}
{t("account.register.andOur")}{" "}
<a target="_blank" href="/privacy-policy" className="text-primary underline">
{t("account.register.privacyPolicy")}
</a>
.
</p>
</div>
);
};
export function getPasswordChecks(password: string, policy?: PasswordPolicy) {
if (!policy) {
return [];
}
const checks: { key: string; valid: boolean; label: string; args: Record<string, string | number> }[] = [
{
key: "minLength",
valid: password.length >= policy.minLength,
label: "account.register.passwordRequirements.minLength",
args: { 0: policy.minLength },
},
];
if (policy.requireUpper) {
checks.push({ key: "upper", valid: /[A-Z]/.test(password), label: "account.register.passwordRequirements.upper", args: {} });
}
if (policy.requireLower) {
checks.push({ key: "lower", valid: /[a-z]/.test(password), label: "account.register.passwordRequirements.lower", args: {} });
}
if (policy.requireDigit) {
checks.push({ key: "digit", valid: /\d/.test(password), label: "account.register.passwordRequirements.digit", args: {} });
}
if (policy.requireSpecial) {
checks.push({ key: "special", valid: /[^A-Za-z0-9]/.test(password), label: "account.register.passwordRequirements.special", args: {} });
}
return checks;
}
@@ -0,0 +1,234 @@
import { useMemo, useState } from "react";
import { Form, Link, useNavigation, useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
import { FeatherMail } from "@subframe/core";
import { useRootData } from "~/utils/data/useRootData";
import { AuthShell, AuthStatus } from "~/components/auth/AuthShell";
import { Button } from "~/ui/components/Button";
import { Checkbox } from "~/ui/components/Checkbox";
import { LinkButton } from "~/ui/components/LinkButton";
import { Progress } from "~/ui/components/Progress";
import { TextInput } from "~/ui/components/TextInput";
import Captcha from "~/components/captcha/Captcha";
import { getPasswordChecks, type PasswordPolicy } from "~/modules/users/components/RegisterForm";
// Signup-Screen nach Davids Subframe-Design ("Signup"): Logo + Card (Konto erstellen) + E-Mail, Passwort
// (+ Staerke-Progress), Firmenname, Vor-/Nachname, Telefon, USt-IdNr, 3 Consent-Checkboxen + "Konto erstellen".
// Voll an die bestehende Register-Action verdrahtet (gleiche Feld-Namen, Password-Policy, Captcha, Lead-Felder).
// verificationEmailSent -> Davids Success-Status. Footer "Schon ein Konto? Anmelden".
//
// 💡 Backend-Luecken (gerendert + gepostet, aber von registerFromRequest noch nicht persistiert):
// - phone: User-Modell hat die Spalte (accounts.prisma) -> Action muss das Feld nur noch lesen.
// - vatNumber (USt-IdNr): keine Spalte im LOCKED-Modell -> braucht Data-Model-Entscheidung.
// - 3 Consents: Backend nutzt impliziten Terms-Consent; hier als Client-Gate (kein Server-Tracking).
type RegisterData = { company?: string; firstName?: string; lastName?: string; email?: string; slug?: string };
export default function RegisterScreen({
requireCaptcha = false,
error,
verificationEmailSent,
data = {},
leadConversion,
passwordPolicy,
}: {
requireCaptcha?: boolean;
error?: string;
verificationEmailSent?: boolean;
data?: RegisterData;
leadConversion?: { leadId: string; token: string; planId?: string | null };
passwordPolicy?: PasswordPolicy;
}) {
const { t } = useTranslation();
const { appConfiguration, csrf } = useRootData();
const navigation = useNavigation();
const [searchParams] = useSearchParams();
const isSubmitting = navigation.state !== "idle";
const redirect = searchParams.get("redirect") ?? "";
const [password, setPassword] = useState("");
const [acceptTerms, setAcceptTerms] = useState(false);
const [acceptPrivacy, setAcceptPrivacy] = useState(false);
const [acceptDpa, setAcceptDpa] = useState(false);
const allConsents = acceptTerms && acceptPrivacy && acceptDpa;
const showPasswordInput = !appConfiguration.auth.requireEmailVerification;
const checks = useMemo(() => getPasswordChecks(password, passwordPolicy), [password, passwordPolicy]);
const strength = checks.length === 0 ? 0 : Math.round((checks.filter((c) => c.valid).length / checks.length) * 100);
if (verificationEmailSent) {
return (
<AuthStatus
icon={<FeatherMail />}
tone="positive"
title={t("account.verify.title")}
message={t("account.verify.emailSent")}
dataUiId="ui.auth.register.verificationSent"
>
<Link to="/login" className="no-underline">
<LinkButton size="small">{t("account.login.backToLogin")}</LinkButton>
</Link>
</AuthStatus>
);
}
return (
<AuthShell
title={t("account.register.title")}
subtitle={t("account.register.subtitle")}
cardClassName="max-w-[480px] gap-5 shadow-lg"
dataUiId="ui.auth.register.page"
footer={
<>
<span className="text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">
{t("account.register.haveAccount")}
</span>
<Link to="/login" className="no-underline" data-ui-id="ui.auth.register.login">
<LinkButton size="small" hideIcon>
{t("account.register.signIn")}
</LinkButton>
</Link>
</>
}
>
{appConfiguration.auth.authMethods.emailPassword.enabled ? (
<Form method="post" noValidate className="flex w-full flex-col gap-5" data-ui-id="ui.auth.register.form">
<input type="hidden" name="redirectTo" value={redirect} readOnly />
<input type="hidden" name="csrf" value={csrf} readOnly />
{leadConversion ? (
<>
<input type="hidden" name="leadId" value={leadConversion.leadId} readOnly />
<input type="hidden" name="leadToken" value={leadConversion.token} readOnly />
{leadConversion.planId ? <input type="hidden" name="leadPlanId" value={leadConversion.planId} readOnly /> : null}
</>
) : null}
{/* Davids Felder-Gruppe: alle Inputs in einem gap-4-Wrapper; Form-gap-5 trennt Gruppe/Consent/Button. */}
<div className="flex w-full flex-col items-start gap-4">
<TextInput className="h-auto w-full flex-none" fillContainer showLabel label={t("account.shared.email")} type="email">
<TextInput.Input
name="email"
type="email"
required
autoComplete="email"
placeholder={t("account.shared.emailPlaceholder")}
defaultValue={data.email}
readOnly={Boolean(leadConversion)}
disabled={isSubmitting}
data-ui-id="ui.auth.register.email"
/>
</TextInput>
{showPasswordInput ? (
<>
<div className="flex w-full flex-col items-start gap-2">
<div className="flex w-full items-end gap-2">
<TextInput className="h-auto grow shrink-0 basis-0" fillContainer showLabel label={t("account.shared.password")} type="password">
<TextInput.Input
name="password"
type="password"
required
autoComplete="new-password"
placeholder={t("account.shared.password")}
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isSubmitting}
data-ui-id="ui.auth.register.password"
/>
</TextInput>
</div>
{passwordPolicy ? (
<>
<Progress className="h-auto w-full flex-none" hideLabel value={strength} />
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-tertiary">
{t("account.register.passwordStrength")}
</span>
</>
) : null}
</div>
{passwordPolicy ? (
<span className="w-full text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">
{t("account.register.passwordHint")}
</span>
) : null}
</>
) : null}
<TextInput className="h-auto w-full flex-none" fillContainer showLabel label={t("account.register.companyLabel")}>
<TextInput.Input
name="company"
required={appConfiguration.auth.requireOrganization}
placeholder={t("account.shared.companyPlaceholder")}
defaultValue={data.company}
disabled={isSubmitting}
data-ui-id="ui.auth.register.company"
/>
</TextInput>
<div className="flex w-full items-start gap-4 mobile:flex-col">
<TextInput className="h-auto grow shrink-0 basis-0" showLabel label={t("models.user.firstName")}>
<TextInput.Input
name="first-name"
placeholder={t("account.shared.firstNamePlaceholder")}
defaultValue={data.firstName}
disabled={isSubmitting}
data-ui-id="ui.auth.register.firstName"
/>
</TextInput>
<TextInput className="h-auto grow shrink-0 basis-0" showLabel label={t("models.user.lastName")}>
<TextInput.Input
name="last-name"
placeholder={t("account.shared.lastNamePlaceholder")}
defaultValue={data.lastName}
disabled={isSubmitting}
data-ui-id="ui.auth.register.lastName"
/>
</TextInput>
</div>
<TextInput className="h-auto w-full flex-none" fillContainer showLabel label={t("account.register.phone")} type="tel">
<TextInput.Input
name="phone"
type="tel"
placeholder={t("account.register.phonePlaceholder")}
disabled={isSubmitting}
data-ui-id="ui.auth.register.phone"
/>
</TextInput>
<TextInput className="h-auto w-full flex-none" fillContainer showLabel label={t("account.register.vatId")}>
<TextInput.Input
name="vatNumber"
placeholder={t("account.register.vatIdPlaceholder")}
disabled={isSubmitting}
data-ui-id="ui.auth.register.vat"
/>
</TextInput>
</div>
<div className="flex w-full flex-col items-start gap-3">
<Checkbox label={t("account.register.consentTerms")} checked={acceptTerms} onCheckedChange={setAcceptTerms} />
<Checkbox label={t("account.register.consentPrivacy")} checked={acceptPrivacy} onCheckedChange={setAcceptPrivacy} />
<Checkbox label={t("account.register.consentDpa")} checked={acceptDpa} onCheckedChange={setAcceptDpa} />
</div>
<Captcha
enabled={requireCaptcha && appConfiguration.auth.captcha.enabled}
instanceUrl={appConfiguration.auth.captcha.instanceUrl}
sitekey={appConfiguration.auth.captcha.loginSiteKey}
/>
<Button type="submit" loading={isSubmitting} disabled={isSubmitting || !allConsents} data-ui-id="ui.auth.register.submit">
{t("account.register.title")}
</Button>
{error && navigation.state === "idle" ? (
<p className="text-center text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative" role="alert">
{error}
</p>
) : null}
</Form>
) : null}
</AuthShell>
);
}
@@ -0,0 +1,145 @@
import { useState } from "react";
import { Form, useLocation, useNavigate, useNavigation } from "react-router";
import { useTranslation } from "react-i18next";
import { FeatherAlertTriangle, FeatherCheck } from "@subframe/core";
import { useRootData } from "~/utils/data/useRootData";
import { AuthShell, AuthStatus } from "~/components/auth/AuthShell";
import { Button } from "~/ui/components/Button";
import { LinkButton } from "~/ui/components/LinkButton";
import { Progress } from "~/ui/components/Progress";
import { TextInput } from "~/ui/components/TextInput";
// Reset-Password-Screen nach Davids Subframe-Design ("Reset Password" + States): Logo + Card
// (Neues Passwort + Staerke-Progress + Bestaetigung + "Passwort speichern"). Token kommt aus der URL (t),
// E-Mail aus dem Loader (e) -> beide als hidden Inputs. action validiert (mismatch/weak/token).
// success -> Davids "Reset Password _ Success"; fehlende/ungueltige Link-Daten -> "Reset Link _ Invalid".
function passwordStrength(pw: string): number {
if (!pw) return 0;
const checks = [pw.length >= 8, /[A-Z]/.test(pw), /[a-z]/.test(pw), /\d/.test(pw), /[^A-Za-z0-9]/.test(pw)];
return Math.round((checks.filter(Boolean).length / checks.length) * 100);
}
export default function ResetPasswordScreen({
data,
actionData,
}: {
data: { email: string };
actionData: { success?: string; error?: string; fields?: { verifyToken?: string; email?: string } } | null | undefined;
}) {
const { t } = useTranslation();
const { csrf } = useRootData();
const navigation = useNavigation();
const navigate = useNavigate();
const location = useLocation();
const isSubmitting = navigation.state !== "idle";
const verifyToken = actionData?.fields?.verifyToken || new URLSearchParams(location.search).get("t") || "";
const [password, setPassword] = useState("");
const strength = passwordStrength(password);
const strengthLabel =
strength < 40 ? t("account.newPassword.strengthWeak") : strength < 80 ? t("account.newPassword.strengthMedium") : t("account.newPassword.strengthStrong");
if (actionData?.success) {
return (
<AuthStatus
icon={<FeatherCheck />}
tone="positive"
title={t("account.newPassword.successTitle")}
message={t("account.newPassword.successBody")}
dataUiId="ui.auth.reset.success"
>
<LinkButton size="small" hideIcon onClick={() => navigate("/login")} data-ui-id="ui.auth.reset.successBackToLogin">
{t("account.forgot.backToLogin")}
</LinkButton>
</AuthStatus>
);
}
// Kein/ungueltiger Link (keine E-Mail im e-Param) -> Davids "Reset Link _ Invalid".
if (!data.email) {
return (
<AuthStatus
icon={<FeatherAlertTriangle />}
tone="negative"
title={t("account.newPassword.invalidTitle")}
message={t("account.newPassword.invalidBody")}
dataUiId="ui.auth.reset.invalid"
>
<Button onClick={() => navigate("/forgot-password")} data-ui-id="ui.auth.reset.requestNew">
{t("account.newPassword.requestNew")}
</Button>
<LinkButton size="small" hideIcon onClick={() => navigate("/login")} data-ui-id="ui.auth.reset.invalidBackToLogin">
{t("account.forgot.backToLogin")}
</LinkButton>
</AuthStatus>
);
}
return (
<AuthShell title={t("account.newPassword.title")} subtitle={t("account.newPassword.subtitle")} dataUiId="ui.auth.reset.page">
<Form method="post" noValidate className="flex w-full flex-col gap-4" data-ui-id="ui.auth.reset.form">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="verify-token" value={verifyToken} readOnly />
<input type="hidden" name="email" value={data.email} readOnly />
<div className="flex w-full flex-col items-start gap-2">
<div className="flex w-full items-end gap-2">
<TextInput className="h-auto grow shrink-0 basis-0" fillContainer showLabel label={t("account.newPassword.passwordLabel")} type="password">
<TextInput.Input
name="password"
type="password"
required
autoFocus
autoComplete="new-password"
placeholder={t("account.shared.password")}
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isSubmitting}
data-ui-id="ui.auth.reset.password"
/>
</TextInput>
</div>
<Progress className="h-auto w-full flex-none" hideLabel value={strength} state={strength >= 80 ? "success" : undefined} />
<div className="flex w-full items-center justify-between">
<span className="text-body-extra-small-regular-0 font-body-extra-small-regular-0 text-text-neutral-tertiary">
{t("account.register.passwordStrength")}: {strengthLabel}
</span>
</div>
<span className="w-full text-body-extra-small-medium-150 font-body-extra-small-medium-150 text-text-neutral-tertiary">
{t("account.register.passwordHint")}
</span>
</div>
<div className="flex w-full items-end gap-2">
<TextInput className="h-auto grow shrink-0 basis-0" fillContainer showLabel label={t("account.register.confirmPassword")} type="password">
<TextInput.Input
name="password-confirm"
type="password"
required
autoComplete="new-password"
placeholder={t("account.register.confirmPassword")}
disabled={isSubmitting}
data-ui-id="ui.auth.reset.passwordConfirm"
/>
</TextInput>
</div>
<Button className="h-9 w-full flex-none" type="submit" loading={isSubmitting} disabled={isSubmitting} data-ui-id="ui.auth.reset.submit">
{t("account.newPassword.button")}
</Button>
{actionData?.error && navigation.state === "idle" ? (
<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={() => navigate("/login")} data-ui-id="ui.auth.reset.backToLogin">
{t("account.forgot.backToLogin")}
</LinkButton>
</div>
</AuthShell>
);
}
@@ -0,0 +1,164 @@
import { useState } from "react";
import { Form, Link, useNavigation } from "react-router";
import { useTranslation } from "react-i18next";
import * as SubframeCore from "@subframe/core";
import { FeatherAlertTriangle, FeatherEdit2, FeatherMoreHorizontal, FeatherPlus, FeatherShield, FeatherTrash } from "@subframe/core";
import type { RoleWithPermissionsAndUsers } from "~/utils/db/permissions/roles.db.server";
import { roleDisplayDescription, roleDisplayName } from "~/components/core/roles/roleDisplay";
import { Button } from "~/ui/components/Button";
import { DropdownMenu } from "~/ui/components/DropdownMenu";
import { IconButton } from "~/ui/components/IconButton";
import { Table } from "~/ui/components/Table";
import { useRootData } from "~/utils/data/useRootData";
// D-P2 — Rollen-Liste nach Davids "Roles Flow"-Design (Table: Rolle/Beschreibung/Mitglieder/Aktionen).
// Anlegen/Bearbeiten via bestehende Routen (new / $id im SlideOver), Löschen via Form-POST an $id (action=delete).
// Degradierung: Delete-mit-Reassign hat kein Backend -> Delete-GUARD (blockt, wenn der Rolle Mitglieder
// zugewiesen sind), statt einen unsicheren Hard-Delete auszulösen. Default-Rollen sind nicht editier-/löschbar.
export default function RolesListView({
items,
canUpdate,
tenantId,
}: {
items: RoleWithPermissionsAndUsers[];
canUpdate: boolean;
tenantId: string;
}) {
const { t } = useTranslation();
const { csrf = "" } = useRootData();
const navigation = useNavigation();
const loading = navigation.state === "loading";
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; memberCount: number } | null>(null);
const memberCountOf = (role: RoleWithPermissionsAndUsers) => role.users.filter((u) => u.tenantId === tenantId).length;
const blocked = confirmDelete ? confirmDelete.memberCount > 0 : false;
return (
<div className="flex w-full flex-col items-start gap-4">
<div className="flex w-full items-start justify-between gap-4 mobile:flex-col">
<div className="flex flex-col items-start gap-1">
<span className="text-title-page font-title-page text-text-neutral-primary mobile:text-subtitle mobile:font-subtitle">
{t("models.role.plural")}
</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-tertiary">
{t("settings.rolesAndPermissions.subtitle")}
</span>
</div>
{canUpdate ? (
<Link to="new" className="no-underline mobile:w-full" data-ui-id="ui.settings.rolesAndPermissions.roles.new">
<Button variant="primary" icon={<FeatherPlus />} className="mobile:w-full">
{t("settings.rolesAndPermissions.create")}
</Button>
</Link>
) : null}
</div>
<Table
header={
<Table.HeaderRow>
<Table.HeaderCell>{t("models.role.object")}</Table.HeaderCell>
<Table.HeaderCell>{t("models.role.description")}</Table.HeaderCell>
<Table.HeaderCell>{t("settings.rolesAndPermissions.columns.members")}</Table.HeaderCell>
<Table.HeaderCell> </Table.HeaderCell>
</Table.HeaderRow>
}
>
{loading ? null : items.length === 0 ? (
<Table.Row clickable={false}>
<Table.Cell colSpan={4}>
<div className="flex grow shrink-0 basis-0 flex-col items-center justify-center gap-6 py-12">
<div className="flex flex-col items-center gap-4">
<FeatherShield className="text-[48px] leading-[48px] text-icon-neutral-tertiary" />
<div className="flex flex-col items-center gap-2">
<span className="text-body-large-0 font-body-large-0 text-text-neutral-primary text-center">{t("settings.rolesAndPermissions.empty.title")}</span>
<span className="max-w-[384px] text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary text-center">
{t("settings.rolesAndPermissions.empty.description")}
</span>
</div>
</div>
{canUpdate ? (
<Link to="new" className="no-underline">
<Button variant="primary" icon={<FeatherPlus />}>{t("settings.rolesAndPermissions.create")}</Button>
</Link>
) : null}
</div>
</Table.Cell>
</Table.Row>
) : (
items.map((role) => {
const editable = canUpdate && !role.isDefault;
return (
<Table.Row key={role.id} data-ui-id="ui.settings.rolesAndPermissions.roles.row">
<Table.Cell>
<span className="whitespace-nowrap text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-secondary">{roleDisplayName(role, t)}</span>
</Table.Cell>
<Table.Cell>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-tertiary">{roleDisplayDescription(role, t) || "—"}</span>
</Table.Cell>
<Table.Cell>
<span className="whitespace-nowrap text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-secondary">{memberCountOf(role)}</span>
</Table.Cell>
<Table.Cell>
<div className="flex grow shrink-0 basis-0 items-center justify-end">
{editable ? (
<SubframeCore.DropdownMenu.Root>
<SubframeCore.DropdownMenu.Trigger asChild>
<IconButton icon={<FeatherMoreHorizontal />} data-ui-id="ui.settings.rolesAndPermissions.roles.rowActions" />
</SubframeCore.DropdownMenu.Trigger>
<SubframeCore.DropdownMenu.Portal>
<SubframeCore.DropdownMenu.Content side="bottom" align="end" sideOffset={4} asChild>
<DropdownMenu>
<Link to={role.id} className="no-underline">
<DropdownMenu.DropdownItem icon={<FeatherEdit2 />}>{t("shared.edit")}</DropdownMenu.DropdownItem>
</Link>
<DropdownMenu.DropdownItem icon={<FeatherTrash />} onClick={() => setConfirmDelete({ id: role.id, name: roleDisplayName(role, t), memberCount: memberCountOf(role) })}>
{t("shared.delete")}
</DropdownMenu.DropdownItem>
</DropdownMenu>
</SubframeCore.DropdownMenu.Content>
</SubframeCore.DropdownMenu.Portal>
</SubframeCore.DropdownMenu.Root>
) : null}
</div>
</Table.Cell>
</Table.Row>
);
})
)}
</Table>
{confirmDelete ? (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-2 bg-surface-neutral-overlay px-4 py-4" data-ui-id="ui.settings.rolesAndPermissions.roles.deleteConfirm">
<div className="flex w-96 max-w-full flex-col items-start gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-modal px-6 py-6 shadow-lg mobile:w-full mobile:flex-none">
<div className="flex h-12 w-12 flex-none items-center justify-center gap-2 rounded-round bg-surface-feedback-subtle-negative">
<FeatherAlertTriangle className="text-heading-0 font-heading-0 text-icon-feedback-negative" />
</div>
<div className="flex w-full flex-col items-start gap-2">
<span className="w-full text-heading-0 font-heading-0 text-text-neutral-primary">
{blocked ? t("settings.rolesAndPermissions.deleteBlocked.title") : t("settings.rolesAndPermissions.delete.title")}
</span>
<span className="w-full text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">
{blocked
? t("settings.rolesAndPermissions.deleteBlocked.description", { name: confirmDelete.name, count: confirmDelete.memberCount })
: t("settings.rolesAndPermissions.delete.description", { name: confirmDelete.name })}
</span>
</div>
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button variant="tertiary" onClick={() => setConfirmDelete(null)}>{blocked ? t("shared.close") : t("shared.cancel")}</Button>
{!blocked ? (
<Form method="post" action={confirmDelete.id} className="contents">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="delete" readOnly />
<Button variant="destructive" type="submit" loading={navigation.state !== "idle"} data-ui-id="ui.settings.rolesAndPermissions.roles.deleteSubmit">
{t("shared.delete")}
</Button>
</Form>
) : null}
</div>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,259 @@
import { Fragment, useState } from "react";
import { Form, useNavigation } from "react-router";
import { useTranslation } from "react-i18next";
import { FeatherAlertTriangle, FeatherLogOut, FeatherMonitor, FeatherShieldOff, FeatherSmartphone } from "@subframe/core";
import type { SessionListVM } from "@toolctrl/view-models";
import { Button } from "~/ui/components/Button";
import { BadgeFeedback } from "~/ui/components/BadgeFeedback";
// D-P2 — Settings > Sicherheit nach Davids Subframe-"Session Management"-Design: Header (Titel + "Überall
// abmelden") + Geräte-Card (Icon + Label/Standort + "Dieses Gerät"-Badge / "Abmelden") + Confirm-Overlay
// (Revoke / Logout-All). Praesentational: VM + csrf werden injiziert, Aktionen laufen ueber die bestehende
// security-Action (intents revoke / revoke-all / forget-device / forget-all-devices). Die Trusted-Devices-
// Sektion hat kein eigenes David-Design -> konsistent in derselben Card-Optik re-skinned.
type TrustedDeviceVM = { id: string; label: string | null; approxLocation: string | null };
type ConfirmState =
| { kind: "revoke-all" }
| { kind: "revoke"; sessionId: string; label: string }
| { kind: "forget-all-devices" }
| { kind: "forget-device"; deviceId: string; label: string };
function isMobileDevice(label?: string | null): boolean {
if (!label) return false;
return /iphone|android|mobile|ipad|tablet/i.test(label);
}
export default function SecuritySessionsScreen({
vm,
trustedDevices,
csrf,
}: {
vm: SessionListVM;
trustedDevices: TrustedDeviceVM[];
csrf: string;
}) {
const { t } = useTranslation();
const navigation = useNavigation();
const submitting = navigation.state !== "idle";
const [confirm, setConfirm] = useState<ConfirmState | null>(null);
const ready = vm.status === "ready";
const devices = ready ? vm.data.devices : [];
const onlyThisDevice = ready && devices.length <= 1 && !vm.data.canRevokeOthers;
return (
<div className="relative flex w-full flex-col items-center" data-ui-id="ui.settings.security.page">
<div className="flex w-full max-w-[768px] flex-col items-start gap-8">
{/* Header */}
<div className="flex w-full items-start justify-between gap-4 mobile:flex-col">
<div className="flex flex-col items-start gap-2">
<span className="text-title-page font-title-page text-text-neutral-primary mobile:text-subtitle mobile:font-subtitle">
{t("settings.security.title")}
</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-tertiary">
{t("settings.security.sessions.subtitle")}
</span>
</div>
{ready && vm.data.canRevokeOthers ? (
<Button
variant="secondary"
icon={<FeatherLogOut />}
className="mobile:w-full"
onClick={() => setConfirm({ kind: "revoke-all" })}
data-ui-id="ui.settings.security.revokeAll"
>
{t("settings.security.sessions.revokeAll")}
</Button>
) : null}
</div>
{/* Aktive Sitzungen */}
<section
className="flex w-full flex-col items-start rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-6 py-6 shadow-sm"
data-ui-id="ui.settings.security.sessions"
aria-label={t("settings.security.sessions.heading")}
>
{!ready ? (
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{t("settings.security.sessions.empty")}
</span>
) : (
devices.map((device, index) => {
const Icon = isMobileDevice(device.deviceLabel) ? FeatherSmartphone : FeatherMonitor;
const label = device.deviceLabel ?? t(device.deviceLabelFallbackKey ?? "settings.security.sessions.unknownDevice");
const meta = [device.approxLocation, t(`settings.security.sessions.lastSeen.${device.lastSeenBucket}`, { count: device.lastSeenCount ?? 0 })]
.filter(Boolean)
.join(" · ");
return (
<Fragment key={device.id}>
{index > 0 ? <div className="flex h-px w-full flex-none flex-col items-start bg-border-default" /> : null}
<div
className="flex w-full items-center gap-4 py-4"
data-ui-id="ui.settings.security.sessionRow"
data-current={device.isCurrent}
>
<Icon className="text-heading- font-heading- text-icon-neutral-secondary" />
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1">
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{label}</span>
{meta ? (
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{meta}</span>
) : null}
</div>
{device.isCurrent ? (
<BadgeFeedback farbe="subtle-positive" size="small">
{t("settings.security.sessions.thisDevice")}
</BadgeFeedback>
) : device.canRevoke ? (
<Button
variant="secondary"
size="small"
onClick={() => setConfirm({ kind: "revoke", sessionId: device.id, label })}
data-ui-id="ui.settings.security.revoke"
>
{t("settings.security.sessions.revoke")}
</Button>
) : null}
</div>
</Fragment>
);
})
)}
</section>
{onlyThisDevice ? (
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{t("settings.security.sessions.onlyThisDevice")}
</span>
) : null}
{/* Vertrauenswürdige Geräte (kein eigenes David-Design -> konsistente Card-Optik) */}
{trustedDevices.length > 0 ? (
<section
className="flex w-full flex-col items-start gap-4"
data-ui-id="ui.settings.security.trustedDevices"
aria-label={t("settings.security.trustedDevices.heading")}
>
<div className="flex w-full items-center justify-between gap-4">
<div className="flex flex-col items-start gap-1">
<span className="text-heading- font-heading- text-text-neutral-primary">
{t("settings.security.trustedDevices.heading")}
</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{t("settings.security.trustedDevices.description")}
</span>
</div>
<Button
variant="tertiary"
size="small"
icon={<FeatherShieldOff />}
onClick={() => setConfirm({ kind: "forget-all-devices" })}
data-ui-id="ui.settings.security.forgetAllDevices"
>
{t("settings.security.trustedDevices.forgetAll")}
</Button>
</div>
<div className="flex w-full flex-col items-start rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-6 py-6 shadow-sm">
{trustedDevices.map((device, index) => (
<Fragment key={device.id}>
{index > 0 ? <div className="flex h-px w-full flex-none flex-col items-start bg-border-default" /> : null}
<div className="flex w-full items-center gap-4 py-4" data-ui-id="ui.settings.security.trustedDeviceRow">
<FeatherShieldOff className="text-heading- font-heading- text-icon-neutral-secondary" />
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1">
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">
{device.label ?? t("settings.security.trustedDevices.unknownDevice")}
</span>
{device.approxLocation ? (
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{device.approxLocation}
</span>
) : null}
</div>
<Button
variant="tertiary"
size="small"
onClick={() => setConfirm({ kind: "forget-device", deviceId: device.id, label: device.label ?? t("settings.security.trustedDevices.unknownDevice") })}
data-ui-id="ui.settings.security.forgetDevice"
>
{t("settings.security.trustedDevices.forget")}
</Button>
</div>
</Fragment>
))}
</div>
</section>
) : null}
</div>
{confirm ? <ConfirmOverlay confirm={confirm} csrf={csrf} submitting={submitting} onClose={() => setConfirm(null)} /> : null}
</div>
);
}
function ConfirmOverlay({
confirm,
csrf,
submitting,
onClose,
}: {
confirm: ConfirmState;
csrf: string;
submitting: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const copy = {
"revoke-all": {
title: t("settings.security.sessions.confirmRevokeAll.title"),
description: t("settings.security.sessions.confirmRevokeAll.description"),
confirm: t("settings.security.sessions.confirmRevokeAll.confirm"),
},
revoke: {
title: t("settings.security.sessions.confirmRevoke.title"),
description: t("settings.security.sessions.confirmRevoke.description", { device: "label" in confirm ? confirm.label : "" }),
confirm: t("settings.security.sessions.confirmRevoke.confirm"),
},
"forget-all-devices": {
title: t("settings.security.trustedDevices.confirmForgetAll.title"),
description: t("settings.security.trustedDevices.confirmForgetAll.description"),
confirm: t("settings.security.trustedDevices.forgetAll"),
},
"forget-device": {
title: t("settings.security.trustedDevices.confirmForget.title"),
description: t("settings.security.trustedDevices.confirmForget.description", { device: "label" in confirm ? confirm.label : "" }),
confirm: t("settings.security.trustedDevices.forget"),
},
}[confirm.kind];
return (
<div
className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-2 bg-surface-neutral-overlay px-4 py-4"
data-ui-id="ui.settings.security.confirm"
>
<div className="flex w-96 max-w-full flex-col items-start gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-modal px-6 py-6 shadow-lg mobile:w-full mobile:flex-none">
<div className="flex h-12 w-12 flex-none items-center justify-center gap-2 rounded-round bg-surface-feedback-subtle-negative">
<FeatherAlertTriangle className="text-heading-0 font-heading-0 text-icon-feedback-negative" />
</div>
<div className="flex w-full flex-col items-start gap-2">
<span className="w-full text-heading-0 font-heading-0 text-text-neutral-primary">{copy.title}</span>
<span className="w-full text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">{copy.description}</span>
</div>
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button variant="tertiary" onClick={onClose} data-ui-id="ui.settings.security.confirm.cancel">
{t("shared.cancel")}
</Button>
<Form method="post" className="contents">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="intent" value={confirm.kind} readOnly />
{confirm.kind === "revoke" ? <input type="hidden" name="sessionId" value={confirm.sessionId} readOnly /> : null}
{confirm.kind === "forget-device" ? <input type="hidden" name="deviceId" value={confirm.deviceId} readOnly /> : null}
<Button variant="destructive" type="submit" loading={submitting} data-ui-id="ui.settings.security.confirm.submit">
{copy.confirm}
</Button>
</Form>
</div>
</div>
</div>
);
}
@@ -0,0 +1,140 @@
import { useSubmit, Link } from "react-router";
import { useTranslation } from "react-i18next";
import type Stripe from "stripe";
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
import MyBillingSession from "~/components/core/settings/subscription/MyBillingSession";
import MyInvoices from "~/components/core/settings/subscription/MyInvoices";
import MyPayments from "~/components/core/settings/subscription/MyPayments";
import MyProducts from "~/components/core/settings/subscription/MyProducts";
import MySubscriptionFeatures from "~/components/core/settings/subscription/MySubscriptionFeatures";
import MyUpcomingInvoice from "~/components/core/settings/subscription/MyUpcomingInvoice";
import SettingSection from "~/components/ui/sections/SettingSection";
import { TenantSubscriptionProductWithDetails } from "~/utils/db/subscriptions/tenantSubscriptionProducts.db.server";
import { TenantSubscriptionWithDetails } from "~/utils/db/tenantSubscriptions.db.server";
import { TenantSimple } from "~/utils/db/tenants.db.server";
import { subscriptionCheckoutPath } from "~/utils/app/SubscriptionUrlUtils";
import { useRootData } from "~/utils/data/useRootData";
export default function SubscriptionSettings({
currentTenant,
mySubscription,
myInvoices,
myPayments,
myFeatures,
myUpcomingInvoices,
permissions,
}: {
currentTenant: TenantSimple;
mySubscription: TenantSubscriptionWithDetails | null;
myInvoices: Stripe.Invoice[];
myPayments: Stripe.PaymentIntent[];
myFeatures: PlanFeatureUsageDto[];
myUpcomingInvoices: Stripe.Invoice[];
permissions: {
viewInvoices: boolean;
};
}) {
const { t } = useTranslation();
const submit = useSubmit();
const { csrf } = useRootData();
function onCancel(item: TenantSubscriptionProductWithDetails) {
const form = new FormData();
form.set("action", "cancel");
form.set("csrf", csrf ?? "");
form.set("tenant-subscription-product-id", item.id);
submit(form, {
method: "post",
});
}
// function onAddPaymentMethod() {
// const form = new FormData();
// form.set("action", "add-payment-method");
// submit(form, {
// method: "post",
// });
// }
function onOpenCustomerPortal() {
const form = new FormData();
form.set("action", "open-customer-portal");
form.set("csrf", csrf ?? "");
submit(form, {
method: "post",
});
}
return (
<div className="space-y-4" data-ui-id="ui.subscription.page">
<SettingSection
title={t("settings.subscription.title")}
description={
<div className="flex flex-col space-y-1">
<div>{t("settings.subscription.description")}</div>
<div>
{mySubscription?.products && mySubscription.products.length > 0 && (
<Link to={subscriptionCheckoutPath(currentTenant.slug)} className="text-theme-600 underline" data-ui-id="ui.subscription.checkout.link">
{t("settings.subscription.viewAllProducts")}
</Link>
)}
</div>
</div>
}
className=""
>
<MyProducts currentTenant={currentTenant} items={mySubscription?.products ?? []} onCancel={onCancel} />
</SettingSection>
{myFeatures.length > 0 && (
<>
<div className="hidden sm:block" aria-hidden="true">
<div className="py-4">
<div className="border-border border-t"></div>
</div>
</div>
<SettingSection title={t("app.subscription.features.title")} description={t("app.subscription.features.description")} className="">
<MySubscriptionFeatures features={myFeatures} withCurrentPlan={false} />
</SettingSection>
</>
)}
{permissions.viewInvoices && (
<>
<div className="hidden sm:block" aria-hidden="true">
<div className="py-4">
<div className="border-border border-t"></div>
</div>
</div>
<SettingSection title={t("app.subscription.invoices.title")} description={t("app.subscription.invoices.description")}>
<div className="space-y-2">
<MyUpcomingInvoice items={myUpcomingInvoices} />
<MyInvoices items={myInvoices} />
<MyPayments items={myPayments} />
</div>
</SettingSection>
</>
)}
<div className="hidden sm:block" aria-hidden="true">
<div className="py-4">
<div className="border-border border-t"></div>
</div>
</div>
{/* <SettingSection title={t("app.subscription.paymentMethods.title")} description={t("app.subscription.paymentMethods.description")}>
<div className="space-y-2">
<MyPaymentMethods items={myPaymentMethods} onAdd={onAddPaymentMethod} onDelete={onDeletePaymentMethod} />
</div>
</SettingSection> */}
<SettingSection title={t("app.subscription.billing.title")} description={t("app.subscription.billing.description")}>
<div className="space-y-2">
<MyBillingSession onClick={onOpenCustomerPortal} />
</div>
</SettingSection>
</div>
);
}
@@ -0,0 +1,271 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen, within } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import UserProfileSettings from "./UserProfileSettings";
const submitMock = vi.fn();
let searchParams = new URLSearchParams();
vi.mock("react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-router")>();
return {
...actual,
Form: ({ children, ...props }: React.FormHTMLAttributes<HTMLFormElement>) => <form {...props}>{children}</form>,
useLocation: () => ({ pathname: "/acme/settings/profile" }),
useMatches: () => [],
useNavigation: () => ({ state: "idle" }),
useSearchParams: () => [searchParams],
useSubmit: () => submitMock,
};
});
vi.mock("react-i18next", () => ({
useTranslation: () => ({
i18n: { language: "en", exists: () => true },
t: (key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
"settings.profile.mfa.authenticatorApp": "Authenticator app",
"settings.profile.mfa.setupUri": "Authenticator setup URI",
"settings.profile.mfa.confirmationCode": "Authenticator code",
"settings.profile.mfa.confirmAuthenticator": "Confirm authenticator",
"settings.profile.mfa.recoveryCodes": "Recovery codes",
"settings.profile.mfa.recoveryCodesCopy": "Recovery codes copy value",
"settings.profile.mfa.saveRecoveryCodes": "Save your recovery codes",
"settings.profile.session.title": "Current session",
"settings.profile.session.description": "Redacted diagnostics for this browser session only.",
"settings.profile.session.lightDarkPreference": "Light/dark preference",
"settings.profile.session.cookieConsentChoices": "Cookie consent choices",
"settings.profile.session.impersonation": "Impersonation",
"settings.profile.session.adminMfaStepUp": "Admin MFA step-up",
"settings.profile.session.clearStepUpVerification": "Clear step-up verification for this session",
};
let value = translations[key] ?? key;
for (const [name, replacement] of Object.entries(options ?? {})) {
value = value.replaceAll(`{{${name}}}`, String(replacement));
}
return value;
},
}),
}));
vi.mock("~/components/ui/input/InputSelect", () => ({
default: ({ options, setValue, value }: { options: { name: string; value: string }[]; setValue?: (value: string) => void; value?: string }) => (
<select aria-label="settings.preferences.language" value={value} onChange={(event) => setValue?.(event.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.name}
</option>
))}
</select>
),
}));
vi.mock("~/components/ui/uploaders/UploadDocument", () => ({
default: () => <button type="button">Upload avatar</button>,
}));
vi.mock("~/components/ui/uploaders/UploadImage", () => ({
default: () => <div>Upload image dialog</div>,
}));
vi.mock("~/components/ui/modals/SuccessModal", () => ({
default: () => null,
}));
vi.mock("~/components/ui/modals/ErrorModal", () => ({
default: () => null,
}));
vi.mock("~/components/ui/modals/ConfirmModal", () => ({
default: ({ onYes, placeholder }: { onYes?: (value: string) => void; placeholder?: string }) => (
<button type="button" aria-label="confirm-account-delete" data-placeholder={placeholder} onClick={() => onYes?.("delete-confirm-password")}>
Confirm delete modal
</button>
),
}));
vi.mock("~/utils/data/useRootData", () => ({
useRootData: () => ({ csrf: "csrf-token" }),
}));
const baseUser = {
email: "ada@example.com",
firstName: "Ada",
lastName: "Lovelace",
avatar: null,
admin: null,
};
const currentSession = {
locale: "en",
lightOrDarkMode: "dark",
theme: "zinc",
cookieConsentCount: 2,
impersonationActive: true,
mfaStepUp: {
present: true,
verified: true,
stale: false,
method: "totp",
} as const,
};
function renderSettings(props: Partial<React.ComponentProps<typeof UserProfileSettings>> = {}) {
return render(<UserProfileSettings user={baseUser} currentSession={currentSession} {...props} />);
}
function textContent() {
return document.body.textContent ?? "";
}
function expectNoSecretsLeak() {
const serialized = document.body.innerHTML;
expect(serialized).not.toContain("encrypted-secret");
expect(serialized).not.toContain("encrypted-pending");
expect(serialized).not.toContain("totpSecretEncrypted");
expect(serialized).not.toContain("hash-1");
expect(serialized).not.toContain("hash-2");
expect(serialized).not.toContain("RJ_session");
expect(serialized).not.toContain("raw-cookie-value");
expect(serialized).not.toContain("csrf-secret");
expect(serialized).not.toContain("new-password-value");
expect(serialized).not.toContain("delete-confirm-password");
}
describe("UserProfileSettings", () => {
beforeEach(() => {
submitMock.mockClear();
searchParams = new URLSearchParams();
});
it("renders profile, preferences, and danger controls with accessible form fields", () => {
renderSettings();
expect(screen.getByText("settings.profile.profileTitle")).toBeInTheDocument();
expect(screen.getByText("account.shared.email")).toBeInTheDocument();
expect(document.querySelector('input[name="email"]')).toHaveValue("ada@example.com");
expect(document.querySelector('input[name="firstName"]')).toHaveValue("Ada");
expect(document.querySelector('input[name="lastName"]')).toHaveValue("Lovelace");
expect(screen.getByText("settings.preferences.title")).toBeInTheDocument();
expect(screen.getByText("settings.preferences.language")).toBeInTheDocument();
expect(screen.getByLabelText("settings.preferences.language")).toHaveValue("en");
expect(screen.getByRole("button", { name: "settings.danger.deleteUserAccount" })).toBeInTheDocument();
expect(document.querySelector('input[name="action"][value="profile"]')).toBeInTheDocument();
expect(document.querySelector('input[name="action"][value="password"]')).toBeInTheDocument();
expect(document.querySelector('input[name="action"][value="deleteAccount"]')).toBeInTheDocument();
expect(document.querySelectorAll('form input[name="csrf"][value="csrf-token"]').length).toBeGreaterThanOrEqual(3);
expectNoSecretsLeak();
});
it("renders disabled, pending, and enabled MFA controls without secret material", () => {
const { rerender } = renderSettings({
mfaStatus: { enabled: false, pending: false, enabledAt: null, pendingCreatedAt: null },
});
expect(screen.getByText("Authenticator app")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("settings.profile.mfa.status.notEnabled");
expect(screen.getByRole("button", { name: "settings.profile.mfa.startAuthenticatorSetup" })).toBeInTheDocument();
expect(document.querySelector('input[name="action"][value="startTotpEnrollment"]')).toBeInTheDocument();
expect(document.querySelector('[data-ui-id="ui.settings.profile.mfa.panel"]')).toBeInTheDocument();
const mfaToggleForm = document.querySelector('[data-ui-id="ui.settings.profile.mfa.toggle.form"]');
expect(mfaToggleForm).toBeInTheDocument();
expect(within(mfaToggleForm as HTMLElement).getByLabelText("settings.profile.passwordCurrent")).toHaveAttribute("name", "passwordCurrent");
rerender(
<UserProfileSettings
user={baseUser}
currentSession={currentSession}
mfaStatus={{ enabled: false, pending: true, enabledAt: null, pendingCreatedAt: "2026-05-08T12:00:00.000Z" }}
otpauthUri="otpauth://totp/ToolCTRL:ada@example.com?issuer=ToolCTRL"
mfaError="Enter the 6-digit authenticator code."
/>
);
expect(screen.getByText("settings.profile.mfa.status.pending")).toBeInTheDocument();
expect(screen.getByLabelText("Authenticator setup URI")).toHaveTextContent("otpauth://totp/ToolCTRL:ada@example.com?issuer=ToolCTRL");
expect(screen.getByRole("alert")).toHaveTextContent("Enter the 6-digit authenticator code.");
expect(screen.getByLabelText("Authenticator code")).toHaveAttribute("name", "code");
expect(screen.getByRole("button", { name: "Confirm authenticator" })).toBeInTheDocument();
expect(document.querySelector('input[name="action"][value="confirmTotpEnrollment"]')).toBeInTheDocument();
rerender(
<UserProfileSettings
user={baseUser}
currentSession={currentSession}
mfaStatus={{ enabled: true, pending: false, enabledAt: "2026-05-08T12:05:00.000Z", pendingCreatedAt: null }}
/>
);
expect(screen.getByText("settings.profile.mfa.status.enabled")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "settings.profile.mfa.disableAuthenticator" })).toBeInTheDocument();
expect(document.querySelector('input[name="action"][value="disableTotpEnrollment"]')).toBeInTheDocument();
expectNoSecretsLeak();
});
it("shows one-time recovery codes but not their persisted hashes", () => {
renderSettings({
recoveryCodes: ["ABCD-EFGH-JKLM", "NPQR-STUV-WXYZ"],
});
const recoveryRegion = screen.getByLabelText("Recovery codes");
expect(within(recoveryRegion).getByText("Save your recovery codes")).toBeInTheDocument();
expect(within(recoveryRegion).getByText("ABCD-EFGH-JKLM")).toBeInTheDocument();
expect(within(recoveryRegion).getByText("NPQR-STUV-WXYZ")).toBeInTheDocument();
expect(screen.getByLabelText("Recovery codes copy value")).toHaveValue("ABCD-EFGH-JKLM\nNPQR-STUV-WXYZ");
expect(document.body.innerHTML).not.toContain("hash-1");
expect(document.body.innerHTML).not.toContain("hash-2");
});
it("renders current-session diagnostics from redacted loader facts only", () => {
renderSettings();
expect(screen.getByText("Current session")).toBeInTheDocument();
expect(screen.getByText("Redacted diagnostics for this browser session only.")).toBeInTheDocument();
expect(screen.getByText("settings.profile.session.locale")).toBeInTheDocument();
expect(screen.getByText("Light/dark preference")).toBeInTheDocument();
expect(screen.getByText("settings.profile.session.theme")).toBeInTheDocument();
expect(screen.getByText("Cookie consent choices")).toBeInTheDocument();
expect(screen.getByText("Impersonation")).toBeInTheDocument();
expect(screen.getByText("Admin MFA step-up")).toBeInTheDocument();
expect(screen.getByText("shared.dark")).toBeInTheDocument();
expect(screen.getByText("zinc")).toBeInTheDocument();
expect(screen.getByText("shared.active")).toBeInTheDocument();
expect(screen.getByText("settings.profile.session.mfaStepUpVerifiedWith")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Clear step-up verification for this session" })).toBeInTheDocument();
expect(document.querySelector('input[name="action"][value="clearMfaStepUp"]')).toBeInTheDocument();
expectNoSecretsLeak();
});
it("submits locale and password-confirmed account deletion without rendering submitted passwords", () => {
renderSettings();
fireEvent.click(screen.getByRole("button", { name: "settings.danger.deleteUserAccount" }));
fireEvent.click(screen.getAllByRole("button", { name: "confirm-account-delete" }).at(-1)!);
const deleteForm = submitMock.mock.calls.at(-1)?.[0] as FormData;
expect(deleteForm.get("action")).toBe("deleteAccount");
expect(deleteForm.get("csrf")).toBe("csrf-token");
expect(deleteForm.get("passwordCurrent")).toBe("delete-confirm-password");
expect(textContent()).not.toContain("delete-confirm-password");
fireEvent.change(screen.getByLabelText("settings.preferences.language"), { target: { value: "de" } });
const localeForm = submitMock.mock.calls.at(-1)?.[0] as FormData;
expect(localeForm.get("action")).toBe("setLocale");
expect(localeForm.get("csrf")).toBe("csrf-token");
expect(localeForm.get("lng")).toBe("de");
expect(localeForm.get("redirect")).toBe("/acme/settings/profile?lng=de");
expect(submitMock.mock.calls.at(-1)?.[1]).toMatchObject({ method: "post", action: "/preferences", preventScrollReset: true });
});
it("does not echo typed password values from security forms", () => {
renderSettings();
const passwordInputs = document.querySelectorAll('input[type="password"]');
passwordInputs.forEach((input) => fireEvent.change(input, { target: { value: "new-password-value" } }));
expect(textContent()).not.toContain("new-password-value");
});
});
@@ -0,0 +1,480 @@
import { Form, useLocation, useNavigation, useSearchParams, useSubmit } from "react-router";
import { type TFunction } from "i18next";
import { useRef, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
import ButtonTertiary from "~/components/ui/buttons/ButtonTertiary";
import { Input } from "~/components/ui/input";
import InputSelect from "~/components/ui/input/InputSelect";
import InputText from "~/components/ui/input/InputText";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
import ErrorModal, { RefErrorModal } from "~/components/ui/modals/ErrorModal";
import SuccessModal, { RefSuccessModal } from "~/components/ui/modals/SuccessModal";
import SettingSection from "~/components/ui/sections/SettingSection";
import UploadDocuments from "~/components/ui/uploaders/UploadDocument";
import UploadImage from "~/components/ui/uploaders/UploadImage";
import { i18nConfig } from "~/locale/i18n";
import type { CurrentSessionSettings } from "~/routes/app.$tenant/settings/profile";
import { useRootData } from "~/utils/data/useRootData";
import { UserIcon } from "~/components/ui/icons/vendor";
type UserProfileMfaStatus = {
enabled: boolean;
pending: boolean;
enabledAt: Date | string | null;
pendingCreatedAt: Date | string | null;
};
function getMfaStatusLabel(t: TFunction, mfaStatus?: UserProfileMfaStatus | null) {
if (mfaStatus?.enabled) {
return t("settings.profile.mfa.status.enabled");
}
if (mfaStatus?.pending) {
return t("settings.profile.mfa.status.pending");
}
return t("settings.profile.mfa.status.notEnabled");
}
function getLightOrDarkModeLabel(t: TFunction, value?: string | null) {
if (value === "light" || value === "dark") {
return t(`shared.${value}`);
}
return value || t("settings.profile.session.systemPreference");
}
function getMfaStepUpLabel(t: TFunction, mfaStepUp?: CurrentSessionSettings["mfaStepUp"]) {
if (mfaStepUp?.verified) {
return t("settings.profile.session.mfaStepUpVerifiedWith", { method: mfaStepUp.method });
}
if (mfaStepUp?.present) {
return mfaStepUp.stale ? t("settings.profile.session.mfaStepUpStale") : t("settings.profile.session.mfaStepUpInvalid");
}
return t("settings.profile.session.mfaStepUpNotPresent");
}
export default function UserProfileSettings({
user,
mfaStatus,
otpauthUri,
mfaError,
recoveryCodes,
currentSession,
}: {
user: {
email: string;
firstName: string;
lastName: string;
avatar: string | null;
admin?: any | null;
};
mfaStatus?: {
enabled: boolean;
pending: boolean;
enabledAt: Date | string | null;
pendingCreatedAt: Date | string | null;
} | null;
otpauthUri?: string;
mfaError?: string;
recoveryCodes?: string[];
currentSession?: CurrentSessionSettings;
}) {
const location = useLocation();
const [searchParams] = useSearchParams();
const { t, i18n } = useTranslation();
const navigation = useNavigation();
const submit = useSubmit();
const { csrf } = useRootData();
const errorModal = useRef<RefErrorModal>(null);
const successModal = useRef<RefSuccessModal>(null);
const confirmModal = useRef<RefConfirmModal>(null);
const inputFirstName = useRef<HTMLInputElement>(null);
useEffect(() => {
inputFirstName.current?.focus();
inputFirstName.current?.select();
}, []);
const [avatar, setAvatar] = useState<string | undefined>(user?.avatar ?? undefined);
const [showUploadImage, setShowUploadImage] = useState(false);
const [uploadingImage, setUploadingImage] = useState(false);
function changedLocale(locale: string) {
const form = new FormData();
form.set("action", "setLocale");
form.set("csrf", csrf ?? "");
searchParams.set("lng", locale);
form.set("redirect", location.pathname + "?" + searchParams.toString());
form.set("lng", locale);
submit(form, { method: "post", action: "/preferences", preventScrollReset: true });
}
function deleteAccount() {
if (user?.admin) {
errorModal.current?.show(t("settings.profile.errors.cannotDeleteAdmin"));
} else {
confirmModal.current?.show(t("settings.danger.confirmDelete"), t("shared.confirm"), t("shared.cancel"), t("shared.warningCannotUndo"), "");
}
}
function confirmDelete(passwordCurrent: string | undefined) {
const form = new FormData();
form.set("action", "deleteAccount");
form.set("csrf", csrf ?? "");
form.set("passwordCurrent", passwordCurrent ?? "");
submit(form, { method: "post" });
}
function loadedImage(image: string | undefined) {
setAvatar(image);
setUploadingImage(true);
}
return (
<div data-ui-id="ui.settings.profile.page">
<div>
<SettingSection title={t("settings.profile.profileTitle")} description={t("settings.profile.profileText")}>
<div className="mt-5 md:col-span-2 md:mt-0">
<Form method="post" data-ui-id="ui.settings.profile.form">
<input hidden type="text" name="action" value="profile" readOnly />
<input hidden type="text" name="csrf" value={csrf ?? ""} readOnly />
<div className="">
<div className="">
<div className="grid grid-cols-6 gap-2">
<div className="col-span-6 sm:col-span-6 md:col-span-6">
<InputText
required
disabled={true}
type="email"
name="email"
title={t("account.shared.email")}
defaultValue={user?.email}
dataUiId="ui.settings.profile.email"
/>
</div>
<div className="col-span-6 md:col-span-3">
<InputText
name="firstName"
title={t("settings.profile.firstName")}
defaultValue={user?.firstName}
required
dataUiId="ui.settings.profile.firstName"
/>
</div>
<div className="col-span-6 md:col-span-3">
<InputText name="lastName" title={t("settings.profile.lastName")} defaultValue={user?.lastName} dataUiId="ui.settings.profile.lastName" />
</div>
<div className="col-span-6 sm:col-span-6">
<label htmlFor="avatar" className="block text-xs leading-5 font-medium">
{t("shared.avatar")}
</label>
<div className="mt-2 flex items-center space-x-3">
<input hidden id="avatar" name="avatar" value={avatar} readOnly />
<div className="bg-secondary h-12 w-12 overflow-hidden rounded-md">
{(() => {
if (avatar) {
return <img id="avatar" alt={t("shared.avatar")} src={avatar} />;
} else {
return (
<UserIcon id="avatar" className="text-muted-foreground h-full w-full" />
);
}
})()}
</div>
{avatar ? (
<ButtonTertiary destructive={true} onClick={() => loadedImage("")} type="button">
{t("shared.delete")}
</ButtonTertiary>
) : (
<UploadDocuments accept="image/png, image/jpg, image/jpeg" description="" className="h-12" onDropped={loadedImage} />
)}
</div>
</div>
</div>
</div>
<div className="pt-3">
<div className="flex justify-between">
<div id="form-success-message" className="flex items-center space-x-2"></div>
<button
disabled={navigation.state === "submitting"}
type="submit"
data-ui-id="ui.settings.profile.save"
className="bg-primary hover:bg-primary/90 focus:ring-primary text-primary-foreground inline-flex items-center space-x-2 rounded-md border border-transparent px-4 py-2 text-sm font-medium focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
>
{t("shared.save")}
</button>
</div>
</div>
</div>
</Form>
</div>
</SettingSection>
{/*Separator */}
<div className="block">
<div className="py-5">
<div className="border-border/30 border-t"></div>
</div>
</div>
<SettingSection
title={t("settings.profile.securityTitle")}
description={
<p className="text-muted-foreground mt-1 text-xs leading-5">
{t("account.login.forgot")}{" "}
<a className="text-muted-foreground font-bold hover:underline" href={"/forgot-password?e=" + user?.email || ""}>
{t("account.reset.button")}
</a>
</p>
}
>
{/*Security */}
<div className="mt-5 space-y-6 md:col-span-2 md:mt-0">
<div className="border-border bg-muted/20 rounded-md border p-4 text-sm" data-ui-id="ui.settings.profile.mfa.panel">
<div className="flex flex-col gap-3">
<div>
<p className="font-medium">{t("settings.profile.mfa.authenticatorApp")}</p>
<p className="text-muted-foreground text-xs" role="status" data-ui-id="ui.settings.profile.mfa.status">
{getMfaStatusLabel(t, mfaStatus)}
</p>
{mfaError ? (
<p className="text-destructive mt-2 text-xs" role="alert" data-ui-id="ui.settings.profile.mfa.error">
{mfaError}
</p>
) : null}
</div>
{otpauthUri ? (
<div className="space-y-2">
<div className="rounded bg-white p-3 font-mono text-xs break-all text-gray-900" aria-label={t("settings.profile.mfa.setupUri")}>
{otpauthUri}
</div>
<Form method="post" className="flex flex-col gap-2 sm:flex-row" data-ui-id="ui.settings.profile.mfa.confirm.form">
<input hidden type="text" name="action" value="confirmTotpEnrollment" readOnly />
<input hidden type="text" name="csrf" value={csrf ?? ""} readOnly />
<Input
required
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
name="code"
placeholder="123456"
aria-label={t("settings.profile.mfa.confirmationCode")}
data-ui-id="ui.settings.profile.mfa.confirm.code"
/>
<ButtonPrimary type="submit" dataUiId="ui.settings.profile.mfa.confirm.submit">
{t("settings.profile.mfa.confirmAuthenticator")}
</ButtonPrimary>
</Form>
</div>
) : null}
{recoveryCodes?.length ? (
<div className="border-border bg-background space-y-3 rounded-md border p-4" aria-label={t("settings.profile.mfa.recoveryCodes")}>
<div>
<p className="font-medium">{t("settings.profile.mfa.saveRecoveryCodes")}</p>
<p className="text-muted-foreground text-xs">{t("settings.profile.mfa.recoveryCodesOneTime")}</p>
</div>
<ul className="grid gap-2 sm:grid-cols-2">
{recoveryCodes.map((code) => (
<li key={code} className="rounded bg-white p-2 font-mono text-xs text-gray-900">
{code}
</li>
))}
</ul>
<textarea className="sr-only" readOnly aria-label={t("settings.profile.mfa.recoveryCodesCopy")} value={recoveryCodes.join("\n")} />
</div>
) : null}
<Form method="post" className="flex flex-col gap-2 sm:flex-row" data-ui-id="ui.settings.profile.mfa.toggle.form">
<input hidden type="text" name="action" value={mfaStatus?.enabled ? "disableTotpEnrollment" : "startTotpEnrollment"} readOnly />
<input hidden type="text" name="csrf" value={csrf ?? ""} readOnly />
<label className="sr-only" htmlFor="mfaPasswordCurrent">
{t("settings.profile.passwordCurrent")}
</label>
<Input
required
type="password"
id="mfaPasswordCurrent"
name="passwordCurrent"
placeholder={t("settings.profile.passwordCurrent")}
data-ui-id="ui.settings.profile.mfa.passwordCurrent"
/>
<ButtonPrimary type="submit" dataUiId="ui.settings.profile.mfa.toggle.submit">
{mfaStatus?.enabled ? t("settings.profile.mfa.disableAuthenticator") : t("settings.profile.mfa.startAuthenticatorSetup")}
</ButtonPrimary>
</Form>
</div>
</div>
<Form method="post" data-ui-id="ui.settings.profile.password.form">
<input hidden type="text" name="action" value="password" readOnly />
<input hidden type="text" name="csrf" value={csrf ?? ""} readOnly />
<div className="">
<div>
<div className="">
<div className="grid grid-cols-6 gap-2">
<div className="col-span-6 sm:col-span-6">
<label htmlFor="passwordCurrent" className="mb-1 block text-xs leading-5 font-medium">
{t("settings.profile.passwordCurrent")}
</label>
<Input required type="password" id="passwordCurrent" name="passwordCurrent" data-ui-id="ui.settings.profile.password.current" />
</div>
<div className="col-span-6 md:col-span-3">
<label htmlFor="password" className="mb-1 block text-xs leading-5 font-medium">
{t("settings.profile.password")}
</label>
<Input
title={t("settings.profile.password")}
required
type="password"
id="passwordNew"
name="passwordNew"
data-ui-id="ui.settings.profile.password.new"
/>
</div>
<div className="col-span-6 md:col-span-3">
<label htmlFor="passwordConfirm" className="mb-1 block text-xs leading-5 font-medium">
{t("settings.profile.passwordConfirm")}
</label>
<Input
title={t("settings.profile.passwordConfirm")}
required
type="password"
id="passwordNewConfirm"
name="passwordNewConfirm"
data-ui-id="ui.settings.profile.password.confirm"
/>
</div>
</div>
</div>
<div className="pt-3">
<div className="flex justify-between space-x-2">
<div id="form-success-message" className="flex items-center space-x-2"></div>
<button
type="submit"
data-ui-id="ui.settings.profile.password.submit"
className="bg-primary hover:bg-primary/90 focus:ring-primary text-primary-foreground inline-flex items-center space-x-2 rounded-md border border-transparent px-4 py-2 text-sm font-medium focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
>
{t("shared.save")}
</button>
</div>
</div>
</div>
</div>
</Form>
</div>
</SettingSection>
{/*Separator */}
<div className="block">
<div className="py-5">
<div className="border-border/30 border-t"></div>
</div>
</div>
<SettingSection title={t("settings.profile.session.title")} description={t("settings.profile.session.description")}>
<div className="mt-5 space-y-4 text-sm md:col-span-2 md:mt-0">
<dl className="border-border bg-muted/20 grid gap-3 rounded-md border p-4 sm:grid-cols-2" data-ui-id="ui.settings.profile.session.panel">
<div>
<dt className="text-muted-foreground text-xs">{t("settings.profile.session.locale")}</dt>
<dd className="font-medium">{currentSession?.locale || t("settings.profile.session.unknown")}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs">{t("settings.profile.session.lightDarkPreference")}</dt>
<dd className="font-medium">{getLightOrDarkModeLabel(t, currentSession?.lightOrDarkMode)}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs">{t("settings.profile.session.theme")}</dt>
<dd className="font-medium">{currentSession?.theme || t("settings.profile.session.defaultTheme")}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs">{t("settings.profile.session.cookieConsentChoices")}</dt>
<dd className="font-medium">{currentSession?.cookieConsentCount ?? 0}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs">{t("settings.profile.session.impersonation")}</dt>
<dd className="font-medium">{currentSession?.impersonationActive ? t("shared.active") : t("shared.inactive")}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs">{t("settings.profile.session.adminMfaStepUp")}</dt>
<dd className="font-medium">{getMfaStepUpLabel(t, currentSession?.mfaStepUp)}</dd>
</div>
</dl>
{currentSession?.mfaStepUp.present ? (
<Form method="post">
<input hidden type="text" name="action" value="clearMfaStepUp" readOnly />
<input hidden type="text" name="csrf" value={csrf ?? ""} readOnly />
<ButtonTertiary type="submit">{t("settings.profile.session.clearStepUpVerification")}</ButtonTertiary>
</Form>
) : null}
</div>
</SettingSection>
{/*Separator */}
<div className="block">
<div className="py-5">
<div className="border-border/30 border-t"></div>
</div>
</div>
<SettingSection title={t("settings.preferences.title")} description={t("settings.preferences.description")}>
{/*Preferences */}
<div className="mt-5 md:col-span-2 md:mt-0">
<div className="">
<div className="">
<div className="grid grid-cols-6 gap-2">
<div className="col-span-6 sm:col-span-6">
<label htmlFor="locale" className="mb-1 block text-xs leading-5 font-medium">
{t("settings.preferences.language")}
</label>
<InputSelect
required
value={i18n.language}
setValue={(e) => changedLocale(e?.toString() || "")}
options={i18nConfig.supportedLngs.map((locale) => ({ value: locale, name: t("shared.locales." + locale) }))}
/>
</div>
</div>
</div>
</div>
</div>
</SettingSection>
{/*Separator */}
<div className="block">
<div className="py-5">
<div className="border-border/30 border-t"></div>
</div>
</div>
<SettingSection title={t("settings.danger.title")} description={t("settings.danger.description")}>
{/*Danger */}
<div className="mt-12 md:col-span-2 md:mt-0">
<div>
<input hidden type="text" name="action" value="deleteAccount" readOnly />
<div className="">
<div className="">
{/* <h3 className="text-lg font-medium leading-6 text-foreground">{t("settings.danger.deleteYourAccount")}</h3>
<div className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
<p>{t("settings.danger.onceYouDelete")}.</p>
</div> */}
<div className="">
<ButtonPrimary destructive={true} onClick={deleteAccount} dataUiId="ui.settings.profile.danger.delete">
{t("settings.danger.deleteUserAccount")}
</ButtonPrimary>
</div>
</div>
</div>
</div>
</div>
</SettingSection>
</div>
{showUploadImage && !uploadingImage && (
<UploadImage onClose={() => setShowUploadImage(false)} title={t("shared.avatar")} initialImage={avatar} onLoaded={loadedImage} />
)}
<SuccessModal ref={successModal} />
<ErrorModal ref={errorModal} />
<ConfirmModal ref={confirmModal} inputType="password" placeholder={t("settings.profile.passwordCurrent")} onYes={confirmDelete} />
</div>
);
}
@@ -0,0 +1,21 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const sourcePath = path.resolve(process.cwd(), "apps/web/app/modules/users/components/UserProfileSettings.tsx");
describe("user profile settings i18n", () => {
it("keeps MFA and session diagnostics copy behind settings keys", () => {
const source = fs.readFileSync(sourcePath, "utf8");
expect(source).toContain("settings.profile.mfa.authenticatorApp");
expect(source).toContain("settings.profile.mfa.recoveryCodesOneTime");
expect(source).toContain("settings.profile.session.lightDarkPreference");
expect(source).toContain("settings.profile.session.clearStepUpVerification");
expect(source).not.toContain("ui.autogenerated");
expect(source).not.toContain("i18nT");
expect(source).not.toContain("Authenticator app");
expect(source).not.toContain("Current session");
expect(source).not.toContain("Cookie consent choices");
});
});
@@ -0,0 +1,100 @@
/**
* Phase 10 Task #68 — DSGVO login guard unit test.
*
* Asserts that the login flow rejects users whose `pendingDeletion`
* flag is set (HTTP 423) or whose `pseudonymized` flag is set (HTTP 410)
* so the worker job and the UI converge on the same gate.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("~/locale/i18next.server", () => ({ getTranslations: vi.fn().mockResolvedValue({ t: (k: string) => k }) }));
vi.mock("~/utils/app/UserValidationUtils", () => ({ default: { validateEmail: () => true, validatePassword: () => true } }));
vi.mock("~/utils/db/logs.db.server", () => ({ createLogLogin: vi.fn() }));
vi.mock("~/utils/db/users.db.server", () => ({
getUserByEmail: vi.fn(),
updateUserPassword: vi.fn(),
getUser: vi.fn(),
getUserTotpStatus: vi.fn(),
getActiveTotpSecretEncrypted: vi.fn(),
consumeUserMfaRecoveryCode: vi.fn(),
}));
vi.mock("~/utils/session.server", () => ({
createUserSession: vi.fn(),
getUserInfo: vi.fn().mockResolvedValue({ lng: "en" }),
setLoggedUser: vi.fn().mockResolvedValue({ defaultTenantId: "t1" }),
validateCSRFToken: vi.fn(),
}));
vi.mock("~/utils/db/tenants.db.server", () => ({ getTenant: vi.fn().mockResolvedValue({ id: "t1", slug: "t1" }) }));
vi.mock("~/utils/db/appConfiguration.db.server", () => ({ getAppConfiguration: vi.fn() }));
vi.mock("~/utils/auth/passwordHasher.server", () => ({ verifyPassword: vi.fn().mockResolvedValue({ valid: true, rehashedHash: null }) }));
vi.mock("~/modules/ipAddress/services/IpAddressService.server", () => ({ default: { log: vi.fn().mockResolvedValue(null) } }));
vi.mock("~/services/auth/admin-fallback.server", () => ({ assertAdminFallbackLoginAllowed: vi.fn(), AdminFallbackLoginRejected: class extends Error {} }));
vi.mock("~/modules/admin-sso/audit.server", () => ({ writeAdminSsoAuditEvent: vi.fn() }));
vi.mock("@toolctrl/config", async () => {
const actual = await vi.importActual<typeof import("@toolctrl/config")>("@toolctrl/config");
return {
...actual,
loadRequiredWebConfig: () => ({
nodeEnv: "test",
routing: { appBaseDomain: "example.com", webAppHost: "app.example.com", adminAppHost: "admin.example.com", apiHost: "api.example.com", protocol: "https" },
}),
};
});
vi.mock("~/utils/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: () => ({ allowed: true }) }));
import { getUserByEmail } from "~/utils/db/users.db.server";
import AuthService from "./AuthService";
const getUserByEmailMock = vi.mocked(getUserByEmail);
function buildForm(): FormData {
const f = new FormData();
f.append("email", "alice@example.com");
f.append("password", "correct-horse-battery-staple");
f.append("redirectTo", "/");
f.append("csrf", "");
return f;
}
describe("AuthService DSGVO login guard", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("blocks login (423 Locked) when the user is pendingDeletion=true", async () => {
getUserByEmailMock.mockResolvedValue({
id: "u1",
email: "alice@example.com",
passwordHash: "$argon2id$v=19$x",
firstName: "Alice",
lastName: "Anderson",
pendingDeletion: true,
pseudonymized: false,
admin: null,
locale: null,
} as unknown as Awaited<ReturnType<typeof getUserByEmail>>);
const req = new Request("https://app.example.com/login", { method: "POST" });
const result = (await AuthService.loginFromRequest(req, buildForm())) as Response;
expect(result).toBeInstanceOf(Response);
expect(result.status).toBe(423);
});
it("blocks login (410 Gone) when the user is pseudonymized=true", async () => {
getUserByEmailMock.mockResolvedValue({
id: "u1",
email: "alice@example.com",
passwordHash: "$argon2id$v=19$x",
firstName: "Anonymized",
lastName: "User-abcd1234",
pendingDeletion: false,
pseudonymized: true,
admin: null,
locale: null,
} as unknown as Awaited<ReturnType<typeof getUserByEmail>>);
const req = new Request("https://app.example.com/login", { method: "POST" });
const result = (await AuthService.loginFromRequest(req, buildForm())) as Response;
expect(result.status).toBe(410);
});
});
@@ -0,0 +1,245 @@
/**
* D1 — 2FA-Login-Gate + Verify.
*
* Deckt die vier Kernzusagen ab:
* 1. TOTP-Nutzer erhalten mit Passwort allein KEINE Session, sondern eine `requiresMfa`-Antwort
* samt Pending-Cookie (Gate).
* 2. Ein vertrauenswuerdiges Geraet ueberspringt das Gate (normaler Login).
* 3. Nutzer ohne TOTP loggen sich unveraendert ein (Regression — Zweig ist additiv).
* 4. Die Verify-Route schliesst den Login bei korrektem TOTP/Recovery-Code ab, lehnt falsche Codes
* ab und schickt einen fehlenden Pending-Zustand zurueck zur Anmeldung.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("~/locale/i18next.server", () => ({ getTranslations: vi.fn().mockResolvedValue({ t: (k: string) => k }) }));
vi.mock("~/utils/app/UserValidationUtils", () => ({ default: { validateEmail: () => true, validatePassword: () => true } }));
vi.mock("~/utils/db/logs.db.server", () => ({ createLogLogin: vi.fn() }));
vi.mock("~/utils/db/users.db.server", () => ({
getUserByEmail: vi.fn(),
updateUserPassword: vi.fn(),
getUser: vi.fn(),
getUserTotpStatus: vi.fn(),
getActiveTotpSecretEncrypted: vi.fn(),
consumeUserMfaRecoveryCode: vi.fn(),
}));
vi.mock("~/utils/session.server", () => ({
createUserSession: vi.fn(),
getUserInfo: vi.fn().mockResolvedValue({ lng: "en" }),
getUserSession: vi.fn(),
setLoggedUser: vi.fn().mockResolvedValue({ userId: "u1", defaultTenantId: "t1", defaultTenant: { id: "t1", slug: "t1" } }),
validateCSRFToken: vi.fn(),
commitPendingMfaLoginCookie: vi.fn().mockResolvedValue("RJ_session=pending; Path=/; HttpOnly"),
}));
vi.mock("~/services/auth/sessionLifecycle.server", () => ({ startServerSession: vi.fn().mockResolvedValue("server-token") }));
vi.mock("~/utils/db/tenants.db.server", () => ({ getTenant: vi.fn().mockResolvedValue({ id: "t1", slug: "t1" }) }));
vi.mock("~/utils/db/appConfiguration.db.server", () => ({ getAppConfiguration: vi.fn().mockResolvedValue({ app: { features: { tenantHome: "/" } } }) }));
vi.mock("~/utils/auth/passwordHasher.server", () => ({ verifyPassword: vi.fn().mockResolvedValue({ valid: true, rehashedHash: null }) }));
vi.mock("~/modules/ipAddress/services/IpAddressService.server", () => ({ default: { log: vi.fn().mockResolvedValue(null) } }));
vi.mock("~/services/auth/password-policy.server", () => ({ validatePassword: vi.fn().mockResolvedValue({ valid: true, errors: [] }) }));
vi.mock("~/services/auth/login-captcha-gate.server", () => ({
clearFailedLogins: vi.fn(),
getFailedLoginCount: vi.fn().mockReturnValue(0),
loginRequiresCaptcha: vi.fn().mockReturnValue(false),
recordFailedLogin: vi.fn(),
}));
vi.mock("@toolctrl/captcha", () => ({
getSitekey: vi.fn(),
isCaptchaConfigured: vi.fn().mockReturnValue(false),
resolveCaptchaConfig: vi.fn().mockReturnValue({}),
verifyCaptchaToken: vi.fn(),
}));
vi.mock("~/services/auth/totp.server", () => ({ verifyTotpCode: vi.fn() }));
vi.mock("~/utils/auth/mfa/challenge-rate-limit.server", () => ({
checkMfaChallengeRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 5, resetAt: new Date() }),
mfaChallengeRateLimitKey: vi.fn().mockReturnValue("u1:ip"),
recordFailedMfaChallengeAttempt: vi.fn(),
resetMfaChallengeRateLimit: vi.fn(),
}));
vi.mock("~/utils/auth/mfa/trusted-device.server", () => ({
isTrustedDevice: vi.fn().mockResolvedValue(false),
rememberDeviceForUser: vi.fn().mockResolvedValue("RJ_trusted_device=marker; Path=/; HttpOnly"),
}));
vi.mock("@toolctrl/config", async () => {
const actual = await vi.importActual<typeof import("@toolctrl/config")>("@toolctrl/config");
return {
...actual,
loadRequiredWebConfig: () => ({
nodeEnv: "test",
routing: { appBaseDomain: "example.com", webAppHost: "app.example.com", adminAppHost: "admin.example.com", apiHost: "api.example.com", protocol: "https" },
}),
};
});
vi.mock("~/services/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: () => ({ allowed: true }) }));
import { getUserByEmail, getUser, getUserTotpStatus, getActiveTotpSecretEncrypted, consumeUserMfaRecoveryCode } from "~/utils/db/users.db.server";
import { createUserSession, getUserSession, commitPendingMfaLoginCookie } from "~/utils/session.server";
import { verifyTotpCode } from "~/services/auth/totp.server";
import { checkMfaChallengeRateLimit, recordFailedMfaChallengeAttempt, resetMfaChallengeRateLimit } from "~/utils/auth/mfa/challenge-rate-limit.server";
import { isTrustedDevice, rememberDeviceForUser } from "~/utils/auth/mfa/trusted-device.server";
import AuthService from "./AuthService";
const getUserByEmailMock = vi.mocked(getUserByEmail);
const getUserMock = vi.mocked(getUser);
const getUserTotpStatusMock = vi.mocked(getUserTotpStatus);
const getActiveTotpSecretEncryptedMock = vi.mocked(getActiveTotpSecretEncrypted);
const consumeUserMfaRecoveryCodeMock = vi.mocked(consumeUserMfaRecoveryCode);
const createUserSessionMock = vi.mocked(createUserSession);
const getUserSessionMock = vi.mocked(getUserSession);
const commitPendingMfaLoginCookieMock = vi.mocked(commitPendingMfaLoginCookie);
const verifyTotpCodeMock = vi.mocked(verifyTotpCode);
const checkMfaChallengeRateLimitMock = vi.mocked(checkMfaChallengeRateLimit);
const isTrustedDeviceMock = vi.mocked(isTrustedDevice);
const baseUser = { id: "u1", email: "alice@example.com", passwordHash: "hash", admin: null, locale: "de", defaultTenantId: "t1" };
function loginForm(): FormData {
const f = new FormData();
f.append("email", "alice@example.com");
f.append("password", "Password123!");
f.append("redirectTo", "");
f.append("csrf", "csrf-token");
return f;
}
function sessionMock(values: Record<string, string | undefined>) {
return { get: vi.fn((key: string) => values[key]), set: vi.fn(), unset: vi.fn() };
}
function redirectResponse() {
return new Response(null, { status: 302, headers: { Location: "https://app.example.com/" } });
}
beforeEach(() => {
vi.clearAllMocks();
getUserByEmailMock.mockResolvedValue(baseUser as never);
getUserMock.mockResolvedValue(baseUser as never);
createUserSessionMock.mockImplementation(async () => redirectResponse());
isTrustedDeviceMock.mockResolvedValue(false);
checkMfaChallengeRateLimitMock.mockResolvedValue({ allowed: true, remaining: 5, resetAt: new Date() } as never);
});
describe("loginFromRequest — 2FA gate", () => {
it("returns requiresMfa and sets a pending cookie for a TOTP user instead of a session", async () => {
getUserTotpStatusMock.mockResolvedValue({ enabled: true } as never);
const response = await AuthService.loginFromRequest(new Request("https://app.example.com/login", { method: "POST" }), loginForm());
const body = (await response.json()) as Record<string, unknown>;
expect(response.status).toBe(200);
expect(body.requiresMfa).toBe(true);
expect(body.mfaVerifyPath).toBe("/login-mfa");
expect(response.headers.get("Set-Cookie")).toContain("RJ_session=pending");
expect(commitPendingMfaLoginCookieMock).toHaveBeenCalledWith(expect.any(Request), { userId: "u1", redirectTo: "" });
expect(createUserSessionMock).not.toHaveBeenCalled();
});
it("skips the gate for a trusted device and issues a normal session", async () => {
getUserTotpStatusMock.mockResolvedValue({ enabled: true } as never);
isTrustedDeviceMock.mockResolvedValue(true);
const response = await AuthService.loginFromRequest(new Request("https://app.example.com/login", { method: "POST" }), loginForm());
expect(response.status).toBe(302);
expect(commitPendingMfaLoginCookieMock).not.toHaveBeenCalled();
expect(createUserSessionMock).toHaveBeenCalledTimes(1);
});
it("logs a non-TOTP user straight in (regression: branch is additive)", async () => {
getUserTotpStatusMock.mockResolvedValue({ enabled: false } as never);
const response = await AuthService.loginFromRequest(new Request("https://app.example.com/login", { method: "POST" }), loginForm());
expect(response.status).toBe(302);
expect(commitPendingMfaLoginCookieMock).not.toHaveBeenCalled();
expect(createUserSessionMock).toHaveBeenCalledTimes(1);
});
});
describe("verifyMfaLoginFromRequest", () => {
beforeEach(() => {
getUserSessionMock.mockResolvedValue(sessionMock({ pendingMfaUserId: "u1", pendingMfaRedirectTo: "" }) as never);
getUserTotpStatusMock.mockResolvedValue({ enabled: true } as never);
});
it("completes the login on a valid TOTP code", async () => {
getActiveTotpSecretEncryptedMock.mockResolvedValue("enc-secret");
verifyTotpCodeMock.mockReturnValue(true);
const form = new FormData();
form.append("code", "123456");
const response = await AuthService.verifyMfaLoginFromRequest(new Request("https://app.example.com/login-mfa", { method: "POST" }), form);
expect(response.status).toBe(302);
expect(verifyTotpCodeMock).toHaveBeenCalledWith("enc-secret", "123456");
expect(resetMfaChallengeRateLimit).toHaveBeenCalled();
expect(createUserSessionMock).toHaveBeenCalledTimes(1);
});
it("completes the login on a valid recovery code when TOTP fails", async () => {
getActiveTotpSecretEncryptedMock.mockResolvedValue("enc-secret");
verifyTotpCodeMock.mockReturnValue(false);
consumeUserMfaRecoveryCodeMock.mockResolvedValue({ consumed: true } as never);
const form = new FormData();
form.append("code", "000000");
form.append("recoveryCode", "abcd-efgh-jkmn");
const response = await AuthService.verifyMfaLoginFromRequest(new Request("https://app.example.com/login-mfa", { method: "POST" }), form);
expect(response.status).toBe(302);
expect(consumeUserMfaRecoveryCodeMock).toHaveBeenCalledWith("u1", "ABCD-EFGH-JKMN");
expect(createUserSessionMock).toHaveBeenCalledTimes(1);
});
it("sets a trusted-device cookie when remember is checked", async () => {
getActiveTotpSecretEncryptedMock.mockResolvedValue("enc-secret");
verifyTotpCodeMock.mockReturnValue(true);
const form = new FormData();
form.append("code", "123456");
form.append("rememberDevice", "on");
const response = await AuthService.verifyMfaLoginFromRequest(new Request("https://app.example.com/login-mfa", { method: "POST" }), form);
expect(rememberDeviceForUser).toHaveBeenCalledWith({ request: expect.any(Request), userId: "u1" });
expect(response.headers.getSetCookie?.().some((c) => c.includes("RJ_trusted_device")) ?? true).toBe(true);
});
it("rejects a wrong code without issuing a session", async () => {
getActiveTotpSecretEncryptedMock.mockResolvedValue("enc-secret");
verifyTotpCodeMock.mockReturnValue(false);
consumeUserMfaRecoveryCodeMock.mockResolvedValue({ consumed: false } as never);
const form = new FormData();
form.append("code", "000000");
const response = await AuthService.verifyMfaLoginFromRequest(new Request("https://app.example.com/login-mfa", { method: "POST" }), form);
const body = (await response.json()) as Record<string, unknown>;
expect(response.status).toBe(400);
expect(body.error).toBe("account.loginMfa.errors.invalidCode");
expect(recordFailedMfaChallengeAttempt).toHaveBeenCalled();
expect(createUserSessionMock).not.toHaveBeenCalled();
});
it("returns 429 when the MFA rate limit is exceeded", async () => {
checkMfaChallengeRateLimitMock.mockResolvedValue({ allowed: false, retryAfterSeconds: 300, resetAt: new Date() } as never);
const form = new FormData();
form.append("code", "123456");
const response = await AuthService.verifyMfaLoginFromRequest(new Request("https://app.example.com/login-mfa", { method: "POST" }), form);
expect(response.status).toBe(429);
expect(response.headers.get("Retry-After")).toBe("300");
expect(verifyTotpCodeMock).not.toHaveBeenCalled();
expect(createUserSessionMock).not.toHaveBeenCalled();
});
it("redirects to /login when there is no pending MFA state", async () => {
getUserSessionMock.mockResolvedValue(sessionMock({}) as never);
const form = new FormData();
form.append("code", "123456");
await expect(AuthService.verifyMfaLoginFromRequest(new Request("https://app.example.com/login-mfa", { method: "POST" }), form)).rejects.toMatchObject({
status: 302,
});
expect(createUserSessionMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,135 @@
/**
* Phase 01 / #27 — password-policy login soft-check.
*
* Asserts that AuthService.loginFromRequest calls validatePassword in
* non-throwing mode after a successful credential verification and
* propagates the result onto the new session as `passwordPolicyOutdated`.
* Critically: a policy-violating password must NEVER block the login —
* the soft-banner is the only consequence.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("~/locale/i18next.server", () => ({ getTranslations: vi.fn().mockResolvedValue({ t: (k: string) => k }) }));
vi.mock("~/utils/app/UserValidationUtils", () => ({ default: { validateEmail: () => true, validatePassword: () => true } }));
vi.mock("~/utils/db/logs.db.server", () => ({ createLogLogin: vi.fn() }));
vi.mock("~/utils/db/users.db.server", () => ({
getUserByEmail: vi.fn(),
updateUserPassword: vi.fn(),
getUser: vi.fn(),
getUserTotpStatus: vi.fn(),
getActiveTotpSecretEncrypted: vi.fn(),
consumeUserMfaRecoveryCode: vi.fn(),
}));
vi.mock("~/utils/session.server", () => ({
createUserSession: vi.fn(),
getUserInfo: vi.fn().mockResolvedValue({ lng: "en" }),
setLoggedUser: vi.fn().mockResolvedValue({ userId: "u1", defaultTenantId: "t1", defaultTenant: { id: "t1", slug: "t1" } }),
validateCSRFToken: vi.fn(),
}));
vi.mock("~/utils/db/tenants.db.server", () => ({ getTenant: vi.fn().mockResolvedValue({ id: "t1", slug: "t1" }) }));
vi.mock("~/utils/db/appConfiguration.db.server", () => ({
getAppConfiguration: vi.fn().mockResolvedValue({ app: { features: { tenantHome: "/" } } }),
}));
vi.mock("~/utils/auth/passwordHasher.server", () => ({
verifyPassword: vi.fn().mockResolvedValue({ valid: true, rehashedHash: null }),
}));
vi.mock("~/modules/ipAddress/services/IpAddressService.server", () => ({ default: { log: vi.fn().mockResolvedValue(null) } }));
vi.mock("~/services/auth/admin-fallback.server", () => ({
assertAdminFallbackLoginAllowed: vi.fn(),
AdminFallbackLoginRejected: class extends Error {},
}));
vi.mock("~/modules/admin-sso/audit.server", () => ({ writeAdminSsoAuditEvent: vi.fn() }));
vi.mock("@toolctrl/config", async () => {
const actual = await vi.importActual<typeof import("@toolctrl/config")>("@toolctrl/config");
return {
...actual,
loadRequiredWebConfig: () => ({
nodeEnv: "test",
routing: { appBaseDomain: "example.com", webAppHost: "app.example.com", adminAppHost: "admin.example.com", apiHost: "api.example.com", protocol: "https" },
}),
};
});
vi.mock("~/utils/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: () => ({ allowed: true }) }));
vi.mock("~/services/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: () => ({ allowed: true }) }));
vi.mock("~/services/auth/password-policy.server", () => ({
validatePassword: vi.fn(),
}));
import { getUserByEmail } from "~/utils/db/users.db.server";
import { createUserSession } from "~/utils/session.server";
import { validatePassword as validatePolicyPassword } from "~/services/auth/password-policy.server";
import AuthService from "./AuthService";
const getUserByEmailMock = vi.mocked(getUserByEmail);
const createUserSessionMock = vi.mocked(createUserSession);
const validatePolicyPasswordMock = vi.mocked(validatePolicyPassword);
function buildForm(): FormData {
const f = new FormData();
f.append("email", "user@example.com");
f.append("password", "whatever-they-typed");
f.append("redirectTo", "/");
f.append("csrf", "");
return f;
}
const baseUser = {
id: "u1",
email: "user@example.com",
passwordHash: "$argon2id$dummy",
firstName: "Test",
lastName: "User",
pendingDeletion: false,
pseudonymized: false,
admin: null,
locale: null,
defaultTenantId: "t1",
};
describe("AuthService password-policy soft-check (#27)", () => {
beforeEach(() => {
vi.clearAllMocks();
createUserSessionMock.mockResolvedValue(new Response(null, { status: 302 }));
});
it("flags passwordPolicyOutdated=true on the session when the submitted password fails the policy", async () => {
getUserByEmailMock.mockResolvedValue(baseUser as unknown as Awaited<ReturnType<typeof getUserByEmail>>);
validatePolicyPasswordMock.mockResolvedValue({
valid: false,
errors: [{ code: "auth.password.errors.tooShort", args: { minLength: 12 } }],
});
const req = new Request("https://app.example.com/login", { method: "POST" });
await AuthService.loginFromRequest(req, buildForm());
expect(validatePolicyPasswordMock).toHaveBeenCalledWith("whatever-they-typed", { email: "user@example.com" });
expect(createUserSessionMock).toHaveBeenCalledTimes(1);
const sessionArg = createUserSessionMock.mock.calls[0][0];
expect(sessionArg.passwordPolicyOutdated).toBe(true);
});
it("omits the flag when the submitted password satisfies the policy", async () => {
getUserByEmailMock.mockResolvedValue(baseUser as unknown as Awaited<ReturnType<typeof getUserByEmail>>);
validatePolicyPasswordMock.mockResolvedValue({ valid: true, errors: [] });
const req = new Request("https://app.example.com/login", { method: "POST" });
await AuthService.loginFromRequest(req, buildForm());
expect(createUserSessionMock).toHaveBeenCalledTimes(1);
const sessionArg = createUserSessionMock.mock.calls[0][0];
expect(sessionArg.passwordPolicyOutdated).toBeUndefined();
});
it("never blocks login when validatePassword throws — fails open with policyOutdated=false", async () => {
getUserByEmailMock.mockResolvedValue(baseUser as unknown as Awaited<ReturnType<typeof getUserByEmail>>);
validatePolicyPasswordMock.mockRejectedValue(new Error("HIBP down"));
const req = new Request("https://app.example.com/login", { method: "POST" });
await AuthService.loginFromRequest(req, buildForm());
// Session is still created — login is never blocked by the soft-check.
expect(createUserSessionMock).toHaveBeenCalledTimes(1);
const sessionArg = createUserSessionMock.mock.calls[0][0];
expect(sessionArg.passwordPolicyOutdated).toBeUndefined();
});
});
@@ -0,0 +1,103 @@
/**
* Phase 01 / #20 — Magic-link-only account login guard.
*
* Asserts that AuthService.loginFromRequest refuses a password-login
* attempt for a user whose `passwordHash` is null (magic-link-only
* account provisioned by the invitation flow), and surfaces a
* `requiresMagicLink: true` hint pointing at /forgot-password.
*
* The critical guarantee: no path grants a session on a null hash, and
* the dummy-hash fallthrough never produces "bad credentials" for these
* accounts — the user gets a friendly, actionable error instead.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("~/locale/i18next.server", () => ({ getTranslations: vi.fn().mockResolvedValue({ t: (k: string) => k }) }));
vi.mock("~/utils/app/UserValidationUtils", () => ({ default: { validateEmail: () => true, validatePassword: () => true } }));
vi.mock("~/utils/db/logs.db.server", () => ({ createLogLogin: vi.fn() }));
vi.mock("~/utils/db/users.db.server", () => ({
getUserByEmail: vi.fn(),
updateUserPassword: vi.fn(),
getUser: vi.fn(),
getUserTotpStatus: vi.fn(),
getActiveTotpSecretEncrypted: vi.fn(),
consumeUserMfaRecoveryCode: vi.fn(),
}));
vi.mock("~/utils/session.server", () => ({
createUserSession: vi.fn(),
getUserInfo: vi.fn().mockResolvedValue({ lng: "en" }),
setLoggedUser: vi.fn().mockResolvedValue({ defaultTenantId: "t1" }),
validateCSRFToken: vi.fn(),
}));
vi.mock("~/utils/db/tenants.db.server", () => ({ getTenant: vi.fn().mockResolvedValue({ id: "t1", slug: "t1" }) }));
vi.mock("~/utils/db/appConfiguration.db.server", () => ({ getAppConfiguration: vi.fn() }));
vi.mock("~/utils/auth/passwordHasher.server", () => ({
verifyPassword: vi.fn().mockResolvedValue({ valid: true, rehashedHash: null }),
}));
vi.mock("~/modules/ipAddress/services/IpAddressService.server", () => ({ default: { log: vi.fn().mockResolvedValue(null) } }));
vi.mock("~/services/auth/admin-fallback.server", () => ({ assertAdminFallbackLoginAllowed: vi.fn(), AdminFallbackLoginRejected: class extends Error {} }));
vi.mock("~/modules/admin-sso/audit.server", () => ({ writeAdminSsoAuditEvent: vi.fn() }));
vi.mock("@toolctrl/config", async () => {
const actual = await vi.importActual<typeof import("@toolctrl/config")>("@toolctrl/config");
return {
...actual,
loadRequiredWebConfig: () => ({
nodeEnv: "test",
routing: { appBaseDomain: "example.com", webAppHost: "app.example.com", adminAppHost: "admin.example.com", apiHost: "api.example.com", protocol: "https" },
}),
};
});
vi.mock("~/utils/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: () => ({ allowed: true }) }));
vi.mock("~/services/auth/auth-rate-limit.server", () => ({ checkAuthRateLimit: () => ({ allowed: true }) }));
import { getUserByEmail } from "~/utils/db/users.db.server";
import { verifyPassword } from "~/utils/auth/passwordHasher.server";
import { setLoggedUser } from "~/utils/session.server";
import AuthService from "./AuthService";
const getUserByEmailMock = vi.mocked(getUserByEmail);
const verifyPasswordMock = vi.mocked(verifyPassword);
const setLoggedUserMock = vi.mocked(setLoggedUser);
function buildForm(): FormData {
const f = new FormData();
f.append("email", "invited@example.com");
f.append("password", "anything-they-typed");
f.append("redirectTo", "/");
f.append("csrf", "");
return f;
}
describe("AuthService magic-link-only account guard (#20)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rejects login when the user's passwordHash is null and signals requiresMagicLink", async () => {
getUserByEmailMock.mockResolvedValue({
id: "u-invited",
email: "invited@example.com",
passwordHash: null,
firstName: "Inv",
lastName: "Ited",
pendingDeletion: false,
pseudonymized: false,
admin: null,
locale: null,
} as unknown as Awaited<ReturnType<typeof getUserByEmail>>);
const req = new Request("https://app.example.com/login", { method: "POST" });
const result = (await AuthService.loginFromRequest(req, buildForm())) as Response;
expect(result).toBeInstanceOf(Response);
expect(result.status).toBe(400);
const body = await result.json();
expect(body.requiresMagicLink).toBe(true);
expect(body.magicLinkHref).toBe("/forgot-password");
expect(body.error).toBe("auth:errors.passwordlessAccountUseMagicLink");
// Critical: must never have called verifyPassword for a null-hash row.
expect(verifyPasswordMock).not.toHaveBeenCalled();
// Critical: must never have created a session.
expect(setLoggedUserMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
/**
* PII-protection regression test for AuthService.
*
* Verifies that no `console.*` call remains in the file (those would
* bypass the redact-paths Pino pipeline) and that we do not log
* raw email / password / token / sessionToken fields through webLogger.
*/
describe("AuthService PII discipline", () => {
const SOURCE = readFileSync(resolve(__dirname, "AuthService.ts"), "utf8");
it("contains no live console.* call sites", () => {
const liveConsole = SOURCE.split("\n").filter((line, idx, all) => {
if (!/console\.(log|error|warn|info|debug)\s*\(/.test(line)) return false;
const trimmed = line.trimStart();
if (trimmed.startsWith("//") || trimmed.startsWith("*")) return false;
const prev = idx > 0 ? all[idx - 1].trim() : "";
if (/eslint-disable-next-line[^\n]*no-console/.test(prev)) return false;
if (/eslint-disable-line[^\n]*no-console/.test(line)) return false;
return true;
});
expect(liveConsole).toEqual([]);
});
it("never passes plaintext email / password / token / sessionToken as authLogger fields", () => {
// Scan property KEYS only (not values like `Boolean(token)` inside helpers).
const pattern = /authLogger\.(info|warn|error|debug)\s*\(\s*\{([\s\S]*?)\}/g;
const forbiddenKeys = ["email", "password", "sessionToken", "token", "passwordHash", "rawPassword"];
const offenders: string[] = [];
let match: RegExpExecArray | null;
while ((match = pattern.exec(SOURCE)) !== null) {
const objContent = match[2];
for (const key of forbiddenKeys) {
const keyRe = new RegExp(`(^|[\\s,{])${key}\\s*:`, "m");
if (keyRe.test(objContent)) offenders.push(`key "${key}" in ${match[0].slice(0, 80)}...`);
}
}
expect(offenders, `authLogger must not log PII fields: ${offenders.join("; ")}`).toEqual([]);
});
it("uses createScopedLogger('auth') for structured logging", () => {
expect(SOURCE).toMatch(/createScopedLogger\(\s*["']auth["']\s*\)/);
});
});
@@ -0,0 +1,356 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("~/locale/i18next.server", () => ({ getTranslations: vi.fn() }));
vi.mock("~/utils/app/UserValidationUtils", () => ({ default: { validateEmail: vi.fn(), validatePassword: vi.fn() } }));
vi.mock("~/utils/db/logs.db.server", () => ({ createLogLogin: vi.fn() }));
vi.mock("~/utils/db/users.db.server", () => ({ getUserByEmail: vi.fn() }));
vi.mock("~/utils/session.server", () => ({
createUserSession: vi.fn(),
getUserInfo: vi.fn(),
setLoggedUser: vi.fn(),
validateCSRFToken: vi.fn(),
}));
vi.mock("bcryptjs", () => ({ default: { compare: vi.fn() } }));
vi.mock("~/utils/db/tenants.db.server", () => ({ getTenant: vi.fn() }));
vi.mock("~/utils/db/appConfiguration.db.server", () => ({ getAppConfiguration: vi.fn() }));
vi.mock("~/utils/services/authService", () => ({
createRegistrationForm: vi.fn(),
getRegistrationFormData: vi.fn(),
validateRegistration: vi.fn(),
}));
vi.mock("~/utils/db/registration.db.server", () => ({ getRegistrationByToken: vi.fn(), updateRegistration: vi.fn() }));
vi.mock("~/modules/ipAddress/services/IpAddressService.server", () => ({ default: { log: vi.fn() } }));
vi.mock("@toolctrl/config", async () => {
const actual = await vi.importActual<typeof import("@toolctrl/config")>("@toolctrl/config");
return {
...actual,
loadRequiredWebConfig: () => ({
routing: {
appBaseDomain: "example.com",
webAppHost: "app.example.com",
adminAppHost: "admin.example.com",
apiHost: "api.example.com",
protocol: "https",
},
}),
};
});
import { getTenant } from "~/utils/db/tenants.db.server";
import { getTranslations } from "~/locale/i18next.server";
import { getAppConfiguration } from "~/utils/db/appConfiguration.db.server";
import { createRegistrationForm, getRegistrationFormData, validateRegistration } from "~/utils/services/authService";
import { createUserSession, getUserInfo, setLoggedUser, validateCSRFToken } from "~/utils/session.server";
import { getRegistrationByToken, updateRegistration } from "~/utils/db/registration.db.server";
import { getSafeLoginRedirect, isProductionTestAccountBlocked, isStagingDemoLoginAllowed } from "./AuthService";
const getTenantMock = vi.mocked(getTenant);
const getTranslationsMock = vi.mocked(getTranslations);
const getAppConfigurationMock = vi.mocked(getAppConfiguration);
const createRegistrationFormMock = vi.mocked(createRegistrationForm);
const getRegistrationFormDataMock = vi.mocked(getRegistrationFormData);
const validateRegistrationMock = vi.mocked(validateRegistration);
const getRegistrationByTokenMock = vi.mocked(getRegistrationByToken);
const updateRegistrationMock = vi.mocked(updateRegistration);
const setLoggedUserMock = vi.mocked(setLoggedUser);
const createUserSessionMock = vi.mocked(createUserSession);
const getUserInfoMock = vi.mocked(getUserInfo);
const validateCSRFTokenMock = vi.mocked(validateCSRFToken);
describe("AuthService login redirects", () => {
beforeEach(() => {
vi.clearAllMocks();
getTenantMock.mockResolvedValue({ id: "tenant-1", slug: "staging-admin" } as Awaited<ReturnType<typeof getTenant>>);
});
it("rejects unsafe redirect targets", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "https://evil.example", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
await expect(getSafeLoginRedirect({ redirectTo: "//evil.example", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
});
it("rejects removed app and API redirect targets", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/app/staging-admin/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
"/dashboard"
);
await expect(getSafeLoginRedirect({ redirectTo: "/v1/health", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
await expect(getSafeLoginRedirect({ redirectTo: "/api/v1/health", isAdmin: true, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
});
it("keeps app tenant redirects for non-admin users", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/staging-admin/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
"https://app.example.com/staging-admin/dashboard"
);
});
it("does not treat default tenant IDs as URL slugs", async () => {
getTenantMock.mockResolvedValueOnce({ id: "tenant-1", slug: null } as unknown as Awaited<ReturnType<typeof getTenant>>);
await expect(getSafeLoginRedirect({ redirectTo: "/tenant-1/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
"/dashboard"
);
});
it("rejects legacy admin redirects for non-admin users", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/admin", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
await expect(getSafeLoginRedirect({ redirectTo: "/admin/accounts/users?filter=active", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("/dashboard");
});
it("rejects canonical admin-host redirects for non-admin users", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/staging-admin/dashboard" })).resolves.toBe(
"/staging-admin/dashboard"
);
await expect(
getSafeLoginRedirect({ redirectTo: "/accounts/users?filter=active", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/staging-admin/dashboard" })
).resolves.toBe("/staging-admin/dashboard");
});
it("keeps non-default tenant deep links on the app host for non-admin users", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/tenant-b/dashboard", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/staging-admin/dashboard" })).resolves.toBe(
"https://app.example.com/tenant-b/dashboard"
);
});
it("moves non-admin root fallback redirects to the app host", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/", isAdmin: false, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe("https://app.example.com/");
});
it("falls back to admin home when an admin login carries an app tenant redirect", async () => {
await expect(
getSafeLoginRedirect({ redirectTo: "/staging-admin/dashboard", isAdmin: true, defaultTenantId: "tenant-1", fallback: "https://admin.example.com/dashboard" })
).resolves.toBe("https://admin.example.com/dashboard");
await expect(
getSafeLoginRedirect({ redirectTo: "/app/staging-admin/dashboard", isAdmin: true, defaultTenantId: "tenant-1", fallback: "https://admin.example.com/dashboard" })
).resolves.toBe("https://admin.example.com/dashboard");
});
it("moves admin redirects to the admin host for admin users", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/accounts/users", isAdmin: true, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
"https://admin.example.com/accounts/users"
);
});
it("canonicalizes legacy admin redirects for admin users", async () => {
await expect(getSafeLoginRedirect({ redirectTo: "/admin/dashboard", isAdmin: true, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
"https://admin.example.com/dashboard"
);
await expect(getSafeLoginRedirect({ redirectTo: "/admin/accounts/users?filter=active", isAdmin: true, defaultTenantId: "tenant-1", fallback: "/dashboard" })).resolves.toBe(
"https://admin.example.com/accounts/users?filter=active"
);
});
});
describe("AuthService production demo login guard", () => {
it("blocks local default and legacy demo accounts even when staging demo login is enabled", () => {
const env = { SEED_DEMO_DATA: "true", ALLOW_STAGING_DEMO_SEED: "true" };
expect(isProductionTestAccountBlocked("admin@email.com", env)).toBe(true);
expect(isProductionTestAccountBlocked("alex.martinez@company.com", env)).toBe(true);
});
it("keeps seeded CRM demo users blocked unless the staging demo guard is explicit", () => {
expect(isStagingDemoLoginAllowed({ SEED_DEMO_DATA: "true", ALLOW_STAGING_DEMO_SEED: "true" })).toBe(true);
expect(isProductionTestAccountBlocked("john.doe@company.com", { SEED_DEMO_DATA: "true", ALLOW_STAGING_DEMO_SEED: "true" })).toBe(false);
expect(isProductionTestAccountBlocked("luna.davis@company.com", { SEED_DEMO_DATA: "true", ALLOW_STAGING_DEMO_SEED: "true" })).toBe(false);
expect(isProductionTestAccountBlocked("john.doe@company.com", { SEED_DEMO_DATA: "true" })).toBe(true);
expect(isProductionTestAccountBlocked("luna.davis@company.com", { ALLOW_STAGING_DEMO_SEED: "true" })).toBe(true);
});
});
describe("AuthService registerFromRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
getTranslationsMock.mockResolvedValue({ t: (key: string) => key, translations: {}, locale: "en" });
getUserInfoMock.mockResolvedValue({ lng: "en" } as Awaited<ReturnType<typeof getUserInfo>>);
getAppConfigurationMock.mockResolvedValue({ app: { features: { tenantHome: "/:tenant/" } } } as Awaited<ReturnType<typeof getAppConfiguration>>);
getTenantMock.mockResolvedValue({ id: "tenant_1", slug: "ada-labs" } as Awaited<ReturnType<typeof getTenant>>);
getRegistrationFormDataMock.mockImplementation(async (request) => {
const formData = await request.formData();
return {
email: formData.get("email")?.toString().toLowerCase().trim(),
password: formData.get("password")?.toString(),
company: formData.get("company")?.toString(),
firstName: formData.get("first-name")?.toString(),
lastName: formData.get("last-name")?.toString(),
slug: formData.get("slug")?.toString(),
leadId: formData.get("leadId")?.toString().trim() || undefined,
leadToken: formData.get("leadToken")?.toString().trim() || undefined,
leadPlanId: formData.get("leadPlanId")?.toString().trim() || undefined,
};
});
validateRegistrationMock.mockResolvedValue({
email: "ada@example.test",
ipAddress: "203.0.113.5",
verificationRequired: false,
registered: {
user: { id: "user_1", email: "ada@example.test", locale: "de" },
tenant: { id: "tenant_1", slug: "ada-labs" },
},
} as Awaited<ReturnType<typeof validateRegistration>>);
setLoggedUserMock.mockResolvedValue({ userId: "user_1", defaultTenantId: "tenant_1" } as Awaited<ReturnType<typeof setLoggedUser>>);
createUserSessionMock.mockImplementation(async (_session, redirectTo) => new Response(null, { status: 302, headers: { Location: redirectTo } }));
});
it("passes lead conversion form fields to direct registration and starts the tenant session", async () => {
const AuthService = (await import("./AuthService")).default;
const formData = new URLSearchParams({
email: "ada@example.test",
password: "Password123!",
company: "Ada Labs",
"first-name": "Ada",
"last-name": "Lovelace",
slug: "ada-labs",
leadId: "lead_1",
leadToken: "signed-token",
leadPlanId: "plan_pro",
});
const request = new Request("https://app.example.com/register", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData,
});
const response = await AuthService.registerFromRequest(request);
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("https://app.example.com/ada-labs/dashboard");
expect(validateCSRFTokenMock).toHaveBeenCalledWith(request);
expect(getRegistrationFormDataMock).toHaveBeenCalledWith(request);
expect(validateRegistrationMock).toHaveBeenCalledWith({
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
company: "Ada Labs",
firstName: "Ada",
lastName: "Lovelace",
slug: "ada-labs",
leadId: "lead_1",
leadToken: "signed-token",
leadPlanId: "plan_pro",
},
addToTrialOrFreePlan: true,
});
expect(createRegistrationFormMock).not.toHaveBeenCalled();
expect(setLoggedUserMock).toHaveBeenCalledWith({ id: "user_1", email: "ada@example.test", locale: "de" });
expect(createUserSessionMock).toHaveBeenCalledWith(
{
lng: "de",
userId: "user_1",
defaultTenantId: "tenant_1",
},
"https://app.example.com/ada-labs/dashboard"
);
});
});
describe("AuthService verifyFromRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
getTranslationsMock.mockResolvedValue({ t: (key: string) => key, translations: {}, locale: "en" });
getUserInfoMock.mockResolvedValue({ lng: "en" } as Awaited<ReturnType<typeof getUserInfo>>);
getRegistrationFormDataMock.mockResolvedValue({ email: "ada@example.test", password: "Password123!" });
getRegistrationByTokenMock.mockResolvedValue({
id: "registration_1",
createdTenantId: null,
leadConversionLeadId: "lead_1",
leadConversionToken: "signed-token",
leadConversionPlanId: "plan_pro",
} as Awaited<ReturnType<typeof getRegistrationByToken>>);
validateRegistrationMock.mockResolvedValue({
email: "ada@example.test",
ipAddress: "203.0.113.5",
verificationRequired: false,
registered: {
user: { id: "user_1", email: "ada@example.test", locale: "de" },
tenant: { id: "tenant_1", slug: "ada" },
},
} as Awaited<ReturnType<typeof validateRegistration>>);
setLoggedUserMock.mockResolvedValue({ userId: "user_1", defaultTenantId: "tenant_1" } as Awaited<ReturnType<typeof setLoggedUser>>);
createUserSessionMock.mockResolvedValue(new Response(null, { status: 302, headers: { Location: "/ada/dashboard" } }) as Awaited<ReturnType<typeof createUserSession>>);
});
it("hydrates persisted lead conversion fields before validating the verified registration", async () => {
const AuthService = (await import("./AuthService")).default;
const request = new Request("https://example.test/verify/signed-registration-token", { method: "POST" });
await AuthService.verifyFromRequest({ request, params: { id: "signed-registration-token" } });
expect(validateRegistrationMock).toHaveBeenCalledWith({
request,
registrationData: {
email: "ada@example.test",
password: "Password123!",
leadId: "lead_1",
leadToken: "signed-token",
leadPlanId: "plan_pro",
},
checkEmailVerification: false,
addToTrialOrFreePlan: true,
});
expect(updateRegistrationMock).toHaveBeenCalledWith("registration_1", { createdTenantId: "tenant_1" });
});
it("rejects missing verification tokens before parsing form data or mutating registration state", async () => {
getRegistrationByTokenMock.mockResolvedValueOnce(null);
const AuthService = (await import("./AuthService")).default;
const response = await AuthService.verifyFromRequest({
request: new Request("https://example.test/verify/missing-token", { method: "POST" }),
params: { id: "missing-token" },
});
const data = await response.json();
expect(response.status).toBe(400);
expect(data).toEqual({ error: "api.errors.userAlreadyRegistered" });
expect(getRegistrationFormDataMock).not.toHaveBeenCalled();
expect(validateRegistrationMock).not.toHaveBeenCalled();
expect(updateRegistrationMock).not.toHaveBeenCalled();
expect(setLoggedUserMock).not.toHaveBeenCalled();
expect(createUserSessionMock).not.toHaveBeenCalled();
});
it("rejects already-consumed verification tokens before tenant/user creation work", async () => {
getRegistrationByTokenMock.mockResolvedValueOnce({
id: "registration_1",
createdTenantId: "tenant_1",
} as Awaited<ReturnType<typeof getRegistrationByToken>>);
const AuthService = (await import("./AuthService")).default;
const response = await AuthService.verifyFromRequest({
request: new Request("https://example.test/verify/used-token", { method: "POST" }),
params: { id: "used-token" },
});
const data = await response.json();
expect(response.status).toBe(400);
expect(data).toEqual({ error: "api.errors.userAlreadyRegistered" });
expect(getRegistrationFormDataMock).not.toHaveBeenCalled();
expect(validateRegistrationMock).not.toHaveBeenCalled();
expect(updateRegistrationMock).not.toHaveBeenCalled();
expect(setLoggedUserMock).not.toHaveBeenCalled();
expect(createUserSessionMock).not.toHaveBeenCalled();
});
it("does not consume the verification token when registration validation does not create an account", async () => {
validateRegistrationMock.mockResolvedValueOnce({
email: "ada@example.test",
ipAddress: "203.0.113.5",
verificationRequired: false,
registered: undefined,
} as Awaited<ReturnType<typeof validateRegistration>>);
const AuthService = (await import("./AuthService")).default;
const response = await AuthService.verifyFromRequest({
request: new Request("https://example.test/verify/signed-registration-token", { method: "POST" }),
params: { id: "signed-registration-token" },
});
const data = await response.json();
expect(response.status).toBe(400);
expect(data).toEqual({ error: "shared.unknownError" });
expect(updateRegistrationMock).not.toHaveBeenCalled();
expect(setLoggedUserMock).not.toHaveBeenCalled();
expect(createUserSessionMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,546 @@
import { getTranslations } from "~/locale/i18next.server";
import UserValidationUtils from "~/utils/app/UserValidationUtils";
import { createLogLogin } from "~/utils/db/logs.db.server";
import { getUserByEmail, getUser, getUserTotpStatus, getActiveTotpSecretEncrypted, consumeUserMfaRecoveryCode } from "~/utils/db/users.db.server";
import { setLoggedUser, createUserSession, getUserInfo, getUserSession, validateCSRFToken, commitPendingMfaLoginCookie } from "~/utils/session.server";
import { startServerSession } from "~/services/auth/sessionLifecycle.server";
import { updateUserPassword } from "~/utils/db/users.db.server";
import { verifyTotpCode } from "~/services/auth/totp.server";
import {
checkMfaChallengeRateLimit,
mfaChallengeRateLimitKey,
recordFailedMfaChallengeAttempt,
resetMfaChallengeRateLimit,
} from "~/utils/auth/mfa/challenge-rate-limit.server";
import { isTrustedDevice, rememberDeviceForUser } from "~/utils/auth/mfa/trusted-device.server";
import { verifyPassword } from "~/utils/auth/passwordHasher.server";
import { getTenant } from "~/utils/db/tenants.db.server";
import { getAppConfiguration } from "~/utils/db/appConfiguration.db.server";
import { getRegistrationFormData, validateRegistration, createRegistrationForm } from "~/utils/services/authService";
import { getRegistrationByToken, updateRegistration } from "~/utils/db/registration.db.server";
import { Params, redirect } from "react-router";
import IpAddressServiceServer from "~/modules/ipAddress/services/IpAddressService.server";
import { isCanonicalAdminHostRoute } from "~/utils/app/HostRouting.server";
import { absoluteAdminPath, absoluteAppPath, absoluteAppRootPath } from "~/utils/app/CrossHostUrls";
import { loadRequiredWebConfig } from "@toolctrl/config";
import { isRemovedRoutePath } from "~/utils/app/RedirectSafety.server";
import { checkAuthRateLimit } from "~/services/auth/auth-rate-limit.server";
import { clearFailedLogins, getFailedLoginCount, loginRequiresCaptcha, recordFailedLogin } from "~/services/auth/login-captcha-gate.server";
import { getSitekey, isCaptchaConfigured, resolveCaptchaConfig, verifyCaptchaToken } from "@toolctrl/captcha";
import { createScopedLogger } from "~/utils/logger.server";
import { validatePassword as validatePolicyPassword } from "~/services/auth/password-policy.server";
const authLogger = createScopedLogger("auth");
const DUMMY_PASSWORD_HASH = "$2a$10$7EqJtq98hPqEX7fNZaFWoOhi8VfIf6z9SjG8/aYx8vvjE5LiE3D0C";
// D1 — tenant-faehige Verify-Route, an die das Login nach Passwort-OK fuer den zweiten Faktor
// weiterleitet (siehe routeModules/auth/login-mfa).
export const LOGIN_MFA_VERIFY_PATH = "/login-mfa";
function isSixDigitTotpCode(code: FormDataEntryValue | null): code is string {
return typeof code === "string" && /^\d{6}$/.test(code);
}
function normalizeRecoveryCodeInput(code: FormDataEntryValue | null) {
return typeof code === "string" ? code.trim().toUpperCase() : "";
}
const blockedProductionSeedAccountEmails = new Set(["admin@email.com", "alex.martinez@company.com"]);
const guardedStagingDemoAccountEmails = new Set(["john.doe@company.com", "luna.davis@company.com"]);
function translateIpAddressError(t: (key: string) => string, error: string): string {
if (error === "blacklisted") {
return t("admin.accounts.ipAddresses.errors.blacklisted");
}
if (error === "unauthorized") {
return t("admin.accounts.ipAddresses.errors.unauthorized");
}
return error;
}
type DemoLoginEnvironment = {
ALLOW_STAGING_DEMO_SEED?: string;
SEED_DEMO_DATA?: string;
};
export function isStagingDemoLoginAllowed(env?: DemoLoginEnvironment) {
const demoLoginEnv = env ?? (process.env as DemoLoginEnvironment);
return demoLoginEnv.SEED_DEMO_DATA === "true" && demoLoginEnv.ALLOW_STAGING_DEMO_SEED === "true";
}
export function isProductionTestAccountBlocked(email: string, env?: DemoLoginEnvironment) {
if (blockedProductionSeedAccountEmails.has(email)) {
return true;
}
if (guardedStagingDemoAccountEmails.has(email)) {
return !isStagingDemoLoginAllowed(env);
}
return false;
}
async function getHome({ isAdmin, tenantId, request }: { isAdmin: boolean; tenantId: string; request: Request }) {
let appHome = "";
const appConfiguration = await getAppConfiguration({ request });
if (isAdmin) {
appHome = absoluteAdminPath(loadRequiredWebConfig().routing, "dashboard");
} else if (appConfiguration.app.features.tenantHome === "/:tenant/") {
const tenant = await getTenant(tenantId);
if (tenant) {
appHome = absoluteAppPath(loadRequiredWebConfig().routing, tenant.slug, "dashboard");
} else {
appHome = absoluteAppRootPath(loadRequiredWebConfig().routing);
}
} else {
appHome = absoluteAppRootPath(loadRequiredWebConfig().routing);
}
return appHome;
}
function isSafeRelativeRedirect(redirectTo: string) {
return redirectTo.startsWith("/") && !redirectTo.startsWith("//") && !redirectTo.includes("\\");
}
function canonicalAdminRedirect(redirectTo: string) {
if (redirectTo === "/admin") {
return absoluteAdminPath(loadRequiredWebConfig().routing, "dashboard");
}
if (redirectTo.startsWith("/admin?") || redirectTo.startsWith("/admin#")) {
return `${absoluteAdminPath(loadRequiredWebConfig().routing, "dashboard")}${redirectTo.slice("/admin".length)}`;
}
if (redirectTo.startsWith("/admin/")) {
return absoluteAdminPath(loadRequiredWebConfig().routing, redirectTo.slice("/admin/".length));
}
return absoluteAdminPath(loadRequiredWebConfig().routing, redirectTo);
}
function isLegacyAdminRedirect(redirectTo: string) {
return redirectTo === "/admin" || redirectTo.startsWith("/admin/") || redirectTo.startsWith("/admin?") || redirectTo.startsWith("/admin#");
}
export async function getSafeLoginRedirect({
redirectTo,
isAdmin,
defaultTenantId,
fallback,
}: {
redirectTo: string;
isAdmin: boolean;
defaultTenantId: string;
fallback: string;
}) {
if (!redirectTo || !isSafeRelativeRedirect(redirectTo)) {
return fallback;
}
const redirectPathname = new URL(redirectTo, "http://local.invalid").pathname;
if (isRemovedRoutePath(redirectPathname) && !(isAdmin && isLegacyAdminRedirect(redirectTo))) {
return fallback;
}
if (!isAdmin && (isLegacyAdminRedirect(redirectTo) || isCanonicalAdminHostRoute(redirectTo))) {
return fallback;
}
if (redirectTo === "/app" || redirectTo.startsWith("/app/")) {
return fallback;
}
const defaultTenant = defaultTenantId ? await getTenant(defaultTenantId) : null;
const tenantSlug = defaultTenant?.slug;
const targetsDefaultTenantId = Boolean(defaultTenantId && (redirectTo === `/${defaultTenantId}` || redirectTo.startsWith(`/${defaultTenantId}/`)));
if (targetsDefaultTenantId) {
return fallback;
}
if (!isAdmin) {
if (tenantSlug && (redirectTo === `/${tenantSlug}` || redirectTo.startsWith(`/${tenantSlug}/`))) {
const path = redirectTo === `/${tenantSlug}` ? undefined : redirectTo.slice(`/${tenantSlug}/`.length);
return absoluteAppPath(loadRequiredWebConfig().routing, tenantSlug, path);
}
if (redirectTo === "/") {
return absoluteAppRootPath(loadRequiredWebConfig().routing);
}
return `${absoluteAppRootPath(loadRequiredWebConfig().routing).replace(/\/$/, "")}${redirectTo}`;
}
const canonicalRedirectTo = canonicalAdminRedirect(redirectTo);
if (tenantSlug && (redirectTo === `/${tenantSlug}` || redirectTo.startsWith(`/${tenantSlug}/`))) {
return fallback;
}
return canonicalRedirectTo;
}
async function loginFromRequest(request: Request, form: FormData) {
const userInfo = await getUserInfo(request);
const { t } = await getTranslations(request);
// `form` was parsed by the caller, so the request body stream is already
// drained. Hand the FormData to validateCSRFToken to avoid the
// TypeError: unusable that undici raises when cloning a consumed body
// (B-009 follow-up).
await validateCSRFToken(request, { formData: form });
const email = form.get("email")?.toString().toLowerCase().trim();
const password = form.get("password");
const redirectTo = form.get("redirectTo");
if (typeof email !== "string" || typeof password !== "string" || typeof redirectTo !== "string") {
throw new Error(t("auth:errors.invalidFormData"));
}
const fields = { email, password };
const fieldErrors = {
email: !UserValidationUtils.validateEmail(email) ? t("auth:errors.invalidEmail") : undefined,
password: !UserValidationUtils.validatePassword(password) ? t("auth:errors.invalidPassword") : undefined,
};
if (Object.values(fieldErrors).some(Boolean)) return Response.json({ fieldErrors, fields }, { status: 400 });
const rateLimit = checkAuthRateLimit({
request,
action: "login",
identifier: email,
limit: 10,
windowMs: 60 * 1000,
});
if (!rateLimit.allowed) {
return Response.json({ fields, error: t("api.errors.tooManyRequests") }, { status: 429, headers: { "Retry-After": String(rateLimit.retryAfterSeconds) } });
}
// Brute-Force-Soft-Gate: ab dem 3. Fehlversuch (pro IP+Email) ein mCaptcha verlangen,
// bevor der Passwort-Check laeuft. No-op, solange mCaptcha nicht konfiguriert ist.
const captchaConfig = resolveCaptchaConfig(process.env);
if (isCaptchaConfigured(captchaConfig) && loginRequiresCaptcha(getFailedLoginCount({ request, identifier: email }))) {
const captchaOk = await verifyCaptchaToken({
token: form.get("captchaToken")?.toString(),
sitekey: getSitekey(captchaConfig, "login"),
secret: captchaConfig.accountSecret,
instanceUrl: captchaConfig.instanceUrl,
failOpen: captchaConfig.failOpen,
logger: authLogger,
});
if (!captchaOk) {
return Response.json(
{ fields, requiresCaptcha: true, sitekey: getSitekey(captchaConfig, "login"), error: t("auth:errors.captchaRequired") },
{ status: 400 }
);
}
}
const user = await getUserByEmail(email);
if (loadRequiredWebConfig().nodeEnv !== "development") {
const ipError = await IpAddressServiceServer.log(request, { action: "login", description: email }).catch((e) => e.message);
if (ipError) {
return Response.json({ error: translateIpAddressError(t, ipError) }, { status: 400 });
}
if (isProductionTestAccountBlocked(email)) {
return Response.json({ fields: { email, password }, error: t("auth:errors.productionTestAccountBlocked") }, { status: 400 });
}
}
// Phase 01 / #20 — Magic-link-only accounts have `passwordHash = null`
// (invitation flow provisions them without a password). Reject the
// password-login attempt *before* the verify step so we never grant a
// session on the dummy-hash fallthrough, and surface a friendly
// message pointing the user at the magic-link entrypoint. We still
// return 400 so the response shape stays compatible with existing
// clients and login-form error rendering.
if (user && user.passwordHash === null) {
return Response.json(
{
fields,
error: t("auth:errors.passwordlessAccountUseMagicLink"),
requiresMagicLink: true,
magicLinkHref: "/forgot-password",
},
{ status: 400 }
);
}
const { valid: isCorrectPassword, rehashedHash } = await verifyPassword(password, user?.passwordHash ?? DUMMY_PASSWORD_HASH);
if (!user) {
recordFailedLogin({ request, identifier: email });
return Response.json({ fields, error: t("api.errors.invalidPassword") }, { status: 400 });
}
if (!isCorrectPassword) {
recordFailedLogin({ request, identifier: email });
return Response.json({ fields, error: t("api.errors.invalidPassword") }, { status: 400 });
}
if (rehashedHash) {
// Drain a legacy bcrypt user to argon2 transparently on successful login.
// Best-effort: never block the login on a rehash-persist failure.
await updateUserPassword({ passwordHash: rehashedHash }, user.id).catch(() => undefined);
}
// Phase 10 Task #68 — block login for users currently in the DSGVO
// 30-day grace period or after pseudonymisation. We return a 423 / 410
// so the UI can render a dedicated "marked for deletion — reactivate?"
// screen instead of issuing a session.
if ((user as { pendingDeletion?: boolean }).pendingDeletion === true) {
return Response.json({ fields, error: t("auth:errors.accountPendingDeletion") }, { status: 423 });
}
if ((user as { pseudonymized?: boolean }).pseudonymized === true) {
return Response.json({ fields, error: t("auth:errors.accountPseudonymized") }, { status: 410 });
}
// Erfolgreicher Login: Brute-Force-Failure-Counter zuruecksetzen (Captcha-Gate faellt wieder weg).
clearFailedLogins({ request, identifier: email });
// D1 (Produktentscheidung 18.06.) — 2FA-Login-Gate: TOTP-Nutzer erhalten mit Passwort allein
// KEINE Session. Stattdessen wird ein signierter Pending-MFA-Zustand ins Cookie geschrieben
// (pendingMfaUserId, NICHT userId) und der Client zur Verify-Route geschickt. Ein als
// vertrauenswuerdig markiertes Geraet (Trust-Device-Cookie) ueberspringt den zweiten Faktor.
const totpStatus = await getUserTotpStatus(user.id);
if (totpStatus?.enabled && !(await isTrustedDevice(request, user.id))) {
const setCookie = await commitPendingMfaLoginCookie(request, { userId: user.id, redirectTo });
authLogger.info({ op: "login.mfaRequired", userId: user.id }, "Password accepted; awaiting second factor before issuing a session.");
return Response.json(
{ requiresMfa: true, redirectTo, mfaVerifyPath: LOGIN_MFA_VERIFY_PATH },
{ status: 200, headers: { "Set-Cookie": setCookie } }
);
}
await createLogLogin(request, user);
const userSession = await setLoggedUser(user);
// const tenant = await getTenant(userSession.defaultTenantId);
const appHome = await getHome({
isAdmin: !!user.admin,
tenantId: userSession.defaultTenantId,
request,
});
const safeRedirectTo = await getSafeLoginRedirect({
redirectTo,
isAdmin: !!user.admin,
defaultTenantId: userSession.defaultTenantId,
fallback: appHome,
});
// Phase 01 / #27 — soft-check the submitted password against the current
// policy. Never blocks the login; only sets a session flag so the tenant
// dashboard layout can render a dismissible banner. validatePassword talks
// to HIBP and is fire-and-forget here: any failure is swallowed and treated
// as "policy not outdated" so a network blip never wedges sign-in.
let passwordPolicyOutdated = false;
try {
const policyResult = await validatePolicyPassword(password, { email });
passwordPolicyOutdated = !policyResult.valid;
if (passwordPolicyOutdated) {
authLogger.info(
{
op: "login.passwordPolicyOutdated",
userId: user.id,
errorCount: policyResult.errors.length,
},
"Login succeeded with a password that no longer satisfies the current policy."
);
}
} catch (e: unknown) {
authLogger.warn(
{
op: "login.passwordPolicySoftCheck",
err: { name: (e as { name?: string } | null)?.name ?? "Error" },
},
"Password policy soft-check raised; falling back to policyOutdated=false."
);
}
// R-P2a S4b — serverseitige Session-Zeile additiv anlegen (best-effort; ein Store-Fehler darf
// den Login nicht blockieren) und ihren Token ins Cookie geben.
const sessionToken = await startServerSession({ request, userId: user.id }).catch(() => null);
return createUserSession(
{
...userInfo,
...userSession,
lng: user.locale ?? userInfo.lng,
passwordPolicyOutdated: passwordPolicyOutdated || undefined,
sessionToken: sessionToken ?? undefined,
},
safeRedirectTo
);
}
/**
* D1 — schliesst den 2FA-Login ab. Liest den Pending-Zustand (`pendingMfaUserId`) aus dem Cookie,
* verifiziert TOTP ODER einen Wiederherstellungscode und erstellt erst dann die echte Session
* (setLoggedUser + serverseitige Session-Zeile + createUserSession, das `userId` setzt und den
* Pending-Zustand loescht). Bei "remember device" wird zusaetzlich ein Trust-Marker gesetzt.
*
* Rueckgaben:
* - kein Pending-Zustand / unbekannter Nutzer -> redirect zur Login-Seite
* - Rate-Limit ueberschritten -> 429
* - falscher Code -> 400 mit { error }
* - Erfolg -> Redirect mit Session-Cookie (+ optional Trust-Cookie)
*/
async function verifyMfaLoginFromRequest(request: Request, form: FormData) {
const { t } = await getTranslations(request);
const session = await getUserSession(request);
const pendingMfaUserId = session.get("pendingMfaUserId");
if (typeof pendingMfaUserId !== "string" || pendingMfaUserId.length === 0) {
// Kein laufender 2FA-Login (Cookie fehlt/abgelaufen) -> sauber zurueck zur Anmeldung.
throw redirect("/login");
}
await validateCSRFToken(request, { formData: form });
const user = await getUser(pendingMfaUserId);
const totpStatus = user ? await getUserTotpStatus(user.id) : null;
if (!user || !totpStatus?.enabled) {
// Pending-Nutzer existiert nicht mehr oder hat TOTP zwischenzeitlich deaktiviert.
throw redirect("/login");
}
const rateLimitKey = mfaChallengeRateLimitKey(request, user.id);
const rateLimit = await checkMfaChallengeRateLimit(rateLimitKey);
if (!rateLimit.allowed) {
return Response.json({ error: t("account.loginMfa.errors.tooManyAttempts") }, { status: 429, headers: { "Retry-After": String(rateLimit.retryAfterSeconds) } });
}
let verified = false;
const code = form.get("code");
if (isSixDigitTotpCode(code)) {
const encryptedSecret = await getActiveTotpSecretEncrypted(user.id);
try {
verified = Boolean(encryptedSecret && verifyTotpCode(encryptedSecret, code));
} catch {
verified = false;
}
}
if (!verified) {
const recoveryCode = normalizeRecoveryCodeInput(form.get("recoveryCode"));
if (recoveryCode.length > 0) {
const result = await consumeUserMfaRecoveryCode(user.id, recoveryCode);
verified = Boolean(result?.consumed);
}
}
if (!verified) {
await recordFailedMfaChallengeAttempt(rateLimitKey);
return Response.json({ error: t("account.loginMfa.errors.invalidCode") }, { status: 400 });
}
await resetMfaChallengeRateLimit(rateLimitKey);
// Ab hier identisch zum normalen Login-Abschluss in loginFromRequest.
const userInfo = await getUserInfo(request);
await createLogLogin(request, user);
const userSession = await setLoggedUser(user);
const appHome = await getHome({ isAdmin: !!user.admin, tenantId: userSession.defaultTenantId, request });
const rawRedirectTo = session.get("pendingMfaRedirectTo");
const safeRedirectTo = await getSafeLoginRedirect({
redirectTo: typeof rawRedirectTo === "string" ? rawRedirectTo : "",
isAdmin: !!user.admin,
defaultTenantId: userSession.defaultTenantId,
fallback: appHome,
});
const sessionToken = await startServerSession({ request, userId: user.id }).catch(() => null);
const response = await createUserSession(
{
...userInfo,
...userSession,
lng: user.locale ?? userInfo.lng,
sessionToken: sessionToken ?? undefined,
},
safeRedirectTo
);
// Trust-Device: dieses Geraet kuenftig ohne zweiten Faktor zulassen. Legt eine widerrufbare
// TrustedDevice-Zeile an und gibt den Token als zusaetzliches Set-Cookie aus.
if (form.get("rememberDevice") === "on" || form.get("rememberDevice") === "true") {
response.headers.append("Set-Cookie", await rememberDeviceForUser({ request, userId: user.id }));
}
return response;
}
async function registerFromRequest(request: Request) {
const { t } = await getTranslations(request);
const userInfo = await getUserInfo(request);
try {
await validateCSRFToken(request);
const registrationData = await getRegistrationFormData(request);
const result = await validateRegistration({ request, registrationData, addToTrialOrFreePlan: true });
if (result.verificationRequired) {
await createRegistrationForm({ ...registrationData, email: result.email, ipAddress: result.ipAddress, recreateToken: false, request });
return Response.json({ verificationEmailSent: true });
} else if (result.registered) {
const userSession = await setLoggedUser(result.registered.user);
const home = await getHome({
isAdmin: false,
tenantId: userSession.defaultTenantId,
request,
});
const sessionToken = await startServerSession({ request, userId: result.registered.user.id }).catch(() => null);
return createUserSession(
{
...userInfo,
...userSession,
lng: result.registered.user.locale ?? userInfo.lng,
sessionToken: sessionToken ?? undefined,
},
home
);
}
return Response.json({ error: t("shared.unknownError") }, { status: 400 });
} catch (e: any) {
// Intentionally do NOT log e.message verbatim — auth flow errors may
// contain user-provided email/registration tokens. Log only the error
// class and a short tag so we can still investigate via Sentry.
authLogger.warn(
{
err: { name: e?.name ?? "Error", message: typeof e?.message === "string" ? "[redacted]" : "[unknown]" },
op: "verifyAndRegister",
},
"Auth verify-and-register failed"
);
return Response.json({ error: t(e.message) }, { status: 400 });
}
}
async function verifyFromRequest({ request, params }: { request: Request; params: Params }) {
const { t } = await getTranslations(request);
const userInfo = await getUserInfo(request);
const registration = await getRegistrationByToken(params.id ?? "");
if (!registration || registration.createdTenantId) {
return Response.json({ error: t("api.errors.userAlreadyRegistered") }, { status: 400 });
}
try {
const registrationData = await getRegistrationFormData(request);
registrationData.leadId = registration.leadConversionLeadId ?? undefined;
registrationData.leadToken = registration.leadConversionToken ?? undefined;
registrationData.leadPlanId = registration.leadConversionPlanId ?? undefined;
const result = await validateRegistration({ request, registrationData, checkEmailVerification: false, addToTrialOrFreePlan: true });
if (!result.registered) {
return Response.json({ error: t("shared.unknownError") }, { status: 400 });
}
await updateRegistration(registration.id, { createdTenantId: result.registered.tenant.id });
const userSession = await setLoggedUser(result.registered.user);
const appHome = await getHome({
isAdmin: false,
tenantId: userSession.defaultTenantId,
request,
});
const sessionToken = await startServerSession({ request, userId: result.registered.user.id }).catch(() => null);
return createUserSession(
{
...userInfo,
...userSession,
lng: result.registered.user.locale ?? userInfo.lng,
sessionToken: sessionToken ?? undefined,
},
appHome
);
} catch {
return Response.json({ error: t("shared.unknownError") }, { status: 400 });
}
}
export default {
loginFromRequest,
verifyMfaLoginFromRequest,
registerFromRequest,
verifyFromRequest,
};