From db046b2d1ca813459a7bf8324a8d9975f32b258f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Nu=C3=B1ez?= <10672208+mind-ar@users.noreply.github.com> Date: Fri, 21 Jan 2022 09:05:46 -0300 Subject: [PATCH] feat(web): i18n (#2697) This adds support for i18n so that users may be presented a familiar language to the language the browser language they are using automatically. Currently supported languages: en, es. Co-authored-by: Amir Zarrinkafsh Co-authored-by: James Elliott --- web/package.json | 7 +++ web/pnpm-lock.yaml | 60 +++++++++++++++++++ web/src/i18n/index.ts | 35 +++++++++++ web/src/i18n/locales/en.json | 53 ++++++++++++++++ web/src/i18n/locales/es.json | 53 ++++++++++++++++ web/src/index.tsx | 1 + .../RegisterOneTimePassword.tsx | 28 ++++++--- web/src/views/LoadingPage/LoadingPage.tsx | 4 +- web/src/views/LoginPortal/Authenticated.tsx | 4 +- .../AuthenticatedView/AuthenticatedView.tsx | 6 +- .../FirstFactor/FirstFactorForm.tsx | 16 ++--- .../SecondFactor/MethodContainer.tsx | 17 +++--- .../SecondFactor/MethodSelectionDialog.tsx | 8 ++- .../SecondFactor/OneTimePasswordMethod.tsx | 12 ++-- .../SecondFactor/SecondFactorForm.tsx | 14 +++-- web/src/views/LoginPortal/SignOut/SignOut.tsx | 10 ++-- .../ResetPassword/ResetPasswordStep1.tsx | 14 +++-- .../ResetPassword/ResetPasswordStep2.tsx | 28 +++++---- web/tsconfig.json | 1 + 19 files changed, 308 insertions(+), 63 deletions(-) create mode 100644 web/src/i18n/index.ts create mode 100644 web/src/i18n/locales/en.json create mode 100644 web/src/i18n/locales/es.json diff --git a/web/package.json b/web/package.json index ed3e7b9bb..3b75dbe17 100644 --- a/web/package.json +++ b/web/package.json @@ -12,11 +12,15 @@ "@material-ui/styles": "4.11.4", "axios": "0.25.0", "classnames": "2.3.1", + "i18next": "21.6.0", + "i18next-browser-languagedetector": "6.1.2", + "i18next-http-backend": "1.3.1", "qrcode.react": "1.0.1", "query-string": "7.1.0", "react": "17.0.2", "react-dom": "17.0.2", "react-ga": "3.3.0", + "react-i18next": "11.14.3", "react-loading": "2.0.3", "react-otp-input": "2.4.0", "react-router-dom": "6.2.1", @@ -77,6 +81,9 @@ "^@hooks/(.*)$": [ "/src/hooks/$1" ], + "^@i18n/(.*)$": [ + "/src/i18n/$1" + ], "^@layouts/(.*)$": [ "/src/layouts/$1" ], diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 5a1efd58e..0346160b6 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -33,6 +33,9 @@ specifiers: eslint-plugin-react: 7.28.0 eslint-plugin-react-hooks: 4.3.0 husky: 7.0.4 + i18next: 21.6.0 + i18next-browser-languagedetector: 6.1.2 + i18next-http-backend: 1.3.1 jest: 27.4.7 jest-transform-stub: 2.0.0 jest-watch-typeahead: 1.0.0 @@ -42,6 +45,7 @@ specifiers: react: 17.0.2 react-dom: 17.0.2 react-ga: 3.3.0 + react-i18next: 11.14.3 react-loading: 2.0.3 react-otp-input: 2.4.0 react-router-dom: 6.2.1 @@ -64,11 +68,15 @@ dependencies: '@material-ui/styles': 4.11.4_b3482aaf5744fc7c2aeb7941b0e0a78f axios: 0.25.0 classnames: 2.3.1 + i18next: 21.6.0 + i18next-browser-languagedetector: 6.1.2 + i18next-http-backend: 1.3.1 qrcode.react: 1.0.1_react@17.0.2 query-string: 7.1.0 react: 17.0.2 react-dom: 17.0.2_react@17.0.2 react-ga: 3.3.0_react@17.0.2 + react-i18next: 11.14.3_i18next@21.6.0+react@17.0.2 react-loading: 2.0.3_react@17.0.2 react-otp-input: 2.4.0_react-dom@17.0.2+react@17.0.2 react-router-dom: 6.2.1_react-dom@17.0.2+react@17.0.2 @@ -3677,6 +3685,12 @@ packages: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} dev: true + /cross-fetch/3.1.4: + resolution: {integrity: sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ==} + dependencies: + node-fetch: 2.6.1 + dev: false + /cross-spawn/6.0.5: resolution: {integrity: sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==} engines: {node: '>=4.8'} @@ -5004,6 +5018,12 @@ packages: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} dev: true + /html-parse-stringify/3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + dependencies: + void-elements: 3.1.0 + dev: false + /http-proxy-agent/4.0.1: resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} engines: {node: '>= 6'} @@ -5040,6 +5060,24 @@ packages: resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==} dev: false + /i18next-browser-languagedetector/6.1.2: + resolution: {integrity: sha512-YDzIGHhMRvr7M+c8B3EQUKyiMBhfqox4o1qkFvt4QXuu5V2cxf74+NCr+VEkUuU0y+RwcupA238eeolW1Yn80g==} + dependencies: + '@babel/runtime': 7.16.3 + dev: false + + /i18next-http-backend/1.3.1: + resolution: {integrity: sha512-o79n4GBBRpl20hByC+ne/S1UaSZ4iGAn59Hu2TEZGjN0WLB72L7WrM39Cshziyrssp6MQfdI8wjToU2Q6kpSvA==} + dependencies: + cross-fetch: 3.1.4 + dev: false + + /i18next/21.6.0: + resolution: {integrity: sha512-RjNuACL35wWZgtkyMcjcCmK7R72u3P6jTNbGKzrvHGI9M0iK5Vn1DsBIwOByppaXLIbe0viJ79Nz2h8w1UwPoQ==} + dependencies: + '@babel/runtime': 7.16.3 + dev: false + /iconv-lite/0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -6474,6 +6512,11 @@ packages: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} dev: true + /node-fetch/2.6.1: + resolution: {integrity: sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==} + engines: {node: 4.x || >=6.0.0} + dev: false + /node-int64/0.4.0: resolution: {integrity: sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=} dev: true @@ -6951,6 +6994,18 @@ packages: react: 17.0.2 dev: false + /react-i18next/11.14.3_i18next@21.6.0+react@17.0.2: + resolution: {integrity: sha512-Hf2aanbKgYxPjG8ZdKr+PBz9sY6sxXuZWizxCYyJD2YzvJ0W9JTQcddVEjDaKyBoCyd3+5HTerdhc9ehFugc6g==} + peerDependencies: + i18next: '>= 19.0.0' + react: '>= 16.8.0' + dependencies: + '@babel/runtime': 7.16.3 + html-parse-stringify: 3.0.1 + i18next: 21.6.0 + react: 17.0.2 + dev: false + /react-is/16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -8102,6 +8157,11 @@ packages: fsevents: 2.3.2 dev: true + /void-elements/3.1.0: + resolution: {integrity: sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=} + engines: {node: '>=0.10.0'} + dev: false + /w3c-hr-time/1.0.2: resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==} dependencies: diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts new file mode 100644 index 000000000..eea659647 --- /dev/null +++ b/web/src/i18n/index.ts @@ -0,0 +1,35 @@ +import i18n from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import XHR from "i18next-http-backend"; +import { initReactI18next } from "react-i18next"; + +import langEn from "@i18n/locales/en.json"; +import langEs from "@i18n/locales/es.json"; + +const resources = { + en: langEn, + es: langEs, +}; + +const options = { + order: ["querystring", "navigator"], + lookupQuerystring: "lng", +}; + +i18n.use(XHR) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + detection: options, + resources, + ns: [""], + defaultNS: "", + fallbackLng: "en", + supportedLngs: ["en", "es"], + interpolation: { + escapeValue: false, + }, + debug: false, + }); + +export default i18n; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json new file mode 100644 index 000000000..8750382db --- /dev/null +++ b/web/src/i18n/locales/en.json @@ -0,0 +1,53 @@ +{ + "Portal": { + "An email has been sent to your address to complete the process": "An email has been sent to your address to complete the process.", + "Authenticated": "Authenticated", + "Cancel": "Cancel", + "Contact your administrator to register a device": "Contact your administrator to register a device.", + "Could not obtain user settings": "Could not obtain user settings", + "Done": "Done", + "Enter new password": "Enter new password", + "Enter one-time password": "Enter one-time password", + "Failed to register device, the provided link is expired or has already been used": "Failed to register device, the provided link is expired or has already been used", + "Hi": "Hi", + "Incorrect username or password": "Incorrect username or password.", + "Loading": "Loading", + "Logout": "Logout", + "Lost your device?": "Lost your device?", + "Methods": "Methods", + "Need Google Authenticator?": "Need Google Authenticator?", + "New password": "New password", + "No verification token provided": "No verification token provided", + "OTP Secret copied to clipboard": "OTP Secret copied to clipboard.", + "OTP URL copied to clipboard": "OTP URL copied to clipboard.", + "One-Time Password": "One-Time Password", + "Password has been reset": "Password has been reset.", + "Password": "Password", + "Passwords do not match": "Passwords do not match.", + "Push Notification": "Push Notification", + "Register device": "Register device", + "Register your first device by clicking on the link below": "Register your first device by clicking on the link below.", + "Remember me": "Remember me", + "Repeat new password": "Repeat new password", + "Reset password": "Reset password", + "Reset password?": "Reset password?", + "Reset": "Reset", + "Scan QR Code": "Scan QR Code", + "Secret": "Secret", + "Security Key - U2F": "Security Key - U2F", + "Select a Device": "Select a Device", + "Sign in": "Sign in", + "Sign out": "Sign out", + "The resource you're attempting to access requires two-factor authentication": "The resource you're attempting to access requires two-factor authentication.", + "There was a problem initiating the registration process": "There was a problem initiating the registration process", + "There was an issue completing the process. The verification token might have expired": "There was an issue completing the process. The verification token might have expired.", + "There was an issue initiating the password reset process": "There was an issue initiating the password reset process.", + "There was an issue resetting the password": "There was an issue resetting the password", + "There was an issue signing out": "There was an issue signing out", + "Time-based One-Time Password": "Time-based One-Time Password", + "Username": "Username", + "You must open the link from the same device and browser that initiated the registration process": "You must open the link from the same device and browser that initiated the registration process", + "You're being signed out and redirected": "You're being signed out and redirected", + "Your supplied password does not meet the password policy requirements": "Your supplied password does not meet the password policy requirements." + } +} diff --git a/web/src/i18n/locales/es.json b/web/src/i18n/locales/es.json new file mode 100644 index 000000000..30c23e7ab --- /dev/null +++ b/web/src/i18n/locales/es.json @@ -0,0 +1,53 @@ +{ + "Portal": { + "An email has been sent to your address to complete the process": "Un correo ha sido enviado a su cuenta para completar el proceso", + "Authenticated": "Autenticado", + "Cancel": "Cancelar", + "Contact your administrator to register a device": "Contacte a su administrador para registrar un dispositivo.", + "Could not obtain user settings": "Error al obtener configuración de usuario", + "Done": "Hecho", + "Enter new password": "Ingrese una nueva contraseña", + "Enter one-time password": "Ingrese contraseña de un solo uso (OTP)", + "Failed to register device, the provided link is expired or has already been used": "Error al registrar dispositivo, el link expiró o ya ha sido utilizado", + "Hi": "Hola", + "Incorrect username or password": "Usuario y/o contraseña incorrectos", + "Loading": "Cargando", + "Logout": "Cerrar Sesión", + "Lost your device?": "Perdió su dispositivo?", + "Methods": "Métodos", + "Need Google Authenticator?": "Necesita Google Authenticator?", + "New password": "Nueva contraseña", + "No verification token provided": "No se ha recibido el token de verificación", + "OTP Secret copied to clipboard": "La clave OTP ha sido copiada al portapapeles", + "OTP URL copied to clipboard": "la URL OTP ha sido copiada al portapapeles.", + "One-Time Password": "Contraseña de un solo uso (OTP)", + "Password has been reset": "La contraseña ha sido restablecida.", + "Password": "Contraseña", + "Passwords do not match": "Las contraseñas no coinciden.", + "Push Notification": "Notificaciones Push", + "Register device": "Registrar Dispositivo", + "Register your first device by clicking on the link below": "Registre su primer dispositivo, haciendo click en el siguiente link.", + "Remember me": "Recordarme", + "Repeat new password": "Repetir la contraseña", + "Reset password": "Restablecer Contraseña", + "Reset password?": "Olvidé mi contraseña", + "Reset": "Restablecer", + "Scan QR Code": "Escanear Código QR", + "Secret": "Secreto", + "Security Key - U2F": "Llave de Seguridad - U2F", + "Select a Device": "Seleccionar Dispositivo", + "Sign in": "Iniciar Sesión", + "Sign out": "Cerrar Sesión", + "The resource you're attempting to access requires two-factor authentication": "El recurso que intenta alcanzar requiere un segundo factor de autenticación (2FA).", + "There was a problem initiating the registration process": "Ocurrió un problema al iniciar el proceso de registración", + "There was an issue completing the process. The verification token might have expired": "Ocurrió un problema mientras se completaba el proceso. El token de verificación pudo haber expirado.", + "There was an issue initiating the password reset process": "Ha ocurrido un error al iniciar el proceso de proceso de restauración de contraseña.", + "There was an issue resetting the password": "Ocurrió un error al intentar restablecer la contraseña", + "There was an issue signing out": "Ocurrió un error al intentar cerrar sesión", + "Time-based One-Time Password": "Contraseña de uso único - OTP", + "Username": "Usuario", + "You must open the link from the same device and browser that initiated the registration process": "Debe abrir el link desde el mismo dispositivo y navegador desde el que inició el proceso de registración", + "You're being signed out and redirected": "Cerrando Sesión y redirigiendo", + "Your supplied password does not meet the password policy requirements": "La contraseña suministrada no cumple con los requerimientos de la política de contraseñas" + } +} diff --git a/web/src/index.tsx b/web/src/index.tsx index f306ac254..ee2d6014f 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -5,6 +5,7 @@ import ReactDOM from "react-dom"; import "@root/index.css"; import App from "@root/App"; import * as serviceWorker from "@root/serviceWorker"; +import "./i18n/index.ts"; ReactDOM.render(, document.getElementById("root")); diff --git a/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx b/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx index 560ebd47a..b26eca2b0 100644 --- a/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx +++ b/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx @@ -6,6 +6,7 @@ import { makeStyles, Typography, Button, IconButton, Link, CircularProgress, Tex import { red } from "@material-ui/core/colors"; import classnames from "classnames"; import QRCode from "qrcode.react"; +import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import AppStoreBadges from "@components/AppStoreBadges"; @@ -26,6 +27,7 @@ const RegisterOneTimePassword = function () { const { createSuccessNotification, createErrorNotification } = useNotifications(); const [hasErrored, setHasErrored] = useState(false); const [isLoading, setIsLoading] = useState(false); + const { t: translate } = useTranslation("Portal"); // Get the token from the query param to give it back to the API when requesting // the secret for OTP. @@ -49,17 +51,19 @@ const RegisterOneTimePassword = function () { console.error(err); if ((err as Error).message.includes("Request failed with status code 403")) { createErrorNotification( - "You must open the link from the same device and browser that initiated the registration process", + translate( + "You must open the link from the same device and browser that initiated the registration process", + ), ); } else { createErrorNotification( - "Failed to register device, the provided link is expired or has already been used", + translate("Failed to register device, the provided link is expired or has already been used"), ); } setHasErrored(true); } setIsLoading(false); - }, [processToken, createErrorNotification]); + }, [processToken, createErrorNotification, translate]); useEffect(() => { completeRegistrationProcess(); @@ -82,10 +86,12 @@ const RegisterOneTimePassword = function () { const qrcodeFuzzyStyle = isLoading || hasErrored ? style.fuzzy : undefined; return ( - +
- Need Google Authenticator? + + {translate("Need Google Authenticator?")} + ) : null} - {secretBase32 ? SecretButton(secretBase32, "OTP Secret copied to clipboard.", faKey) : null} - {secretURL !== "empty" ? SecretButton(secretURL, "OTP URL copied to clipboard.", faCopy) : null} + {secretBase32 + ? SecretButton(secretBase32, translate("OTP Secret copied to clipboard"), faKey) + : null} + {secretURL !== "empty" + ? SecretButton(secretURL, translate("OTP URL copied to clipboard"), faCopy) + : null}
diff --git a/web/src/views/LoadingPage/LoadingPage.tsx b/web/src/views/LoadingPage/LoadingPage.tsx index a37dd8365..da7d0e52e 100644 --- a/web/src/views/LoadingPage/LoadingPage.tsx +++ b/web/src/views/LoadingPage/LoadingPage.tsx @@ -1,15 +1,17 @@ import React from "react"; import { useTheme, Typography, Grid } from "@material-ui/core"; +import { useTranslation } from "react-i18next"; import ReactLoading from "react-loading"; const LoadingPage = function () { const theme = useTheme(); + const { t: translate } = useTranslation("Portal"); return ( - Loading... + {translate("Loading")}... ); diff --git a/web/src/views/LoginPortal/Authenticated.tsx b/web/src/views/LoginPortal/Authenticated.tsx index 1f99807bd..aa2f4dd98 100644 --- a/web/src/views/LoginPortal/Authenticated.tsx +++ b/web/src/views/LoginPortal/Authenticated.tsx @@ -1,17 +1,19 @@ import React from "react"; import { Typography, makeStyles } from "@material-ui/core"; +import { useTranslation } from "react-i18next"; import SuccessIcon from "@components/SuccessIcon"; const Authenticated = function () { const classes = useStyles(); + const { t: translate } = useTranslation("Portal"); return (
- Authenticated + {translate("Authenticated")}
); }; diff --git a/web/src/views/LoginPortal/AuthenticatedView/AuthenticatedView.tsx b/web/src/views/LoginPortal/AuthenticatedView/AuthenticatedView.tsx index 40c1241cc..15a73d623 100644 --- a/web/src/views/LoginPortal/AuthenticatedView/AuthenticatedView.tsx +++ b/web/src/views/LoginPortal/AuthenticatedView/AuthenticatedView.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Grid, makeStyles, Button } from "@material-ui/core"; +import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { LogoutRoute as SignOutRoute } from "@constants/Routes"; @@ -14,17 +15,18 @@ export interface Props { const AuthenticatedView = function (props: Props) { const style = useStyles(); const navigate = useNavigate(); + const { t: translate } = useTranslation("Portal"); const handleLogoutClick = () => { navigate(SignOutRoute); }; return ( - + diff --git a/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx b/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx index 43e62e2bb..df92f0610 100644 --- a/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx +++ b/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx @@ -2,6 +2,7 @@ import React, { MutableRefObject, useEffect, useRef, useState } from "react"; import { makeStyles, Grid, Button, FormControlLabel, Checkbox, Link } from "@material-ui/core"; import classnames from "classnames"; +import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import FixedTextField from "@components/FixedTextField"; @@ -37,6 +38,7 @@ const FirstFactorForm = function (props: Props) { // TODO (PR: #806, Issue: #511) potentially refactor const usernameRef = useRef() as MutableRefObject; const passwordRef = useRef() as MutableRefObject; + const { t: translate } = useTranslation("Portal"); useEffect(() => { const timeout = setTimeout(() => usernameRef.current.focus(), 10); return () => clearTimeout(timeout); @@ -66,7 +68,7 @@ const FirstFactorForm = function (props: Props) { props.onAuthenticationSuccess(res ? res.redirect : undefined); } catch (err) { console.error(err); - createErrorNotification("Incorrect username or password."); + createErrorNotification(translate("Incorrect username or password")); props.onAuthenticationFailure(); setPassword(""); passwordRef.current.focus(); @@ -78,14 +80,14 @@ const FirstFactorForm = function (props: Props) { }; return ( - + } className={style.rememberMe} - label="Remember me" + label={translate("Remember me")} /> ) : null} @@ -176,7 +178,7 @@ const FirstFactorForm = function (props: Props) { disabled={disabled} onClick={handleSignIn} > - Sign in + {translate("Sign in")} {props.resetPassword ? ( @@ -187,7 +189,7 @@ const FirstFactorForm = function (props: Props) { onClick={handleResetPasswordClick} className={style.resetLink} > - Reset password? + {translate("Reset password?")} ) : null} diff --git a/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx b/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx index 6874a50cf..1d36b1804 100644 --- a/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx +++ b/web/src/views/LoginPortal/SecondFactor/MethodContainer.tsx @@ -2,6 +2,7 @@ import React, { ReactNode, Fragment } from "react"; import { makeStyles, Typography, Link, useTheme } from "@material-ui/core"; import classnames from "classnames"; +import { useTranslation } from "react-i18next"; import InformationIcon from "@components/InformationIcon"; import Authenticated from "@views/LoginPortal/Authenticated"; @@ -27,12 +28,13 @@ export interface Props { const DefaultMethodContainer = function (props: Props) { const style = useStyles(); + const { t: translate } = useTranslation("Portal"); const registerMessage = props.registered ? props.title === "Push Notification" ? "" - : "Lost your device?" - : "Register device"; - const selectMessage = "Select a Device"; + : translate("Lost your device?") + : translate("Register device"); + const selectMessage = translate("Select a Device"); let container: ReactNode; let stateClass: string = ""; @@ -95,6 +97,7 @@ interface NotRegisteredContainerProps { } function NotRegisteredContainer(props: NotRegisteredContainerProps) { + const { t: translate } = useTranslation("Portal"); const theme = useTheme(); return ( @@ -102,14 +105,14 @@ function NotRegisteredContainer(props: NotRegisteredContainerProps) { - The resource you're attempting to access requires two-factor authentication. + {translate("The resource you're attempting to access requires two-factor authentication")} {props.title === "Push Notification" ? props.duoSelfEnrollment - ? "Register your first device by clicking on the link below." - : "Contact your administrator to register a device." - : "Register your first device by clicking on the link below."} + ? translate("Register your first device by clicking on the link below") + : translate("Contact your administrator to register a device.") + : translate("Register your first device by clicking on the link below")} ); diff --git a/web/src/views/LoginPortal/SecondFactor/MethodSelectionDialog.tsx b/web/src/views/LoginPortal/SecondFactor/MethodSelectionDialog.tsx index 1ba5d9038..ce9cb697d 100644 --- a/web/src/views/LoginPortal/SecondFactor/MethodSelectionDialog.tsx +++ b/web/src/views/LoginPortal/SecondFactor/MethodSelectionDialog.tsx @@ -10,6 +10,7 @@ import { Typography, useTheme, } from "@material-ui/core"; +import { useTranslation } from "react-i18next"; import FingerTouchIcon from "@components/FingerTouchIcon"; import PushNotificationIcon from "@components/PushNotificationIcon"; @@ -28,6 +29,7 @@ export interface Props { const MethodSelectionDialog = function (props: Props) { const style = useStyles(); const theme = useTheme(); + const { t: translate } = useTranslation("Portal"); const pieChartIcon = ( @@ -40,7 +42,7 @@ const MethodSelectionDialog = function (props: Props) { {props.methods.has(SecondFactorMethod.TOTP) ? ( props.onClick(SecondFactorMethod.TOTP)} /> @@ -48,7 +50,7 @@ const MethodSelectionDialog = function (props: Props) { {props.methods.has(SecondFactorMethod.U2F) && props.u2fSupported ? ( } onClick={() => props.onClick(SecondFactorMethod.U2F)} /> @@ -56,7 +58,7 @@ const MethodSelectionDialog = function (props: Props) { {props.methods.has(SecondFactorMethod.MobilePush) ? ( } onClick={() => props.onClick(SecondFactorMethod.MobilePush)} /> diff --git a/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx b/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx index c0920c2ac..d8f38a779 100644 --- a/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx +++ b/web/src/views/LoginPortal/SecondFactor/OneTimePasswordMethod.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + import { useRedirectionURL } from "@hooks/RedirectionURL"; import { useUserInfoTOTPConfiguration } from "@hooks/UserInfoTOTPConfiguration"; import { completeTOTPSignIn } from "@services/OneTimePassword"; @@ -31,20 +33,20 @@ const OneTimePasswordMethod = function (props: Props) { props.authenticationLevel === AuthenticationLevel.TwoFactor ? State.Success : State.Idle, ); const redirectionURL = useRedirectionURL(); + const { t: translate } = useTranslation("Portal"); const { onSignInSuccess, onSignInError } = props; const onSignInErrorCallback = useRef(onSignInError).current; const onSignInSuccessCallback = useRef(onSignInSuccess).current; - const [resp, fetch, , err] = useUserInfoTOTPConfiguration(); useEffect(() => { if (err) { console.error(err); - onSignInErrorCallback(new Error("Could not obtain user settings")); + onSignInErrorCallback(new Error(translate("Could not obtain user settings"))); setState(State.Failure); } - }, [onSignInErrorCallback, err]); + }, [onSignInErrorCallback, err, translate]); useEffect(() => { if (props.registered && props.authenticationLevel === AuthenticationLevel.OneFactor) { @@ -105,8 +107,8 @@ const OneTimePasswordMethod = function (props: Props) { return ( { @@ -59,10 +61,10 @@ const SecondFactorForm = function (props: Props) { setRegistrationInProgress(true); try { await initiateRegistrationFunc(); - createInfoNotification(EMAIL_SENT_NOTIFICATION); + createInfoNotification(translate(EMAIL_SENT_NOTIFICATION)); } catch (err) { console.error(err); - createErrorNotification("There was a problem initiating the registration process"); + createErrorNotification(translate("There was a problem initiating the registration process")); } setRegistrationInProgress(false); }; @@ -88,7 +90,7 @@ const SecondFactorForm = function (props: Props) { }; return ( - + {" | "} diff --git a/web/src/views/LoginPortal/SignOut/SignOut.tsx b/web/src/views/LoginPortal/SignOut/SignOut.tsx index 2c77df1cd..4346f76d6 100644 --- a/web/src/views/LoginPortal/SignOut/SignOut.tsx +++ b/web/src/views/LoginPortal/SignOut/SignOut.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useCallback, useState } from "react"; import { Typography, makeStyles } from "@material-ui/core"; +import { useTranslation } from "react-i18next"; import { Navigate } from "react-router-dom"; import { FirstFactorRoute } from "@constants/Routes"; @@ -21,6 +22,7 @@ const SignOut = function (props: Props) { const redirector = useRedirector(); const [timedOut, setTimedOut] = useState(false); const [safeRedirect, setSafeRedirect] = useState(false); + const { t: translate } = useTranslation("Portal"); const doSignOut = useCallback(async () => { try { @@ -36,9 +38,9 @@ const SignOut = function (props: Props) { }, 2000); } catch (err) { console.error(err); - createErrorNotification("There was an issue signing out"); + createErrorNotification(translate("There was an issue signing out")); } - }, [createErrorNotification, redirectionURL, setSafeRedirect, setTimedOut, mounted]); + }, [createErrorNotification, redirectionURL, setSafeRedirect, setTimedOut, mounted, translate]); useEffect(() => { doSignOut(); @@ -53,8 +55,8 @@ const SignOut = function (props: Props) { } return ( - - You're being signed out and redirected... + + {translate("You're being signed out and redirected")}... ); }; diff --git a/web/src/views/ResetPassword/ResetPasswordStep1.tsx b/web/src/views/ResetPassword/ResetPasswordStep1.tsx index 605ec5e9c..ce6f77185 100644 --- a/web/src/views/ResetPassword/ResetPasswordStep1.tsx +++ b/web/src/views/ResetPassword/ResetPasswordStep1.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import { Grid, Button, makeStyles } from "@material-ui/core"; +import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import FixedTextField from "@components/FixedTextField"; @@ -15,6 +16,7 @@ const ResetPasswordStep1 = function () { const [error, setError] = useState(false); const { createInfoNotification, createErrorNotification } = useNotifications(); const navigate = useNavigate(); + const { t: translate } = useTranslation("Portal"); const doInitiateResetPasswordProcess = async () => { if (username === "") { @@ -24,9 +26,9 @@ const ResetPasswordStep1 = function () { try { await initiateResetPasswordProcess(username); - createInfoNotification("An email has been sent to your address to complete the process."); + createInfoNotification(translate("An email has been sent to your address to complete the process")); } catch (err) { - createErrorNotification("There was an issue initiating the password reset process."); + createErrorNotification(translate("There was an issue initiating the password reset process")); } }; @@ -39,12 +41,12 @@ const ResetPasswordStep1 = function () { }; return ( - + @@ -71,7 +73,7 @@ const ResetPasswordStep1 = function () { fullWidth onClick={handleCancelClick} > - Cancel + {translate("Cancel")} diff --git a/web/src/views/ResetPassword/ResetPasswordStep2.tsx b/web/src/views/ResetPassword/ResetPasswordStep2.tsx index 6b43b0c50..eb0f96b1c 100644 --- a/web/src/views/ResetPassword/ResetPasswordStep2.tsx +++ b/web/src/views/ResetPassword/ResetPasswordStep2.tsx @@ -2,6 +2,7 @@ import React, { useState, useCallback, useEffect } from "react"; import { Grid, Button, makeStyles } from "@material-ui/core"; import classnames from "classnames"; +import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import FixedTextField from "@components/FixedTextField"; @@ -20,6 +21,7 @@ const ResetPasswordStep2 = function () { const [errorPassword1, setErrorPassword1] = useState(false); const [errorPassword2, setErrorPassword2] = useState(false); const { createSuccessNotification, createErrorNotification } = useNotifications(); + const { t: translate } = useTranslation("Portal"); const navigate = useNavigate(); // Get the token from the query param to give it back to the API when requesting // the secret for OTP. @@ -28,7 +30,7 @@ const ResetPasswordStep2 = function () { const completeProcess = useCallback(async () => { if (!processToken) { setFormDisabled(true); - createErrorNotification("No verification token provided"); + createErrorNotification(translate("No verification token provided")); return; } @@ -39,11 +41,11 @@ const ResetPasswordStep2 = function () { } catch (err) { console.error(err); createErrorNotification( - "There was an issue completing the process. The verification token might have expired.", + translate("There was an issue completing the process. The verification token might have expired"), ); setFormDisabled(true); } - }, [processToken, createErrorNotification]); + }, [processToken, createErrorNotification, translate]); useEffect(() => { completeProcess(); @@ -62,21 +64,23 @@ const ResetPasswordStep2 = function () { if (password1 !== password2) { setErrorPassword1(true); setErrorPassword2(true); - createErrorNotification("Passwords do not match."); + createErrorNotification(translate("Passwords do not match")); return; } try { await resetPassword(password1); - createSuccessNotification("Password has been reset."); + createSuccessNotification(translate("Password has been reset")); setTimeout(() => navigate(FirstFactorRoute), 1500); setFormDisabled(true); } catch (err) { console.error(err); if ((err as Error).message.includes("0000052D.")) { - createErrorNotification("Your supplied password does not meet the password policy requirements."); + createErrorNotification( + translate("Your supplied password does not meet the password policy requirements"), + ); } else { - createErrorNotification("There was an issue resetting the password."); + createErrorNotification(translate("There was an issue resetting the password")); } } }; @@ -86,12 +90,12 @@ const ResetPasswordStep2 = function () { const handleCancelClick = () => navigate(FirstFactorRoute); return ( - + - Reset + {translate("Reset")} @@ -144,7 +148,7 @@ const ResetPasswordStep2 = function () { onClick={handleCancelClick} className={style.fullWidth} > - Cancel + {translate("Cancel")} diff --git a/web/tsconfig.json b/web/tsconfig.json index 696d414db..4d1c4cbad 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -7,6 +7,7 @@ "@components/*": ["components/*"], "@constants/*": ["constants/*"], "@hooks/*": ["hooks/*"], + "@i18n/*": ["i18n/*"], "@layouts/*": ["layouts/*"], "@models/*": ["models/*"], "@services/*": ["services/*"],