Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/users/components/UserProfileSettings.tsx
T

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>
);
}