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",
"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",
"prestart": "node scripts/generate-locales.js",
"start": "concurrently \"npm run start-react\" \"npm run watch-css\" ",
"prebuild": "node scripts/generate-locales.js",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-scripts eject",

View File

@ -24,5 +24,7 @@
"SsoManager": "Sso Manager",
"Support": "Support",
"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 SsoManager from "./modules/manager/ssoManager/ssoManager";
import SsoProviderDetails from "./modules/manager/ssoManager/SsoProviderDetails";
import { Namespaces } from "./i18n";
import { Namespaces } from "./i18n/i18n";
function GetSecureRoutes() {
const { t } = useTranslation<typeof Namespaces.Common>();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,69 +1,75 @@
import React from "react";
import React, { useCallback, useMemo } from "react";
import Option from "./option";
import Autocomplete from "./AutoComplete";
import Pill from "./Pill";
interface MultiSelectProps {
includeLabel? : boolean,
name : string,
label : string,
error? : string,
includeLabel?: boolean;
name: string;
label: string;
error?: string;
// value : unknown
options? : Option[],
selectedOptions : Option[],
// includeBlankFirstEntry? : boolean,
// onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
options?: Option[];
selectedOptions: Option[];
onAdd: (item: Option) => void;
onDelete: (item: Option) => void;
}
interface MultiSelectState {
}
class MultiSelect extends React.Component<MultiSelectProps, MultiSelectState> {
handleDelete = ( id: string) => {
const { options, onDelete } = this.props;
const foundItem : Option | undefined = options?.filter( x => x._id === id)[0];
if (foundItem)
onDelete(foundItem);
const MultiSelect: React.FC<MultiSelectProps> = ({
includeLabel = true,
name,
label,
error,
options,
selectedOptions,
onAdd,
onDelete,
}) => {
const handleDelete = useCallback(
(id: string) => {
const found = options?.find((x) => x._id === id);
if (found) {
onDelete(found);
}
},
[options, onDelete],
);
render() {
const { includeLabel, name, label, error, options, selectedOptions, onAdd
// value, includeBlankFirstEntry, onChange, ...rest
} = this.props;
const selectedBlock = useMemo(() => {
if (selectedOptions.length === 0) return <></>;
let selectedBlock = <></>;
if (selectedOptions.length > 0)
{
selectedBlock = <>
{
selectedOptions.map( x =>
<Pill key={x._id} pillKey={x._id} displayText={x.name} readOnly={false} onClick={this.handleDelete} />
)
}
return (
<>
{selectedOptions.map((x) => (
<Pill
key={x._id}
pillKey={x._id}
displayText={x.name}
readOnly={false}
onClick={handleDelete}
/>
))}
</>
}
);
}, [selectedOptions, handleDelete]);
return (
<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">
<Autocomplete options={options} selectedOptions={selectedOptions} onSelect={onAdd}/>
<Autocomplete
options={options}
selectedOptions={selectedOptions}
onSelect={onAdd}
/>
{selectedBlock}
</div>
{error && <div className="alert alert-danger">{error}</div>}
</div>
);
}
}
};
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCaretDown, faCaretUp } from "@fortawesome/free-solid-svg-icons";
interface ExpandoProps {
name:string,
title : JSX.Element,
children:JSX.Element,
name: string;
title: JSX.Element;
children: JSX.Element;
error: string;
}
interface ExpandoState {
expanded : boolean;
}
const Expando: React.FC<ExpandoProps> = ({ title, children }) => {
const [expanded, setExpanded] = useState(false);
class Expando extends React.Component<ExpandoProps, ExpandoState> {
state : ExpandoState = {
expanded : false
}
const open = useCallback(() => setExpanded(true), []);
const close = useCallback(() => setExpanded(false), []);
DropDownClick = () => {
this.setState({expanded :true})
}
return (
<div>
{!expanded && (
<Button buttonType={ButtonType.secondary} onClick={open}>
{title} <FontAwesomeIcon icon={faCaretDown} />
</Button>
)}
CloseUpClick = () => {
this.setState({expanded :false})
}
render() {
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>
{expanded && (
<div>
<Button buttonType={ButtonType.secondary} onClick={close}>
{title} <FontAwesomeIcon icon={faCaretUp} />
</Button>
{children}
</div> );
}
}
}
</div>
)}
</div>
);
};
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 { initReactI18next } from "react-i18next";
import HttpBackend from "i18next-http-backend";
import { determineInitialLanguage } from "./modules/frame/services/lanugageService";
import { determineInitialLocale } from "../modules/frame/services/lanugageService";
export const Namespaces = {
Common: "common",
@ -15,7 +15,7 @@ i18n
.use(HttpBackend) // load translations from /public/locales
.use(initReactI18next)
.init({
lng: determineInitialLanguage(),
lng: determineInitialLocale(),
fallbackLng: "en",
defaultNS: "common",
interpolation: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ export async function getMyProfile(): Promise<ProfileDetails> {
password: data.password,
usingTwoFactorAuthentication: data.usingTwoFactorAuthentication,
twoFactorAuthenticationSettings: data.twoFactorAuthenticationSettings,
preferredLocale: data.preferredLocale,
};
return result;
@ -26,7 +27,7 @@ export async function putMyProfile(
email: string,
usingTwoFactorAuthentication: boolean,
securityCode: string,
password: string
password: string,
) {
return await httpService.put(apiEndpoint + "/myProfile", {
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 = {
getMyProfile,
putMyProfile,
patchMyProfile,
};
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 { toast } from "react-toastify";
Object.defineProperty(BigInt.prototype, "toJSON", {
get() {
return () => Number(this);
}
},
});
const onRequest = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
const onRequest = (
config: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig => {
return config;
};
@ -23,7 +30,7 @@ export function handleDates(body: any) {
for (const key of Object.keys(body)) {
const value = body[key];
if (value !== undefined && value !== "" && isNaN(value)) {
const parsedValue : Date = parseISO(value);
const parsedValue: Date = parseISO(value);
if (isValid(parsedValue)) body[key] = parsedValue;
else if (typeof value === "object") handleDates(value);
}
@ -39,7 +46,9 @@ const onResponseError = (error: AxiosError): Promise<AxiosError> => {
return Promise.reject(error);
};
export function setupInterceptorsTo(axiosInstance: AxiosInstance): AxiosInstance {
export function setupInterceptorsTo(
axiosInstance: AxiosInstance,
): AxiosInstance {
axiosInstance.interceptors.request.use(onRequest, onRequestError);
axiosInstance.interceptors.response.use(onResponse, onResponseError);
return axiosInstance;
@ -57,7 +66,8 @@ export function setJwt(jwt: string | null) {
}
export function Get<T>(url: string, config?: any): any {
return axios.get<T>(url, config)
return axios
.get<T>(url, config)
.then((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 {
return axios.post<T>(url, data, config)
return axios
.post<T>(url, data, config)
.then((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 {
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) => {
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 {
return axios.delete<T>(url, config)
return axios
.delete<T>(url, config)
.then((response) => {
return response;
})
@ -100,6 +124,7 @@ const httpService = {
get: Get,
post: Post,
put: Put,
patch: Patch,
delete: Delete,
setJwt,
};