Track HiveGrid Cloud planning reference

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

No files matched your search

@@ -0,0 +1,90 @@
import { useState } from "react";
import { Form, useNavigate, useNavigation } from "react-router";
import { useTranslation } from "react-i18next";
import { FeatherMail } from "@subframe/core";
import { useRootData } from "~/utils/data/useRootData";
import { AuthShell, AuthStatus } from "~/components/auth/AuthShell";
import { Button } from "~/ui/components/Button";
import { LinkButton } from "~/ui/components/LinkButton";
import { TextInput } from "~/ui/components/TextInput";
// Forgot-Password-Screen nach Davids Subframe-Design ("Login_Forgot password" + "_ Sent"): Logo + Card
// (Passwort vergessen? + E-Mail + "Link senden") -> action (mode=reset) gibt den Reset-Link aus ->
// Davids "E-Mail gesendet"-Status. Magic-Link-Alternative liegt jetzt im Login-Screen, daher hier nur reset.
export default function ForgotPasswordScreen({
actionData,
}: {
actionData: { success?: string; error?: string } | null | undefined;
}) {
const { t } = useTranslation();
const { csrf } = useRootData();
const navigation = useNavigation();
const navigate = useNavigate();
const isSubmitting = navigation.state !== "idle";
const [email, setEmail] = useState("");
if (actionData?.success) {
return (
<AuthStatus
icon={<FeatherMail />}
tone="positive"
title={t("account.forgot.sentTitle")}
message={t("account.forgot.sentBody", { email })}
hint={t("account.login.magicLinkSentHint")}
dataUiId="ui.auth.forgot.sent"
>
<Form method="post" className="contents">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="mode" value="reset" readOnly />
<input type="hidden" name="email" value={email} readOnly />
<LinkButton type="submit" size="small" hideIcon disabled={isSubmitting} data-ui-id="ui.auth.forgot.resend">
{t("account.login.magicLinkResend")}
</LinkButton>
</Form>
<LinkButton size="small" hideIcon onClick={() => navigate("/login")} data-ui-id="ui.auth.forgot.backToLogin">
{t("account.forgot.backToLogin")}
</LinkButton>
</AuthStatus>
);
}
return (
<AuthShell title={t("account.forgot.title")} subtitle={t("account.forgot.subtitle")} subtitleStrong dataUiId="ui.auth.forgot.page">
<Form method="post" noValidate className="flex w-full flex-col gap-4" data-ui-id="ui.auth.forgot.form">
<input type="hidden" name="csrf" value={csrf} readOnly />
<TextInput className="h-auto w-full flex-none" showLabel label={t("account.shared.email")} type="email">
<TextInput.Input
name="email"
type="email"
required
autoFocus
autoComplete="email"
placeholder={t("account.shared.emailPlaceholder")}
value={email}
onChange={(event) => setEmail(event.target.value)}
disabled={isSubmitting}
data-ui-id="ui.auth.forgot.email"
/>
</TextInput>
<Button type="submit" name="mode" value="reset" loading={isSubmitting} disabled={isSubmitting} data-ui-id="ui.auth.forgot.submit">
{t("account.forgot.sendLink")}
</Button>
{actionData?.error && navigation.state === "idle" ? (
<p className="text-center text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative" role="alert">
{actionData.error}
</p>
) : null}
</Form>
<div className="flex w-full items-center justify-center">
<LinkButton size="small" hideIcon onClick={() => navigate("/login")} data-ui-id="ui.auth.forgot.backToLoginLink">
{t("account.forgot.backToLogin")}
</LinkButton>
</div>
</AuthShell>
);
}
@@ -0,0 +1,157 @@
import { useState } from "react";
import { Form, Link, useNavigation } from "react-router";
import { useTranslation } from "react-i18next";
import * as SubframeCore from "@subframe/core";
import { FeatherAlertTriangle, FeatherEdit2, FeatherMoreHorizontal, FeatherPlus, FeatherTrash, FeatherUsers } from "@subframe/core";
import type { GroupWithDetails } from "~/utils/db/permissions/groups.db.server";
import { Button } from "~/ui/components/Button";
import { DropdownMenu } from "~/ui/components/DropdownMenu";
import { IconButton } from "~/ui/components/IconButton";
import { IconWithBackground } from "~/ui/components/IconWithBackground";
import { SkeletonText } from "~/ui/components/SkeletonText";
import { Table } from "~/ui/components/Table";
import { useRootData } from "~/utils/data/useRootData";
// D-P2 — Gruppen-Liste nach Davids "Groups Flow"-Design (Table: Gruppe/Beschreibung/Mitglieder/Aktionen).
// Praesentational: items + csrf injiziert; Anlegen/Bearbeiten via bestehende Routen (new / $id im SlideOver),
// Löschen via Form-POST an den $id-Pfad (action=delete). Empty-/Loading-States nach Davids Vorlage.
export default function GroupsListView({ items, canManage }: { items: GroupWithDetails[]; canManage: boolean }) {
const { t } = useTranslation();
const { csrf = "" } = useRootData();
const navigation = useNavigation();
const loading = navigation.state === "loading";
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string } | null>(null);
return (
<div className="flex w-full flex-col items-start gap-4">
<div className="flex w-full items-start justify-between gap-4 mobile:flex-col">
<div className="flex flex-col items-start gap-1">
<span className="text-title-page font-title-page text-text-neutral-primary mobile:text-subtitle mobile:font-subtitle">
{t("models.group.plural")}
</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-tertiary">
{t("settings.members.groups.subtitle")}
</span>
</div>
{canManage ? (
<Link to="new" className="no-underline mobile:w-full" data-ui-id="ui.settings.groups.new">
<Button variant="primary" icon={<FeatherPlus />} className="mobile:w-full">
{t("settings.members.groups.createCta")}
</Button>
</Link>
) : null}
</div>
<Table
header={
<Table.HeaderRow>
<Table.HeaderCell>{t("models.group.object")}</Table.HeaderCell>
<Table.HeaderCell>{t("models.group.description")}</Table.HeaderCell>
<Table.HeaderCell>{t("settings.members.groups.membersCount")}</Table.HeaderCell>
<Table.HeaderCell> </Table.HeaderCell>
</Table.HeaderRow>
}
>
{loading ? (
[0, 1, 2, 3].map((i) => (
<Table.Row key={`sk-${i}`} clickable={false}>
<Table.Cell><SkeletonText className="h-5 w-32 flex-none" /></Table.Cell>
<Table.Cell><SkeletonText className="h-5 w-48 flex-none" /></Table.Cell>
<Table.Cell><SkeletonText className="h-5 w-8 flex-none" /></Table.Cell>
<Table.Cell>
<div className="flex grow shrink-0 basis-0 items-center justify-end">
<SkeletonText className="h-5 w-6 flex-none" />
</div>
</Table.Cell>
</Table.Row>
))
) : items.length === 0 ? (
<Table.Row clickable={false}>
<Table.Cell colSpan={4}>
<div className="flex grow shrink-0 basis-0 flex-col items-center justify-center gap-6 py-12">
<div className="flex flex-col items-center gap-4">
<IconWithBackground variant="neutral" icon={<FeatherUsers />} />
<div className="flex flex-col items-center gap-2">
<span className="text-body-large-0 font-body-large-0 text-text-neutral-primary text-center">{t("settings.members.groups.empty.title")}</span>
<span className="max-w-[384px] text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary text-center">
{t("settings.members.groups.empty.description")}
</span>
</div>
</div>
{canManage ? (
<Link to="new" className="no-underline">
<Button variant="primary" icon={<FeatherPlus />}>{t("settings.members.groups.createCta")}</Button>
</Link>
) : null}
</div>
</Table.Cell>
</Table.Row>
) : (
items.map((group) => (
<Table.Row key={group.id} data-ui-id="ui.settings.groups.row">
<Table.Cell>
<span className="whitespace-nowrap text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-secondary">{group.name}</span>
</Table.Cell>
<Table.Cell>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{group.description || "—"}</span>
</Table.Cell>
<Table.Cell>
<span className="whitespace-nowrap text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-secondary">{group.users.length}</span>
</Table.Cell>
<Table.Cell>
<div className="flex grow shrink-0 basis-0 items-center justify-end">
{canManage ? (
<SubframeCore.DropdownMenu.Root>
<SubframeCore.DropdownMenu.Trigger asChild>
<IconButton icon={<FeatherMoreHorizontal />} data-ui-id="ui.settings.groups.rowActions" />
</SubframeCore.DropdownMenu.Trigger>
<SubframeCore.DropdownMenu.Portal>
<SubframeCore.DropdownMenu.Content side="bottom" align="end" sideOffset={4} asChild>
<DropdownMenu>
<Link to={group.id} className="no-underline">
<DropdownMenu.DropdownItem icon={<FeatherEdit2 />}>{t("shared.edit")}</DropdownMenu.DropdownItem>
</Link>
<DropdownMenu.DropdownItem icon={<FeatherTrash />} onClick={() => setConfirmDelete({ id: group.id, name: group.name })}>
{t("shared.delete")}
</DropdownMenu.DropdownItem>
</DropdownMenu>
</SubframeCore.DropdownMenu.Content>
</SubframeCore.DropdownMenu.Portal>
</SubframeCore.DropdownMenu.Root>
) : null}
</div>
</Table.Cell>
</Table.Row>
))
)}
</Table>
{confirmDelete ? (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-2 bg-surface-neutral-overlay px-4 py-4" data-ui-id="ui.settings.groups.deleteConfirm">
<div className="flex w-96 max-w-full flex-col items-start gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-modal px-6 py-6 shadow-lg mobile:w-full mobile:flex-none">
<div className="flex h-12 w-12 flex-none items-center justify-center gap-2 rounded-round bg-surface-feedback-subtle-negative">
<FeatherAlertTriangle className="text-heading-0 font-heading-0 text-icon-feedback-negative" />
</div>
<div className="flex w-full flex-col items-start gap-2">
<span className="w-full text-heading-0 font-heading-0 text-text-neutral-primary">{t("settings.members.groups.delete.title")}</span>
<span className="w-full text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">
{t("settings.members.groups.delete.description", { name: confirmDelete.name })}
</span>
</div>
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button variant="tertiary" onClick={() => setConfirmDelete(null)}>{t("shared.cancel")}</Button>
<Form method="post" action={confirmDelete.id} className="contents">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="delete" readOnly />
<Button variant="destructive" type="submit" loading={navigation.state !== "idle"} data-ui-id="ui.settings.groups.deleteSubmit">
{t("shared.delete")}
</Button>
</Form>
</div>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,158 @@
import { Form, Link, useNavigate, useNavigation, useSearchParams } from "react-router";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import ExclamationTriangleIcon from "~/components/ui/icons/ExclamationTriangleIcon";
import InputText, { RefInputText } from "~/components/ui/input/InputText";
import { useRootData } from "~/utils/data/useRootData";
export type LoginActionData = {
error?: string;
fieldErrors?: {
email: string | undefined;
password: string | undefined;
};
fields?: {
email: string;
password: string;
};
/**
* D1 — gesetzt, wenn das Passwort stimmt, der Nutzer aber 2FA aktiviert hat. Das Session-Cookie
* traegt dann nur den Pending-Zustand; der Client wird zur Verify-Route (`mfaVerifyPath`) geleitet.
*/
requiresMfa?: boolean;
mfaVerifyPath?: string;
/** D-D0a — Magic-Link-Login: gesetzt, wenn der Anmeldelink ausgegeben wurde (zeigt den "Link gesendet"-Screen). */
magicLinkSent?: boolean;
magicLinkEmail?: string;
};
export default function LoginForm({ actionData, redirectTo }: { actionData: LoginActionData | null | undefined; redirectTo?: string }) {
const { t } = useTranslation();
const { appConfiguration, csrf } = useRootData();
const navigation = useNavigation();
const [searchParams] = useSearchParams();
const emailErrorId = actionData?.fieldErrors?.email ? "email-error" : undefined;
const passwordErrorId = actionData?.fieldErrors?.password ? "password-error" : undefined;
const navigate = useNavigate();
const mfaVerifyPath = actionData?.mfaVerifyPath ?? "/login-mfa";
const emailInput = useRef<RefInputText>(null);
useEffect(() => {
if (appConfiguration.auth.authMethods.emailPassword.enabled) {
setTimeout(() => {
emailInput.current?.input.current?.focus();
}, 300);
}
}, [appConfiguration.auth.authMethods.emailPassword.enabled]);
// D1 — Passwort ok, aber 2FA noetig: das Pending-Cookie ist bereits gesetzt, jetzt zur Verify-Route.
useEffect(() => {
if (actionData?.requiresMfa) {
navigate(mfaVerifyPath);
}
}, [actionData?.requiresMfa, mfaVerifyPath, navigate]);
if (actionData?.requiresMfa) {
return (
<div className="space-y-4 text-center" data-ui-id="ui.auth.login.mfaRedirect">
<p className="text-sm">{t("account.loginMfa.redirecting")}</p>
<Link to={mfaVerifyPath} className="text-primary font-medium hover:underline" data-ui-id="ui.auth.login.mfaContinue">
{t("account.loginMfa.continue")}
</Link>
</div>
);
}
return (
<div className="space-y-5">
<div className="flex flex-col items-center">
<h1 className="text-left text-2xl font-extrabold">{t("account.login.headline")}</h1>
{appConfiguration.subscription.allowSignUpBeforeSubscribe && (
<p className="mt-1 text-left">
<Link to="/register" className="text-primary font-medium hover:underline">
{t("account.login.orRegister")}
</Link>
</p>
)}
</div>
<div className="border-border mx-auto flex flex-col items-center space-y-6 rounded-lg border p-6">
{appConfiguration.auth.authMethods.emailPassword.enabled && (
<Form method="post" noValidate className="w-full space-y-3" data-ui-id="ui.auth.login.form">
<input type="hidden" name="action" value="login" hidden readOnly />
<input type="hidden" name="csrf" value={csrf} hidden readOnly />
<input type="hidden" name="redirectTo" value={redirectTo ?? searchParams.get("redirect") ?? searchParams.get("redirectTo") ?? undefined} />
<div>
{/* <label className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground" htmlFor="email">
{t("account.shared.email")}
</label> */}
<InputText
ref={emailInput}
title={t("account.shared.email")}
autoFocus
id="email"
name="email"
dataUiId="ui.auth.login.email"
type="email"
required
ariaDescribedBy={emailErrorId}
ariaInvalid={emailErrorId ? true : undefined}
// className="focus:border-theme-500 focus:ring-ring relative mt-1 block w-full appearance-none rounded-md border border-border px-3 py-2 text-sm text-foreground placeholder-gray-500 focus:z-10 focus:outline-hidden sm:text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-slate-200"
placeholder={t("account.shared.emailPlaceholder")}
disabled={navigation.state !== "idle"}
defaultValue={actionData?.fields?.email}
/>
{actionData?.fieldErrors?.email ? (
<p className="text-destructive-foreground py-2 text-xs" role="alert" id="email-error">
{actionData.fieldErrors.email}
</p>
) : null}
</div>
<div>
{/* <label className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground" htmlFor="password">
{t("account.shared.password")}
</label> */}
<InputText
id="password"
title={t("account.shared.password")}
name="password"
dataUiId="ui.auth.login.password"
type="password"
required
ariaDescribedBy={passwordErrorId}
ariaInvalid={passwordErrorId ? true : undefined}
// className="focus:border-theme-500 focus:ring-ring relative mt-1 block w-full appearance-none rounded-md border border-border px-3 py-2 text-sm text-foreground placeholder-gray-500 focus:z-10 focus:outline-hidden sm:text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-slate-200"
placeholder="************"
disabled={navigation.state !== "idle"}
defaultValue={actionData?.fields?.password}
/>
{actionData?.fieldErrors?.password ? (
<p className="text-destructive-foreground py-2 text-xs" role="alert" id="password-error">
{actionData.fieldErrors.password}
</p>
) : null}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Link to="/forgot-password" className="text-foreground inline-block align-baseline text-xs font-medium hover:underline" data-ui-id="ui.auth.login.forgot">
{t("account.login.forgot")}
</Link>
<LoadingButton disabled={navigation.state !== "idle"} className="block w-full sm:w-auto" type="submit" actionName="login" dataUiId="ui.auth.login.submit">
{t("account.login.button")}
</LoadingButton>
</div>
<div id="form-error-message">
{actionData?.error && navigation.state === "idle" ? (
<div className="flex items-center justify-center space-x-2 text-sm text-red-600 dark:text-red-300" role="alert">
<ExclamationTriangleIcon className="h-4 w-4" />
<div>{actionData.error}</div>
</div>
) : null}
</div>
</Form>
)}
</div>
</div>
);
}
@@ -0,0 +1,188 @@
import { useEffect } from "react";
import { Form, Link, useNavigate, useNavigation, useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
import { FeatherArrowLeft, FeatherMail, FeatherRotateCw, FeatherWand2 } from "@subframe/core";
import { Button } from "~/ui/components/Button";
import { LinkButton } from "~/ui/components/LinkButton";
import { TextInput } from "~/ui/components/TextInput";
import { useRootData } from "~/utils/data/useRootData";
import { AuthShell, AuthStatus, AuthDivider } from "~/components/auth/AuthShell";
import type { LoginActionData } from "~/modules/users/components/LoginForm";
// Login-Screen nach Davids Subframe-Design ("Login"): Logo + Card (Anmelden/Willkommen zurueck) +
// E-Mail/Passwort + "Passwort vergessen?" + Anmelden + "oder" + Magic-Link + Footer "Registrieren".
// Voll an die bestehende Login-Action verdrahtet (action=login, csrf, redirectTo, Feldfehler, 2FA-Redirect).
// Reines ~/ui (TextInput/Button/LinkButton) + AuthShell. Wird vom Login-Route fuer den NICHT-Admin-Pfad
// gerendert; der Admin-/SSO-Pfad bleibt in login.tsx unveraendert.
export default function LoginScreen({
actionData,
redirectTo,
}: {
actionData: LoginActionData | null | undefined;
redirectTo?: string;
}) {
const { t } = useTranslation();
const { csrf } = useRootData();
const navigation = useNavigation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const isSubmitting = navigation.state !== "idle";
const mfaVerifyPath = actionData?.mfaVerifyPath ?? "/login-mfa";
const emailError = actionData?.fieldErrors?.email;
const passwordError = actionData?.fieldErrors?.password;
// D1 — Passwort ok, aber 2FA noetig: Pending-Cookie ist gesetzt, jetzt zur Verify-Route.
useEffect(() => {
if (actionData?.requiresMfa) {
navigate(mfaVerifyPath);
}
}, [actionData?.requiresMfa, mfaVerifyPath, navigate]);
if (actionData?.requiresMfa) {
return (
<AuthShell title={t("account.loginMfa.title")} dataUiId="ui.auth.login.page">
<div className="flex flex-col items-center gap-3 text-center" data-ui-id="ui.auth.login.mfaRedirect">
<p className="text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">
{t("account.loginMfa.redirecting")}
</p>
<Link to={mfaVerifyPath} className="no-underline" data-ui-id="ui.auth.login.mfaContinue">
<LinkButton hideIcon>{t("account.loginMfa.continue")}</LinkButton>
</Link>
</div>
</AuthShell>
);
}
// D-D0a — Magic-Link ausgegeben -> Davids "Link gesendet"-Screen (AuthStatus).
if (actionData?.magicLinkSent) {
return (
<AuthStatus
icon={<FeatherMail />}
tone="positive"
title={t("account.login.magicLinkSentTitle")}
message={t("account.login.magicLinkSentBody", { email: actionData.magicLinkEmail ?? "" })}
hint={t("account.login.magicLinkSentHint")}
dataUiId="ui.auth.login.magicLinkSent"
>
<Form method="post" className="contents">
<input type="hidden" name="intent" value="magicLink" readOnly />
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="email" value={actionData.magicLinkEmail ?? ""} readOnly />
<LinkButton type="submit" size="small" icon={<FeatherRotateCw />} disabled={isSubmitting} data-ui-id="ui.auth.login.magicLinkResend">
{t("account.login.magicLinkResend")}
</LinkButton>
</Form>
<LinkButton size="small" icon={<FeatherArrowLeft />} onClick={() => navigate("/login")} data-ui-id="ui.auth.login.backToLogin">
{t("account.login.backToLogin")}
</LinkButton>
</AuthStatus>
);
}
return (
<AuthShell
title={t("account.login.title")}
subtitle={t("account.login.headline")}
titleAlign="center"
dataUiId="ui.auth.login.page"
footer={
<>
<span className="text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">
{t("account.login.noAccount")}
</span>
<Link to="/register" className="no-underline" data-ui-id="ui.auth.login.register">
<LinkButton size="small" hideIcon>
{t("account.login.register")}
</LinkButton>
</Link>
</>
}
>
<Form id="loginForm" method="post" noValidate className="flex w-full flex-col gap-4" data-ui-id="ui.auth.login.form">
<input type="hidden" name="action" value="login" readOnly />
<input type="hidden" name="csrf" value={csrf} readOnly />
<input
type="hidden"
name="redirectTo"
value={redirectTo ?? searchParams.get("redirect") ?? searchParams.get("redirectTo") ?? ""}
readOnly
/>
<TextInput
className="h-auto w-full flex-none"
showLabel
label={t("account.shared.email")}
helpText={emailError ?? ""}
error={!!emailError}
type="email"
>
<TextInput.Input
name="email"
type="email"
required
autoFocus
autoComplete="email"
placeholder={t("account.shared.emailPlaceholder")}
defaultValue={actionData?.fields?.email}
disabled={isSubmitting}
data-ui-id="ui.auth.login.email"
/>
</TextInput>
<TextInput
className="h-auto w-full flex-none"
showLabel
label={t("account.shared.password")}
helpText={passwordError ?? ""}
error={!!passwordError}
type="password"
>
<TextInput.Input
name="password"
type="password"
required
autoComplete="current-password"
placeholder=""
defaultValue={actionData?.fields?.password}
disabled={isSubmitting}
data-ui-id="ui.auth.login.password"
/>
</TextInput>
<Link to="/forgot-password" className="no-underline" data-ui-id="ui.auth.login.forgot">
<LinkButton size="small" hideIcon>
{t("account.login.forgot")}
</LinkButton>
</Link>
<Button type="submit" loading={isSubmitting} disabled={isSubmitting} data-ui-id="ui.auth.login.submit">
{t("account.login.button")}
</Button>
{actionData?.error && navigation.state === "idle" ? (
<p className="text-center text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative" role="alert">
{actionData.error}
</p>
) : null}
</Form>
<AuthDivider label={t("account.login.divider")} />
{/* Magic-Link-Login: submitet die Login-Form (via form="loginForm") mit intent=magicLink ->
login-Action gibt den Login-Magic-Link aus -> "Link gesendet"-Screen. */}
<Button
type="submit"
form="loginForm"
name="intent"
value="magicLink"
variant="secondary"
icon={<FeatherWand2 />}
loading={isSubmitting}
data-ui-id="ui.auth.login.magicLink"
>
{t("account.login.magicLink")}
</Button>
</AuthShell>
);
}
@@ -0,0 +1,120 @@
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import * as SubframeCore from "@subframe/core";
import { FeatherMoreHorizontal, FeatherShield, FeatherTrash } from "@subframe/core";
import type { TenantUserWithUser } from "~/utils/db/tenants.db.server";
import { roleDisplayName } from "~/components/core/roles/roleDisplay";
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
import { Avatar } from "~/ui/components/Avatar";
import { BadgeFeedback } from "~/ui/components/BadgeFeedback";
import { DropdownMenu } from "~/ui/components/DropdownMenu";
import { IconButton } from "~/ui/components/IconButton";
import { Table } from "~/ui/components/Table";
// D-P2 — Mitarbeiter-Liste nach Davids "User Management"-Design (Table: Name/Rolle/Status/2FA/Aktionen).
// Rollen-Management + Einladen laufen ueber die bestehenden Routen (edit-SlideOver, members/new). Die fruehere
// Inline-Rollen-Matrix + Permission-Matrix-Modals entfallen hier (Davids Liste ist schlank) -> Rollen werden im
// Editor bzw. auf dem Rollen-Screen verwaltet. 2FA-Spalte aus mfaEnabledUserIds (Loader liefert sie).
function statusBadge(t: ReturnType<typeof useTranslation>["t"], status: number) {
if (status === TenantUserStatus.ACTIVE) return { farbe: "subtle-positive" as const, label: t("settings.members.status.active") };
if (status === TenantUserStatus.INACTIVE) return { farbe: "subtle-neutral" as const, label: t("settings.members.status.deactivated") };
return { farbe: "subtle-warning" as const, label: t("settings.members.status.invited") };
}
export default function MembersListView({
items,
mfaEnabledUserIds,
tenantId,
canEdit,
editRouteFor,
rolesUrl,
}: {
items: TenantUserWithUser[];
mfaEnabledUserIds: string[];
tenantId: string;
canEdit: boolean;
editRouteFor: (tenantUserId: string) => string;
rolesUrl: string;
}) {
const { t } = useTranslation();
const mfaSet = new Set(mfaEnabledUserIds);
return (
<Table
header={
<Table.HeaderRow>
<Table.HeaderCell>{t("models.user.object")}</Table.HeaderCell>
<Table.HeaderCell>{t("models.role.object")}</Table.HeaderCell>
<Table.HeaderCell>{t("settings.members.columns.status")}</Table.HeaderCell>
<Table.HeaderCell>{t("settings.members.columns.twoFactor")}</Table.HeaderCell>
<Table.HeaderCell> </Table.HeaderCell>
</Table.HeaderRow>
}
>
{items.map((member) => {
const user = member.user;
const name = [user.firstName, user.lastName].filter(Boolean).join(" ") || user.email;
const initials = `${(user.firstName ?? "").charAt(0)}${(user.lastName ?? "").charAt(0)}`.toUpperCase() || user.email.charAt(0).toUpperCase();
const roleNames = user.roles
.filter((r) => r.tenantId === tenantId)
.map((r) => roleDisplayName(r.role, t))
.join(", ");
const status = statusBadge(t, member.status);
const mfaOn = mfaSet.has(user.id);
const isInvited = member.status === TenantUserStatus.PENDING_INVITATION || member.status === TenantUserStatus.PENDING_ACCEPTANCE;
return (
<Table.Row key={member.id} data-ui-id="ui.settings.members.row">
<Table.Cell>
<div className="flex items-center gap-3">
<Avatar size="small">{initials}</Avatar>
<div className="flex flex-col items-start">
<span className="whitespace-nowrap text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{name}</span>
<span className="whitespace-nowrap text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">{user.email}</span>
</div>
</div>
</Table.Cell>
<Table.Cell>
<span className="whitespace-nowrap text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-secondary">{roleNames || "—"}</span>
</Table.Cell>
<Table.Cell>
<BadgeFeedback farbe={status.farbe}>{status.label}</BadgeFeedback>
</Table.Cell>
<Table.Cell>
{isInvited ? (
<span className="whitespace-nowrap text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-subtle">—</span>
) : (
<BadgeFeedback farbe={mfaOn ? "subtle-positive" : "subtle-neutral"}>
{mfaOn ? t("settings.members.twoFactor.on") : t("settings.members.twoFactor.off")}
</BadgeFeedback>
)}
</Table.Cell>
<Table.Cell>
<div className="flex grow shrink-0 basis-0 items-center justify-end">
{canEdit ? (
<SubframeCore.DropdownMenu.Root>
<SubframeCore.DropdownMenu.Trigger asChild>
<IconButton icon={<FeatherMoreHorizontal />} data-ui-id="ui.settings.members.rowActions" />
</SubframeCore.DropdownMenu.Trigger>
<SubframeCore.DropdownMenu.Portal>
<SubframeCore.DropdownMenu.Content side="bottom" align="end" sideOffset={4} asChild>
<DropdownMenu>
<Link to={rolesUrl} className="no-underline">
<DropdownMenu.DropdownItem icon={<FeatherShield />}>{t("settings.members.menu.manageRoles")}</DropdownMenu.DropdownItem>
</Link>
<Link to={editRouteFor(member.id)} className="no-underline">
<DropdownMenu.DropdownItem icon={<FeatherTrash />}>{t("settings.members.menu.remove")}</DropdownMenu.DropdownItem>
</Link>
</DropdownMenu>
</SubframeCore.DropdownMenu.Content>
</SubframeCore.DropdownMenu.Portal>
</SubframeCore.DropdownMenu.Root>
) : null}
</div>
</Table.Cell>
</Table.Row>
);
})}
</Table>
);
}
@@ -0,0 +1,480 @@
import { useEffect, useRef, useState } from "react";
import { Form, useActionData, useNavigation, useSubmit } from "react-router";
import { useTranslation } from "react-i18next";
import { QRCodeSVG } from "qrcode.react";
import {
FeatherCheck,
FeatherCheckCircle,
FeatherKey,
FeatherShieldCheck,
FeatherUpload,
FeatherX,
} from "@subframe/core";
import { Avatar } from "~/ui/components/Avatar";
import { BadgeFeedback } from "~/ui/components/BadgeFeedback";
import { Button } from "~/ui/components/Button";
import { CopyToClipboardButton } from "~/ui/components/CopyToClipboardButton";
import { IconButton } from "~/ui/components/IconButton";
import { InputOtp } from "~/ui/components/InputOtp";
import { Progress } from "~/ui/components/Progress";
import { TextInput } from "~/ui/components/TextInput";
import { useRootData } from "~/utils/data/useRootData";
// D-P2 — Settings > Profil nach Davids Subframe-"My Profile"-Design: Profil-Card (Avatar + Name inline-edit +
// E-Mail read-only) + Sicherheits-Card (Passwort ändern -> Modal, 2FA -> Setup/Disable-Modal). Praesentational,
// an die bestehende profile-Action verdrahtet (action=profile / password / startTotpEnrollment /
// confirmTotpEnrollment / disableTotpEnrollment). QR via qrcode.react. Position/Abteilung/Telefon sind in Davids
// Mock enthalten, haben aber kein Backing-Feld -> bewusst weggelassen (CR), statt Mock-Daten zu erfinden.
type ProfileUser = { email: string; firstName?: string | null; lastName?: string | null; avatar?: string | null };
type MfaStatus = { enabled?: boolean; pending?: boolean } | null;
function passwordStrength(pw: string): number {
if (!pw) return 0;
const checks = [pw.length >= 8, /[A-Z]/.test(pw), /[a-z]/.test(pw), /\d/.test(pw), /[^A-Za-z0-9]/.test(pw)];
return Math.round((checks.filter(Boolean).length / checks.length) * 100);
}
function initials(user: ProfileUser): string {
const a = (user.firstName ?? "").trim();
const b = (user.lastName ?? "").trim();
return `${a.charAt(0)}${b.charAt(0)}`.toUpperCase() || user.email.charAt(0).toUpperCase();
}
export default function MyProfileScreen({
user,
mfaStatus,
otpauthUri,
recoveryCodes,
mfaError,
}: {
user: ProfileUser;
mfaStatus: MfaStatus;
otpauthUri?: string;
recoveryCodes?: string[];
mfaError?: string;
}) {
const { t } = useTranslation();
const { csrf = "" } = useRootData();
const navigation = useNavigation();
const actionData = useActionData<{ success?: string; passwordSuccess?: string; error?: string }>();
const submit = useSubmit();
const submitting = navigation.state !== "idle";
const saved = actionData?.success ?? actionData?.passwordSuccess;
const [editingName, setEditingName] = useState(false);
const [pwOpen, setPwOpen] = useState(false);
// 2FA-Modal: null | "password" (Setup-Gate, Action verlangt aktuelles PW) | "qr" | "verify" | "success" | "disable"
const [twoFa, setTwoFa] = useState<null | "password" | "qr" | "verify" | "success" | "disable">(null);
const fileInput = useRef<HTMLInputElement | null>(null);
const avatarForm = useRef<HTMLFormElement | null>(null);
const [avatarData, setAvatarData] = useState<string>("");
// Schritt-Übergänge des 2FA-Flows aus der Action-Antwort ableiten.
useEffect(() => {
if (otpauthUri && twoFa === "password") setTwoFa("qr");
}, [otpauthUri]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (recoveryCodes && recoveryCodes.length > 0) setTwoFa("success");
}, [recoveryCodes]);
// Modal/Inline-Edit erst bei bestätigtem Erfolg schließen (nicht optimistisch beim Submit) -> Fehler bleiben sichtbar.
useEffect(() => {
if (saved) {
setPwOpen(false);
setEditingName(false);
}
}, [saved]);
// Disable-Modal schließen, sobald 2FA tatsächlich deaktiviert ist.
useEffect(() => {
if (!mfaStatus?.enabled && twoFa === "disable") setTwoFa(null);
}, [mfaStatus?.enabled, twoFa]);
function onPickAvatar(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setAvatarData(String(reader.result ?? ""));
requestAnimationFrame(() => avatarForm.current && submit(avatarForm.current));
};
reader.readAsDataURL(file);
}
const mfaEnabled = Boolean(mfaStatus?.enabled);
return (
<div className="flex w-full flex-col items-center" data-ui-id="ui.settings.profile.page">
<div className="flex w-full max-w-[768px] flex-col items-start gap-8">
{/* Header */}
<div className="flex w-full flex-col items-start gap-2">
<span className="text-title-page font-title-page text-text-neutral-primary mobile:text-subtitle mobile:font-subtitle">
{t("settings.profile.pageTitle")}
</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-tertiary">
{t("settings.profile.pageSubtitle")}
</span>
</div>
{/* Profil-Card */}
<section className="flex w-full flex-col items-start gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-6 py-6 shadow-sm">
<div className="flex w-full flex-col items-start gap-1">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.section.profile.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{t("settings.profile.section.profile.subtitle")}
</span>
</div>
{/* Avatar-Reihe */}
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start">
<Avatar size="large" image={user.avatar ?? undefined}>{initials(user)}</Avatar>
<div className="flex flex-col items-start gap-1">
<Form ref={avatarForm} method="post" replace>
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="profile" readOnly />
<input type="hidden" name="firstName" value={user.firstName ?? ""} readOnly />
<input type="hidden" name="lastName" value={user.lastName ?? ""} readOnly />
<input type="hidden" name="avatar" value={avatarData} readOnly />
<input ref={fileInput} type="file" accept="image/png,image/jpeg" className="hidden" onChange={onPickAvatar} data-ui-id="ui.settings.profile.avatarInput" />
<Button
type="button"
variant="secondary"
size="small"
icon={<FeatherUpload />}
loading={submitting && Boolean(avatarData)}
onClick={() => fileInput.current?.click()}
data-ui-id="ui.settings.profile.avatarUpload"
>
{t("settings.profile.avatar.upload")}
</Button>
</Form>
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">
{t("settings.profile.avatar.hint")}
</span>
</div>
</div>
<div className="flex h-px w-full flex-none flex-col items-center bg-border-default" />
{/* Name (inline editierbar) */}
{editingName ? (
<Form method="post" replace className="flex w-full items-end gap-2">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="profile" readOnly />
<TextInput className="h-auto grow shrink-0 basis-0" variant="filled" showLabel label={t("models.user.firstName")}>
<TextInput.Input name="firstName" required defaultValue={user.firstName ?? ""} data-ui-id="ui.settings.profile.firstName" />
</TextInput>
<TextInput className="h-auto grow shrink-0 basis-0" variant="filled" showLabel label={t("models.user.lastName")}>
<TextInput.Input name="lastName" defaultValue={user.lastName ?? ""} data-ui-id="ui.settings.profile.lastName" />
</TextInput>
<IconButton type="submit" variant="primary" icon={<FeatherCheck />} loading={submitting} data-ui-id="ui.settings.profile.nameSave" />
<IconButton type="button" icon={<FeatherX />} onClick={() => setEditingName(false)} data-ui-id="ui.settings.profile.nameCancel" />
</Form>
) : (
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start mobile:gap-2">
<span className="w-40 flex-none text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-secondary mobile:w-full">
{t("settings.profile.field.name")}
</span>
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-primary">
{[user.firstName, user.lastName].filter(Boolean).join(" ") || "—"}
</span>
<Button variant="tertiary" size="small" onClick={() => setEditingName(true)} data-ui-id="ui.settings.profile.nameEdit">
{t("shared.edit")}
</Button>
</div>
)}
{/* E-Mail (read-only) */}
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start mobile:gap-2">
<span className="w-40 flex-none text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-secondary mobile:w-full">
{t("settings.profile.field.email")}
</span>
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-primary">{user.email}</span>
<BadgeFeedback farbe="subtle-neutral" size="small">{t("settings.profile.field.notEditable")}</BadgeFeedback>
</div>
</section>
{/* Sicherheits-Card */}
<section className="flex w-full flex-col items-start gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-6 py-6 shadow-sm">
<div className="flex w-full flex-col items-start gap-1">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.section.security.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{t("settings.profile.section.security.subtitle")}
</span>
</div>
{/* Passwort */}
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start mobile:gap-2">
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1">
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{t("settings.profile.password")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{t("settings.profile.passwordHint")}</span>
</div>
<Button variant="tertiary" icon={<FeatherKey />} className="mobile:w-full" onClick={() => setPwOpen(true)} data-ui-id="ui.settings.profile.password.open">
{t("settings.profile.changePassword")}
</Button>
</div>
<div className="flex h-px w-full flex-none flex-col items-center bg-border-default" />
{/* 2FA */}
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start mobile:gap-2">
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1">
<div className="flex items-center gap-2">
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{t("settings.profile.mfa.title")}</span>
<BadgeFeedback farbe={mfaEnabled ? "subtle-positive" : "subtle-neutral"} size="small">
{mfaEnabled ? t("settings.profile.mfa.badge.active") : t("settings.profile.mfa.badge.inactive")}
</BadgeFeedback>
</div>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{t("settings.profile.mfa.subtitle")}</span>
</div>
<Button
variant="tertiary"
icon={<FeatherShieldCheck />}
className="mobile:w-full"
onClick={() => setTwoFa(mfaEnabled ? "disable" : "password")}
data-ui-id="ui.settings.profile.mfa.manage"
>
{mfaEnabled ? t("settings.profile.mfa.disable.cta") : t("settings.profile.mfa.setup.cta")}
</Button>
</div>
</section>
</div>
{pwOpen ? <PasswordModal csrf={csrf} submitting={submitting} error={actionData?.error} onClose={() => setPwOpen(false)} /> : null}
{twoFa ? (
<TwoFaModal
step={twoFa}
csrf={csrf}
submitting={submitting}
otpauthUri={otpauthUri}
recoveryCodes={recoveryCodes}
mfaError={mfaError}
onClose={() => setTwoFa(null)}
/>
) : null}
</div>
);
}
function ModalShell({ children, dataUiId, center = false }: { children: React.ReactNode; dataUiId: string; center?: boolean }) {
return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-2 bg-surface-neutral-overlay px-4 py-4" data-ui-id={dataUiId}>
<div
className={`flex w-full max-w-[480px] flex-col gap-6 rounded-md border border-solid border-border-default bg-surface-neutral-modal px-6 py-6 shadow-lg mobile:max-w-full ${
center ? "items-center text-center" : "items-start"
}`}
>
{children}
</div>
</div>
);
}
function PasswordModal({ csrf, submitting, error, onClose }: { csrf: string; submitting: boolean; error?: string; onClose: () => void }) {
const { t } = useTranslation();
const [pw, setPw] = useState("");
const strength = passwordStrength(pw);
return (
<ModalShell dataUiId="ui.settings.profile.password.modal">
<div className="flex w-full flex-col items-start gap-1">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.passwordDialog.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{t("settings.profile.passwordDialog.subtitle")}</span>
</div>
<Form method="post" replace className="flex w-full flex-col items-start gap-4">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="password" readOnly />
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.current")} type="password">
<TextInput.Input name="passwordCurrent" type="password" required autoComplete="current-password" data-ui-id="ui.settings.profile.password.current" />
</TextInput>
<div className="flex w-full flex-col items-start gap-2">
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.new")} type="password">
<TextInput.Input
name="passwordNew"
type="password"
required
autoComplete="new-password"
value={pw}
onChange={(event) => setPw(event.target.value)}
data-ui-id="ui.settings.profile.password.new"
/>
</TextInput>
<Progress className="h-auto w-full flex-none" hideLabel value={strength} state={strength >= 80 ? "success" : undefined} />
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">{t("settings.profile.passwordDialog.hint")}</span>
</div>
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.confirm")} type="password">
<TextInput.Input name="passwordNewConfirm" type="password" required autoComplete="new-password" data-ui-id="ui.settings.profile.password.confirm" />
</TextInput>
{error ? <span className="text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative">{error}</span> : null}
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button type="button" variant="tertiary" onClick={onClose} data-ui-id="ui.settings.profile.password.cancel">{t("shared.cancel")}</Button>
<Button type="submit" variant="primary" icon={<FeatherKey />} loading={submitting} data-ui-id="ui.settings.profile.password.submit">
{t("settings.profile.changePassword")}
</Button>
</div>
</Form>
</ModalShell>
);
}
function TwoFaModal({
step,
csrf,
submitting,
otpauthUri,
recoveryCodes,
mfaError,
onClose,
}: {
step: "password" | "qr" | "verify" | "success" | "disable";
csrf: string;
submitting: boolean;
otpauthUri?: string;
recoveryCodes?: string[];
mfaError?: string;
onClose: () => void;
}) {
const { t } = useTranslation();
const secret = otpauthUri ? new URLSearchParams(otpauthUri.split("?")[1] ?? "").get("secret") ?? "" : "";
const [showVerify, setShowVerify] = useState(false);
const [digits, setDigits] = useState<string[]>(Array(6).fill(""));
const code = digits.join("");
if (step === "success") {
return (
<ModalShell dataUiId="ui.settings.profile.mfa.success" center>
<FeatherCheckCircle className="text-[48px] font-[400] text-icon-feedback-positive" />
<div className="flex w-full flex-col items-center gap-2">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.mfa.success.title")}</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">{t("settings.profile.mfa.success.subtitle")}</span>
</div>
{recoveryCodes && recoveryCodes.length > 0 ? (
<div className="flex w-full flex-col items-start gap-2 rounded-md border border-solid border-border-default bg-surface-neutral-primary px-4 py-4">
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-secondary">{t("settings.profile.mfa.recoveryCodes")}</span>
<div className="grid w-full grid-cols-2 gap-1">
{recoveryCodes.map((code) => (
<span key={code} className="font-mono text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-primary">{code}</span>
))}
</div>
</div>
) : null}
<Button className="h-10 w-full flex-none" variant="primary" onClick={onClose} data-ui-id="ui.settings.profile.mfa.done">{t("settings.profile.mfa.success.done")}</Button>
</ModalShell>
);
}
if (step === "qr") {
return (
<ModalShell dataUiId="ui.settings.profile.mfa.setup">
<div className="flex w-full flex-col items-start gap-2">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.mfa.setup.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-secondary">{t("settings.profile.mfa.setup.subtitle")}</span>
</div>
<div className="flex w-full flex-col items-center gap-4">
<div className="flex h-40 w-40 flex-none items-center justify-center rounded-md border border-solid border-border-default bg-surface-neutral-secondary">
{otpauthUri ? <QRCodeSVG value={otpauthUri} size={144} /> : null}
</div>
{secret ? (
<>
<div className="flex w-full items-center justify-center gap-3">
<div className="h-px grow shrink-0 basis-0 bg-border-default" />
<span className="text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">{t("settings.profile.mfa.setup.manualKey")}</span>
<div className="h-px grow shrink-0 basis-0 bg-border-default" />
</div>
<div className="flex w-full items-center justify-center gap-2">
<span className="font-mono tracking-widest text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{secret}</span>
<CopyToClipboardButton clipboardText={secret} />
</div>
</>
) : null}
</div>
{!showVerify ? (
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button variant="tertiary" onClick={onClose} data-ui-id="ui.settings.profile.mfa.setup.cancel">{t("shared.cancel")}</Button>
<Button variant="primary" onClick={() => setShowVerify(true)} data-ui-id="ui.settings.profile.mfa.next">
{t("settings.profile.mfa.setup.next")}
</Button>
</div>
) : (
<div className="flex w-full flex-col items-start gap-4 border-t border-solid border-border-default pt-4">
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-secondary">{t("settings.profile.mfa.verify.subtitle")}</span>
<Form method="post" replace className="flex w-full flex-col items-center gap-4">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="confirmTotpEnrollment" readOnly />
<input type="hidden" name="code" value={code} readOnly />
<InputOtp state={mfaError ? "error" : "default"}>
{Array.from({ length: 6 }).map((_, i) => (
<InputOtp.OtpInputItem key={i} error={Boolean(mfaError)}>
<InputOtp.Input
className="h-auto w-12 flex-none self-stretch"
value={digits[i]}
onChange={(event) => {
const clean = event.target.value.replace(/\D/g, "").slice(0, 1);
setDigits((prev) => prev.map((d, idx) => (idx === i ? clean : d)));
}}
inputMode="numeric"
aria-label={`${t("account.loginMfa.fields.authenticatorCode")} ${i + 1}`}
data-ui-id={i === 0 ? "ui.settings.profile.mfa.code" : undefined}
/>
</InputOtp.OtpInputItem>
))}
</InputOtp>
{mfaError ? <span className="text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative">{mfaError}</span> : null}
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button type="button" variant="tertiary" onClick={() => setShowVerify(false)} data-ui-id="ui.settings.profile.mfa.back">
{t("settings.profile.mfa.setup.back")}
</Button>
<Button type="submit" variant="primary" icon={<FeatherShieldCheck />} loading={submitting} disabled={code.length !== 6} data-ui-id="ui.settings.profile.mfa.confirm">
{t("settings.profile.mfa.verify.confirm")}
</Button>
</div>
</Form>
</div>
)}
</ModalShell>
);
}
if (step === "disable") {
return (
<ModalShell dataUiId="ui.settings.profile.mfa.disable">
<div className="flex w-full flex-col items-start gap-2">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.mfa.disable.title")}</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">{t("settings.profile.mfa.disable.subtitle")}</span>
</div>
<Form method="post" replace className="flex w-full flex-col items-start gap-4">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="disableTotpEnrollment" readOnly />
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.current")} type="password">
<TextInput.Input name="passwordCurrent" type="password" required autoComplete="current-password" data-ui-id="ui.settings.profile.mfa.disablePassword" />
</TextInput>
{mfaError ? <span className="text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative">{mfaError}</span> : null}
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button type="button" variant="tertiary" onClick={onClose}>{t("shared.cancel")}</Button>
<Button type="submit" variant="destructive" loading={submitting} data-ui-id="ui.settings.profile.mfa.disableSubmit">{t("settings.profile.mfa.disable.cta")}</Button>
</div>
</Form>
</ModalShell>
);
}
// step === "password" — Setup-Gate (Action verlangt aktuelles Passwort, bevor der QR erzeugt wird)
return (
<ModalShell dataUiId="ui.settings.profile.mfa.gate">
<div className="flex w-full flex-col items-start gap-2">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.mfa.setup.title")}</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">{t("settings.profile.mfa.gate.subtitle")}</span>
</div>
<Form method="post" replace className="flex w-full flex-col items-start gap-4">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="startTotpEnrollment" readOnly />
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.current")} type="password">
<TextInput.Input name="passwordCurrent" type="password" required autoFocus autoComplete="current-password" data-ui-id="ui.settings.profile.mfa.gatePassword" />
</TextInput>
{mfaError ? <span className="text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative">{mfaError}</span> : null}
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button type="button" variant="tertiary" onClick={onClose}>{t("shared.cancel")}</Button>
<Button type="submit" variant="primary" loading={submitting} data-ui-id="ui.settings.profile.mfa.gateSubmit">{t("settings.profile.mfa.setup.next")}</Button>
</div>
</Form>
</ModalShell>
);
}
@@ -0,0 +1,127 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen, within } from "@testing-library/react";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { RegisterForm } from "./RegisterForm";
const routerState = vi.hoisted(() => ({
searchParams: new URLSearchParams(),
}));
vi.mock("react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-router")>();
return {
...actual,
Form: ({ children, ...props }: React.FormHTMLAttributes<HTMLFormElement>) => <form {...props}>{children}</form>,
useLocation: () => ({ pathname: "/register" }),
useMatches: () => [],
useNavigation: () => ({ state: "idle" }),
useSearchParams: () => [routerState.searchParams],
};
});
vi.mock("react-i18next", () => ({
useTranslation: () => ({
i18n: { exists: () => true },
t: (key: string) => key,
}),
}));
vi.mock("~/utils/data/useRootData", () => ({
useRootData: () => ({
csrf: "csrf-token",
appConfiguration: {
auth: {
requireEmailVerification: false,
requireName: false,
requireOrganization: false,
authMethods: {
emailPassword: { enabled: true },
},
captcha: {
enabled: false,
instanceUrl: undefined,
loginSiteKey: undefined,
},
},
},
}),
}));
describe("RegisterForm", () => {
it("posts redirectTo from inside the email-password form", () => {
routerState.searchParams = new URLSearchParams({ redirect: "/app/acme/dashboard" });
render(<RegisterForm error={undefined} />);
const form = document.querySelector('[data-ui-id="ui.auth.register.form"]');
expect(form).toBeInTheDocument();
expect(form?.querySelector('input[name="redirectTo"]')).toHaveValue("/app/acme/dashboard");
});
it("renders registration copy and localized password requirement labels", () => {
routerState.searchParams = new URLSearchParams();
render(
<RegisterForm
error={undefined}
passwordPolicy={{
minLength: 8,
requireUpper: true,
requireLower: true,
requireDigit: true,
requireSpecial: true,
}}
/>
);
expect(document.querySelector('[data-ui-id="ui.auth.register.form"]')).toBeInTheDocument();
expect(screen.getByRole("button", { name: "account.register.prompts.register.title" })).toBeInTheDocument();
expect(screen.getByText("account.register.bySigningUp", { exact: false })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "account.register.termsAndConditions" })).toHaveAttribute("href", "/terms-and-conditions");
expect(screen.getByRole("link", { name: "account.register.privacyPolicy" })).toHaveAttribute("href", "/privacy-policy");
const requirements = screen.getByRole("list");
expect(requirements).toHaveAccessibleName("account.register.passwordRequirements.label");
const items = within(requirements).getAllByRole("listitem");
expect(items.map((item) => item.textContent)).toEqual([
"[ ] account.register.passwordRequirements.minLength",
"[ ] account.register.passwordRequirements.upper",
"[ ] account.register.passwordRequirements.lower",
"[ ] account.register.passwordRequirements.digit",
"[ ] account.register.passwordRequirements.special",
]);
expect(items[0]).toHaveAccessibleName("account.register.passwordRequirements.notMet: account.register.passwordRequirements.minLength");
fireEvent.change(screen.getByLabelText(/account\.shared\.password/), { target: { value: "Abcdef1!" } });
expect(items.map((item) => item.textContent)).toEqual([
"[x] account.register.passwordRequirements.minLength",
"[x] account.register.passwordRequirements.upper",
"[x] account.register.passwordRequirements.lower",
"[x] account.register.passwordRequirements.digit",
"[x] account.register.passwordRequirements.special",
]);
expect(items[0]).toHaveAccessibleName("account.register.passwordRequirements.met: account.register.passwordRequirements.minLength");
});
it("keeps lead-conversion email locked while preserving hidden conversion fields", () => {
routerState.searchParams = new URLSearchParams();
render(
<RegisterForm
error={undefined}
data={{ email: "lead@example.com" }}
leadConversion={{ leadId: "lead_123", token: "signed-token", planId: "plan_pro" }}
passwordPolicy={{ minLength: 8, requireUpper: false, requireLower: false, requireDigit: false, requireSpecial: false }}
/>
);
expect(screen.getByLabelText(/account\.shared\.email/)).toHaveAttribute("readonly");
expect(screen.getByLabelText(/account\.shared\.email/)).toHaveAttribute("type", "email");
expect(document.querySelector('input[name="leadId"]')).toHaveValue("lead_123");
expect(document.querySelector('input[name="leadToken"]')).toHaveValue("signed-token");
expect(document.querySelector('input[name="leadPlanId"]')).toHaveValue("plan_pro");
});
});
@@ -0,0 +1,260 @@
import { Form, useNavigation, useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
import { useMemo, useState } from "react";
import { useRootData } from "~/utils/data/useRootData";
import LoadingButton from "~/components/ui/buttons/LoadingButton";
import ExclamationTriangleIcon from "~/components/ui/icons/ExclamationTriangleIcon";
import Captcha from "~/components/captcha/Captcha";
import InputText from "~/components/ui/input/InputText";
export type PasswordPolicy = {
minLength: number;
requireUpper: boolean;
requireLower: boolean;
requireDigit: boolean;
requireSpecial: boolean;
};
interface Props {
requireCaptcha?: boolean;
isVerifyingEmail?: boolean;
isSettingUpAccount?: boolean;
data?: { company?: string; firstName?: string; lastName?: string; email?: string; slug?: string };
error: string | undefined;
/**
* Marketing-funnel Phase 2 (Phase 05 #79). When present, hidden form
* inputs are emitted so the register action can route the submission
* through `convertLeadToTenant` (atomic Tenant + Lead-status update +
* TenantAcquisition + outbox event). Email is rendered read-only to
* prevent lead-hijacking ("I use your token but with my email").
*/
leadConversion?: { leadId: string; token: string; planId?: string | null };
passwordPolicy?: PasswordPolicy;
}
export const RegisterForm = ({
requireCaptcha = false,
isVerifyingEmail = false,
isSettingUpAccount = false,
data = {},
error,
leadConversion,
passwordPolicy,
}: Props) => {
const { t } = useTranslation();
const { appConfiguration, csrf } = useRootData();
const navigation = useNavigation();
const [password, setPassword] = useState("");
const [searchParams] = useSearchParams();
const redirect = searchParams.get("redirect") ?? undefined;
const showPasswordInput = !appConfiguration.auth.requireEmailVerification || isVerifyingEmail || isSettingUpAccount;
const passwordChecks = useMemo(() => getPasswordChecks(password, passwordPolicy), [password, passwordPolicy]);
return (
<div className="border-border mx-auto flex flex-col items-center space-y-6 rounded-lg border p-6">
{appConfiguration.auth.authMethods.emailPassword.enabled && (
<Form method="post" className="w-full space-y-3" data-ui-id="ui.auth.register.form">
<input type="hidden" name="redirectTo" value={redirect} hidden readOnly />
<input type="hidden" name="csrf" value={csrf} hidden readOnly />
{leadConversion ? (
<>
<input type="hidden" name="leadId" value={leadConversion.leadId} hidden readOnly />
<input type="hidden" name="leadToken" value={leadConversion.token} hidden readOnly />
{leadConversion.planId ? (
<input type="hidden" name="leadPlanId" value={leadConversion.planId} hidden readOnly />
) : null}
</>
) : null}
{/* Tenant */}
{appConfiguration.auth.requireOrganization && (
<div>
{/* <label className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground" htmlFor="company">
{t("models.tenant.object")}
</label> */}
<InputText
title={t("models.tenant.object")}
disabled={navigation.state !== "idle"}
autoFocus
type="text"
name="company"
dataUiId="ui.auth.register.company"
id="company"
placeholder={t("account.shared.companyPlaceholder")}
required
defaultValue={data.company}
// className="focus:border-theme-500 focus:ring-ring relative mt-1 block w-full appearance-none rounded-md border border-border px-3 py-2 text-sm text-foreground placeholder-gray-500 focus:z-10 focus:outline-hidden dark:border-gray-700 dark:bg-gray-900 dark:text-slate-200 sm:text-sm"
/>
</div>
)}
{/* Tenant: End */}
{/* Personal Info */}
{appConfiguration.auth.requireName && (
<div className="grid gap-2 sm:grid-cols-2">
<div>
{/* <label htmlFor="first-name" className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground">
{t("account.shared.name")}
</label> */}
<InputText
title={t("account.shared.name")}
type="text"
name="first-name"
dataUiId="ui.auth.register.firstName"
id="first-name"
required
defaultValue={data.firstName}
className="appearance-none rounded-md"
placeholder={t("account.shared.firstNamePlaceholder")}
disabled={navigation.state !== "idle"}
/>
</div>
<div>
{/* <label htmlFor="last-name" className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground">
<span className="sr-only">{t("models.user.lastName")}</span>
</label> */}
<InputText
title=""
type="text"
name="last-name"
dataUiId="ui.auth.register.lastName"
id="last-name"
defaultValue={data.lastName}
required
className="appearance-none rounded-md"
placeholder={t("account.shared.lastNamePlaceholder")}
disabled={navigation.state !== "idle"}
/>
</div>
</div>
)}
<div>
{/* <label className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground" htmlFor="email">
{t("account.shared.email")}
</label> */}
<InputText
type="email"
title={t("account.shared.email")}
id="email"
name="email"
dataUiId="ui.auth.register.email"
autoComplete="email"
required
readOnly={isVerifyingEmail || Boolean(leadConversion)}
defaultValue={data.email}
// className={clsx(
// "focus:border-theme-500 focus:ring-ring relative mt-1 block w-full appearance-none rounded-md border border-border px-3 py-2 text-sm text-foreground placeholder-gray-500 focus:z-10 focus:outline-hidden dark:border-gray-700 dark:bg-gray-900 dark:text-slate-200 sm:text-sm",
// isVerifyingEmail && "cursor-not-allowed bg-secondary/90 dark:bg-slate-800"
// )}
placeholder={t("account.shared.emailPlaceholder")}
disabled={navigation.state !== "idle"}
/>
</div>
{showPasswordInput && (
<div>
{/* <label className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground" htmlFor="password">
{t("account.shared.password")}
</label> */}
<InputText
title={t("account.shared.password")}
type="password"
id="password"
name="password"
dataUiId="ui.auth.register.password"
required
placeholder="************"
// className="focus:border-theme-500 focus:ring-ring relative mt-1 block w-full appearance-none rounded-md border border-border px-3 py-2 text-sm text-foreground placeholder-gray-500 focus:z-10 focus:outline-hidden dark:border-gray-700 dark:bg-gray-900 dark:text-slate-200 sm:text-sm"
disabled={navigation.state !== "idle"}
value={password}
setValue={setPassword}
/>
{passwordPolicy ? (
<ul
className="mt-2 space-y-1 text-xs"
aria-live="polite"
aria-label={t("account.register.passwordRequirements.label")}
data-ui-id="ui.auth.register.passwordRequirements"
>
{passwordChecks.map((check) => {
const label = t(check.label, check.args);
const status = check.valid ? t("account.register.passwordRequirements.met") : t("account.register.passwordRequirements.notMet");
return (
<li key={check.key} className={check.valid ? "text-emerald-700 dark:text-emerald-300" : "text-muted-foreground"} aria-label={`${status}: ${label}`}>
<span aria-hidden="true">{check.valid ? "[x]" : "[ ]"}</span> {label}
</li>
);
})}
</ul>
) : null}
</div>
)}
{/* Personal Info: End */}
<Captcha
enabled={requireCaptcha && appConfiguration.auth.captcha.enabled}
instanceUrl={appConfiguration.auth.captcha.instanceUrl}
sitekey={appConfiguration.auth.captcha.loginSiteKey}
/>
<div>
<LoadingButton disabled={navigation.state !== "idle"} className="w-full" type="submit" dataUiId="ui.auth.register.submit">
{t("account.register.prompts.register.title")}
</LoadingButton>
</div>
<div id="form-error-message">
{error && navigation.state === "idle" ? (
<div className="flex items-center justify-center space-x-2 text-sm text-red-600 dark:text-red-300" role="alert">
<ExclamationTriangleIcon className="h-4 w-4" />
<div>{error}</div>
</div>
) : null}
</div>
</Form>
)}
<p className="border-border mt-3 border-t py-2 text-center text-sm">
{t("account.register.bySigningUp")}{" "}
<a target="_blank" href="/terms-and-conditions" className="text-primary underline">
{t("account.register.termsAndConditions")}
</a>{" "}
{t("account.register.andOur")}{" "}
<a target="_blank" href="/privacy-policy" className="text-primary underline">
{t("account.register.privacyPolicy")}
</a>
.
</p>
</div>
);
};
export function getPasswordChecks(password: string, policy?: PasswordPolicy) {
if (!policy) {
return [];
}
const checks: { key: string; valid: boolean; label: string; args: Record<string, string | number> }[] = [
{
key: "minLength",
valid: password.length >= policy.minLength,
label: "account.register.passwordRequirements.minLength",
args: { 0: policy.minLength },
},
];
if (policy.requireUpper) {
checks.push({ key: "upper", valid: /[A-Z]/.test(password), label: "account.register.passwordRequirements.upper", args: {} });
}
if (policy.requireLower) {
checks.push({ key: "lower", valid: /[a-z]/.test(password), label: "account.register.passwordRequirements.lower", args: {} });
}
if (policy.requireDigit) {
checks.push({ key: "digit", valid: /\d/.test(password), label: "account.register.passwordRequirements.digit", args: {} });
}
if (policy.requireSpecial) {
checks.push({ key: "special", valid: /[^A-Za-z0-9]/.test(password), label: "account.register.passwordRequirements.special", args: {} });
}
return checks;
}
@@ -0,0 +1,234 @@
import { useMemo, useState } from "react";
import { Form, Link, useNavigation, useSearchParams } from "react-router";
import { useTranslation } from "react-i18next";
import { FeatherMail } from "@subframe/core";
import { useRootData } from "~/utils/data/useRootData";
import { AuthShell, AuthStatus } from "~/components/auth/AuthShell";
import { Button } from "~/ui/components/Button";
import { Checkbox } from "~/ui/components/Checkbox";
import { LinkButton } from "~/ui/components/LinkButton";
import { Progress } from "~/ui/components/Progress";
import { TextInput } from "~/ui/components/TextInput";
import Captcha from "~/components/captcha/Captcha";
import { getPasswordChecks, type PasswordPolicy } from "~/modules/users/components/RegisterForm";
// Signup-Screen nach Davids Subframe-Design ("Signup"): Logo + Card (Konto erstellen) + E-Mail, Passwort
// (+ Staerke-Progress), Firmenname, Vor-/Nachname, Telefon, USt-IdNr, 3 Consent-Checkboxen + "Konto erstellen".
// Voll an die bestehende Register-Action verdrahtet (gleiche Feld-Namen, Password-Policy, Captcha, Lead-Felder).
// verificationEmailSent -> Davids Success-Status. Footer "Schon ein Konto? Anmelden".
//
// 💡 Backend-Luecken (gerendert + gepostet, aber von registerFromRequest noch nicht persistiert):
// - phone: User-Modell hat die Spalte (accounts.prisma) -> Action muss das Feld nur noch lesen.
// - vatNumber (USt-IdNr): keine Spalte im LOCKED-Modell -> braucht Data-Model-Entscheidung.
// - 3 Consents: Backend nutzt impliziten Terms-Consent; hier als Client-Gate (kein Server-Tracking).
type RegisterData = { company?: string; firstName?: string; lastName?: string; email?: string; slug?: string };
export default function RegisterScreen({
requireCaptcha = false,
error,
verificationEmailSent,
data = {},
leadConversion,
passwordPolicy,
}: {
requireCaptcha?: boolean;
error?: string;
verificationEmailSent?: boolean;
data?: RegisterData;
leadConversion?: { leadId: string; token: string; planId?: string | null };
passwordPolicy?: PasswordPolicy;
}) {
const { t } = useTranslation();
const { appConfiguration, csrf } = useRootData();
const navigation = useNavigation();
const [searchParams] = useSearchParams();
const isSubmitting = navigation.state !== "idle";
const redirect = searchParams.get("redirect") ?? "";
const [password, setPassword] = useState("");
const [acceptTerms, setAcceptTerms] = useState(false);
const [acceptPrivacy, setAcceptPrivacy] = useState(false);
const [acceptDpa, setAcceptDpa] = useState(false);
const allConsents = acceptTerms && acceptPrivacy && acceptDpa;
const showPasswordInput = !appConfiguration.auth.requireEmailVerification;
const checks = useMemo(() => getPasswordChecks(password, passwordPolicy), [password, passwordPolicy]);
const strength = checks.length === 0 ? 0 : Math.round((checks.filter((c) => c.valid).length / checks.length) * 100);
if (verificationEmailSent) {
return (
<AuthStatus
icon={<FeatherMail />}
tone="positive"
title={t("account.verify.title")}
message={t("account.verify.emailSent")}
dataUiId="ui.auth.register.verificationSent"
>
<Link to="/login" className="no-underline">
<LinkButton size="small">{t("account.login.backToLogin")}</LinkButton>
</Link>
</AuthStatus>
);
}
return (
<AuthShell
title={t("account.register.title")}
subtitle={t("account.register.subtitle")}
cardClassName="max-w-[480px] gap-5 shadow-lg"
dataUiId="ui.auth.register.page"
footer={
<>
<span className="text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">
{t("account.register.haveAccount")}
</span>
<Link to="/login" className="no-underline" data-ui-id="ui.auth.register.login">
<LinkButton size="small" hideIcon>
{t("account.register.signIn")}
</LinkButton>
</Link>
</>
}
>
{appConfiguration.auth.authMethods.emailPassword.enabled ? (
<Form method="post" noValidate className="flex w-full flex-col gap-5" data-ui-id="ui.auth.register.form">
<input type="hidden" name="redirectTo" value={redirect} readOnly />
<input type="hidden" name="csrf" value={csrf} readOnly />
{leadConversion ? (
<>
<input type="hidden" name="leadId" value={leadConversion.leadId} readOnly />
<input type="hidden" name="leadToken" value={leadConversion.token} readOnly />
{leadConversion.planId ? <input type="hidden" name="leadPlanId" value={leadConversion.planId} readOnly /> : null}
</>
) : null}
{/* Davids Felder-Gruppe: alle Inputs in einem gap-4-Wrapper; Form-gap-5 trennt Gruppe/Consent/Button. */}
<div className="flex w-full flex-col items-start gap-4">
<TextInput className="h-auto w-full flex-none" fillContainer showLabel label={t("account.shared.email")} type="email">
<TextInput.Input
name="email"
type="email"
required
autoComplete="email"
placeholder={t("account.shared.emailPlaceholder")}
defaultValue={data.email}
readOnly={Boolean(leadConversion)}
disabled={isSubmitting}
data-ui-id="ui.auth.register.email"
/>
</TextInput>
{showPasswordInput ? (
<>
<div className="flex w-full flex-col items-start gap-2">
<div className="flex w-full items-end gap-2">
<TextInput className="h-auto grow shrink-0 basis-0" fillContainer showLabel label={t("account.shared.password")} type="password">
<TextInput.Input
name="password"
type="password"
required
autoComplete="new-password"
placeholder={t("account.shared.password")}
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isSubmitting}
data-ui-id="ui.auth.register.password"
/>
</TextInput>
</div>
{passwordPolicy ? (
<>
<Progress className="h-auto w-full flex-none" hideLabel value={strength} />
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-tertiary">
{t("account.register.passwordStrength")}
</span>
</>
) : null}
</div>
{passwordPolicy ? (
<span className="w-full text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">
{t("account.register.passwordHint")}
</span>
) : null}
</>
) : null}
<TextInput className="h-auto w-full flex-none" fillContainer showLabel label={t("account.register.companyLabel")}>
<TextInput.Input
name="company"
required={appConfiguration.auth.requireOrganization}
placeholder={t("account.shared.companyPlaceholder")}
defaultValue={data.company}
disabled={isSubmitting}
data-ui-id="ui.auth.register.company"
/>
</TextInput>
<div className="flex w-full items-start gap-4 mobile:flex-col">
<TextInput className="h-auto grow shrink-0 basis-0" showLabel label={t("models.user.firstName")}>
<TextInput.Input
name="first-name"
placeholder={t("account.shared.firstNamePlaceholder")}
defaultValue={data.firstName}
disabled={isSubmitting}
data-ui-id="ui.auth.register.firstName"
/>
</TextInput>
<TextInput className="h-auto grow shrink-0 basis-0" showLabel label={t("models.user.lastName")}>
<TextInput.Input
name="last-name"
placeholder={t("account.shared.lastNamePlaceholder")}
defaultValue={data.lastName}
disabled={isSubmitting}
data-ui-id="ui.auth.register.lastName"
/>
</TextInput>
</div>
<TextInput className="h-auto w-full flex-none" fillContainer showLabel label={t("account.register.phone")} type="tel">
<TextInput.Input
name="phone"
type="tel"
placeholder={t("account.register.phonePlaceholder")}
disabled={isSubmitting}
data-ui-id="ui.auth.register.phone"
/>
</TextInput>
<TextInput className="h-auto w-full flex-none" fillContainer showLabel label={t("account.register.vatId")}>
<TextInput.Input
name="vatNumber"
placeholder={t("account.register.vatIdPlaceholder")}
disabled={isSubmitting}
data-ui-id="ui.auth.register.vat"
/>
</TextInput>
</div>
<div className="flex w-full flex-col items-start gap-3">
<Checkbox label={t("account.register.consentTerms")} checked={acceptTerms} onCheckedChange={setAcceptTerms} />
<Checkbox label={t("account.register.consentPrivacy")} checked={acceptPrivacy} onCheckedChange={setAcceptPrivacy} />
<Checkbox label={t("account.register.consentDpa")} checked={acceptDpa} onCheckedChange={setAcceptDpa} />
</div>
<Captcha
enabled={requireCaptcha && appConfiguration.auth.captcha.enabled}
instanceUrl={appConfiguration.auth.captcha.instanceUrl}
sitekey={appConfiguration.auth.captcha.loginSiteKey}
/>
<Button type="submit" loading={isSubmitting} disabled={isSubmitting || !allConsents} data-ui-id="ui.auth.register.submit">
{t("account.register.title")}
</Button>
{error && navigation.state === "idle" ? (
<p className="text-center text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative" role="alert">
{error}
</p>
) : null}
</Form>
) : null}
</AuthShell>
);
}
@@ -0,0 +1,145 @@
import { useState } from "react";
import { Form, useLocation, useNavigate, useNavigation } from "react-router";
import { useTranslation } from "react-i18next";
import { FeatherAlertTriangle, FeatherCheck } from "@subframe/core";
import { useRootData } from "~/utils/data/useRootData";
import { AuthShell, AuthStatus } from "~/components/auth/AuthShell";
import { Button } from "~/ui/components/Button";
import { LinkButton } from "~/ui/components/LinkButton";
import { Progress } from "~/ui/components/Progress";
import { TextInput } from "~/ui/components/TextInput";
// Reset-Password-Screen nach Davids Subframe-Design ("Reset Password" + States): Logo + Card
// (Neues Passwort + Staerke-Progress + Bestaetigung + "Passwort speichern"). Token kommt aus der URL (t),
// E-Mail aus dem Loader (e) -> beide als hidden Inputs. action validiert (mismatch/weak/token).
// success -> Davids "Reset Password _ Success"; fehlende/ungueltige Link-Daten -> "Reset Link _ Invalid".
function passwordStrength(pw: string): number {
if (!pw) return 0;
const checks = [pw.length >= 8, /[A-Z]/.test(pw), /[a-z]/.test(pw), /\d/.test(pw), /[^A-Za-z0-9]/.test(pw)];
return Math.round((checks.filter(Boolean).length / checks.length) * 100);
}
export default function ResetPasswordScreen({
data,
actionData,
}: {
data: { email: string };
actionData: { success?: string; error?: string; fields?: { verifyToken?: string; email?: string } } | null | undefined;
}) {
const { t } = useTranslation();
const { csrf } = useRootData();
const navigation = useNavigation();
const navigate = useNavigate();
const location = useLocation();
const isSubmitting = navigation.state !== "idle";
const verifyToken = actionData?.fields?.verifyToken || new URLSearchParams(location.search).get("t") || "";
const [password, setPassword] = useState("");
const strength = passwordStrength(password);
const strengthLabel =
strength < 40 ? t("account.newPassword.strengthWeak") : strength < 80 ? t("account.newPassword.strengthMedium") : t("account.newPassword.strengthStrong");
if (actionData?.success) {
return (
<AuthStatus
icon={<FeatherCheck />}
tone="positive"
title={t("account.newPassword.successTitle")}
message={t("account.newPassword.successBody")}
dataUiId="ui.auth.reset.success"
>
<LinkButton size="small" hideIcon onClick={() => navigate("/login")} data-ui-id="ui.auth.reset.successBackToLogin">
{t("account.forgot.backToLogin")}
</LinkButton>
</AuthStatus>
);
}
// Kein/ungueltiger Link (keine E-Mail im e-Param) -> Davids "Reset Link _ Invalid".
if (!data.email) {
return (
<AuthStatus
icon={<FeatherAlertTriangle />}
tone="negative"
title={t("account.newPassword.invalidTitle")}
message={t("account.newPassword.invalidBody")}
dataUiId="ui.auth.reset.invalid"
>
<Button onClick={() => navigate("/forgot-password")} data-ui-id="ui.auth.reset.requestNew">
{t("account.newPassword.requestNew")}
</Button>
<LinkButton size="small" hideIcon onClick={() => navigate("/login")} data-ui-id="ui.auth.reset.invalidBackToLogin">
{t("account.forgot.backToLogin")}
</LinkButton>
</AuthStatus>
);
}
return (
<AuthShell title={t("account.newPassword.title")} subtitle={t("account.newPassword.subtitle")} dataUiId="ui.auth.reset.page">
<Form method="post" noValidate className="flex w-full flex-col gap-4" data-ui-id="ui.auth.reset.form">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="verify-token" value={verifyToken} readOnly />
<input type="hidden" name="email" value={data.email} readOnly />
<div className="flex w-full flex-col items-start gap-2">
<div className="flex w-full items-end gap-2">
<TextInput className="h-auto grow shrink-0 basis-0" fillContainer showLabel label={t("account.newPassword.passwordLabel")} type="password">
<TextInput.Input
name="password"
type="password"
required
autoFocus
autoComplete="new-password"
placeholder={t("account.shared.password")}
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isSubmitting}
data-ui-id="ui.auth.reset.password"
/>
</TextInput>
</div>
<Progress className="h-auto w-full flex-none" hideLabel value={strength} state={strength >= 80 ? "success" : undefined} />
<div className="flex w-full items-center justify-between">
<span className="text-body-extra-small-regular-0 font-body-extra-small-regular-0 text-text-neutral-tertiary">
{t("account.register.passwordStrength")}: {strengthLabel}
</span>
</div>
<span className="w-full text-body-extra-small-medium-150 font-body-extra-small-medium-150 text-text-neutral-tertiary">
{t("account.register.passwordHint")}
</span>
</div>
<div className="flex w-full items-end gap-2">
<TextInput className="h-auto grow shrink-0 basis-0" fillContainer showLabel label={t("account.register.confirmPassword")} type="password">
<TextInput.Input
name="password-confirm"
type="password"
required
autoComplete="new-password"
placeholder={t("account.register.confirmPassword")}
disabled={isSubmitting}
data-ui-id="ui.auth.reset.passwordConfirm"
/>
</TextInput>
</div>
<Button className="h-9 w-full flex-none" type="submit" loading={isSubmitting} disabled={isSubmitting} data-ui-id="ui.auth.reset.submit">
{t("account.newPassword.button")}
</Button>
{actionData?.error && navigation.state === "idle" ? (
<p className="text-center text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative" role="alert">
{actionData.error}
</p>
) : null}
</Form>
<div className="flex w-full items-center justify-center">
<LinkButton size="small" hideIcon onClick={() => navigate("/login")} data-ui-id="ui.auth.reset.backToLogin">
{t("account.forgot.backToLogin")}
</LinkButton>
</div>
</AuthShell>
);
}
@@ -0,0 +1,164 @@
import { useState } from "react";
import { Form, Link, useNavigation } from "react-router";
import { useTranslation } from "react-i18next";
import * as SubframeCore from "@subframe/core";
import { FeatherAlertTriangle, FeatherEdit2, FeatherMoreHorizontal, FeatherPlus, FeatherShield, FeatherTrash } from "@subframe/core";
import type { RoleWithPermissionsAndUsers } from "~/utils/db/permissions/roles.db.server";
import { roleDisplayDescription, roleDisplayName } from "~/components/core/roles/roleDisplay";
import { Button } from "~/ui/components/Button";
import { DropdownMenu } from "~/ui/components/DropdownMenu";
import { IconButton } from "~/ui/components/IconButton";
import { Table } from "~/ui/components/Table";
import { useRootData } from "~/utils/data/useRootData";
// D-P2 — Rollen-Liste nach Davids "Roles Flow"-Design (Table: Rolle/Beschreibung/Mitglieder/Aktionen).
// Anlegen/Bearbeiten via bestehende Routen (new / $id im SlideOver), Löschen via Form-POST an $id (action=delete).
// Degradierung: Delete-mit-Reassign hat kein Backend -> Delete-GUARD (blockt, wenn der Rolle Mitglieder
// zugewiesen sind), statt einen unsicheren Hard-Delete auszulösen. Default-Rollen sind nicht editier-/löschbar.
export default function RolesListView({
items,
canUpdate,
tenantId,
}: {
items: RoleWithPermissionsAndUsers[];
canUpdate: boolean;
tenantId: string;
}) {
const { t } = useTranslation();
const { csrf = "" } = useRootData();
const navigation = useNavigation();
const loading = navigation.state === "loading";
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; memberCount: number } | null>(null);
const memberCountOf = (role: RoleWithPermissionsAndUsers) => role.users.filter((u) => u.tenantId === tenantId).length;
const blocked = confirmDelete ? confirmDelete.memberCount > 0 : false;
return (
<div className="flex w-full flex-col items-start gap-4">
<div className="flex w-full items-start justify-between gap-4 mobile:flex-col">
<div className="flex flex-col items-start gap-1">
<span className="text-title-page font-title-page text-text-neutral-primary mobile:text-subtitle mobile:font-subtitle">
{t("models.role.plural")}
</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-tertiary">
{t("settings.rolesAndPermissions.subtitle")}
</span>
</div>
{canUpdate ? (
<Link to="new" className="no-underline mobile:w-full" data-ui-id="ui.settings.rolesAndPermissions.roles.new">
<Button variant="primary" icon={<FeatherPlus />} className="mobile:w-full">
{t("settings.rolesAndPermissions.create")}
</Button>
</Link>
) : null}
</div>
<Table
header={
<Table.HeaderRow>
<Table.HeaderCell>{t("models.role.object")}</Table.HeaderCell>
<Table.HeaderCell>{t("models.role.description")}</Table.HeaderCell>
<Table.HeaderCell>{t("settings.rolesAndPermissions.columns.members")}</Table.HeaderCell>
<Table.HeaderCell> </Table.HeaderCell>
</Table.HeaderRow>
}
>
{loading ? null : items.length === 0 ? (
<Table.Row clickable={false}>
<Table.Cell colSpan={4}>
<div className="flex grow shrink-0 basis-0 flex-col items-center justify-center gap-6 py-12">
<div className="flex flex-col items-center gap-4">
<FeatherShield className="text-[48px] leading-[48px] text-icon-neutral-tertiary" />
<div className="flex flex-col items-center gap-2">
<span className="text-body-large-0 font-body-large-0 text-text-neutral-primary text-center">{t("settings.rolesAndPermissions.empty.title")}</span>
<span className="max-w-[384px] text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary text-center">
{t("settings.rolesAndPermissions.empty.description")}
</span>
</div>
</div>
{canUpdate ? (
<Link to="new" className="no-underline">
<Button variant="primary" icon={<FeatherPlus />}>{t("settings.rolesAndPermissions.create")}</Button>
</Link>
) : null}
</div>
</Table.Cell>
</Table.Row>
) : (
items.map((role) => {
const editable = canUpdate && !role.isDefault;
return (
<Table.Row key={role.id} data-ui-id="ui.settings.rolesAndPermissions.roles.row">
<Table.Cell>
<span className="whitespace-nowrap text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-secondary">{roleDisplayName(role, t)}</span>
</Table.Cell>
<Table.Cell>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-tertiary">{roleDisplayDescription(role, t) || "—"}</span>
</Table.Cell>
<Table.Cell>
<span className="whitespace-nowrap text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-secondary">{memberCountOf(role)}</span>
</Table.Cell>
<Table.Cell>
<div className="flex grow shrink-0 basis-0 items-center justify-end">
{editable ? (
<SubframeCore.DropdownMenu.Root>
<SubframeCore.DropdownMenu.Trigger asChild>
<IconButton icon={<FeatherMoreHorizontal />} data-ui-id="ui.settings.rolesAndPermissions.roles.rowActions" />
</SubframeCore.DropdownMenu.Trigger>
<SubframeCore.DropdownMenu.Portal>
<SubframeCore.DropdownMenu.Content side="bottom" align="end" sideOffset={4} asChild>
<DropdownMenu>
<Link to={role.id} className="no-underline">
<DropdownMenu.DropdownItem icon={<FeatherEdit2 />}>{t("shared.edit")}</DropdownMenu.DropdownItem>
</Link>
<DropdownMenu.DropdownItem icon={<FeatherTrash />} onClick={() => setConfirmDelete({ id: role.id, name: roleDisplayName(role, t), memberCount: memberCountOf(role) })}>
{t("shared.delete")}
</DropdownMenu.DropdownItem>
</DropdownMenu>
</SubframeCore.DropdownMenu.Content>
</SubframeCore.DropdownMenu.Portal>
</SubframeCore.DropdownMenu.Root>
) : null}
</div>
</Table.Cell>
</Table.Row>
);
})
)}
</Table>
{confirmDelete ? (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-2 bg-surface-neutral-overlay px-4 py-4" data-ui-id="ui.settings.rolesAndPermissions.roles.deleteConfirm">
<div className="flex w-96 max-w-full flex-col items-start gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-modal px-6 py-6 shadow-lg mobile:w-full mobile:flex-none">
<div className="flex h-12 w-12 flex-none items-center justify-center gap-2 rounded-round bg-surface-feedback-subtle-negative">
<FeatherAlertTriangle className="text-heading-0 font-heading-0 text-icon-feedback-negative" />
</div>
<div className="flex w-full flex-col items-start gap-2">
<span className="w-full text-heading-0 font-heading-0 text-text-neutral-primary">
{blocked ? t("settings.rolesAndPermissions.deleteBlocked.title") : t("settings.rolesAndPermissions.delete.title")}
</span>
<span className="w-full text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">
{blocked
? t("settings.rolesAndPermissions.deleteBlocked.description", { name: confirmDelete.name, count: confirmDelete.memberCount })
: t("settings.rolesAndPermissions.delete.description", { name: confirmDelete.name })}
</span>
</div>
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button variant="tertiary" onClick={() => setConfirmDelete(null)}>{blocked ? t("shared.close") : t("shared.cancel")}</Button>
{!blocked ? (
<Form method="post" action={confirmDelete.id} className="contents">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="delete" readOnly />
<Button variant="destructive" type="submit" loading={navigation.state !== "idle"} data-ui-id="ui.settings.rolesAndPermissions.roles.deleteSubmit">
{t("shared.delete")}
</Button>
</Form>
) : null}
</div>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,259 @@
import { Fragment, useState } from "react";
import { Form, useNavigation } from "react-router";
import { useTranslation } from "react-i18next";
import { FeatherAlertTriangle, FeatherLogOut, FeatherMonitor, FeatherShieldOff, FeatherSmartphone } from "@subframe/core";
import type { SessionListVM } from "@toolctrl/view-models";
import { Button } from "~/ui/components/Button";
import { BadgeFeedback } from "~/ui/components/BadgeFeedback";
// D-P2 — Settings > Sicherheit nach Davids Subframe-"Session Management"-Design: Header (Titel + "Überall
// abmelden") + Geräte-Card (Icon + Label/Standort + "Dieses Gerät"-Badge / "Abmelden") + Confirm-Overlay
// (Revoke / Logout-All). Praesentational: VM + csrf werden injiziert, Aktionen laufen ueber die bestehende
// security-Action (intents revoke / revoke-all / forget-device / forget-all-devices). Die Trusted-Devices-
// Sektion hat kein eigenes David-Design -> konsistent in derselben Card-Optik re-skinned.
type TrustedDeviceVM = { id: string; label: string | null; approxLocation: string | null };
type ConfirmState =
| { kind: "revoke-all" }
| { kind: "revoke"; sessionId: string; label: string }
| { kind: "forget-all-devices" }
| { kind: "forget-device"; deviceId: string; label: string };
function isMobileDevice(label?: string | null): boolean {
if (!label) return false;
return /iphone|android|mobile|ipad|tablet/i.test(label);
}
export default function SecuritySessionsScreen({
vm,
trustedDevices,
csrf,
}: {
vm: SessionListVM;
trustedDevices: TrustedDeviceVM[];
csrf: string;
}) {
const { t } = useTranslation();
const navigation = useNavigation();
const submitting = navigation.state !== "idle";
const [confirm, setConfirm] = useState<ConfirmState | null>(null);
const ready = vm.status === "ready";
const devices = ready ? vm.data.devices : [];
const onlyThisDevice = ready && devices.length <= 1 && !vm.data.canRevokeOthers;
return (
<div className="relative flex w-full flex-col items-center" data-ui-id="ui.settings.security.page">
<div className="flex w-full max-w-[768px] flex-col items-start gap-8">
{/* Header */}
<div className="flex w-full items-start justify-between gap-4 mobile:flex-col">
<div className="flex flex-col items-start gap-2">
<span className="text-title-page font-title-page text-text-neutral-primary mobile:text-subtitle mobile:font-subtitle">
{t("settings.security.title")}
</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-tertiary">
{t("settings.security.sessions.subtitle")}
</span>
</div>
{ready && vm.data.canRevokeOthers ? (
<Button
variant="secondary"
icon={<FeatherLogOut />}
className="mobile:w-full"
onClick={() => setConfirm({ kind: "revoke-all" })}
data-ui-id="ui.settings.security.revokeAll"
>
{t("settings.security.sessions.revokeAll")}
</Button>
) : null}
</div>
{/* Aktive Sitzungen */}
<section
className="flex w-full flex-col items-start rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-6 py-6 shadow-sm"
data-ui-id="ui.settings.security.sessions"
aria-label={t("settings.security.sessions.heading")}
>
{!ready ? (
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{t("settings.security.sessions.empty")}
</span>
) : (
devices.map((device, index) => {
const Icon = isMobileDevice(device.deviceLabel) ? FeatherSmartphone : FeatherMonitor;
const label = device.deviceLabel ?? t(device.deviceLabelFallbackKey ?? "settings.security.sessions.unknownDevice");
const meta = [device.approxLocation, t(`settings.security.sessions.lastSeen.${device.lastSeenBucket}`, { count: device.lastSeenCount ?? 0 })]
.filter(Boolean)
.join(" · ");
return (
<Fragment key={device.id}>
{index > 0 ? <div className="flex h-px w-full flex-none flex-col items-start bg-border-default" /> : null}
<div
className="flex w-full items-center gap-4 py-4"
data-ui-id="ui.settings.security.sessionRow"
data-current={device.isCurrent}
>
<Icon className="text-heading- font-heading- text-icon-neutral-secondary" />
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1">
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{label}</span>
{meta ? (
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{meta}</span>
) : null}
</div>
{device.isCurrent ? (
<BadgeFeedback farbe="subtle-positive" size="small">
{t("settings.security.sessions.thisDevice")}
</BadgeFeedback>
) : device.canRevoke ? (
<Button
variant="secondary"
size="small"
onClick={() => setConfirm({ kind: "revoke", sessionId: device.id, label })}
data-ui-id="ui.settings.security.revoke"
>
{t("settings.security.sessions.revoke")}
</Button>
) : null}
</div>
</Fragment>
);
})
)}
</section>
{onlyThisDevice ? (
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{t("settings.security.sessions.onlyThisDevice")}
</span>
) : null}
{/* Vertrauenswürdige Geräte (kein eigenes David-Design -> konsistente Card-Optik) */}
{trustedDevices.length > 0 ? (
<section
className="flex w-full flex-col items-start gap-4"
data-ui-id="ui.settings.security.trustedDevices"
aria-label={t("settings.security.trustedDevices.heading")}
>
<div className="flex w-full items-center justify-between gap-4">
<div className="flex flex-col items-start gap-1">
<span className="text-heading- font-heading- text-text-neutral-primary">
{t("settings.security.trustedDevices.heading")}
</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{t("settings.security.trustedDevices.description")}
</span>
</div>
<Button
variant="tertiary"
size="small"
icon={<FeatherShieldOff />}
onClick={() => setConfirm({ kind: "forget-all-devices" })}
data-ui-id="ui.settings.security.forgetAllDevices"
>
{t("settings.security.trustedDevices.forgetAll")}
</Button>
</div>
<div className="flex w-full flex-col items-start rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-6 py-6 shadow-sm">
{trustedDevices.map((device, index) => (
<Fragment key={device.id}>
{index > 0 ? <div className="flex h-px w-full flex-none flex-col items-start bg-border-default" /> : null}
<div className="flex w-full items-center gap-4 py-4" data-ui-id="ui.settings.security.trustedDeviceRow">
<FeatherShieldOff className="text-heading- font-heading- text-icon-neutral-secondary" />
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1">
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">
{device.label ?? t("settings.security.trustedDevices.unknownDevice")}
</span>
{device.approxLocation ? (
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{device.approxLocation}
</span>
) : null}
</div>
<Button
variant="tertiary"
size="small"
onClick={() => setConfirm({ kind: "forget-device", deviceId: device.id, label: device.label ?? t("settings.security.trustedDevices.unknownDevice") })}
data-ui-id="ui.settings.security.forgetDevice"
>
{t("settings.security.trustedDevices.forget")}
</Button>
</div>
</Fragment>
))}
</div>
</section>
) : null}
</div>
{confirm ? <ConfirmOverlay confirm={confirm} csrf={csrf} submitting={submitting} onClose={() => setConfirm(null)} /> : null}
</div>
);
}
function ConfirmOverlay({
confirm,
csrf,
submitting,
onClose,
}: {
confirm: ConfirmState;
csrf: string;
submitting: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const copy = {
"revoke-all": {
title: t("settings.security.sessions.confirmRevokeAll.title"),
description: t("settings.security.sessions.confirmRevokeAll.description"),
confirm: t("settings.security.sessions.confirmRevokeAll.confirm"),
},
revoke: {
title: t("settings.security.sessions.confirmRevoke.title"),
description: t("settings.security.sessions.confirmRevoke.description", { device: "label" in confirm ? confirm.label : "" }),
confirm: t("settings.security.sessions.confirmRevoke.confirm"),
},
"forget-all-devices": {
title: t("settings.security.trustedDevices.confirmForgetAll.title"),
description: t("settings.security.trustedDevices.confirmForgetAll.description"),
confirm: t("settings.security.trustedDevices.forgetAll"),
},
"forget-device": {
title: t("settings.security.trustedDevices.confirmForget.title"),
description: t("settings.security.trustedDevices.confirmForget.description", { device: "label" in confirm ? confirm.label : "" }),
confirm: t("settings.security.trustedDevices.forget"),
},
}[confirm.kind];
return (
<div
className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-2 bg-surface-neutral-overlay px-4 py-4"
data-ui-id="ui.settings.security.confirm"
>
<div className="flex w-96 max-w-full flex-col items-start gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-modal px-6 py-6 shadow-lg mobile:w-full mobile:flex-none">
<div className="flex h-12 w-12 flex-none items-center justify-center gap-2 rounded-round bg-surface-feedback-subtle-negative">
<FeatherAlertTriangle className="text-heading-0 font-heading-0 text-icon-feedback-negative" />
</div>
<div className="flex w-full flex-col items-start gap-2">
<span className="w-full text-heading-0 font-heading-0 text-text-neutral-primary">{copy.title}</span>
<span className="w-full text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">{copy.description}</span>
</div>
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button variant="tertiary" onClick={onClose} data-ui-id="ui.settings.security.confirm.cancel">
{t("shared.cancel")}
</Button>
<Form method="post" className="contents">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="intent" value={confirm.kind} readOnly />
{confirm.kind === "revoke" ? <input type="hidden" name="sessionId" value={confirm.sessionId} readOnly /> : null}
{confirm.kind === "forget-device" ? <input type="hidden" name="deviceId" value={confirm.deviceId} readOnly /> : null}
<Button variant="destructive" type="submit" loading={submitting} data-ui-id="ui.settings.security.confirm.submit">
{copy.confirm}
</Button>
</Form>
</div>
</div>
</div>
);
}
@@ -0,0 +1,140 @@
import { useSubmit, Link } from "react-router";
import { useTranslation } from "react-i18next";
import type Stripe from "stripe";
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
import MyBillingSession from "~/components/core/settings/subscription/MyBillingSession";
import MyInvoices from "~/components/core/settings/subscription/MyInvoices";
import MyPayments from "~/components/core/settings/subscription/MyPayments";
import MyProducts from "~/components/core/settings/subscription/MyProducts";
import MySubscriptionFeatures from "~/components/core/settings/subscription/MySubscriptionFeatures";
import MyUpcomingInvoice from "~/components/core/settings/subscription/MyUpcomingInvoice";
import SettingSection from "~/components/ui/sections/SettingSection";
import { TenantSubscriptionProductWithDetails } from "~/utils/db/subscriptions/tenantSubscriptionProducts.db.server";
import { TenantSubscriptionWithDetails } from "~/utils/db/tenantSubscriptions.db.server";
import { TenantSimple } from "~/utils/db/tenants.db.server";
import { subscriptionCheckoutPath } from "~/utils/app/SubscriptionUrlUtils";
import { useRootData } from "~/utils/data/useRootData";
export default function SubscriptionSettings({
currentTenant,
mySubscription,
myInvoices,
myPayments,
myFeatures,
myUpcomingInvoices,
permissions,
}: {
currentTenant: TenantSimple;
mySubscription: TenantSubscriptionWithDetails | null;
myInvoices: Stripe.Invoice[];
myPayments: Stripe.PaymentIntent[];
myFeatures: PlanFeatureUsageDto[];
myUpcomingInvoices: Stripe.Invoice[];
permissions: {
viewInvoices: boolean;
};
}) {
const { t } = useTranslation();
const submit = useSubmit();
const { csrf } = useRootData();
function onCancel(item: TenantSubscriptionProductWithDetails) {
const form = new FormData();
form.set("action", "cancel");
form.set("csrf", csrf ?? "");
form.set("tenant-subscription-product-id", item.id);
submit(form, {
method: "post",
});
}
// function onAddPaymentMethod() {
// const form = new FormData();
// form.set("action", "add-payment-method");
// submit(form, {
// method: "post",
// });
// }
function onOpenCustomerPortal() {
const form = new FormData();
form.set("action", "open-customer-portal");
form.set("csrf", csrf ?? "");
submit(form, {
method: "post",
});
}
return (
<div className="space-y-4" data-ui-id="ui.subscription.page">
<SettingSection
title={t("settings.subscription.title")}
description={
<div className="flex flex-col space-y-1">
<div>{t("settings.subscription.description")}</div>
<div>
{mySubscription?.products && mySubscription.products.length > 0 && (
<Link to={subscriptionCheckoutPath(currentTenant.slug)} className="text-theme-600 underline" data-ui-id="ui.subscription.checkout.link">
{t("settings.subscription.viewAllProducts")}
</Link>
)}
</div>
</div>
}
className=""
>
<MyProducts currentTenant={currentTenant} items={mySubscription?.products ?? []} onCancel={onCancel} />
</SettingSection>
{myFeatures.length > 0 && (
<>
<div className="hidden sm:block" aria-hidden="true">
<div className="py-4">
<div className="border-border border-t"></div>
</div>
</div>
<SettingSection title={t("app.subscription.features.title")} description={t("app.subscription.features.description")} className="">
<MySubscriptionFeatures features={myFeatures} withCurrentPlan={false} />
</SettingSection>
</>
)}
{permissions.viewInvoices && (
<>
<div className="hidden sm:block" aria-hidden="true">
<div className="py-4">
<div className="border-border border-t"></div>
</div>
</div>
<SettingSection title={t("app.subscription.invoices.title")} description={t("app.subscription.invoices.description")}>
<div className="space-y-2">
<MyUpcomingInvoice items={myUpcomingInvoices} />
<MyInvoices items={myInvoices} />
<MyPayments items={myPayments} />
</div>
</SettingSection>
</>
)}
<div className="hidden sm:block" aria-hidden="true">
<div className="py-4">
<div className="border-border border-t"></div>
</div>
</div>
{/* <SettingSection title={t("app.subscription.paymentMethods.title")} description={t("app.subscription.paymentMethods.description")}>
<div className="space-y-2">
<MyPaymentMethods items={myPaymentMethods} onAdd={onAddPaymentMethod} onDelete={onDeletePaymentMethod} />
</div>
</SettingSection> */}
<SettingSection title={t("app.subscription.billing.title")} description={t("app.subscription.billing.description")}>
<div className="space-y-2">
<MyBillingSession onClick={onOpenCustomerPortal} />
</div>
</SettingSection>
</div>
);
}
@@ -0,0 +1,271 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen, within } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import UserProfileSettings from "./UserProfileSettings";
const submitMock = vi.fn();
let searchParams = new URLSearchParams();
vi.mock("react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-router")>();
return {
...actual,
Form: ({ children, ...props }: React.FormHTMLAttributes<HTMLFormElement>) => <form {...props}>{children}</form>,
useLocation: () => ({ pathname: "/acme/settings/profile" }),
useMatches: () => [],
useNavigation: () => ({ state: "idle" }),
useSearchParams: () => [searchParams],
useSubmit: () => submitMock,
};
});
vi.mock("react-i18next", () => ({
useTranslation: () => ({
i18n: { language: "en", exists: () => true },
t: (key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
"settings.profile.mfa.authenticatorApp": "Authenticator app",
"settings.profile.mfa.setupUri": "Authenticator setup URI",
"settings.profile.mfa.confirmationCode": "Authenticator code",
"settings.profile.mfa.confirmAuthenticator": "Confirm authenticator",
"settings.profile.mfa.recoveryCodes": "Recovery codes",
"settings.profile.mfa.recoveryCodesCopy": "Recovery codes copy value",
"settings.profile.mfa.saveRecoveryCodes": "Save your recovery codes",
"settings.profile.session.title": "Current session",
"settings.profile.session.description": "Redacted diagnostics for this browser session only.",
"settings.profile.session.lightDarkPreference": "Light/dark preference",
"settings.profile.session.cookieConsentChoices": "Cookie consent choices",
"settings.profile.session.impersonation": "Impersonation",
"settings.profile.session.adminMfaStepUp": "Admin MFA step-up",
"settings.profile.session.clearStepUpVerification": "Clear step-up verification for this session",
};
let value = translations[key] ?? key;
for (const [name, replacement] of Object.entries(options ?? {})) {
value = value.replaceAll(`{{${name}}}`, String(replacement));
}
return value;
},
}),
}));
vi.mock("~/components/ui/input/InputSelect", () => ({
default: ({ options, setValue, value }: { options: { name: string; value: string }[]; setValue?: (value: string) => void; value?: string }) => (
<select aria-label="settings.preferences.language" value={value} onChange={(event) => setValue?.(event.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.name}
</option>
))}
</select>
),
}));
vi.mock("~/components/ui/uploaders/UploadDocument", () => ({
default: () => <button type="button">Upload avatar</button>,
}));
vi.mock("~/components/ui/uploaders/UploadImage", () => ({
default: () => <div>Upload image dialog</div>,
}));
vi.mock("~/components/ui/modals/SuccessModal", () => ({
default: () => null,
}));
vi.mock("~/components/ui/modals/ErrorModal", () => ({
default: () => null,
}));
vi.mock("~/components/ui/modals/ConfirmModal", () => ({
default: ({ onYes, placeholder }: { onYes?: (value: string) => void; placeholder?: string }) => (
<button type="button" aria-label="confirm-account-delete" data-placeholder={placeholder} onClick={() => onYes?.("delete-confirm-password")}>
Confirm delete modal
</button>
),
}));
vi.mock("~/utils/data/useRootData", () => ({
useRootData: () => ({ csrf: "csrf-token" }),
}));
const baseUser = {
email: "ada@example.com",
firstName: "Ada",
lastName: "Lovelace",
avatar: null,
admin: null,
};
const currentSession = {
locale: "en",
lightOrDarkMode: "dark",
theme: "zinc",
cookieConsentCount: 2,
impersonationActive: true,
mfaStepUp: {
present: true,
verified: true,
stale: false,
method: "totp",
} as const,
};
function renderSettings(props: Partial<React.ComponentProps<typeof UserProfileSettings>> = {}) {
return render(<UserProfileSettings user={baseUser} currentSession={currentSession} {...props} />);
}
function textContent() {
return document.body.textContent ?? "";
}
function expectNoSecretsLeak() {
const serialized = document.body.innerHTML;
expect(serialized).not.toContain("encrypted-secret");
expect(serialized).not.toContain("encrypted-pending");
expect(serialized).not.toContain("totpSecretEncrypted");
expect(serialized).not.toContain("hash-1");
expect(serialized).not.toContain("hash-2");
expect(serialized).not.toContain("RJ_session");
expect(serialized).not.toContain("raw-cookie-value");
expect(serialized).not.toContain("csrf-secret");
expect(serialized).not.toContain("new-password-value");
expect(serialized).not.toContain("delete-confirm-password");
}
describe("UserProfileSettings", () => {
beforeEach(() => {
submitMock.mockClear();
searchParams = new URLSearchParams();
});
it("renders profile, preferences, and danger controls with accessible form fields", () => {
renderSettings();
expect(screen.getByText("settings.profile.profileTitle")).toBeInTheDocument();
expect(screen.getByText("account.shared.email")).toBeInTheDocument();
expect(document.querySelector('input[name="email"]')).toHaveValue("ada@example.com");
expect(document.querySelector('input[name="firstName"]')).toHaveValue("Ada");
expect(document.querySelector('input[name="lastName"]')).toHaveValue("Lovelace");
expect(screen.getByText("settings.preferences.title")).toBeInTheDocument();
expect(screen.getByText("settings.preferences.language")).toBeInTheDocument();
expect(screen.getByLabelText("settings.preferences.language")).toHaveValue("en");
expect(screen.getByRole("button", { name: "settings.danger.deleteUserAccount" })).toBeInTheDocument();
expect(document.querySelector('input[name="action"][value="profile"]')).toBeInTheDocument();
expect(document.querySelector('input[name="action"][value="password"]')).toBeInTheDocument();
expect(document.querySelector('input[name="action"][value="deleteAccount"]')).toBeInTheDocument();
expect(document.querySelectorAll('form input[name="csrf"][value="csrf-token"]').length).toBeGreaterThanOrEqual(3);
expectNoSecretsLeak();
});
it("renders disabled, pending, and enabled MFA controls without secret material", () => {
const { rerender } = renderSettings({
mfaStatus: { enabled: false, pending: false, enabledAt: null, pendingCreatedAt: null },
});
expect(screen.getByText("Authenticator app")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("settings.profile.mfa.status.notEnabled");
expect(screen.getByRole("button", { name: "settings.profile.mfa.startAuthenticatorSetup" })).toBeInTheDocument();
expect(document.querySelector('input[name="action"][value="startTotpEnrollment"]')).toBeInTheDocument();
expect(document.querySelector('[data-ui-id="ui.settings.profile.mfa.panel"]')).toBeInTheDocument();
const mfaToggleForm = document.querySelector('[data-ui-id="ui.settings.profile.mfa.toggle.form"]');
expect(mfaToggleForm).toBeInTheDocument();
expect(within(mfaToggleForm as HTMLElement).getByLabelText("settings.profile.passwordCurrent")).toHaveAttribute("name", "passwordCurrent");
rerender(
<UserProfileSettings
user={baseUser}
currentSession={currentSession}
mfaStatus={{ enabled: false, pending: true, enabledAt: null, pendingCreatedAt: "2026-05-08T12:00:00.000Z" }}
otpauthUri="otpauth://totp/ToolCTRL:ada@example.com?issuer=ToolCTRL"
mfaError="Enter the 6-digit authenticator code."
/>
);
expect(screen.getByText("settings.profile.mfa.status.pending")).toBeInTheDocument();
expect(screen.getByLabelText("Authenticator setup URI")).toHaveTextContent("otpauth://totp/ToolCTRL:ada@example.com?issuer=ToolCTRL");
expect(screen.getByRole("alert")).toHaveTextContent("Enter the 6-digit authenticator code.");
expect(screen.getByLabelText("Authenticator code")).toHaveAttribute("name", "code");
expect(screen.getByRole("button", { name: "Confirm authenticator" })).toBeInTheDocument();
expect(document.querySelector('input[name="action"][value="confirmTotpEnrollment"]')).toBeInTheDocument();
rerender(
<UserProfileSettings
user={baseUser}
currentSession={currentSession}
mfaStatus={{ enabled: true, pending: false, enabledAt: "2026-05-08T12:05:00.000Z", pendingCreatedAt: null }}
/>
);
expect(screen.getByText("settings.profile.mfa.status.enabled")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "settings.profile.mfa.disableAuthenticator" })).toBeInTheDocument();
expect(document.querySelector('input[name="action"][value="disableTotpEnrollment"]')).toBeInTheDocument();
expectNoSecretsLeak();
});
it("shows one-time recovery codes but not their persisted hashes", () => {
renderSettings({
recoveryCodes: ["ABCD-EFGH-JKLM", "NPQR-STUV-WXYZ"],
});
const recoveryRegion = screen.getByLabelText("Recovery codes");
expect(within(recoveryRegion).getByText("Save your recovery codes")).toBeInTheDocument();
expect(within(recoveryRegion).getByText("ABCD-EFGH-JKLM")).toBeInTheDocument();
expect(within(recoveryRegion).getByText("NPQR-STUV-WXYZ")).toBeInTheDocument();
expect(screen.getByLabelText("Recovery codes copy value")).toHaveValue("ABCD-EFGH-JKLM\nNPQR-STUV-WXYZ");
expect(document.body.innerHTML).not.toContain("hash-1");
expect(document.body.innerHTML).not.toContain("hash-2");
});
it("renders current-session diagnostics from redacted loader facts only", () => {
renderSettings();
expect(screen.getByText("Current session")).toBeInTheDocument();
expect(screen.getByText("Redacted diagnostics for this browser session only.")).toBeInTheDocument();
expect(screen.getByText("settings.profile.session.locale")).toBeInTheDocument();
expect(screen.getByText("Light/dark preference")).toBeInTheDocument();
expect(screen.getByText("settings.profile.session.theme")).toBeInTheDocument();
expect(screen.getByText("Cookie consent choices")).toBeInTheDocument();
expect(screen.getByText("Impersonation")).toBeInTheDocument();
expect(screen.getByText("Admin MFA step-up")).toBeInTheDocument();
expect(screen.getByText("shared.dark")).toBeInTheDocument();
expect(screen.getByText("zinc")).toBeInTheDocument();
expect(screen.getByText("shared.active")).toBeInTheDocument();
expect(screen.getByText("settings.profile.session.mfaStepUpVerifiedWith")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Clear step-up verification for this session" })).toBeInTheDocument();
expect(document.querySelector('input[name="action"][value="clearMfaStepUp"]')).toBeInTheDocument();
expectNoSecretsLeak();
});
it("submits locale and password-confirmed account deletion without rendering submitted passwords", () => {
renderSettings();
fireEvent.click(screen.getByRole("button", { name: "settings.danger.deleteUserAccount" }));
fireEvent.click(screen.getAllByRole("button", { name: "confirm-account-delete" }).at(-1)!);
const deleteForm = submitMock.mock.calls.at(-1)?.[0] as FormData;
expect(deleteForm.get("action")).toBe("deleteAccount");
expect(deleteForm.get("csrf")).toBe("csrf-token");
expect(deleteForm.get("passwordCurrent")).toBe("delete-confirm-password");
expect(textContent()).not.toContain("delete-confirm-password");
fireEvent.change(screen.getByLabelText("settings.preferences.language"), { target: { value: "de" } });
const localeForm = submitMock.mock.calls.at(-1)?.[0] as FormData;
expect(localeForm.get("action")).toBe("setLocale");
expect(localeForm.get("csrf")).toBe("csrf-token");
expect(localeForm.get("lng")).toBe("de");
expect(localeForm.get("redirect")).toBe("/acme/settings/profile?lng=de");
expect(submitMock.mock.calls.at(-1)?.[1]).toMatchObject({ method: "post", action: "/preferences", preventScrollReset: true });
});
it("does not echo typed password values from security forms", () => {
renderSettings();
const passwordInputs = document.querySelectorAll('input[type="password"]');
passwordInputs.forEach((input) => fireEvent.change(input, { target: { value: "new-password-value" } }));
expect(textContent()).not.toContain("new-password-value");
});
});
@@ -0,0 +1,480 @@
import { Form, useLocation, useNavigation, useSearchParams, useSubmit } from "react-router";
import { type TFunction } from "i18next";
import { useRef, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
import ButtonTertiary from "~/components/ui/buttons/ButtonTertiary";
import { Input } from "~/components/ui/input";
import InputSelect from "~/components/ui/input/InputSelect";
import InputText from "~/components/ui/input/InputText";
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
import ErrorModal, { RefErrorModal } from "~/components/ui/modals/ErrorModal";
import SuccessModal, { RefSuccessModal } from "~/components/ui/modals/SuccessModal";
import SettingSection from "~/components/ui/sections/SettingSection";
import UploadDocuments from "~/components/ui/uploaders/UploadDocument";
import UploadImage from "~/components/ui/uploaders/UploadImage";
import { i18nConfig } from "~/locale/i18n";
import type { CurrentSessionSettings } from "~/routes/app.$tenant/settings/profile";
import { useRootData } from "~/utils/data/useRootData";
import { UserIcon } from "~/components/ui/icons/vendor";
type UserProfileMfaStatus = {
enabled: boolean;
pending: boolean;
enabledAt: Date | string | null;
pendingCreatedAt: Date | string | null;
};
function getMfaStatusLabel(t: TFunction, mfaStatus?: UserProfileMfaStatus | null) {
if (mfaStatus?.enabled) {
return t("settings.profile.mfa.status.enabled");
}
if (mfaStatus?.pending) {
return t("settings.profile.mfa.status.pending");
}
return t("settings.profile.mfa.status.notEnabled");
}
function getLightOrDarkModeLabel(t: TFunction, value?: string | null) {
if (value === "light" || value === "dark") {
return t(`shared.${value}`);
}
return value || t("settings.profile.session.systemPreference");
}
function getMfaStepUpLabel(t: TFunction, mfaStepUp?: CurrentSessionSettings["mfaStepUp"]) {
if (mfaStepUp?.verified) {
return t("settings.profile.session.mfaStepUpVerifiedWith", { method: mfaStepUp.method });
}
if (mfaStepUp?.present) {
return mfaStepUp.stale ? t("settings.profile.session.mfaStepUpStale") : t("settings.profile.session.mfaStepUpInvalid");
}
return t("settings.profile.session.mfaStepUpNotPresent");
}
export default function UserProfileSettings({
user,
mfaStatus,
otpauthUri,
mfaError,
recoveryCodes,
currentSession,
}: {
user: {
email: string;
firstName: string;
lastName: string;
avatar: string | null;
admin?: any | null;
};
mfaStatus?: {
enabled: boolean;
pending: boolean;
enabledAt: Date | string | null;
pendingCreatedAt: Date | string | null;
} | null;
otpauthUri?: string;
mfaError?: string;
recoveryCodes?: string[];
currentSession?: CurrentSessionSettings;
}) {
const location = useLocation();
const [searchParams] = useSearchParams();
const { t, i18n } = useTranslation();
const navigation = useNavigation();
const submit = useSubmit();
const { csrf } = useRootData();
const errorModal = useRef<RefErrorModal>(null);
const successModal = useRef<RefSuccessModal>(null);
const confirmModal = useRef<RefConfirmModal>(null);
const inputFirstName = useRef<HTMLInputElement>(null);
useEffect(() => {
inputFirstName.current?.focus();
inputFirstName.current?.select();
}, []);
const [avatar, setAvatar] = useState<string | undefined>(user?.avatar ?? undefined);
const [showUploadImage, setShowUploadImage] = useState(false);
const [uploadingImage, setUploadingImage] = useState(false);
function changedLocale(locale: string) {
const form = new FormData();
form.set("action", "setLocale");
form.set("csrf", csrf ?? "");
searchParams.set("lng", locale);
form.set("redirect", location.pathname + "?" + searchParams.toString());
form.set("lng", locale);
submit(form, { method: "post", action: "/preferences", preventScrollReset: true });
}
function deleteAccount() {
if (user?.admin) {
errorModal.current?.show(t("settings.profile.errors.cannotDeleteAdmin"));
} else {
confirmModal.current?.show(t("settings.danger.confirmDelete"), t("shared.confirm"), t("shared.cancel"), t("shared.warningCannotUndo"), "");
}
}
function confirmDelete(passwordCurrent: string | undefined) {
const form = new FormData();
form.set("action", "deleteAccount");
form.set("csrf", csrf ?? "");
form.set("passwordCurrent", passwordCurrent ?? "");
submit(form, { method: "post" });
}
function loadedImage(image: string | undefined) {
setAvatar(image);
setUploadingImage(true);
}
return (
<div data-ui-id="ui.settings.profile.page">
<div>
<SettingSection title={t("settings.profile.profileTitle")} description={t("settings.profile.profileText")}>
<div className="mt-5 md:col-span-2 md:mt-0">
<Form method="post" data-ui-id="ui.settings.profile.form">
<input hidden type="text" name="action" value="profile" readOnly />
<input hidden type="text" name="csrf" value={csrf ?? ""} readOnly />
<div className="">
<div className="">
<div className="grid grid-cols-6 gap-2">
<div className="col-span-6 sm:col-span-6 md:col-span-6">
<InputText
required
disabled={true}
type="email"
name="email"
title={t("account.shared.email")}
defaultValue={user?.email}
dataUiId="ui.settings.profile.email"
/>
</div>
<div className="col-span-6 md:col-span-3">
<InputText
name="firstName"
title={t("settings.profile.firstName")}
defaultValue={user?.firstName}
required
dataUiId="ui.settings.profile.firstName"
/>
</div>
<div className="col-span-6 md:col-span-3">
<InputText name="lastName" title={t("settings.profile.lastName")} defaultValue={user?.lastName} dataUiId="ui.settings.profile.lastName" />
</div>
<div className="col-span-6 sm:col-span-6">
<label htmlFor="avatar" className="block text-xs leading-5 font-medium">
{t("shared.avatar")}
</label>
<div className="mt-2 flex items-center space-x-3">
<input hidden id="avatar" name="avatar" value={avatar} readOnly />
<div className="bg-secondary h-12 w-12 overflow-hidden rounded-md">
{(() => {
if (avatar) {
return <img id="avatar" alt={t("shared.avatar")} src={avatar} />;
} else {
return (
<UserIcon id="avatar" className="text-muted-foreground h-full w-full" />
);
}
})()}
</div>
{avatar ? (
<ButtonTertiary destructive={true} onClick={() => loadedImage("")} type="button">
{t("shared.delete")}
</ButtonTertiary>
) : (
<UploadDocuments accept="image/png, image/jpg, image/jpeg" description="" className="h-12" onDropped={loadedImage} />
)}
</div>
</div>
</div>
</div>
<div className="pt-3">
<div className="flex justify-between">
<div id="form-success-message" className="flex items-center space-x-2"></div>
<button
disabled={navigation.state === "submitting"}
type="submit"
data-ui-id="ui.settings.profile.save"
className="bg-primary hover:bg-primary/90 focus:ring-primary text-primary-foreground inline-flex items-center space-x-2 rounded-md border border-transparent px-4 py-2 text-sm font-medium focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
>
{t("shared.save")}
</button>
</div>
</div>
</div>
</Form>
</div>
</SettingSection>
{/*Separator */}
<div className="block">
<div className="py-5">
<div className="border-border/30 border-t"></div>
</div>
</div>
<SettingSection
title={t("settings.profile.securityTitle")}
description={
<p className="text-muted-foreground mt-1 text-xs leading-5">
{t("account.login.forgot")}{" "}
<a className="text-muted-foreground font-bold hover:underline" href={"/forgot-password?e=" + user?.email || ""}>
{t("account.reset.button")}
</a>
</p>
}
>
{/*Security */}
<div className="mt-5 space-y-6 md:col-span-2 md:mt-0">
<div className="border-border bg-muted/20 rounded-md border p-4 text-sm" data-ui-id="ui.settings.profile.mfa.panel">
<div className="flex flex-col gap-3">
<div>
<p className="font-medium">{t("settings.profile.mfa.authenticatorApp")}</p>
<p className="text-muted-foreground text-xs" role="status" data-ui-id="ui.settings.profile.mfa.status">
{getMfaStatusLabel(t, mfaStatus)}
</p>
{mfaError ? (
<p className="text-destructive mt-2 text-xs" role="alert" data-ui-id="ui.settings.profile.mfa.error">
{mfaError}
</p>
) : null}
</div>
{otpauthUri ? (
<div className="space-y-2">
<div className="rounded bg-white p-3 font-mono text-xs break-all text-gray-900" aria-label={t("settings.profile.mfa.setupUri")}>
{otpauthUri}
</div>
<Form method="post" className="flex flex-col gap-2 sm:flex-row" data-ui-id="ui.settings.profile.mfa.confirm.form">
<input hidden type="text" name="action" value="confirmTotpEnrollment" readOnly />
<input hidden type="text" name="csrf" value={csrf ?? ""} readOnly />
<Input
required
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
name="code"
placeholder="123456"
aria-label={t("settings.profile.mfa.confirmationCode")}
data-ui-id="ui.settings.profile.mfa.confirm.code"
/>
<ButtonPrimary type="submit" dataUiId="ui.settings.profile.mfa.confirm.submit">
{t("settings.profile.mfa.confirmAuthenticator")}
</ButtonPrimary>
</Form>
</div>
) : null}
{recoveryCodes?.length ? (
<div className="border-border bg-background space-y-3 rounded-md border p-4" aria-label={t("settings.profile.mfa.recoveryCodes")}>
<div>
<p className="font-medium">{t("settings.profile.mfa.saveRecoveryCodes")}</p>
<p className="text-muted-foreground text-xs">{t("settings.profile.mfa.recoveryCodesOneTime")}</p>
</div>
<ul className="grid gap-2 sm:grid-cols-2">
{recoveryCodes.map((code) => (
<li key={code} className="rounded bg-white p-2 font-mono text-xs text-gray-900">
{code}
</li>
))}
</ul>
<textarea className="sr-only" readOnly aria-label={t("settings.profile.mfa.recoveryCodesCopy")} value={recoveryCodes.join("\n")} />
</div>
) : null}
<Form method="post" className="flex flex-col gap-2 sm:flex-row" data-ui-id="ui.settings.profile.mfa.toggle.form">
<input hidden type="text" name="action" value={mfaStatus?.enabled ? "disableTotpEnrollment" : "startTotpEnrollment"} readOnly />
<input hidden type="text" name="csrf" value={csrf ?? ""} readOnly />
<label className="sr-only" htmlFor="mfaPasswordCurrent">
{t("settings.profile.passwordCurrent")}
</label>
<Input
required
type="password"
id="mfaPasswordCurrent"
name="passwordCurrent"
placeholder={t("settings.profile.passwordCurrent")}
data-ui-id="ui.settings.profile.mfa.passwordCurrent"
/>
<ButtonPrimary type="submit" dataUiId="ui.settings.profile.mfa.toggle.submit">
{mfaStatus?.enabled ? t("settings.profile.mfa.disableAuthenticator") : t("settings.profile.mfa.startAuthenticatorSetup")}
</ButtonPrimary>
</Form>
</div>
</div>
<Form method="post" data-ui-id="ui.settings.profile.password.form">
<input hidden type="text" name="action" value="password" readOnly />
<input hidden type="text" name="csrf" value={csrf ?? ""} readOnly />
<div className="">
<div>
<div className="">
<div className="grid grid-cols-6 gap-2">
<div className="col-span-6 sm:col-span-6">
<label htmlFor="passwordCurrent" className="mb-1 block text-xs leading-5 font-medium">
{t("settings.profile.passwordCurrent")}
</label>
<Input required type="password" id="passwordCurrent" name="passwordCurrent" data-ui-id="ui.settings.profile.password.current" />
</div>
<div className="col-span-6 md:col-span-3">
<label htmlFor="password" className="mb-1 block text-xs leading-5 font-medium">
{t("settings.profile.password")}
</label>
<Input
title={t("settings.profile.password")}
required
type="password"
id="passwordNew"
name="passwordNew"
data-ui-id="ui.settings.profile.password.new"
/>
</div>
<div className="col-span-6 md:col-span-3">
<label htmlFor="passwordConfirm" className="mb-1 block text-xs leading-5 font-medium">
{t("settings.profile.passwordConfirm")}
</label>
<Input
title={t("settings.profile.passwordConfirm")}
required
type="password"
id="passwordNewConfirm"
name="passwordNewConfirm"
data-ui-id="ui.settings.profile.password.confirm"
/>
</div>
</div>
</div>
<div className="pt-3">
<div className="flex justify-between space-x-2">
<div id="form-success-message" className="flex items-center space-x-2"></div>
<button
type="submit"
data-ui-id="ui.settings.profile.password.submit"
className="bg-primary hover:bg-primary/90 focus:ring-primary text-primary-foreground inline-flex items-center space-x-2 rounded-md border border-transparent px-4 py-2 text-sm font-medium focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
>
{t("shared.save")}
</button>
</div>
</div>
</div>
</div>
</Form>
</div>
</SettingSection>
{/*Separator */}
<div className="block">
<div className="py-5">
<div className="border-border/30 border-t"></div>
</div>
</div>
<SettingSection title={t("settings.profile.session.title")} description={t("settings.profile.session.description")}>
<div className="mt-5 space-y-4 text-sm md:col-span-2 md:mt-0">
<dl className="border-border bg-muted/20 grid gap-3 rounded-md border p-4 sm:grid-cols-2" data-ui-id="ui.settings.profile.session.panel">
<div>
<dt className="text-muted-foreground text-xs">{t("settings.profile.session.locale")}</dt>
<dd className="font-medium">{currentSession?.locale || t("settings.profile.session.unknown")}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs">{t("settings.profile.session.lightDarkPreference")}</dt>
<dd className="font-medium">{getLightOrDarkModeLabel(t, currentSession?.lightOrDarkMode)}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs">{t("settings.profile.session.theme")}</dt>
<dd className="font-medium">{currentSession?.theme || t("settings.profile.session.defaultTheme")}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs">{t("settings.profile.session.cookieConsentChoices")}</dt>
<dd className="font-medium">{currentSession?.cookieConsentCount ?? 0}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs">{t("settings.profile.session.impersonation")}</dt>
<dd className="font-medium">{currentSession?.impersonationActive ? t("shared.active") : t("shared.inactive")}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs">{t("settings.profile.session.adminMfaStepUp")}</dt>
<dd className="font-medium">{getMfaStepUpLabel(t, currentSession?.mfaStepUp)}</dd>
</div>
</dl>
{currentSession?.mfaStepUp.present ? (
<Form method="post">
<input hidden type="text" name="action" value="clearMfaStepUp" readOnly />
<input hidden type="text" name="csrf" value={csrf ?? ""} readOnly />
<ButtonTertiary type="submit">{t("settings.profile.session.clearStepUpVerification")}</ButtonTertiary>
</Form>
) : null}
</div>
</SettingSection>
{/*Separator */}
<div className="block">
<div className="py-5">
<div className="border-border/30 border-t"></div>
</div>
</div>
<SettingSection title={t("settings.preferences.title")} description={t("settings.preferences.description")}>
{/*Preferences */}
<div className="mt-5 md:col-span-2 md:mt-0">
<div className="">
<div className="">
<div className="grid grid-cols-6 gap-2">
<div className="col-span-6 sm:col-span-6">
<label htmlFor="locale" className="mb-1 block text-xs leading-5 font-medium">
{t("settings.preferences.language")}
</label>
<InputSelect
required
value={i18n.language}
setValue={(e) => changedLocale(e?.toString() || "")}
options={i18nConfig.supportedLngs.map((locale) => ({ value: locale, name: t("shared.locales." + locale) }))}
/>
</div>
</div>
</div>
</div>
</div>
</SettingSection>
{/*Separator */}
<div className="block">
<div className="py-5">
<div className="border-border/30 border-t"></div>
</div>
</div>
<SettingSection title={t("settings.danger.title")} description={t("settings.danger.description")}>
{/*Danger */}
<div className="mt-12 md:col-span-2 md:mt-0">
<div>
<input hidden type="text" name="action" value="deleteAccount" readOnly />
<div className="">
<div className="">
{/* <h3 className="text-lg font-medium leading-6 text-foreground">{t("settings.danger.deleteYourAccount")}</h3>
<div className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
<p>{t("settings.danger.onceYouDelete")}.</p>
</div> */}
<div className="">
<ButtonPrimary destructive={true} onClick={deleteAccount} dataUiId="ui.settings.profile.danger.delete">
{t("settings.danger.deleteUserAccount")}
</ButtonPrimary>
</div>
</div>
</div>
</div>
</div>
</SettingSection>
</div>
{showUploadImage && !uploadingImage && (
<UploadImage onClose={() => setShowUploadImage(false)} title={t("shared.avatar")} initialImage={avatar} onLoaded={loadedImage} />
)}
<SuccessModal ref={successModal} />
<ErrorModal ref={errorModal} />
<ConfirmModal ref={confirmModal} inputType="password" placeholder={t("settings.profile.passwordCurrent")} onYes={confirmDelete} />
</div>
);
}
@@ -0,0 +1,21 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const sourcePath = path.resolve(process.cwd(), "apps/web/app/modules/users/components/UserProfileSettings.tsx");
describe("user profile settings i18n", () => {
it("keeps MFA and session diagnostics copy behind settings keys", () => {
const source = fs.readFileSync(sourcePath, "utf8");
expect(source).toContain("settings.profile.mfa.authenticatorApp");
expect(source).toContain("settings.profile.mfa.recoveryCodesOneTime");
expect(source).toContain("settings.profile.session.lightDarkPreference");
expect(source).toContain("settings.profile.session.clearStepUpVerification");
expect(source).not.toContain("ui.autogenerated");
expect(source).not.toContain("i18nT");
expect(source).not.toContain("Authenticator app");
expect(source).not.toContain("Current session");
expect(source).not.toContain("Cookie consent choices");
});
});