From 471e239591fbc9094fb8e99f7f8dd2217bea39b7 Mon Sep 17 00:00:00 2001 From: Colin Dawson Date: Wed, 28 Jan 2026 23:47:51 +0000 Subject: [PATCH] Added multi language support and started refactoring components to be functions instead of classes. --- package.json | 2 + public/locales/en/common.json | 4 +- public/locales/fr/common.json | 30 +++ scripts/generate-locales.js | 32 +++ src/App.tsx | 2 +- src/components/common/ConfirmButton.tsx | 6 +- src/components/common/CustomFieldsEditor.tsx | 162 ++++++------ src/components/common/HorizionalTabs.tsx | 111 ++++---- src/components/common/Input.tsx | 246 ++++++++++-------- src/components/common/Loading.tsx | 28 +- src/components/common/LoadingPanel.tsx | 20 +- src/components/common/MultiSelect.tsx | 116 +++++---- src/components/common/expando.tsx | 67 +++-- src/i18n/generatedLocales.ts | 11 + src/{ => i18n}/i18n.ts | 4 +- src/index.tsx | 2 +- .../frame/components/LanguageSelector.tsx | 76 ++++++ src/modules/frame/components/LeftMenu.tsx | 8 +- src/modules/frame/components/TopMenu.tsx | 49 ++-- src/modules/frame/models/JwtToken.ts | 2 +- .../frame/services/authenticationService.ts | 187 +++++++------ src/modules/frame/services/lanugageService.ts | 36 ++- src/modules/profile/models/ProfileDetails.ts | 15 +- .../profile/services/profileService.ts | 64 ++--- src/services/httpService.ts | 149 ++++++----- 25 files changed, 834 insertions(+), 595 deletions(-) create mode 100644 public/locales/fr/common.json create mode 100644 scripts/generate-locales.js create mode 100644 src/i18n/generatedLocales.ts rename src/{ => i18n}/i18n.ts (79%) create mode 100644 src/modules/frame/components/LanguageSelector.tsx diff --git a/package.json b/package.json index 4f12978..75d6eb8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 3176a53..1edf65c 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -24,5 +24,7 @@ "SsoManager": "Sso Manager", "Support": "Support", "UserManager": "User Manager", - "Users": "Users" + "Users": "Users", + "Name": "Name", + "Loading": "Loading" } diff --git a/public/locales/fr/common.json b/public/locales/fr/common.json new file mode 100644 index 0000000..d5b8f08 --- /dev/null +++ b/public/locales/fr/common.json @@ -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)" +} diff --git a/scripts/generate-locales.js b/scripts/generate-locales.js new file mode 100644 index 0000000..8a83f68 --- /dev/null +++ b/scripts/generate-locales.js @@ -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"); diff --git a/src/App.tsx b/src/App.tsx index 2ea2dcc..6d1d2e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(); diff --git a/src/components/common/ConfirmButton.tsx b/src/components/common/ConfirmButton.tsx index 001ca90..1897bec 100644 --- a/src/components/common/ConfirmButton.tsx +++ b/src/components/common/ConfirmButton.tsx @@ -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 { delayMS?: number; @@ -21,7 +21,7 @@ function ConfirmButton({ onClick, }: ConfirmButtonProps) { const [firstClick, setFirstClick] = useState(false); - const t = useTranslation(); + const { t } = useTranslation(); const handleFirstClick = useCallback(() => { setFirstClick(true); @@ -47,7 +47,7 @@ function ConfirmButton({ {firstClick && ( )} diff --git a/src/components/common/CustomFieldsEditor.tsx b/src/components/common/CustomFieldsEditor.tsx index dcdaa17..5bfd48c 100644 --- a/src/components/common/CustomFieldsEditor.tsx +++ b/src/components/common/CustomFieldsEditor.tsx @@ -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 + name: string; + label: string; + error?: string; + value: CustomField[]; + exclude: CustomField[]; + onAdd?: CustomFieldEditorAdd; + onDelete?: CustomFieldEditorDelete; } -interface CustomFieldsEditorState { - id : GeneralIdRef | undefined, - displayName : string | undefined -} +const CustomFieldsEditor: React.FC = ({ + value, + exclude, + label, + onAdd, + onDelete, +}) => { + const { t } = useTranslation(); + const [id, setId] = useState(undefined); + const [displayName, setDisplayName] = useState(undefined); -class CustomFieldsEditor extends React.Component { - columns : Column[] = [ - { key: "name", label: "Name", order: "asc" }, - ]; + const columns: Column[] = useMemo( + () => [{ key: "name", label: t("Name"), order: "asc" }], + [], + ); - state : CustomFieldsEditorState = { - id : undefined, - displayName: undefined - } + const paginated: Paginated = 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 - }; - - onAdd(newField); - - this.setState( { - id : undefined, - displayName: undefined - }); - } - } - } - - handleChange = (name: string, value: GeneralIdRef, displayValue : string) => { - this.setState({ - id : value, - displayName : displayValue - }); + const newField: CustomField = { + id: id.id ?? BigInt(-1), + name: String(displayName), + fieldType: "", + defaultValue: "", + minEntries: 1, + guid: id.guid, }; - render() { - const { value, exclude, label, onDelete } = this.props; + onAdd(newField); - const paginated : Paginated = { - count: 0, - page: 1, - pageSize: 10, - totalPages: 0, - data: value - } + setId(undefined); + setDisplayName(undefined); + }, [onAdd, id, displayName]); - return
- {label} - -
- - -
- ; - } -} + const handleChange = useCallback( + (name: string, value: GeneralIdRef, displayValue: string) => { + setId(value); + setDisplayName(displayValue); + }, + [], + ); + + return ( +
+ {label} +
+ +
+ + + +
+ + ); +}; export default CustomFieldsEditor; diff --git a/src/components/common/HorizionalTabs.tsx b/src/components/common/HorizionalTabs.tsx index 85967dc..aa483ee 100644 --- a/src/components/common/HorizionalTabs.tsx +++ b/src/components/common/HorizionalTabs.tsx @@ -1,74 +1,61 @@ -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 = ({ children }) => { + const [activeTab, setActiveTab] = useState(""); + const [redirect, setRedirect] = useState(""); -class HorizontalTabs extends React.Component { - 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]); - onClickTabItem = (tab : string) => { - let { activeTab } = this.state; + const onClickTabItem = useCallback((tab: string) => { + setActiveTab((prev) => (prev !== tab ? tab : prev)); + }, []); - if (activeTab !== tab) { - activeTab = tab; - } + const activeTabChildren = useMemo(() => { + const match = children.find((child) => child.props.label === activeTab); + return match ? match.props.children : <>; + }, [children, activeTab]); - this.setState({ activeTab } ); - }; + if (redirect !== "") { + return ; + } - state : HorizontalTabsState= { - activeTab : "", - redirect : "" - } + // If only one tab, just render its content + if (children.length === 1) { + return <>{activeTabChildren}; + } - render() { - const { children } = this.props; - const { activeTab, redirect } = this.state; + return ( +
+
+
    + {children.map((child) => { + const { label } = child.props; + return ( + + ); + })} +
+
- if (redirect !== "") return ; - - const filteredTabs = children.filter( child => child.props.label === activeTab ); - - const activeTabChildren = (filteredTabs?.length > 0) ? filteredTabs[0].props.children : <> +
{activeTabChildren}
+
+ ); +}; - if (children?.length === 1) { - return (<>{activeTabChildren}) - } - - return ( -
-
-
    - {children.map((child) => { - const { label } = child.props; - - return ( - - ); - })} -
-
-
- {activeTabChildren} -
-
- ); - } -} - -export default HorizontalTabs; \ No newline at end of file +export default HorizontalTabs; diff --git a/src/components/common/Input.tsx b/src/components/common/Input.tsx index 61f43f8..75c0446 100644 --- a/src/components/common/Input.tsx +++ b/src/components/common/Input.tsx @@ -1,129 +1,161 @@ 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", - color = "color", - date = "date", - datetimelocal = "datetime-local", - email = "email", - file = "file", - hidden = "hidden", - image = "image", - month = "month", - number = "number", - password = "password", - radio = "radio", - range = "range", - reset = "reset", - search = "search", - submit = "submit", - tel = "tel", - text = "text", - textarea = "textarea", - time = "time", - url = "url", - week = "week", + button = "button", + checkbox = "checkbox", + color = "color", + date = "date", + datetimelocal = "datetime-local", + email = "email", + file = "file", + hidden = "hidden", + image = "image", + month = "month", + number = "number", + password = "password", + radio = "radio", + range = "range", + reset = "reset", + search = "search", + submit = "submit", + tel = "tel", + text = "text", + textarea = "textarea", + time = "time", + url = "url", + week = "week", } export interface InputProps { - includeLabel?: boolean; - name: string; - label: string; - error: string; - placeHolder?: string; - readOnly?: boolean; - type: InputType; - value?: string | number | readonly string[] | undefined; - defaultValue?: string | number | readonly string[] | undefined; - min?: number; - max?: number; - step?: number; - hidden? : boolean; - autoComplete? : string; - onChange?: (e: React.ChangeEvent) => void; - onTextAreaChange?: (e: React.ChangeEvent) => void; - maxLength?: number; + includeLabel?: boolean; + name: string; + label: string; + error: string; + placeHolder?: string; + readOnly?: boolean; + type: InputType; + value?: string | number | readonly string[] | undefined; + defaultValue?: string | number | readonly string[] | undefined; + min?: number; + max?: number; + step?: number; + hidden?: boolean; + autoComplete?: string; + onChange?: (e: React.ChangeEvent) => void; + onTextAreaChange?: (e: React.ChangeEvent) => 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 showValue = value; + let checked: boolean = false; - let divClassName = "form-group" - let labelClassName = "label"; - let className = "form-control"; - let flexClassName = ""; + let divClassName = "form-group"; + let labelClassName = "label"; + let className = "form-control"; + let flexClassName = ""; - const [showPasswordIcon, setShowPasswordIcon] = useState(faEyeSlash); + const [showPasswordIcon, setShowPasswordIcon] = useState(faEyeSlash); - if (type === InputType.checkbox) { - checked = (value === String(true)); - showValue = undefined; - divClassName = "form-check"; - className = "form-check-input"; - labelClassName += " form-check-label"; - flexClassName += "checkbox"; - } + if (type === InputType.checkbox) { + checked = value === String(true); + showValue = undefined; + divClassName = "form-check"; + className = "form-check-input"; + labelClassName += " form-check-label"; + flexClassName += "checkbox"; + } - if (type === InputType.checkbox) { - divClassName += ' allignedCheckBox'; - } + if (type === InputType.checkbox) { + 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"; - } + if (type === InputType.password) { + flexClassName += "flex"; + } - return ( - - ); + return ( + + ); } export default Input; diff --git a/src/components/common/Loading.tsx b/src/components/common/Loading.tsx index f54f2c5..0e5d62d 100644 --- a/src/components/common/Loading.tsx +++ b/src/components/common/Loading.tsx @@ -2,26 +2,16 @@ import React from "react"; import LoadingPanel from "./LoadingPanel"; interface Loading2Props { - loaded : boolean - children: React.ReactNode; -} - -interface Loading2State { + loaded: boolean; + children: React.ReactNode; } -class Loading extends React.Component { - state = { loaded : false } +const Loading: React.FC = ({ loaded, children }) => { + if (!loaded) { + return ; + } - render() { - const { loaded, children } = this.props; + return <>{children}; +}; - if (!loaded) { - return () - } - else { - return (<>{children}); - } - } -} - -export default Loading; \ No newline at end of file +export default Loading; diff --git a/src/components/common/LoadingPanel.tsx b/src/components/common/LoadingPanel.tsx index aa81610..26c780b 100644 --- a/src/components/common/LoadingPanel.tsx +++ b/src/components/common/LoadingPanel.tsx @@ -1,10 +1,12 @@ -import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { Namespaces } from "../../i18n/i18n"; -interface LoadingProps { -} - -const LoadingPanel: FunctionComponent = () => { - return (
Loading
); -} - -export default LoadingPanel; \ No newline at end of file +interface LoadingProps {} + +const LoadingPanel: React.FC = () => { + const { t } = useTranslation(); + + return
{t("Loading")}
; +}; + +export default LoadingPanel; diff --git a/src/components/common/MultiSelect.tsx b/src/components/common/MultiSelect.tsx index d4d9e05..2ac85f0 100644 --- a/src/components/common/MultiSelect.tsx +++ b/src/components/common/MultiSelect.tsx @@ -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) => void; + options?: Option[]; + selectedOptions: Option[]; - onAdd: (item: Option) => void; - onDelete: (item: Option) => void; + onAdd: (item: Option) => void; + onDelete: (item: Option) => void; } - -interface MultiSelectState { - -} - -class MultiSelect extends React.Component { - handleDelete = ( id: string) => { - const { options, onDelete } = this.props; - - const foundItem : Option | undefined = options?.filter( x => x._id === id)[0]; +const MultiSelect: React.FC = ({ + 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], + ); - if (foundItem) - onDelete(foundItem); - } - - 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 = <>; + return ( + <> + {selectedOptions.map((x) => ( + + ))} + + ); + }, [selectedOptions, handleDelete]); - if (selectedOptions.length > 0) - { - selectedBlock = <> - { - selectedOptions.map( x => - - ) - } - - } + return ( +
+ {includeLabel && } - return ( -
- {(includeLabel===undefined || includeLabel===true) && } +
+ + {selectedBlock} +
-
- - {selectedBlock} -
- {error &&
{error}
} -
- ); - } -} - -export default MultiSelect; \ No newline at end of file + {error &&
{error}
} +
+ ); +}; + +export default MultiSelect; diff --git a/src/components/common/expando.tsx b/src/components/common/expando.tsx index 6e86288..bcba001 100644 --- a/src/components/common/expando.tsx +++ b/src/components/common/expando.tsx @@ -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, - error: string; + name: string; + title: JSX.Element; + children: JSX.Element; + error: string; } - -interface ExpandoState { - expanded : boolean; -} - -class Expando extends React.Component { - state : ExpandoState = { - expanded : false - } - DropDownClick = () => { - this.setState({expanded :true}) - } +const Expando: React.FC = ({ title, children }) => { + const [expanded, setExpanded] = useState(false); - CloseUpClick = () => { - this.setState({expanded :false}) - } + const open = useCallback(() => setExpanded(true), []); + const close = useCallback(() => setExpanded(false), []); - render() { - const { title, children } = this.props; - const { expanded } = this.state; + return ( +
+ {!expanded && ( + + )} - if (!expanded){ - return (
- -
); - } - else { - return (
- - {children} -
); - } - } -} - -export default Expando; \ No newline at end of file + {expanded && ( +
+ + {children} +
+ )} +
+ ); +}; + +export default Expando; diff --git a/src/i18n/generatedLocales.ts b/src/i18n/generatedLocales.ts new file mode 100644 index 0000000..d874e7a --- /dev/null +++ b/src/i18n/generatedLocales.ts @@ -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]; diff --git a/src/i18n.ts b/src/i18n/i18n.ts similarity index 79% rename from src/i18n.ts rename to src/i18n/i18n.ts index 1f782c1..aeb80c2 100644 --- a/src/i18n.ts +++ b/src/i18n/i18n.ts @@ -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: { diff --git a/src/index.tsx b/src/index.tsx index 1e4d479..d330605 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,4 @@ -import "./i18n"; +import "./i18n/i18n"; import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; diff --git a/src/modules/frame/components/LanguageSelector.tsx b/src/modules/frame/components/LanguageSelector.tsx new file mode 100644 index 0000000..a4ba2e8 --- /dev/null +++ b/src/modules/frame/components/LanguageSelector.tsx @@ -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 ( + + {availableLocales.map((locale) => { + const flag = flagEmoji(locale); + const label = formatLocaleLabel(locale); + + return ( + handleSelect(locale)} + > + {flag} {label} + + ); + })} + + ); +} diff --git a/src/modules/frame/components/LeftMenu.tsx b/src/modules/frame/components/LeftMenu.tsx index 478edc2..f79e4b6 100644 --- a/src/modules/frame/components/LeftMenu.tsx +++ b/src/modules/frame/components/LeftMenu.tsx @@ -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(); @@ -60,7 +60,11 @@ const LeftMenu: React.FC = () => { {viewOrganisation && ( - + )} {viewAdmin && ( diff --git a/src/modules/frame/components/TopMenu.tsx b/src/modules/frame/components/TopMenu.tsx index 5a36ae0..b46a4db 100644 --- a/src/modules/frame/components/TopMenu.tsx +++ b/src/modules/frame/components/TopMenu.tsx @@ -3,34 +3,41 @@ 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{ - title?: string; +export interface TopMenuProps { + title?: string; } -function TopMenu(props : TopMenuProps) { - const user = getCurrentUser(); +function TopMenu(props: TopMenuProps) { + const user = getCurrentUser(); - return ( - - - - -
{props.title}
-
- - Account - Logout {user?.name} - -
-
- ); + return ( + + + + +
{props.title}
+
+ + + Account + + + + Logout{" "} + {user?.name}{" "} + + +
+
+ ); } export default TopMenu; diff --git a/src/modules/frame/models/JwtToken.ts b/src/modules/frame/models/JwtToken.ts index 073b8c9..103e311 100644 --- a/src/modules/frame/models/JwtToken.ts +++ b/src/modules/frame/models/JwtToken.ts @@ -5,5 +5,5 @@ export default interface JwtToken { email: string; domainid: bigint; securityPrivileges: []; - language?: string; + preferredLocale: string; } diff --git a/src/modules/frame/services/authenticationService.ts b/src/modules/frame/services/authenticationService.ts index 4fdd099..08667e6 100644 --- a/src/modules/frame/services/authenticationService.ts +++ b/src/modules/frame/services/authenticationService.ts @@ -7,131 +7,146 @@ 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. + if (loginResponse?.status === 202) return 1; //TFA information needed, or TFA Removal request accepted. - if (loginResponse?.status === 200) { - return 2; - } + if (loginResponse?.status === 200) { + return 2; + } - return 0; + return 0; } -export function logout() { -} +export function logout() {} async function refreshToken() { - const currentUser = getCurrentUser(); - if (currentUser) { - 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 { status } = await httpService.get(refreshTokenRoute); - if (status === 200) { - } - } + const currentUser = getCurrentUser(); + if (currentUser) { + 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 { status } = await httpService.get(refreshTokenRoute); + if (status === 200) { + } } + } } -export function hasToken(): boolean{ - const jwt = getJwt(); - if (jwt) - return true; +export function hasToken(): boolean { + const jwt = getJwt(); + if (jwt) return true; - return false; + return false; } -export function tokenExpired(): boolean{ - const jwt = getJwt(); - if (jwt) { - const decodedToken: any = jwt_decode(jwt); +export function tokenExpired(): boolean { + const jwt = getJwt(); + if (jwt) { + const decodedToken: any = jwt_decode(jwt); - const expiry: Date = new Date(decodedToken.exp * 1000); - const now: Date = new Date(Date.now()); + const expiry: Date = new Date(decodedToken.exp * 1000); + const now: Date = new Date(Date.now()); - if (expiry > now) { - return false; - } - }; - return true; + if (expiry > now) { + return false; + } + } + return true; } export function getCurrentUser(): JwtToken | null { - try { - const jwt = getJwt(); - if (jwt) { - const decodedToken: any = jwt_decode(jwt); + try { + const jwt = getJwt(); + if (jwt) { + const decodedToken: any = jwt_decode(jwt); - const expiry: Date = new Date(decodedToken.exp * 1000); - const now: Date = new Date(Date.now()); + const expiry: Date = new Date(decodedToken.exp * 1000); + const now: Date = new Date(Date.now()); - if (expiry < now) { - logout(); //The JWT Has expired, so there's no point keeping it laying around - return null; - } - - const jwtToken: JwtToken = { - expiry: expiry, - email: decodedToken.email, - name: decodedToken.unique_name, - primarysid: decodedToken.primarysid, - domainid: decodedToken.domainid, - securityPrivileges: JSON.parse(decodedToken.securityPrivileges) - }; - - return jwtToken; - } else return null; - } catch (ex) { + if (expiry < now) { + logout(); //The JWT Has expired, so there's no point keeping it laying around return null; - } + } + + const jwtToken: JwtToken = { + expiry: expiry, + email: decodedToken.email, + name: decodedToken.unique_name, + primarysid: decodedToken.primarysid, + domainid: decodedToken.domainid, + securityPrivileges: JSON.parse(decodedToken.securityPrivileges), + preferredLocale: decodedToken.preferredLocale, + }; + + return jwtToken; + } else return null; + } catch (ex) { + return null; + } } function getJwt(): string | null { - const eSuiteSession = Cookies.get("eSuiteSession"); - if (eSuiteSession){ - return eSuiteSession; - } - return null; + const eSuiteSession = Cookies.get("eSuiteSession"); + if (eSuiteSession) { + return eSuiteSession; + } + return null; } export async function forgotPassword(email: string) { - return await httpService.post(apiEndpoint + "/forgotPassword", { email }); + return await httpService.post(apiEndpoint + "/forgotPassword", { email }); } 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; - } + if (response?.status === 200) { + return 1; + } - return 0; + return 0; } export function hasAccess(accessKey: string): boolean { - var currentUser = getCurrentUser()!; - - const result = currentUser?.securityPrivileges!.filter(x => x === accessKey); - if (result?.length > 0) - return true; + var currentUser = getCurrentUser()!; - return false; + const result = currentUser?.securityPrivileges!.filter( + (x) => x === accessKey, + ); + if (result?.length > 0) return true; + + return false; } const authentication = { - login, - logout, - getCurrentUser, - refreshToken, - forgotPassword, - completeEmailAction, - hasToken, - tokenExpired, - hasAccess + login, + logout, + getCurrentUser, + refreshToken, + forgotPassword, + completeEmailAction, + hasToken, + tokenExpired, + hasAccess, }; export default authentication; diff --git a/src/modules/frame/services/lanugageService.ts b/src/modules/frame/services/lanugageService.ts index c4090a8..00afb6e 100644 --- a/src/modules/frame/services/lanugageService.ts +++ b/src/modules/frame/services/lanugageService.ts @@ -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; } diff --git a/src/modules/profile/models/ProfileDetails.ts b/src/modules/profile/models/ProfileDetails.ts index 8b5241a..21e7e99 100644 --- a/src/modules/profile/models/ProfileDetails.ts +++ b/src/modules/profile/models/ProfileDetails.ts @@ -1,11 +1,12 @@ import { TwoFactorAuthenticationSettings } from "./TwoFactorAuthenticationSettings"; export interface ProfileDetails { - firstName: string; - middleNames: string; - lastName: string; - email: string; - password: string; - usingTwoFactorAuthentication: boolean; - twoFactorAuthenticationSettings: TwoFactorAuthenticationSettings; + firstName: string; + middleNames: string; + lastName: string; + email: string; + password: string; + usingTwoFactorAuthentication: boolean; + twoFactorAuthenticationSettings: TwoFactorAuthenticationSettings; + preferredLocale: string; } diff --git a/src/modules/profile/services/profileService.ts b/src/modules/profile/services/profileService.ts index daf69bb..5e63336 100644 --- a/src/modules/profile/services/profileService.ts +++ b/src/modules/profile/services/profileService.ts @@ -4,44 +4,50 @@ import { ProfileDetails } from "../models/ProfileDetails"; const apiEndpoint = "/Profile"; export async function getMyProfile(): Promise { - const { data } = await httpService.get(apiEndpoint + "/myProfile"); + const { data } = await httpService.get(apiEndpoint + "/myProfile"); - const result: ProfileDetails = { - firstName: data.firstName, - middleNames: data.middleNames, - lastName: data.lastName, - email: data.email, - password: data.password, - usingTwoFactorAuthentication: data.usingTwoFactorAuthentication, - twoFactorAuthenticationSettings: data.twoFactorAuthenticationSettings, - }; + const result: ProfileDetails = { + firstName: data.firstName, + middleNames: data.middleNames, + lastName: data.lastName, + email: data.email, + password: data.password, + usingTwoFactorAuthentication: data.usingTwoFactorAuthentication, + twoFactorAuthenticationSettings: data.twoFactorAuthenticationSettings, + preferredLocale: data.preferredLocale, + }; - return result; + return result; } export async function putMyProfile( - firstName: string, - middleNames: string, - lastName: string, - email: string, - usingTwoFactorAuthentication: boolean, - securityCode: string, - password: string + firstName: string, + middleNames: string, + lastName: string, + email: string, + usingTwoFactorAuthentication: boolean, + securityCode: string, + password: string, ) { - return await httpService.put(apiEndpoint + "/myProfile", { - firstName, - middleNames, - lastName, - email, - password, - usingTwoFactorAuthentication, - securityCode, - }); + return await httpService.put(apiEndpoint + "/myProfile", { + firstName, + middleNames, + lastName, + email, + password, + usingTwoFactorAuthentication, + securityCode, + }); +} + +export async function patchMyProfile(patch: Partial) { + return await httpService.patch(apiEndpoint + "/myProfile", patch); } const profileService = { - getMyProfile, - putMyProfile, + getMyProfile, + putMyProfile, + patchMyProfile, }; export default profileService; diff --git a/src/services/httpService.ts b/src/services/httpService.ts index ac6e21f..bdae79c 100644 --- a/src/services/httpService.ts +++ b/src/services/httpService.ts @@ -1,107 +1,132 @@ -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); - } + get() { + return () => Number(this); + }, }); -const onRequest = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { - return config; +const onRequest = ( + config: InternalAxiosRequestConfig, +): InternalAxiosRequestConfig => { + return config; }; const onRequestError = (error: AxiosError): Promise => { - return Promise.reject(error); + return Promise.reject(error); }; export function handleDates(body: any) { - if (body === null || body === undefined || typeof body !== "object") - return body; - - for (const key of Object.keys(body)) { - const value = body[key]; - if (value !== undefined && value !== "" && isNaN(value)) { - const parsedValue : Date = parseISO(value); - if (isValid(parsedValue)) body[key] = parsedValue; - else if (typeof value === "object") handleDates(value); - } + if (body === null || body === undefined || typeof body !== "object") + return body; + + for (const key of Object.keys(body)) { + const value = body[key]; + if (value !== undefined && value !== "" && isNaN(value)) { + const parsedValue: Date = parseISO(value); + if (isValid(parsedValue)) body[key] = parsedValue; + else if (typeof value === "object") handleDates(value); } + } } const onResponse = (response: AxiosResponse): AxiosResponse => { - handleDates(response.data); - return response; + handleDates(response.data); + return response; }; const onResponseError = (error: AxiosError): Promise => { - return Promise.reject(error); + return Promise.reject(error); }; -export function setupInterceptorsTo(axiosInstance: AxiosInstance): AxiosInstance { - axiosInstance.interceptors.request.use(onRequest, onRequestError); - axiosInstance.interceptors.response.use(onResponse, onResponseError); - return axiosInstance; +export function setupInterceptorsTo( + axiosInstance: AxiosInstance, +): AxiosInstance { + axiosInstance.interceptors.request.use(onRequest, onRequestError); + axiosInstance.interceptors.response.use(onResponse, onResponseError); + return axiosInstance; } axios.defaults.baseURL = window.__RUNTIME_CONFIG__.API_URL; setupInterceptorsTo(axios); export function setJwt(jwt: string | null) { - if (jwt) { - axios.defaults.headers.common["Authorization"] = "Bearer " + jwt; - } else { - delete axios.defaults.headers.common["Authorization"]; - } + if (jwt) { + axios.defaults.headers.common["Authorization"] = "Bearer " + jwt; + } else { + delete axios.defaults.headers.common["Authorization"]; + } } export function Get(url: string, config?: any): any { - return axios.get(url, config) - .then((response) => { - return response; - }) - .catch((error) => { - toast.error(error.message); - }); + return axios + .get(url, config) + .then((response) => { + return response; + }) + .catch((error) => { + toast.error(error.message); + }); } export function Post(url: string, data?: any, config?: any): any { - return axios.post(url, data, config) - .then((response) => { - return response; - }) - .catch((error) => { - toast.error(error.message); - }); + return axios + .post(url, data, config) + .then((response) => { + return response; + }) + .catch((error) => { + toast.error(error.message); + }); } export function Put(url: string, data?: any, config?: any): any { - return axios.put(url, data, config) - .then((response) => { - return response; - }) - .catch((error) => { - toast.error(error.message); - }); + return axios + .put(url, data, config) + .then((response) => { + return response; + }) + .catch((error) => { + toast.error(error.message); + }); +} + +export function Patch(url: string, data?: any, config?: any): any { + return axios + .patch(url, data, config) + .then((response) => { + return response; + }) + .catch((error) => { + toast.error(error.message); + }); } export function Delete(url: string, config?: any): any { - return axios.delete(url, config) - .then((response) => { - return response; - }) - .catch((error) => { - toast.error(error.message); - }); + return axios + .delete(url, config) + .then((response) => { + return response; + }) + .catch((error) => { + toast.error(error.message); + }); } const httpService = { - get: Get, - post: Post, - put: Put, - delete: Delete, - setJwt, + get: Get, + post: Post, + put: Put, + patch: Patch, + delete: Delete, + setJwt, }; export default httpService;