Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
+21
@@ -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();
|
||||
});
|
||||
});
|
||||
+135
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user