htmlIsland support added, so the profile and login forms are now embedded into react controls.

This commit is contained in:
Colin Dawson 2026-02-02 23:12:52 +00:00
parent 62e0f966b8
commit 5fb966996a
14 changed files with 872 additions and 603 deletions

View File

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -28,8 +28,12 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/styles.css" />
<title>e-suite</title> <title>e-suite</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.3/jquery.validate.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validation-unobtrusive/3.2.12/jquery.validate.unobtrusive.js"></script>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -134,6 +134,7 @@
"PressAgainToUnblock": "Press again to unblock", "PressAgainToUnblock": "Press again to unblock",
"PrintSpecification": "Print Specification", "PrintSpecification": "Print Specification",
"Profile": "Profile", "Profile": "Profile",
"ProfileSaved": "Profile updated.",
"ResendConfirm": "Resend Confirm", "ResendConfirm": "Resend Confirm",
"Required": "Required", "Required": "Required",
"ResetPassword": "Reset Password", "ResetPassword": "Reset Password",

View File

@ -0,0 +1,8 @@
{
"island": {
"loadError": "Failed to load this section.",
"networkError": "Network error while saving.",
"serverError": "Server error while saving.",
"saveSuccess": "Saved successfully."
}
}

View File

@ -134,6 +134,7 @@
"PressAgainToUnblock": "Appuyez de nouveau pour débloquer", "PressAgainToUnblock": "Appuyez de nouveau pour débloquer",
"PrintSpecification": "Imprimer la spécification", "PrintSpecification": "Imprimer la spécification",
"Profile": "Profil", "Profile": "Profil",
"ProfileSaved": "Profil mis à jour.",
"ResendConfirm": "Renvoyer la confirmation", "ResendConfirm": "Renvoyer la confirmation",
"Required": "Requis", "Required": "Requis",
"ResetPassword": "Réinitialiser le mot de passe", "ResetPassword": "Réinitialiser le mot de passe",

View File

@ -0,0 +1,8 @@
{
"island": {
"loadError": "Échec du chargement de cette section.",
"networkError": "Erreur réseau lors de lenregistrement.",
"serverError": "Erreur du serveur lors de lenregistrement.",
"saveSuccess": "Enregistré avec succès."
}
}

View File

@ -9,7 +9,6 @@ import ForgotPassword from "./modules/frame/components/ForgotPassword";
import NotFound from "./modules/frame/components/NotFound"; import NotFound from "./modules/frame/components/NotFound";
import Logout from "./modules/frame/components/Logout"; import Logout from "./modules/frame/components/Logout";
import LoginForm from "./modules/frame/components/LoginForm"; import LoginForm from "./modules/frame/components/LoginForm";
import Redirect from "./components/common/Redirect";
import Mainframe from "./modules/frame/components/Mainframe"; import Mainframe from "./modules/frame/components/Mainframe";
import EmailUserAction from "./modules/frame/components/EmailUserAction"; import EmailUserAction from "./modules/frame/components/EmailUserAction";
import { HashNavigationProvider } from "./utils/HashNavigationContext"; import { HashNavigationProvider } from "./utils/HashNavigationContext";
@ -48,9 +47,7 @@ import { Namespaces } from "./i18n/i18n";
function GetSecureRoutes() { function GetSecureRoutes() {
const { t } = useTranslation<typeof Namespaces.Common>(); const { t } = useTranslation<typeof Namespaces.Common>();
const profileRoute = window.__RUNTIME_CONFIG__.EXTERNAL_LOGIN ? ( const profileRoute = (
<Route path="/profile" element={<Redirect to="/account/profile" />} />
) : (
<Route <Route
path="/profile" path="/profile"
element={ element={
@ -444,19 +441,6 @@ function App() {
window.location.reload(); window.location.reload();
}); });
const loginRoute = window.__RUNTIME_CONFIG__.EXTERNAL_LOGIN ? (
<Route path="/login" element={<Redirect to="/account/login" />} />
) : (
<Route
path="/login"
element={
<LoginFrame>
<LoginForm />
</LoginFrame>
}
/>
);
return ( return (
<HelmetProvider> <HelmetProvider>
<HashNavigationProvider> <HashNavigationProvider>
@ -466,7 +450,14 @@ function App() {
<main> <main>
<Routes> <Routes>
<Route path="/env" element={<EnvPage />} /> <Route path="/env" element={<EnvPage />} />
{loginRoute} <Route
path="/login"
element={
<LoginFrame>
<LoginForm />
</LoginFrame>
}
/>
<Route <Route
path="/forgot-password" path="/forgot-password"
element={ element={

View File

@ -0,0 +1,181 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
import { toast } from "react-toastify";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../i18n/i18n";
export interface HtmlIslandProps {
url: string;
islandId?: string;
successMessage?: string;
}
const HtmlIsland: React.FC<HtmlIslandProps> = ({
url,
islandId,
successMessage,
}) => {
const [html, setHtml] = useState("");
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const { t: tIsland } = useTranslation(Namespaces.HtmlIsland);
//
// Load the island HTML
//
const loadIsland = useCallback(() => {
setError(null);
fetch(url, { credentials: "include" })
.then((r) => {
if (!r.ok) throw new Error(`Failed to load island: ${r.status}`);
return r.text();
})
.then((text) => setHtml(text))
.catch(() => {
setError(tIsland("island.loadError"));
toast.error(tIsland("island.loadError"));
});
}, [url, tIsland]);
//
// Initial load + reload when URL changes
//
useEffect(() => {
loadIsland();
}, [loadIsland]);
//
// After HTML is injected, run scripts + bind form handling
//
useEffect(() => {
if (!html || !containerRef.current) return;
const container = containerRef.current;
//
// 1. Extract and execute <script> tags
//
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const scripts = Array.from(doc.querySelectorAll("script"));
scripts.forEach((script) => {
const newScript = document.createElement("script");
if (script.src) {
newScript.src = script.src;
} else {
newScript.textContent = script.textContent;
}
if (islandId) {
newScript.dataset.island = islandId;
}
document.body.appendChild(newScript);
});
//
// 2. Rebind jQuery Unobtrusive Validation (if available)
//
const w = window as any;
if (
w.jQuery &&
w.jQuery.validator &&
w.jQuery.validator.unobtrusive &&
container
) {
w.jQuery.validator.unobtrusive.parse(container);
}
//
// 3. Intercept form submissions (unless opted out)
//
const form = container.querySelector("form");
if (!form) return;
const shouldFullPageRedirect =
form.getAttribute("data-full-page-redirect") === "true";
if (shouldFullPageRedirect) {
// Do NOT intercept this form let the browser handle POST + redirect
return;
}
const submitHandler = async (e: Event) => {
e.preventDefault();
setError(null);
const formData = new FormData(form);
let response: Response;
try {
response = await fetch(form.action, {
method: "POST",
body: formData,
credentials: "include",
redirect: "manual",
});
} catch {
setError(tIsland("island.networkError"));
toast.error(tIsland("island.networkError"));
return;
}
const isHttpRedirect = [301, 302, 303, 307, 308].includes(
response.status,
);
if (isHttpRedirect) {
toast.success(
successMessage ? successMessage : tIsland("island.saveSuccess"),
);
loadIsland();
return;
}
if (!response.ok) {
setError(tIsland("island.serverError"));
toast.error(tIsland("island.serverError"));
return;
}
const newHtml = await response.text();
setHtml(newHtml);
toast.success(
successMessage ? successMessage : tIsland("island.saveSuccess"),
);
};
form.addEventListener("submit", submitHandler);
// Cleanup on re-render
return () => {
form.removeEventListener("submit", submitHandler);
};
}, [html, islandId, successMessage, tIsland, loadIsland]);
return (
<div>
{error && (
<div
style={{
background: "#fee",
border: "1px solid #c00",
padding: "8px",
marginBottom: "10px",
borderRadius: "4px",
color: "#900",
}}
>
{error}
</div>
)}
<div ref={containerRef} dangerouslySetInnerHTML={{ __html: html }} />
</div>
);
};
export default HtmlIsland;

View File

@ -8,6 +8,7 @@ import { fallbackLng } from "./generatedLocales";
export const Namespaces = { export const Namespaces = {
Common: "common", Common: "common",
MailTypes: "mailTypes", MailTypes: "mailTypes",
HtmlIsland: "htmlIsland",
} as const; } as const;
export type Namespace = (typeof Namespaces)[keyof typeof Namespaces]; export type Namespace = (typeof Namespaces)[keyof typeof Namespaces];

View File

@ -0,0 +1,21 @@
import { useTranslation } from "react-i18next";
import HtmlIsland from "../../../components/common/HtmlIsland";
import { Namespaces } from "../../../i18n/i18n";
export interface ExternalLoginFormProps {
url: string;
}
const ExternalLoginForm: React.FC<ExternalLoginFormProps> = ({ url }) => {
const { t } = useTranslation(Namespaces.Common);
return (
<HtmlIsland
url={url}
islandId="loginform"
successMessage={t("ProfileSaved")}
/>
);
};
export default ExternalLoginForm;

View File

@ -0,0 +1,245 @@
import React, { useCallback, useEffect, useState } from "react";
import { Link, Navigate } from "react-router-dom";
import Joi from "joi";
import authentication from "../services/authenticationService";
import { InputType } from "../../../components/common/Input";
import { ButtonType } from "../../../components/common/Button";
import { useForm } from "../../../components/common/useForm";
import {
renderButton,
renderError,
renderInput,
} from "../../../components/common/formHelpers";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../../i18n/i18n";
const InternalLoginForm: React.FC = () => {
const { t } = useTranslation<typeof Namespaces.Common>();
const [isInNextStage, setIsInNextStage] = useState(false);
const [emailSent, setEmailSent] = useState(false);
const passwordMaxLength = 255;
const form = useForm({
loaded: true,
data: {
username: "",
password: "",
tfaNeeded: false,
requestTfaRemoval: false,
securityCode: "",
},
errors: {},
redirect: "",
});
const schema = {
username: Joi.string()
.required()
.email({ tlds: { allow: false } })
.label(t("Email")),
password: Joi.string().required().label(t("Password")),
tfaNeeded: Joi.boolean().required(),
requestTfaRemoval: Joi.boolean().required(),
securityCode: Joi.string().allow("").label(t("Authenticate")),
};
form.schema = schema;
useEffect(() => {
window.location.replace("/login");
}, []);
const performLogin = useCallback(
async (data: any) => {
try {
const result = await authentication.login(
data.username,
data.password,
data.securityCode,
data.requestTfaRemoval,
);
switch (result) {
case 1: {
const nextData = { ...form.state.data };
if (!nextData.tfaNeeded) {
nextData.tfaNeeded = true;
form.setState({ data: nextData });
}
break;
}
case 2:
window.location.href = "/";
break;
default:
break;
}
} catch (ex: any) {
form.handleGeneralError(ex);
}
},
[form],
);
const doSubmit = async () => {
const { data } = form.state;
await performLogin(data);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
form.handleSubmit(e, async () => doSubmit());
};
const handleNextClick = async () => {
const data = { ...form.state.data };
const validationResult = schema.username.validate(data.username);
if (validationResult.error === undefined) {
setIsInNextStage(true);
}
};
const handleForgetPassword = async () => {
try {
await authentication.forgotPassword(form.state.data.username as string);
setEmailSent(true);
const nextData = { ...form.state.data, username: "", password: "" };
form.setState({ data: nextData });
} catch (ex: any) {
form.handleGeneralError(ex);
}
};
const authenticationWorkAround = async () => {
const data = { ...form.state.data, requestTfaRemoval: true };
await performLogin(data);
form.setState({ data });
};
const { tfaNeeded, requestTfaRemoval } = form.state.data as any;
const result = schema.username.validate(form.state.data.username);
const validEmail = result.error === undefined;
if (authentication.getCurrentUser()) return <Navigate to="/" />;
const requestTfaRemovalPanel = (
<div>
An email has been sent to you so that you can regain control of your
account.
</div>
);
const loginPanel = (
<>
<form onSubmit={handleSubmit}>
{renderInput(
"username",
"",
form.state.data,
form.state.errors,
InputType.text,
isInNextStage,
"",
t("Email") as string,
0,
true,
"username",
form.handleChange,
)}
{renderInput(
"password",
"",
form.state.data,
form.state.errors,
InputType.password,
emailSent,
"",
t("Password") as string,
passwordMaxLength,
isInNextStage,
"current-password",
form.handleChange,
)}
{!isInNextStage &&
renderButton(
t("Next"),
form.state.errors,
"next",
handleNextClick,
"login",
validEmail,
ButtonType.primary,
true,
)}
{isInNextStage && (
<div className="clickables">
{renderButton(
"Login",
form.state.errors,
"login",
undefined,
"login",
!emailSent,
)}
</div>
)}
</form>
{isInNextStage && (
<div className="forgottenLink">
{renderButton(
t("ForgottenPassword") as string,
form.state.errors,
"forgot-password",
handleForgetPassword,
"forgot-password",
validEmail,
ButtonType.secondary,
true,
)}
</div>
)}
{emailSent && (
<div className="alert alert-info emailSent">
If you have a registered account, you will receive an email.
</div>
)}
{renderError("_general", form.state.errors)}
</>
);
const tfaPanel = (
<form onSubmit={handleSubmit}>
{renderError("_general", form.state.errors)}
{renderInput(
"securityCode",
t("Authenticate") as string,
form.state.data,
form.state.errors,
InputType.text,
false,
"",
"",
0,
true,
undefined,
form.handleChange,
)}
{renderButton(t("Authenticate"), form.state.errors, "authenticate")}
<Link to="#" onClick={authenticationWorkAround}>
My Authenticator is not working
</Link>
</form>
);
return (
<div>
{requestTfaRemoval
? requestTfaRemovalPanel
: tfaNeeded
? tfaPanel
: loginPanel}
</div>
);
};
export default InternalLoginForm;

View File

@ -1,244 +1,19 @@
import React, { useCallback, useEffect, useState } from "react"; import ExternalLoginForm from "./ExternalLoginForm";
import { Link, Navigate } from "react-router-dom"; import InternalLoginForm from "./InternalLoginForm";
import Joi from "joi";
import authentication from "../services/authenticationService";
import { InputType } from "../../../components/common/Input";
import { ButtonType } from "../../../components/common/Button";
import { useForm } from "../../../components/common/useForm";
import {
renderButton,
renderError,
renderInput,
} from "../../../components/common/formHelpers";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../../i18n/i18n";
const LoginForm: React.FC = () => { const LoginForm: React.FC = () => {
const { t } = useTranslation<typeof Namespaces.Common>(); if (window.__RUNTIME_CONFIG__.EXTERNAL_LOGIN) {
const [isInNextStage, setIsInNextStage] = useState(false); return (
const [emailSent, setEmailSent] = useState(false);
const passwordMaxLength = 255;
const form = useForm({
loaded: true,
data: {
username: "",
password: "",
tfaNeeded: false,
requestTfaRemoval: false,
securityCode: "",
},
errors: {},
redirect: "",
});
const schema = {
username: Joi.string()
.required()
.email({ tlds: { allow: false } })
.label(t("Email")),
password: Joi.string().required().label(t("Password")),
tfaNeeded: Joi.boolean().required(),
requestTfaRemoval: Joi.boolean().required(),
securityCode: Joi.string().allow("").label(t("Authenticate")),
};
form.schema = schema;
useEffect(() => {
window.location.replace("/login");
}, []);
const performLogin = useCallback(
async (data: any) => {
try {
const result = await authentication.login(
data.username,
data.password,
data.securityCode,
data.requestTfaRemoval,
);
switch (result) {
case 1: {
const nextData = { ...form.state.data };
if (!nextData.tfaNeeded) {
nextData.tfaNeeded = true;
form.setState({ data: nextData });
}
break;
}
case 2:
window.location.href = "/";
break;
default:
break;
}
} catch (ex: any) {
form.handleGeneralError(ex);
}
},
[form],
);
const doSubmit = async () => {
const { data } = form.state;
await performLogin(data);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
form.handleSubmit(e, async () => doSubmit());
};
const handleNextClick = async () => {
const data = { ...form.state.data };
const validationResult = schema.username.validate(data.username);
if (validationResult.error === undefined) {
setIsInNextStage(true);
}
};
const handleForgetPassword = async () => {
try {
await authentication.forgotPassword(form.state.data.username as string);
setEmailSent(true);
const nextData = { ...form.state.data, username: "", password: "" };
form.setState({ data: nextData });
} catch (ex: any) {
form.handleGeneralError(ex);
}
};
const authenticationWorkAround = async () => {
const data = { ...form.state.data, requestTfaRemoval: true };
await performLogin(data);
form.setState({ data });
};
const { tfaNeeded, requestTfaRemoval } = form.state.data as any;
const result = schema.username.validate(form.state.data.username);
const validEmail = result.error === undefined;
if (authentication.getCurrentUser()) return <Navigate to="/" />;
const requestTfaRemovalPanel = (
<div>
An email has been sent to you so that you can regain control of your
account.
</div>
);
const loginPanel = (
<> <>
<form onSubmit={handleSubmit}> <ExternalLoginForm url="/account/login" />
{renderInput(
"username",
"",
form.state.data,
form.state.errors,
InputType.text,
isInNextStage,
"",
t("Email") as string,
0,
true,
"username",
form.handleChange,
)}
{renderInput(
"password",
"",
form.state.data,
form.state.errors,
InputType.password,
emailSent,
"",
t("Password") as string,
passwordMaxLength,
isInNextStage,
"current-password",
form.handleChange,
)}
{!isInNextStage &&
renderButton(
t("Next"),
form.state.errors,
"next",
handleNextClick,
"login",
validEmail,
ButtonType.primary,
true,
)}
{isInNextStage && (
<div className="clickables">
{renderButton(
"Login",
form.state.errors,
"login",
undefined,
"login",
!emailSent,
)}
</div>
)}
</form>
{isInNextStage && (
<div className="forgottenLink">
{renderButton(
t("ForgottenPassword") as string,
form.state.errors,
"forgot-password",
handleForgetPassword,
"forgot-password",
validEmail,
ButtonType.secondary,
true,
)}
</div>
)}
{emailSent && (
<div className="alert alert-info emailSent">
If you have a registered account, you will receive an email.
</div>
)}
{renderError("_general", form.state.errors)}
</> </>
); );
}
const tfaPanel = (
<form onSubmit={handleSubmit}>
{renderError("_general", form.state.errors)}
{renderInput(
"securityCode",
t("Authenticate") as string,
form.state.data,
form.state.errors,
InputType.text,
false,
"",
"",
0,
true,
undefined,
form.handleChange,
)}
{renderButton(t("Authenticate"), form.state.errors, "authenticate")}
<Link to="#" onClick={authenticationWorkAround}>
My Authenticator is not working
</Link>
</form>
);
return ( return (
<div> <>
{requestTfaRemoval <InternalLoginForm />
? requestTfaRemovalPanel </>
: tfaNeeded
? tfaPanel
: loginPanel}
</div>
); );
}; };

View File

@ -1,352 +1,12 @@
import React, { useEffect, useState } from "react"; import ExternalProfile from "./components/ExternalProfile";
import Joi from "joi"; import InternalProfile from "./components/InternalProfile";
import { toast } from "react-toastify";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../i18n/i18n";
import { useForm } from "../../components/common/useForm";
import profileService from "./services/profileService";
import { InputType } from "../../components/common/Input";
import { TwoFactorAuthenticationSettings } from "./models/TwoFactorAuthenticationSettings";
import Loading from "../../components/common/Loading";
import {
renderError,
renderButton,
renderInput,
renderToggle,
renderDropSection,
} from "../../components/common/formHelpers";
const Profile: React.FC = () => { const Profile: React.FC = () => {
const { t } = useTranslation<typeof Namespaces.Common>(); if (window.__RUNTIME_CONFIG__.EXTERNAL_LOGIN) {
return <ExternalProfile url="/account/profile" />;
const labelFirstName = t("FirstName");
const labelMiddleNames = t("MiddleNames");
const labelLastName = t("LastName");
const labelEmail = t("Email");
const labelNewPassword = t("NewPassword");
const labelConfirmPassword = t("ConfirmPassword");
const labelUsingTwoFactorAuthentication = t("TwoFactorAuthentication");
const labelTfaCode = t("AuthenticationCode");
const labelApply = t("Save");
const [twoFactorAuthenticationSettings, setTwoFactorAuthenticationSettings] =
useState<TwoFactorAuthenticationSettings>({
manualEntrySetupCode: "",
qrCodeImageUrl: "",
});
const form = useForm({
loaded: false,
data: {
firstName: "",
middleNames: "",
lastName: "",
email: "",
newPassword: "",
confirmPassword: "",
originalUsingTwoFactorAuthentication: false,
usingTwoFactorAuthentication: false,
tfaCode: "",
},
errors: {},
redirect: "",
});
form.schema = {
firstName: Joi.string().required().label(labelFirstName),
middleNames: Joi.string().allow("").required().label(labelMiddleNames),
lastName: Joi.string().required().label(labelLastName),
email: Joi.string()
.required()
.email({ tlds: { allow: false } })
.label(labelEmail),
newPassword: Joi.string().allow("").min(5).label(labelNewPassword),
confirmPassword: Joi.string()
.when("newPassword", {
is: "",
then: Joi.allow("").optional(),
otherwise: Joi.valid(Joi.ref("newPassword")).error(() => {
const e = new Error("Passwords must match");
e.name = "confirmPassword";
return e;
}),
})
.label(labelConfirmPassword),
originalUsingTwoFactorAuthentication: Joi.boolean().required(),
usingTwoFactorAuthentication: Joi.boolean()
.required()
.label(labelUsingTwoFactorAuthentication),
tfaCode: Joi.string()
.when("originalUsingTwoFactorAuthentication", {
is: Joi.ref("usingTwoFactorAuthentication"),
then: Joi.allow("").optional(),
otherwise: Joi.when("usingTwoFactorAuthentication", {
is: true,
then: Joi.string()
.length(6)
.required()
.error(() => {
const e = new Error(
"You must enter the code from the authenicator",
);
e.name = "tfaCode";
return e;
}),
otherwise: Joi.allow("").optional(),
}),
})
.label(labelTfaCode),
};
const loadProfile = async () => {
try {
const profile = await profileService.getMyProfile();
if (profile) {
const {
firstName,
middleNames,
lastName,
email,
usingTwoFactorAuthentication,
twoFactorAuthenticationSettings,
} = profile;
const data = {
firstName,
middleNames,
lastName,
email,
newPassword: "",
confirmPassword: "",
originalUsingTwoFactorAuthentication: usingTwoFactorAuthentication,
usingTwoFactorAuthentication,
tfaCode: "",
};
form.setState({ loaded: true, data });
setTwoFactorAuthenticationSettings(twoFactorAuthenticationSettings);
} }
} catch (ex: any) {
form.handleGeneralError(ex);
}
};
useEffect(() => { return <InternalProfile />;
void loadProfile();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const doSubmit = async (buttonName: string) => {
try {
const {
firstName,
middleNames,
lastName,
email,
usingTwoFactorAuthentication,
tfaCode,
newPassword,
confirmPassword,
} = form.state.data;
let password = "";
if (newPassword === confirmPassword) password = newPassword as string;
const response = await profileService.putMyProfile(
firstName as string,
middleNames as string,
lastName as string,
email as string,
usingTwoFactorAuthentication as boolean,
tfaCode as string,
password,
);
if (response) {
await loadProfile();
toast.info(t("YourProfileSettingsHaveBeenSaved"));
}
} catch (ex: any) {
form.handleGeneralError(ex);
}
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
form.handleSubmit(e, doSubmit);
};
const { loaded } = form.state;
const { usingTwoFactorAuthentication, newPassword } = form.state.data;
const passwordMaxLength = 255;
const tfaEnabled = usingTwoFactorAuthentication
? t("Enabled")
: t("Disabled");
let tfaImageBlock = null;
if (twoFactorAuthenticationSettings)
tfaImageBlock = (
<React.Fragment>
<label>{twoFactorAuthenticationSettings.manualEntrySetupCode}</label>
<img
src={twoFactorAuthenticationSettings.qrCodeImageUrl}
alt={twoFactorAuthenticationSettings.manualEntrySetupCode}
/>
</React.Fragment>
);
const tfaSection = (
<div>
{renderToggle(
"usingTwoFactorAuthentication",
labelUsingTwoFactorAuthentication,
form.state.data,
form.state.errors,
form.handleToggleChange,
)}
{tfaImageBlock}
{renderInput(
"tfaCode",
labelTfaCode,
form.state.data,
form.state.errors,
InputType.text,
false,
"",
"",
0,
true,
undefined,
form.handleChange,
)}
</div>
);
let passwordSection = (
<React.Fragment>
{renderInput(
"newPassword",
labelNewPassword,
form.state.data,
form.state.errors,
InputType.password,
false,
"",
"",
0,
true,
undefined,
form.handleChange,
)}
</React.Fragment>
);
if (newPassword !== "")
passwordSection = (
<React.Fragment>
{renderInput(
"newPassword",
labelNewPassword,
form.state.data,
form.state.errors,
InputType.password,
false,
"",
"",
passwordMaxLength,
true,
undefined,
form.handleChange,
)}
{renderInput(
"confirmPassword",
labelConfirmPassword,
form.state.data,
form.state.errors,
InputType.password,
false,
"",
"",
passwordMaxLength,
true,
undefined,
form.handleChange,
)}
</React.Fragment>
);
return (
<Loading loaded={loaded}>
<h1>{t("Profile")}</h1>
<form onSubmit={handleSubmit}>
{renderError("_general", form.state.errors)}
{renderInput(
"email",
labelEmail,
form.state.data,
form.state.errors,
InputType.text,
true,
"",
"",
0,
true,
undefined,
form.handleChange,
)}
{renderInput(
"firstName",
labelFirstName,
form.state.data,
form.state.errors,
InputType.text,
false,
"",
"",
0,
true,
undefined,
form.handleChange,
)}
{renderInput(
"middleNames",
labelMiddleNames,
form.state.data,
form.state.errors,
InputType.text,
false,
"",
"",
0,
true,
undefined,
form.handleChange,
)}
{renderInput(
"lastName",
labelLastName,
form.state.data,
form.state.errors,
InputType.text,
false,
"",
"",
0,
true,
undefined,
form.handleChange,
)}
{passwordSection}
{renderDropSection(
"turnOnTfa",
<label>Two Factor Authentication {tfaEnabled}</label>,
tfaSection,
form.state.errors,
)}
<br />
{renderButton(labelApply, form.state.errors, "apply")}
</form>
</Loading>
);
}; };
export default Profile; export default Profile;

View File

@ -0,0 +1,21 @@
import { useTranslation } from "react-i18next";
import HtmlIsland from "../../../components/common/HtmlIsland";
import { Namespaces } from "../../../i18n/i18n";
export interface ExternalProfileProps {
url: string;
}
const ExternalProfile: React.FC<ExternalProfileProps> = ({ url }) => {
const { t } = useTranslation(Namespaces.Common);
return (
<HtmlIsland
url={url}
islandId="profile"
successMessage={t("ProfileSaved")}
/>
);
};
export default ExternalProfile;

View File

@ -0,0 +1,352 @@
import React, { useEffect, useState } from "react";
import Joi from "joi";
import { toast } from "react-toastify";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../../i18n/i18n";
import { useForm } from "../../../components/common/useForm";
import profileService from "../services/profileService";
import { InputType } from "../../../components/common/Input";
import { TwoFactorAuthenticationSettings } from "../models/TwoFactorAuthenticationSettings";
import Loading from "../../../components/common/Loading";
import {
renderError,
renderButton,
renderInput,
renderToggle,
renderDropSection,
} from "../../../components/common/formHelpers";
const InternalProfile: React.FC = () => {
const { t } = useTranslation<typeof Namespaces.Common>();
//Internal login version
const labelFirstName = t("FirstName");
const labelMiddleNames = t("MiddleNames");
const labelLastName = t("LastName");
const labelEmail = t("Email");
const labelNewPassword = t("NewPassword");
const labelConfirmPassword = t("ConfirmPassword");
const labelUsingTwoFactorAuthentication = t("TwoFactorAuthentication");
const labelTfaCode = t("AuthenticationCode");
const labelApply = t("Save");
const [twoFactorAuthenticationSettings, setTwoFactorAuthenticationSettings] =
useState<TwoFactorAuthenticationSettings>({
manualEntrySetupCode: "",
qrCodeImageUrl: "",
});
const form = useForm({
loaded: false,
data: {
firstName: "",
middleNames: "",
lastName: "",
email: "",
newPassword: "",
confirmPassword: "",
originalUsingTwoFactorAuthentication: false,
usingTwoFactorAuthentication: false,
tfaCode: "",
},
errors: {},
redirect: "",
});
form.schema = {
firstName: Joi.string().required().label(labelFirstName),
middleNames: Joi.string().allow("").required().label(labelMiddleNames),
lastName: Joi.string().required().label(labelLastName),
email: Joi.string()
.required()
.email({ tlds: { allow: false } })
.label(labelEmail),
newPassword: Joi.string().allow("").min(5).label(labelNewPassword),
confirmPassword: Joi.string()
.when("newPassword", {
is: "",
then: Joi.allow("").optional(),
otherwise: Joi.valid(Joi.ref("newPassword")).error(() => {
const e = new Error("Passwords must match");
e.name = "confirmPassword";
return e;
}),
})
.label(labelConfirmPassword),
originalUsingTwoFactorAuthentication: Joi.boolean().required(),
usingTwoFactorAuthentication: Joi.boolean()
.required()
.label(labelUsingTwoFactorAuthentication),
tfaCode: Joi.string()
.when("originalUsingTwoFactorAuthentication", {
is: Joi.ref("usingTwoFactorAuthentication"),
then: Joi.allow("").optional(),
otherwise: Joi.when("usingTwoFactorAuthentication", {
is: true,
then: Joi.string()
.length(6)
.required()
.error(() => {
const e = new Error(
"You must enter the code from the authenicator",
);
e.name = "tfaCode";
return e;
}),
otherwise: Joi.allow("").optional(),
}),
})
.label(labelTfaCode),
};
const loadProfile = async () => {
try {
const profile = await profileService.getMyProfile();
if (profile) {
const {
firstName,
middleNames,
lastName,
email,
usingTwoFactorAuthentication,
twoFactorAuthenticationSettings,
} = profile;
const data = {
firstName,
middleNames,
lastName,
email,
newPassword: "",
confirmPassword: "",
originalUsingTwoFactorAuthentication: usingTwoFactorAuthentication,
usingTwoFactorAuthentication,
tfaCode: "",
};
form.setState({ loaded: true, data });
setTwoFactorAuthenticationSettings(twoFactorAuthenticationSettings);
}
} catch (ex: any) {
form.handleGeneralError(ex);
}
};
useEffect(() => {
void loadProfile();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const doSubmit = async (buttonName: string) => {
try {
const {
firstName,
middleNames,
lastName,
email,
usingTwoFactorAuthentication,
tfaCode,
newPassword,
confirmPassword,
} = form.state.data;
let password = "";
if (newPassword === confirmPassword) password = newPassword as string;
const response = await profileService.putMyProfile(
firstName as string,
middleNames as string,
lastName as string,
email as string,
usingTwoFactorAuthentication as boolean,
tfaCode as string,
password,
);
if (response) {
await loadProfile();
toast.info(t("YourProfileSettingsHaveBeenSaved"));
}
} catch (ex: any) {
form.handleGeneralError(ex);
}
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
form.handleSubmit(e, doSubmit);
};
const { loaded } = form.state;
const { usingTwoFactorAuthentication, newPassword } = form.state.data;
const passwordMaxLength = 255;
const tfaEnabled = usingTwoFactorAuthentication
? t("Enabled")
: t("Disabled");
let tfaImageBlock = null;
if (twoFactorAuthenticationSettings)
tfaImageBlock = (
<React.Fragment>
<label>{twoFactorAuthenticationSettings.manualEntrySetupCode}</label>
<img
src={twoFactorAuthenticationSettings.qrCodeImageUrl}
alt={twoFactorAuthenticationSettings.manualEntrySetupCode}
/>
</React.Fragment>
);
const tfaSection = (
<div>
{renderToggle(
"usingTwoFactorAuthentication",
labelUsingTwoFactorAuthentication,
form.state.data,
form.state.errors,
form.handleToggleChange,
)}
{tfaImageBlock}
{renderInput(
"tfaCode",
labelTfaCode,
form.state.data,
form.state.errors,
InputType.text,
false,
"",
"",
0,
true,
undefined,
form.handleChange,
)}
</div>
);
let passwordSection = (
<React.Fragment>
{renderInput(
"newPassword",
labelNewPassword,
form.state.data,
form.state.errors,
InputType.password,
false,
"",
"",
0,
true,
undefined,
form.handleChange,
)}
</React.Fragment>
);
if (newPassword !== "")
passwordSection = (
<React.Fragment>
{renderInput(
"newPassword",
labelNewPassword,
form.state.data,
form.state.errors,
InputType.password,
false,
"",
"",
passwordMaxLength,
true,
undefined,
form.handleChange,
)}
{renderInput(
"confirmPassword",
labelConfirmPassword,
form.state.data,
form.state.errors,
InputType.password,
false,
"",
"",
passwordMaxLength,
true,
undefined,
form.handleChange,
)}
</React.Fragment>
);
return (
<Loading loaded={loaded}>
<h1>{t("Profile")}</h1>
<form onSubmit={handleSubmit}>
{renderError("_general", form.state.errors)}
{renderInput(
"email",
labelEmail,
form.state.data,
form.state.errors,
InputType.text,
true,
"",
"",
0,
true,
undefined,
form.handleChange,
)}
{renderInput(
"firstName",
labelFirstName,
form.state.data,
form.state.errors,
InputType.text,
false,
"",
"",
0,
true,
undefined,
form.handleChange,
)}
{renderInput(
"middleNames",
labelMiddleNames,
form.state.data,
form.state.errors,
InputType.text,
false,
"",
"",
0,
true,
undefined,
form.handleChange,
)}
{renderInput(
"lastName",
labelLastName,
form.state.data,
form.state.errors,
InputType.text,
false,
"",
"",
0,
true,
undefined,
form.handleChange,
)}
{passwordSection}
{renderDropSection(
"turnOnTfa",
<label>Two Factor Authentication {tfaEnabled}</label>,
tfaSection,
form.state.errors,
)}
<br />
{renderButton(labelApply, form.state.errors, "apply")}
</form>
</Loading>
);
};
export default InternalProfile;