481 lines
22 KiB
TypeScript
481 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|