Added multi language support and started refactoring components to be functions instead of classes.

This commit is contained in:
Colin Dawson 2026-01-28 23:47:51 +00:00
parent b559d9260c
commit 471e239591
25 changed files with 834 additions and 595 deletions

View File

@ -55,7 +55,9 @@
"build-css": "sass src/Sass/global.scss public/styles.css", "build-css": "sass src/Sass/global.scss public/styles.css",
"watch-css": "nodemon -e scss -x \"npm run build-css\" ", "watch-css": "nodemon -e scss -x \"npm run build-css\" ",
"start-react": "cross-env NODE_ENV=development runtime-env-cra --config-name=./public/runtime-env.js && react-app-rewired start", "start-react": "cross-env NODE_ENV=development runtime-env-cra --config-name=./public/runtime-env.js && react-app-rewired start",
"prestart": "node scripts/generate-locales.js",
"start": "concurrently \"npm run start-react\" \"npm run watch-css\" ", "start": "concurrently \"npm run start-react\" \"npm run watch-css\" ",
"prebuild": "node scripts/generate-locales.js",
"build": "react-app-rewired build", "build": "react-app-rewired build",
"test": "react-app-rewired test", "test": "react-app-rewired test",
"eject": "react-scripts eject", "eject": "react-scripts eject",

View File

@ -24,5 +24,7 @@
"SsoManager": "Sso Manager", "SsoManager": "Sso Manager",
"Support": "Support", "Support": "Support",
"UserManager": "User Manager", "UserManager": "User Manager",
"Users": "Users" "Users": "Users",
"Name": "Name",
"Loading": "Loading"
} }

View File

@ -0,0 +1,30 @@
{
"Admin": "Admin (In french)",
"AuditLog": "Audit Logs (In french)",
"AuditLogs": "Audit Logs (In french)",
"BlockedIPAddresses": "Blocked IP addresses (In french)",
"BlockedIPs": "Blocked IPs (In french)",
"ClientDomainManager": "Client Domain Manager (In french)",
"ClientDomains": "Client Domains (In french)",
"CustomFieldManager": "Custom Field Manager (In french)",
"CustomFields": "Custom Fields (In french)",
"e-print": "e-print (In french)",
"e-suite": "e-suite (In french)",
"ErrorLogs": "Error Logs (In french)",
"ExceptionLogs": "Exception Logs (In french)",
"Forms": "Forms (In french)",
"FormTemplateManager": "Form Template Manager (In french)",
"Glossaries": "Glossaries (In french)",
"GlossaryManager": "Glossary Manager (In french)",
"Home": "Home (In french)",
"Sequence": "Sequence (In french)",
"SequenceManager": "Sequence Manager (In french)",
"SiteManager": "Site Manager (In french)",
"SpecificationManager": "Specification Manager (In french)",
"SsoManager": "Sso Manager (In french)",
"Support": "Support (In french)",
"UserManager": "User Manager (In french)",
"Users": "Users (In french)",
"Name": "Name (In french)",
"Loading": "Loading (In french)"
}

View File

@ -0,0 +1,32 @@
// scripts/generate-locales.js
const fs = require("fs");
const path = require("path");
const localesDir = path.join(__dirname, "../public/locales");
const outputFile = path.join(__dirname, "../src/i18n/generatedLocales.ts");
// Discover locale folders
const rawLocales = fs
.readdirSync(localesDir)
.filter((name) => fs.statSync(path.join(localesDir, name)).isDirectory());
// Normalise folder names (e.g., en_GB → en-GB)
const locales = rawLocales.map((l) => l.replace("_", "-"));
// Sort by locale code
locales.sort((a, b) => a.localeCompare(b));
// Only region-specific locales (those with a hyphen)
const availableLocales = locales.filter((l) => l.includes("-"));
// Generate TS file
const ts = `
// AUTO-GENERATED FILE — DO NOT EDIT MANUALLY
export const availableLocales = ${JSON.stringify(availableLocales, null, 2)} as const;
export type Locale = typeof availableLocales[number];
`;
fs.writeFileSync(outputFile, ts);
console.log("Generated generatedLocales.ts");

View File

@ -43,7 +43,7 @@ import BlockedIPs from "./modules/blockedIPs/blockedIPs";
import ErrorLogs from "./modules/errorLogs/errorLogs"; import ErrorLogs from "./modules/errorLogs/errorLogs";
import SsoManager from "./modules/manager/ssoManager/ssoManager"; import SsoManager from "./modules/manager/ssoManager/ssoManager";
import SsoProviderDetails from "./modules/manager/ssoManager/SsoProviderDetails"; import SsoProviderDetails from "./modules/manager/ssoManager/SsoProviderDetails";
import { Namespaces } from "./i18n"; import { Namespaces } from "./i18n/i18n";
function GetSecureRoutes() { function GetSecureRoutes() {
const { t } = useTranslation<typeof Namespaces.Common>(); const { t } = useTranslation<typeof Namespaces.Common>();

View File

@ -1,7 +1,7 @@
import React, { useState, useCallback } from "react"; import React, { useState, useCallback } from "react";
import Button, { ButtonType } from "./Button"; import Button, { ButtonType } from "./Button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Namespaces } from "../../i18n"; import { Namespaces } from "../../i18n/i18n";
export interface ConfirmButtonProps<T> { export interface ConfirmButtonProps<T> {
delayMS?: number; delayMS?: number;
@ -21,7 +21,7 @@ function ConfirmButton<T>({
onClick, onClick,
}: ConfirmButtonProps<T>) { }: ConfirmButtonProps<T>) {
const [firstClick, setFirstClick] = useState(false); const [firstClick, setFirstClick] = useState(false);
const t = useTranslation<typeof Namespaces.Common>(); const { t } = useTranslation<typeof Namespaces.Common>();
const handleFirstClick = useCallback(() => { const handleFirstClick = useCallback(() => {
setFirstClick(true); setFirstClick(true);
@ -47,7 +47,7 @@ function ConfirmButton<T>({
{firstClick && ( {firstClick && (
<Button buttonType={ButtonType.danger} onClick={handleSecondClick}> <Button buttonType={ButtonType.danger} onClick={handleSecondClick}>
{confirmMessage ?? <>t("Are you sure?")</>} {confirmMessage ?? t("Are you sure?")}
</Button> </Button>
)} )}
</> </>

View File

@ -1,6 +1,6 @@
import { faAdd } from "@fortawesome/free-solid-svg-icons"; import { faAdd } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React from "react"; import React, { useCallback, useMemo, useState } from "react";
import { CustomField } from "../../modules/manager/customfields/services/customFieldsService"; import { CustomField } from "../../modules/manager/customfields/services/customFieldsService";
import { Paginated } from "../../services/Paginated"; import { Paginated } from "../../services/Paginated";
import { GeneralIdRef } from "../../utils/GeneralIdRef"; import { GeneralIdRef } from "../../utils/GeneralIdRef";
@ -8,92 +8,104 @@ import CustomFieldPicker from "../pickers/CustomFieldPicker";
import Column from "./columns"; import Column from "./columns";
import Table from "./Table"; import Table from "./Table";
import Button, { ButtonType } from "./Button"; import Button, { ButtonType } from "./Button";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../i18n/i18n";
export type CustomFieldEditorAdd = ( newField : CustomField) => void; export type CustomFieldEditorAdd = (newField: CustomField) => void;
export type CustomFieldEditorDelete = ( keyValue : any ) => void; export type CustomFieldEditorDelete = (keyValue: any) => void;
interface CustomFieldsEditorProps { interface CustomFieldsEditorProps {
name: string; name: string;
label: string; label: string;
error?: string; error?: string;
value: CustomField[]; value: CustomField[];
exclude : CustomField[]; exclude: CustomField[];
onAdd? : CustomFieldEditorAdd; onAdd?: CustomFieldEditorAdd;
onDelete?: CustomFieldEditorDelete onDelete?: CustomFieldEditorDelete;
} }
interface CustomFieldsEditorState { const CustomFieldsEditor: React.FC<CustomFieldsEditorProps> = ({
id : GeneralIdRef | undefined, value,
displayName : string | undefined exclude,
} label,
onAdd,
onDelete,
}) => {
const { t } = useTranslation<typeof Namespaces.Common>();
const [id, setId] = useState<GeneralIdRef | undefined>(undefined);
const [displayName, setDisplayName] = useState<string | undefined>(undefined);
class CustomFieldsEditor extends React.Component<CustomFieldsEditorProps, CustomFieldsEditorState> { const columns: Column<CustomField>[] = useMemo(
columns : Column<CustomField>[] = [ () => [{ key: "name", label: t("Name"), order: "asc" }],
{ key: "name", label: "Name", order: "asc" }, [],
]; );
state : CustomFieldsEditorState = { const paginated: Paginated<CustomField> = useMemo(
id : undefined, () => ({
displayName: undefined count: 0,
} page: 1,
pageSize: 10,
totalPages: 0,
data: value,
}),
[value],
);
handleAdd = () => { const handleAdd = useCallback(() => {
const { onAdd } = this.props; if (!onAdd || !id) return;
if (onAdd)
{
const {id, displayName} = this.state;
if (id)
{
const newField: CustomField = { const newField: CustomField = {
id: id.id ?? BigInt(-1), id: id.id ?? BigInt(-1),
name: String(displayName), name: String(displayName),
fieldType: "", fieldType: "",
defaultValue: "", defaultValue: "",
minEntries: 1, minEntries: 1,
guid : id.guid guid: id.guid,
}; };
onAdd(newField); onAdd(newField);
this.setState( { setId(undefined);
id : undefined, setDisplayName(undefined);
displayName: undefined }, [onAdd, id, displayName]);
});
}
}
}
handleChange = (name: string, value: GeneralIdRef, displayValue : string) => { const handleChange = useCallback(
this.setState({ (name: string, value: GeneralIdRef, displayValue: string) => {
id : value, setId(value);
displayName : displayValue setDisplayName(displayValue);
}); },
}; [],
);
render() { return (
const { value, exclude, label, onDelete } = this.props;
const paginated : Paginated<CustomField> = {
count: 0,
page: 1,
pageSize: 10,
totalPages: 0,
data: value
}
return <div>
{label}
<Table data={ paginated } keyName="id" columns={this.columns} onDelete={onDelete}/>
<div> <div>
<CustomFieldPicker name="customField" label={"Add"} exclude={exclude} value={undefined} onChange={this.handleChange}/> {label}
<Button buttonType={ButtonType.primary} onClick={this.handleAdd} disabled={this.state.id === undefined}> <Table
<FontAwesomeIcon icon={faAdd}/> data={paginated}
keyName="id"
columns={columns}
onDelete={onDelete}
/>
<div>
<CustomFieldPicker
name="customField"
label="Add"
exclude={exclude}
value={undefined}
onChange={handleChange}
/>
<Button
buttonType={ButtonType.primary}
onClick={handleAdd}
disabled={id === undefined}
>
<FontAwesomeIcon icon={faAdd} />
</Button> </Button>
</div> </div>
</div>; </div>
} );
} };
export default CustomFieldsEditor; export default CustomFieldsEditor;

View File

@ -1,48 +1,38 @@
import React from 'react'; import React, { useEffect, useState, useCallback, useMemo } from "react";
import { Navigate } from 'react-router-dom'; import { Navigate } from "react-router-dom";
import TabHeader from './TabHeader'; import TabHeader from "./TabHeader";
interface HorizontalTabsProps{ interface HorizontalTabsProps {
children : JSX.Element[]; children: JSX.Element[];
} }
interface HorizontalTabsState{ const HorizontalTabs: React.FC<HorizontalTabsProps> = ({ children }) => {
activeTab : string; const [activeTab, setActiveTab] = useState<string>("");
redirect: string; const [redirect, setRedirect] = useState<string>("");
}
class HorizontalTabs extends React.Component<HorizontalTabsProps, HorizontalTabsState> { // Set initial tab on mount
componentDidMount(): void { useEffect(() => {
this.onClickTabItem(this.props.children[0].props.label); if (children.length > 0) {
setActiveTab(children[0].props.label);
}
}, [children]);
const onClickTabItem = useCallback((tab: string) => {
setActiveTab((prev) => (prev !== tab ? tab : prev));
}, []);
const activeTabChildren = useMemo(() => {
const match = children.find((child) => child.props.label === activeTab);
return match ? match.props.children : <></>;
}, [children, activeTab]);
if (redirect !== "") {
return <Navigate to={redirect} />;
} }
onClickTabItem = (tab : string) => { // If only one tab, just render its content
let { activeTab } = this.state; if (children.length === 1) {
return <>{activeTabChildren}</>;
if (activeTab !== tab) {
activeTab = tab;
}
this.setState({ activeTab } );
};
state : HorizontalTabsState= {
activeTab : "",
redirect : ""
}
render() {
const { children } = this.props;
const { activeTab, redirect } = this.state;
if (redirect !== "") return <Navigate to={redirect} />;
const filteredTabs = children.filter( child => child.props.label === activeTab );
const activeTabChildren = (filteredTabs?.length > 0) ? filteredTabs[0].props.children : <></>
if (children?.length === 1) {
return (<>{activeTabChildren}</>)
} }
return ( return (
@ -51,24 +41,21 @@ class HorizontalTabs extends React.Component<HorizontalTabsProps, HorizontalTabs
<ul className="tab-list"> <ul className="tab-list">
{children.map((child) => { {children.map((child) => {
const { label } = child.props; const { label } = child.props;
return ( return (
<TabHeader <TabHeader
isActive={label === activeTab}
key={label} key={label}
label={label} label={label}
onClick={this.onClickTabItem} isActive={label === activeTab}
onClick={onClickTabItem}
/> />
); );
})} })}
</ul> </ul>
</div> </div>
<div>
{activeTabChildren} <div>{activeTabChildren}</div>
</div>
</div> </div>
); );
} };
}
export default HorizontalTabs; export default HorizontalTabs;

View File

@ -1,8 +1,9 @@
import React, { useState } from "react"; import React, { useState } from "react";
import '../../Sass/_forms.scss'; import "../../Sass/_forms.scss";
import ErrorBlock from "./ErrorBlock"; import ErrorBlock from "./ErrorBlock";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons"; import { faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons";
export enum InputType { export enum InputType {
button = "button", button = "button",
checkbox = "checkbox", checkbox = "checkbox",
@ -42,20 +43,36 @@ export interface InputProps {
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
hidden? : boolean; hidden?: boolean;
autoComplete? : string; autoComplete?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void; onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onTextAreaChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; onTextAreaChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
maxLength?: number; maxLength?: number;
} }
function Input(props: InputProps) { function Input(props: InputProps) {
const { includeLabel, name, label, error, placeHolder, readOnly, type, value, defaultValue, maxLength, hidden, autoComplete, onChange, onTextAreaChange, ...rest } = props; const {
includeLabel,
name,
label,
error,
placeHolder,
readOnly,
type,
value,
defaultValue,
maxLength,
hidden,
autoComplete,
onChange,
onTextAreaChange,
...rest
} = props;
let showValue = value; let showValue = value;
let checked: boolean = false; let checked: boolean = false;
let divClassName = "form-group" let divClassName = "form-group";
let labelClassName = "label"; let labelClassName = "label";
let className = "form-control"; let className = "form-control";
let flexClassName = ""; let flexClassName = "";
@ -63,7 +80,7 @@ function Input(props: InputProps) {
const [showPasswordIcon, setShowPasswordIcon] = useState(faEyeSlash); const [showPasswordIcon, setShowPasswordIcon] = useState(faEyeSlash);
if (type === InputType.checkbox) { if (type === InputType.checkbox) {
checked = (value === String(true)); checked = value === String(true);
showValue = undefined; showValue = undefined;
divClassName = "form-check"; divClassName = "form-check";
className = "form-check-input"; className = "form-check-input";
@ -72,11 +89,16 @@ function Input(props: InputProps) {
} }
if (type === InputType.checkbox) { if (type === InputType.checkbox) {
divClassName += ' allignedCheckBox'; divClassName += " allignedCheckBox";
} }
const renderType = (type === InputType.password && showPasswordIcon === faEye) ? InputType.text : type; const renderType =
const divEyeIconClassName = (readOnly) ? "fullHeight disabledIcon" : "fullHeight"; type === InputType.password && showPasswordIcon === faEye
? InputType.text
: type;
const divEyeIconClassName = readOnly
? "fullHeight disabledIcon"
: "fullHeight";
if (type === InputType.password) { if (type === InputType.password) {
flexClassName += "flex"; flexClassName += "flex";
@ -84,11 +106,13 @@ function Input(props: InputProps) {
return ( return (
<div className={divClassName} hidden={hidden}> <div className={divClassName} hidden={hidden}>
{(includeLabel === true || includeLabel === undefined) && <label className={labelClassName} htmlFor={name} hidden={hidden}> {(includeLabel === true || includeLabel === undefined) && (
<label className={labelClassName} htmlFor={name} hidden={hidden}>
{label} {label}
</label>} </label>
)}
<div className={flexClassName}> <div className={flexClassName}>
{type === InputType.textarea && {type === InputType.textarea && (
<textarea <textarea
id={name} id={name}
className={className} className={className}
@ -96,9 +120,10 @@ function Input(props: InputProps) {
onChange={onTextAreaChange} onChange={onTextAreaChange}
disabled={readOnly} disabled={readOnly}
value={showValue || defaultValue} value={showValue || defaultValue}
autoComplete={autoComplete}> autoComplete={autoComplete}
</textarea>} ></textarea>
{type !== InputType.textarea && )}
{type !== InputType.textarea && (
<input <input
{...rest} {...rest}
id={name} id={name}
@ -111,15 +136,22 @@ function Input(props: InputProps) {
value={showValue} value={showValue}
checked={checked} checked={checked}
defaultValue={defaultValue} defaultValue={defaultValue}
maxLength={maxLength! > 0 ? maxLength: undefined} maxLength={maxLength! > 0 ? maxLength : undefined}
autoComplete={autoComplete} autoComplete={autoComplete}
/>} />
{type === InputType.password && <div className={divEyeIconClassName} ><FontAwesomeIcon className="passwordIcon" icon={showPasswordIcon} onClick={() => { )}
{type === InputType.password && (
<div className={divEyeIconClassName}>
<FontAwesomeIcon
className="passwordIcon"
icon={showPasswordIcon}
onClick={() => {
const newIcon = showPasswordIcon === faEye ? faEyeSlash : faEye; const newIcon = showPasswordIcon === faEye ? faEyeSlash : faEye;
setShowPasswordIcon(newIcon) setShowPasswordIcon(newIcon);
} }}
} /> />
</div>} </div>
)}
</div> </div>
<ErrorBlock error={error}></ErrorBlock> <ErrorBlock error={error}></ErrorBlock>
</div> </div>

View File

@ -2,26 +2,16 @@ import React from "react";
import LoadingPanel from "./LoadingPanel"; import LoadingPanel from "./LoadingPanel";
interface Loading2Props { interface Loading2Props {
loaded : boolean loaded: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
interface Loading2State { const Loading: React.FC<Loading2Props> = ({ loaded, children }) => {
}
class Loading extends React.Component<Loading2Props, Loading2State> {
state = { loaded : false }
render() {
const { loaded, children } = this.props;
if (!loaded) { if (!loaded) {
return (<LoadingPanel/>) return <LoadingPanel />;
} }
else {
return (<>{children}</>); return <>{children}</>;
} };
}
}
export default Loading; export default Loading;

View File

@ -1,10 +1,12 @@
import { FunctionComponent } from "react"; import { useTranslation } from "react-i18next";
import { Namespaces } from "../../i18n/i18n";
interface LoadingProps { interface LoadingProps {}
}
const LoadingPanel: FunctionComponent<LoadingProps> = () => { const LoadingPanel: React.FC<LoadingProps> = () => {
return ( <div>Loading</div> ); const { t } = useTranslation<typeof Namespaces.Common>();
}
return <div>{t("Loading")}</div>;
};
export default LoadingPanel; export default LoadingPanel;

View File

@ -1,69 +1,75 @@
import React from "react"; import React, { useCallback, useMemo } from "react";
import Option from "./option"; import Option from "./option";
import Autocomplete from "./AutoComplete"; import Autocomplete from "./AutoComplete";
import Pill from "./Pill"; import Pill from "./Pill";
interface MultiSelectProps { interface MultiSelectProps {
includeLabel? : boolean, includeLabel?: boolean;
name : string, name: string;
label : string, label: string;
error? : string, error?: string;
// value : unknown options?: Option[];
options? : Option[], selectedOptions: Option[];
selectedOptions : Option[],
// includeBlankFirstEntry? : boolean,
// onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
onAdd: (item: Option) => void; onAdd: (item: Option) => void;
onDelete: (item: Option) => void; onDelete: (item: Option) => void;
} }
interface MultiSelectState { const MultiSelect: React.FC<MultiSelectProps> = ({
includeLabel = true,
} name,
label,
class MultiSelect extends React.Component<MultiSelectProps, MultiSelectState> { error,
options,
handleDelete = ( id: string) => { selectedOptions,
const { options, onDelete } = this.props; onAdd,
onDelete,
const foundItem : Option | undefined = options?.filter( x => x._id === id)[0]; }) => {
const handleDelete = useCallback(
if (foundItem) (id: string) => {
onDelete(foundItem); const found = options?.find((x) => x._id === id);
if (found) {
onDelete(found);
} }
},
[options, onDelete],
);
render() { const selectedBlock = useMemo(() => {
const { includeLabel, name, label, error, options, selectedOptions, onAdd if (selectedOptions.length === 0) return <></>;
// value, includeBlankFirstEntry, onChange, ...rest
} = this.props;
let selectedBlock = <></>; return (
<>
if (selectedOptions.length > 0) {selectedOptions.map((x) => (
{ <Pill
selectedBlock = <> key={x._id}
{ pillKey={x._id}
selectedOptions.map( x => displayText={x.name}
<Pill key={x._id} pillKey={x._id} displayText={x.name} readOnly={false} onClick={this.handleDelete} /> readOnly={false}
) onClick={handleDelete}
} />
))}
</> </>
} );
}, [selectedOptions, handleDelete]);
return ( return (
<div className="form-group multiSelect multiSelectContainer"> <div className="form-group multiSelect multiSelectContainer">
{(includeLabel===undefined || includeLabel===true) && <label htmlFor={name}>{label}</label>} {includeLabel && <label htmlFor={name}>{label}</label>}
<div className="form-control"> <div className="form-control">
<Autocomplete options={options} selectedOptions={selectedOptions} onSelect={onAdd}/> <Autocomplete
options={options}
selectedOptions={selectedOptions}
onSelect={onAdd}
/>
{selectedBlock} {selectedBlock}
</div> </div>
{error && <div className="alert alert-danger">{error}</div>} {error && <div className="alert alert-danger">{error}</div>}
</div> </div>
); );
} };
}
export default MultiSelect; export default MultiSelect;

View File

@ -1,48 +1,39 @@
import React from "react"; import React, { useState, useCallback } from "react";
import Button, { ButtonType } from "./Button"; import Button, { ButtonType } from "./Button";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCaretDown, faCaretUp } from "@fortawesome/free-solid-svg-icons"; import { faCaretDown, faCaretUp } from "@fortawesome/free-solid-svg-icons";
interface ExpandoProps { interface ExpandoProps {
name:string, name: string;
title : JSX.Element, title: JSX.Element;
children:JSX.Element, children: JSX.Element;
error: string; error: string;
} }
interface ExpandoState { const Expando: React.FC<ExpandoProps> = ({ title, children }) => {
expanded : boolean; const [expanded, setExpanded] = useState(false);
}
class Expando extends React.Component<ExpandoProps, ExpandoState> { const open = useCallback(() => setExpanded(true), []);
state : ExpandoState = { const close = useCallback(() => setExpanded(false), []);
expanded : false
}
DropDownClick = () => { return (
this.setState({expanded :true}) <div>
} {!expanded && (
<Button buttonType={ButtonType.secondary} onClick={open}>
{title} <FontAwesomeIcon icon={faCaretDown} />
</Button>
)}
CloseUpClick = () => { {expanded && (
this.setState({expanded :false}) <div>
} <Button buttonType={ButtonType.secondary} onClick={close}>
{title} <FontAwesomeIcon icon={faCaretUp} />
render() { </Button>
const { title, children } = this.props;
const { expanded } = this.state;
if (!expanded){
return ( <div>
<Button buttonType={ButtonType.secondary} onClick={this.DropDownClick}>{title} <FontAwesomeIcon icon={faCaretDown}/></Button>
</div> );
}
else {
return ( <div>
<Button buttonType={ButtonType.secondary} onClick={this.CloseUpClick}>{title} <FontAwesomeIcon icon={faCaretUp}/></Button>
{children} {children}
</div> ); </div>
} )}
} </div>
} );
};
export default Expando; export default Expando;

View File

@ -0,0 +1,11 @@
// AUTO-GENERATED FILE — DO NOT EDIT MANUALLY
export const availableLocales = [
"en-GB",
"en-US",
"fr-CA",
"fr-FR"
] as const;
export type Locale = typeof availableLocales[number];

View File

@ -2,7 +2,7 @@
import i18n from "i18next"; import i18n from "i18next";
import { initReactI18next } from "react-i18next"; import { initReactI18next } from "react-i18next";
import HttpBackend from "i18next-http-backend"; import HttpBackend from "i18next-http-backend";
import { determineInitialLanguage } from "./modules/frame/services/lanugageService"; import { determineInitialLocale } from "../modules/frame/services/lanugageService";
export const Namespaces = { export const Namespaces = {
Common: "common", Common: "common",
@ -15,7 +15,7 @@ i18n
.use(HttpBackend) // load translations from /public/locales .use(HttpBackend) // load translations from /public/locales
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
lng: determineInitialLanguage(), lng: determineInitialLocale(),
fallbackLng: "en", fallbackLng: "en",
defaultNS: "common", defaultNS: "common",
interpolation: { interpolation: {

View File

@ -1,4 +1,4 @@
import "./i18n"; import "./i18n/i18n";
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";

View File

@ -0,0 +1,76 @@
import { NavDropdown } from "react-bootstrap";
import { availableLocales } from "../../../i18n/generatedLocales";
import i18n from "../../../i18n/i18n";
import profileService from "../../profile/services/profileService";
function regionToFlag(region: string) {
return region
.toUpperCase()
.replace(/./g, (char) => String.fromCodePoint(127397 + char.charCodeAt(0)));
}
function flagEmoji(locale: string) {
const parts = locale.split("-");
const region = parts[1];
if (!region) return "";
return regionToFlag(region);
}
function prettyLanguageName(language: string) {
const display = new Intl.DisplayNames([i18n.language], { type: "language" });
return display.of(language) || language;
}
function prettyRegionName(region: string) {
const display = new Intl.DisplayNames([i18n.language], { type: "region" });
return display.of(region) || region;
}
function formatLocaleLabel(locale: string) {
const [language, region] = locale.split("-");
const langName = prettyLanguageName(language);
if (!region) return langName;
const regionName = prettyRegionName(region);
return `${langName} (${regionName})`;
}
export function LanguageSelectorMenuItem() {
const current = i18n.language;
const currentFlag = flagEmoji(current);
const currentLabel = formatLocaleLabel(current);
async function handleSelect(locale: string) {
i18n.changeLanguage(locale);
try {
await profileService.patchMyProfile({
preferredLocale: locale,
});
} catch (err) {
console.error("Failed to update preferred locale", err);
// Optional: show toast or revert language
}
}
return (
<NavDropdown align="end" title={`${currentFlag} ${currentLabel}`}>
{availableLocales.map((locale) => {
const flag = flagEmoji(locale);
const label = formatLocaleLabel(locale);
return (
<NavDropdown.Item
key={locale}
active={locale === current}
onClick={() => handleSelect(locale)}
>
{flag} {label}
</NavDropdown.Item>
);
})}
</NavDropdown>
);
}

View File

@ -10,7 +10,7 @@ import {
import LeftMenuItem from "./LeftMenuItem"; import LeftMenuItem from "./LeftMenuItem";
import LeftMenuSubMenu, { LOCLeftMenuSubMenu } from "./LeftMenuSubMenu"; import LeftMenuSubMenu, { LOCLeftMenuSubMenu } from "./LeftMenuSubMenu";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Namespaces } from "../../../i18n"; import { Namespaces } from "../../../i18n/i18n";
const LeftMenu: React.FC = () => { const LeftMenu: React.FC = () => {
const { t } = useTranslation<typeof Namespaces.Common>(); const { t } = useTranslation<typeof Namespaces.Common>();
@ -60,7 +60,11 @@ const LeftMenu: React.FC = () => {
<LeftMenuItem to="/" icon={faHome} label={t("Home")} /> <LeftMenuItem to="/" icon={faHome} label={t("Home")} />
{viewOrganisation && ( {viewOrganisation && (
<LeftMenuItem to="/organisations" icon={faPrint} label="e-print" /> <LeftMenuItem
to="/organisations"
icon={faPrint}
label={t("e-print")}
/>
)} )}
{viewAdmin && ( {viewAdmin && (

View File

@ -3,30 +3,37 @@ import { Navbar, Nav, NavDropdown } from "react-bootstrap";
import "bootstrap/dist/css/bootstrap.css"; import "bootstrap/dist/css/bootstrap.css";
import Logo from "../../../img/logo"; import Logo from "../../../img/logo";
import '../../../Sass/_nav.scss'; import "../../../Sass/_nav.scss";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {faArrowRightFromBracket} from "@fortawesome/free-solid-svg-icons"; import { faArrowRightFromBracket } from "@fortawesome/free-solid-svg-icons";
import {faUser} from "@fortawesome/free-solid-svg-icons"; import { faUser } from "@fortawesome/free-solid-svg-icons";
import { getCurrentUser } from "../services/authenticationService"; import { getCurrentUser } from "../services/authenticationService";
import { LanguageSelectorMenuItem } from "./LanguageSelector";
export interface TopMenuProps{ export interface TopMenuProps {
title?: string; title?: string;
} }
function TopMenu(props : TopMenuProps) { function TopMenu(props: TopMenuProps) {
const user = getCurrentUser(); const user = getCurrentUser();
return ( return (
<Navbar className="navbar bg-body-tertiary px-4 Header"> <Navbar className="navbar bg-body-tertiary px-4 Header">
<Navbar.Brand href="/"> <Navbar.Brand href="/">
<Logo alt="esuite logo"/> <Logo alt="esuite logo" />
</Navbar.Brand> </Navbar.Brand>
<div className="navbar-left">{props.title}</div> <div className="navbar-left">{props.title}</div>
<div className="navbar-right"> <div className="navbar-right">
<NavDropdown align="end" title="User"> <NavDropdown align="end" title="User">
<Nav.Link href="/profile" className="ps-1"><FontAwesomeIcon icon={faUser}/> Account</Nav.Link> <Nav.Link href="/profile" className="ps-1">
<Nav.Link href="/logout" className="ps-1"><FontAwesomeIcon icon={faArrowRightFromBracket}/> Logout {user?.name} </Nav.Link> <FontAwesomeIcon icon={faUser} /> Account
</Nav.Link>
<LanguageSelectorMenuItem />
<Nav.Link href="/logout" className="ps-1">
<FontAwesomeIcon icon={faArrowRightFromBracket} /> Logout{" "}
{user?.name}{" "}
</Nav.Link>
</NavDropdown> </NavDropdown>
</div> </div>
</Navbar> </Navbar>

View File

@ -5,5 +5,5 @@ export default interface JwtToken {
email: string; email: string;
domainid: bigint; domainid: bigint;
securityPrivileges: []; securityPrivileges: [];
language?: string; preferredLocale: string;
} }

View File

@ -7,8 +7,18 @@ import JwtToken from "../models/JwtToken";
const apiEndpoint = "/Authentication"; const apiEndpoint = "/Authentication";
//const tokenKey = "token"; //const tokenKey = "token";
export async function login(email: string, password: string, securityCode: string, requestTfaRemoval: boolean) { export async function login(
const loginResponse = await httpService.post(apiEndpoint + "/login", { email, password, securityCode, requestTfaRemoval }); email: string,
password: string,
securityCode: string,
requestTfaRemoval: boolean,
) {
const loginResponse = await httpService.post(apiEndpoint + "/login", {
email,
password,
securityCode,
requestTfaRemoval,
});
if (loginResponse?.status === 202) return 1; //TFA information needed, or TFA Removal request accepted. if (loginResponse?.status === 202) return 1; //TFA information needed, or TFA Removal request accepted.
@ -19,8 +29,7 @@ export async function login(email: string, password: string, securityCode: strin
return 0; return 0;
} }
export function logout() { export function logout() {}
}
async function refreshToken() { async function refreshToken() {
const currentUser = getCurrentUser(); const currentUser = getCurrentUser();
@ -28,7 +37,9 @@ async function refreshToken() {
const fiveMinutesFromNow: Date = new Date(Date.now() + 5 * 60 * 1000); const fiveMinutesFromNow: Date = new Date(Date.now() + 5 * 60 * 1000);
if (currentUser.expiry < fiveMinutesFromNow) { if (currentUser.expiry < fiveMinutesFromNow) {
const refreshTokenRoute = window.__RUNTIME_CONFIG__.EXTERNAL_LOGIN ? "/../account/refreshToken" : apiEndpoint + "/refreshToken"; const refreshTokenRoute = window.__RUNTIME_CONFIG__.EXTERNAL_LOGIN
? "/../account/refreshToken"
: apiEndpoint + "/refreshToken";
const { status } = await httpService.get(refreshTokenRoute); const { status } = await httpService.get(refreshTokenRoute);
if (status === 200) { if (status === 200) {
@ -37,15 +48,14 @@ async function refreshToken() {
} }
} }
export function hasToken(): boolean{ export function hasToken(): boolean {
const jwt = getJwt(); const jwt = getJwt();
if (jwt) if (jwt) return true;
return true;
return false; return false;
} }
export function tokenExpired(): boolean{ export function tokenExpired(): boolean {
const jwt = getJwt(); const jwt = getJwt();
if (jwt) { if (jwt) {
const decodedToken: any = jwt_decode(jwt); const decodedToken: any = jwt_decode(jwt);
@ -56,7 +66,7 @@ export function tokenExpired(): boolean{
if (expiry > now) { if (expiry > now) {
return false; return false;
} }
}; }
return true; return true;
} }
@ -80,7 +90,8 @@ export function getCurrentUser(): JwtToken | null {
name: decodedToken.unique_name, name: decodedToken.unique_name,
primarysid: decodedToken.primarysid, primarysid: decodedToken.primarysid,
domainid: decodedToken.domainid, domainid: decodedToken.domainid,
securityPrivileges: JSON.parse(decodedToken.securityPrivileges) securityPrivileges: JSON.parse(decodedToken.securityPrivileges),
preferredLocale: decodedToken.preferredLocale,
}; };
return jwtToken; return jwtToken;
@ -92,7 +103,7 @@ export function getCurrentUser(): JwtToken | null {
function getJwt(): string | null { function getJwt(): string | null {
const eSuiteSession = Cookies.get("eSuiteSession"); const eSuiteSession = Cookies.get("eSuiteSession");
if (eSuiteSession){ if (eSuiteSession) {
return eSuiteSession; return eSuiteSession;
} }
return null; return null;
@ -103,7 +114,10 @@ export async function forgotPassword(email: string) {
} }
async function completeEmailAction(action: IEmailUserAction) { async function completeEmailAction(action: IEmailUserAction) {
const response = await httpService.post(apiEndpoint + "/completeEmailAction", action); const response = await httpService.post(
apiEndpoint + "/completeEmailAction",
action,
);
if (response?.status === 200) { if (response?.status === 200) {
return 1; return 1;
@ -115,9 +129,10 @@ async function completeEmailAction(action: IEmailUserAction) {
export function hasAccess(accessKey: string): boolean { export function hasAccess(accessKey: string): boolean {
var currentUser = getCurrentUser()!; var currentUser = getCurrentUser()!;
const result = currentUser?.securityPrivileges!.filter(x => x === accessKey); const result = currentUser?.securityPrivileges!.filter(
if (result?.length > 0) (x) => x === accessKey,
return true; );
if (result?.length > 0) return true;
return false; return false;
} }
@ -131,7 +146,7 @@ const authentication = {
completeEmailAction, completeEmailAction,
hasToken, hasToken,
tokenExpired, tokenExpired,
hasAccess hasAccess,
}; };
export default authentication; export default authentication;

View File

@ -1,21 +1,29 @@
import { getCurrentUser } from "./authenticationService"; import { getCurrentUser } from "./authenticationService";
import { availableLocales } from "../../../i18n/generatedLocales";
export function determineInitialLanguage() { const defaultLocale = "en";
// 1. JWT preference
const currentUser = getCurrentUser(); function normalise(raw: string): string {
if (currentUser !== null) { return raw.replace("_", "-").toLowerCase();
const jwtLang = currentUser.language || null; }
if (jwtLang) return jwtLang;
export function determineInitialLocale(): string {
var currentUser = getCurrentUser();
if (currentUser) {
return currentUser.preferredLocale;
} }
// 2. LocalStorage const raw = navigator.language || navigator.languages?.[0] || defaultLocale;
//const storedLang = localStorage.getItem("appLanguage"); const locale = normalise(raw); // "en-gb"
//if (storedLang) return storedLang; const base = locale.split("-")[0]; // "en"
// 3. Browser // Exact match (case-insensitive)
const browserLang = navigator.language?.split("-")[0]; const exact = availableLocales.find((l) => l.toLowerCase() === locale);
if (browserLang) return browserLang; if (exact) return exact;
// 4. Default // Base match
return "en"; const baseMatch = availableLocales.find((l) => l.toLowerCase() === base);
if (baseMatch) return baseMatch;
return defaultLocale;
} }

View File

@ -8,4 +8,5 @@ export interface ProfileDetails {
password: string; password: string;
usingTwoFactorAuthentication: boolean; usingTwoFactorAuthentication: boolean;
twoFactorAuthenticationSettings: TwoFactorAuthenticationSettings; twoFactorAuthenticationSettings: TwoFactorAuthenticationSettings;
preferredLocale: string;
} }

View File

@ -14,6 +14,7 @@ export async function getMyProfile(): Promise<ProfileDetails> {
password: data.password, password: data.password,
usingTwoFactorAuthentication: data.usingTwoFactorAuthentication, usingTwoFactorAuthentication: data.usingTwoFactorAuthentication,
twoFactorAuthenticationSettings: data.twoFactorAuthenticationSettings, twoFactorAuthenticationSettings: data.twoFactorAuthenticationSettings,
preferredLocale: data.preferredLocale,
}; };
return result; return result;
@ -26,7 +27,7 @@ export async function putMyProfile(
email: string, email: string,
usingTwoFactorAuthentication: boolean, usingTwoFactorAuthentication: boolean,
securityCode: string, securityCode: string,
password: string password: string,
) { ) {
return await httpService.put(apiEndpoint + "/myProfile", { return await httpService.put(apiEndpoint + "/myProfile", {
firstName, firstName,
@ -39,9 +40,14 @@ export async function putMyProfile(
}); });
} }
export async function patchMyProfile(patch: Partial<ProfileDetails>) {
return await httpService.patch(apiEndpoint + "/myProfile", patch);
}
const profileService = { const profileService = {
getMyProfile, getMyProfile,
putMyProfile, putMyProfile,
patchMyProfile,
}; };
export default profileService; export default profileService;

View File

@ -1,14 +1,21 @@
import axios, { InternalAxiosRequestConfig, AxiosError, AxiosInstance, AxiosResponse } from "axios"; import axios, {
InternalAxiosRequestConfig,
AxiosError,
AxiosInstance,
AxiosResponse,
} from "axios";
import { isValid, parseISO } from "date-fns"; import { isValid, parseISO } from "date-fns";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
Object.defineProperty(BigInt.prototype, "toJSON", { Object.defineProperty(BigInt.prototype, "toJSON", {
get() { get() {
return () => Number(this); return () => Number(this);
} },
}); });
const onRequest = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { const onRequest = (
config: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig => {
return config; return config;
}; };
@ -23,7 +30,7 @@ export function handleDates(body: any) {
for (const key of Object.keys(body)) { for (const key of Object.keys(body)) {
const value = body[key]; const value = body[key];
if (value !== undefined && value !== "" && isNaN(value)) { if (value !== undefined && value !== "" && isNaN(value)) {
const parsedValue : Date = parseISO(value); const parsedValue: Date = parseISO(value);
if (isValid(parsedValue)) body[key] = parsedValue; if (isValid(parsedValue)) body[key] = parsedValue;
else if (typeof value === "object") handleDates(value); else if (typeof value === "object") handleDates(value);
} }
@ -39,7 +46,9 @@ const onResponseError = (error: AxiosError): Promise<AxiosError> => {
return Promise.reject(error); return Promise.reject(error);
}; };
export function setupInterceptorsTo(axiosInstance: AxiosInstance): AxiosInstance { export function setupInterceptorsTo(
axiosInstance: AxiosInstance,
): AxiosInstance {
axiosInstance.interceptors.request.use(onRequest, onRequestError); axiosInstance.interceptors.request.use(onRequest, onRequestError);
axiosInstance.interceptors.response.use(onResponse, onResponseError); axiosInstance.interceptors.response.use(onResponse, onResponseError);
return axiosInstance; return axiosInstance;
@ -57,7 +66,8 @@ export function setJwt(jwt: string | null) {
} }
export function Get<T>(url: string, config?: any): any { export function Get<T>(url: string, config?: any): any {
return axios.get<T>(url, config) return axios
.get<T>(url, config)
.then((response) => { .then((response) => {
return response; return response;
}) })
@ -67,7 +77,8 @@ export function Get<T>(url: string, config?: any): any {
} }
export function Post<T>(url: string, data?: any, config?: any): any { export function Post<T>(url: string, data?: any, config?: any): any {
return axios.post<T>(url, data, config) return axios
.post<T>(url, data, config)
.then((response) => { .then((response) => {
return response; return response;
}) })
@ -77,7 +88,19 @@ export function Post<T>(url: string, data?: any, config?: any): any {
} }
export function Put<T>(url: string, data?: any, config?: any): any { export function Put<T>(url: string, data?: any, config?: any): any {
return axios.put<T>(url, data, config) return axios
.put<T>(url, data, config)
.then((response) => {
return response;
})
.catch((error) => {
toast.error(error.message);
});
}
export function Patch<T>(url: string, data?: any, config?: any): any {
return axios
.patch<T>(url, data, config)
.then((response) => { .then((response) => {
return response; return response;
}) })
@ -87,7 +110,8 @@ export function Put<T>(url: string, data?: any, config?: any): any {
} }
export function Delete<T>(url: string, config?: any): any { export function Delete<T>(url: string, config?: any): any {
return axios.delete<T>(url, config) return axios
.delete<T>(url, config)
.then((response) => { .then((response) => {
return response; return response;
}) })
@ -100,6 +124,7 @@ const httpService = {
get: Get, get: Get,
post: Post, post: Post,
put: Put, put: Put,
patch: Patch,
delete: Delete, delete: Delete,
setJwt, setJwt,
}; };