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 <nightah@me.com> Co-authored-by: James Elliott <james-d-elliott@users.noreply.github.com>pull/2820/head
parent
a7a2bc63fe
commit
db046b2d1c
|
@ -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/(.*)$": [
|
||||
"<rootDir>/src/hooks/$1"
|
||||
],
|
||||
"^@i18n/(.*)$": [
|
||||
"<rootDir>/src/i18n/$1"
|
||||
],
|
||||
"^@layouts/(.*)$": [
|
||||
"<rootDir>/src/layouts/$1"
|
||||
],
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
|
@ -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."
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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(<App />, document.getElementById("root"));
|
||||
|
||||
|
|
|
@ -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(
|
||||
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 (
|
||||
<LoginLayout title="Scan QR Code">
|
||||
<LoginLayout title={translate("Scan QR Code")}>
|
||||
<div className={style.root}>
|
||||
<div className={style.googleAuthenticator}>
|
||||
<Typography className={style.googleAuthenticatorText}>Need Google Authenticator?</Typography>
|
||||
<Typography className={style.googleAuthenticatorText}>
|
||||
{translate("Need Google Authenticator?")}
|
||||
</Typography>
|
||||
<AppStoreBadges
|
||||
iconSize={128}
|
||||
targetBlank
|
||||
|
@ -105,7 +111,7 @@ const RegisterOneTimePassword = function () {
|
|||
{secretURL !== "empty" ? (
|
||||
<TextField
|
||||
id="secret-url"
|
||||
label="Secret"
|
||||
label={translate("Secret")}
|
||||
className={style.secret}
|
||||
value={secretURL}
|
||||
InputProps={{
|
||||
|
@ -113,8 +119,12 @@ const RegisterOneTimePassword = function () {
|
|||
}}
|
||||
/>
|
||||
) : 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}
|
||||
</div>
|
||||
<Button
|
||||
variant="contained"
|
||||
|
@ -123,7 +133,7 @@ const RegisterOneTimePassword = function () {
|
|||
onClick={handleDoneClick}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Done
|
||||
{translate("Done")}
|
||||
</Button>
|
||||
</div>
|
||||
</LoginLayout>
|
||||
|
|
|
@ -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 (
|
||||
<Grid container alignItems="center" justifyContent="center" style={{ minHeight: "100vh" }}>
|
||||
<Grid item style={{ textAlign: "center", display: "inline-block" }}>
|
||||
<ReactLoading width={64} height={64} color={theme.custom.loadingBar} type="bars" />
|
||||
<Typography>Loading...</Typography>
|
||||
<Typography>{translate("Loading")}...</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
|
|
@ -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 (
|
||||
<div id="authenticated-stage">
|
||||
<div className={classes.iconContainer}>
|
||||
<SuccessIcon />
|
||||
</div>
|
||||
<Typography>Authenticated</Typography>
|
||||
<Typography>{translate("Authenticated")}</Typography>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
<LoginLayout id="authenticated-stage" title={`Hi ${props.name}`} showBrand>
|
||||
<LoginLayout id="authenticated-stage" title={`${translate("Hi")} ${props.name}`} showBrand>
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<Button color="secondary" onClick={handleLogoutClick} id="logout-button">
|
||||
Logout
|
||||
{translate("Logout")}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} className={style.mainContainer}>
|
||||
|
|
|
@ -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<HTMLInputElement>;
|
||||
const passwordRef = useRef() as MutableRefObject<HTMLInputElement>;
|
||||
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 (
|
||||
<LoginLayout id="first-factor-stage" title="Sign in" showBrand>
|
||||
<LoginLayout id="first-factor-stage" title={translate("Sign in")} showBrand>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<FixedTextField
|
||||
// TODO (PR: #806, Issue: #511) potentially refactor
|
||||
inputRef={usernameRef}
|
||||
id="username-textfield"
|
||||
label="Username"
|
||||
label={translate("Username")}
|
||||
variant="outlined"
|
||||
required
|
||||
value={username}
|
||||
|
@ -115,7 +117,7 @@ const FirstFactorForm = function (props: Props) {
|
|||
// TODO (PR: #806, Issue: #511) potentially refactor
|
||||
inputRef={passwordRef}
|
||||
id="password-textfield"
|
||||
label="Password"
|
||||
label={translate("Password")}
|
||||
variant="outlined"
|
||||
required
|
||||
fullWidth
|
||||
|
@ -163,7 +165,7 @@ const FirstFactorForm = function (props: Props) {
|
|||
/>
|
||||
}
|
||||
className={style.rememberMe}
|
||||
label="Remember me"
|
||||
label={translate("Remember me")}
|
||||
/>
|
||||
</Grid>
|
||||
) : null}
|
||||
|
@ -176,7 +178,7 @@ const FirstFactorForm = function (props: Props) {
|
|||
disabled={disabled}
|
||||
onClick={handleSignIn}
|
||||
>
|
||||
Sign in
|
||||
{translate("Sign in")}
|
||||
</Button>
|
||||
</Grid>
|
||||
{props.resetPassword ? (
|
||||
|
@ -187,7 +189,7 @@ const FirstFactorForm = function (props: Props) {
|
|||
onClick={handleResetPasswordClick}
|
||||
className={style.resetLink}
|
||||
>
|
||||
Reset password?
|
||||
{translate("Reset password?")}
|
||||
</Link>
|
||||
</Grid>
|
||||
) : null}
|
||||
|
|
|
@ -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 (
|
||||
<Fragment>
|
||||
|
@ -102,14 +105,14 @@ function NotRegisteredContainer(props: NotRegisteredContainerProps) {
|
|||
<InformationIcon />
|
||||
</div>
|
||||
<Typography style={{ color: "#5858ff" }}>
|
||||
The resource you're attempting to access requires two-factor authentication.
|
||||
{translate("The resource you're attempting to access requires two-factor authentication")}
|
||||
</Typography>
|
||||
<Typography style={{ color: "#5858ff" }}>
|
||||
{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")}
|
||||
</Typography>
|
||||
</Fragment>
|
||||
);
|
||||
|
|
|
@ -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 = (
|
||||
<TimerIcon width={24} height={24} period={15} color={theme.palette.primary.main} backgroundColor={"white"} />
|
||||
|
@ -40,7 +42,7 @@ const MethodSelectionDialog = function (props: Props) {
|
|||
{props.methods.has(SecondFactorMethod.TOTP) ? (
|
||||
<MethodItem
|
||||
id="one-time-password-option"
|
||||
method="Time-based One-Time Password"
|
||||
method={translate("Time-based One-Time Password")}
|
||||
icon={pieChartIcon}
|
||||
onClick={() => props.onClick(SecondFactorMethod.TOTP)}
|
||||
/>
|
||||
|
@ -48,7 +50,7 @@ const MethodSelectionDialog = function (props: Props) {
|
|||
{props.methods.has(SecondFactorMethod.U2F) && props.u2fSupported ? (
|
||||
<MethodItem
|
||||
id="security-key-option"
|
||||
method="Security Key - U2F"
|
||||
method={translate("Security Key - U2F")}
|
||||
icon={<FingerTouchIcon size={32} />}
|
||||
onClick={() => props.onClick(SecondFactorMethod.U2F)}
|
||||
/>
|
||||
|
@ -56,7 +58,7 @@ const MethodSelectionDialog = function (props: Props) {
|
|||
{props.methods.has(SecondFactorMethod.MobilePush) ? (
|
||||
<MethodItem
|
||||
id="push-notification-option"
|
||||
method="Push Notification"
|
||||
method={translate("Push Notification")}
|
||||
icon={<PushNotificationIcon width={32} height={32} />}
|
||||
onClick={() => props.onClick(SecondFactorMethod.MobilePush)}
|
||||
/>
|
||||
|
|
|
@ -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 (
|
||||
<MethodContainer
|
||||
id={props.id}
|
||||
title="One-Time Password"
|
||||
explanation="Enter one-time password"
|
||||
title={translate("One-Time Password")}
|
||||
explanation={translate("Enter one-time password")}
|
||||
duoSelfEnrollment={false}
|
||||
registered={props.registered}
|
||||
state={methodState}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import { Grid, makeStyles, Button } from "@material-ui/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Route, Routes, useNavigate } from "react-router-dom";
|
||||
import u2fApi from "u2f-api";
|
||||
|
||||
|
@ -23,7 +24,7 @@ import OneTimePasswordMethod from "@views/LoginPortal/SecondFactor/OneTimePasswo
|
|||
import PushNotificationMethod from "@views/LoginPortal/SecondFactor/PushNotificationMethod";
|
||||
import SecurityKeyMethod from "@views/LoginPortal/SecondFactor/SecurityKeyMethod";
|
||||
|
||||
const EMAIL_SENT_NOTIFICATION = "An email has been sent to your address to complete the process.";
|
||||
const EMAIL_SENT_NOTIFICATION = "An email has been sent to your address to complete the process";
|
||||
|
||||
export interface Props {
|
||||
authenticationLevel: AuthenticationLevel;
|
||||
|
@ -42,6 +43,7 @@ const SecondFactorForm = function (props: Props) {
|
|||
const { createInfoNotification, createErrorNotification } = useNotifications();
|
||||
const [registrationInProgress, setRegistrationInProgress] = useState(false);
|
||||
const [u2fSupported, setU2fSupported] = useState(false);
|
||||
const { t: translate } = useTranslation("Portal");
|
||||
|
||||
// Check that U2F is supported.
|
||||
useEffect(() => {
|
||||
|
@ -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 (
|
||||
<LoginLayout id="second-factor-stage" title={`Hi ${props.userInfo.display_name}`} showBrand>
|
||||
<LoginLayout id="second-factor-stage" title={`${translate("Hi")} ${props.userInfo.display_name}`} showBrand>
|
||||
<MethodSelectionDialog
|
||||
open={methodSelectionOpen}
|
||||
methods={props.configuration.available_methods}
|
||||
|
@ -99,11 +101,11 @@ const SecondFactorForm = function (props: Props) {
|
|||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<Button color="secondary" onClick={handleLogoutClick} id="logout-button">
|
||||
Logout
|
||||
{translate("Logout")}
|
||||
</Button>
|
||||
{" | "}
|
||||
<Button color="secondary" onClick={handleMethodSelectionClick} id="methods-button">
|
||||
Methods
|
||||
{translate("Methods")}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} className={style.methodContainer}>
|
||||
|
|
|
@ -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 (
|
||||
<LoginLayout title="Sign out">
|
||||
<Typography className={style.typo}>You're being signed out and redirected...</Typography>
|
||||
<LoginLayout title={translate("Sign out")}>
|
||||
<Typography className={style.typo}>{translate("You're being signed out and redirected")}...</Typography>
|
||||
</LoginLayout>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
<LoginLayout title="Reset password" id="reset-password-step1-stage">
|
||||
<LoginLayout title={translate("Reset password")} id="reset-password-step1-stage">
|
||||
<Grid container className={style.root} spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<FixedTextField
|
||||
id="username-textfield"
|
||||
label="Username"
|
||||
label={translate("Username")}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
error={error}
|
||||
|
@ -60,7 +62,7 @@ const ResetPasswordStep1 = function () {
|
|||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Button id="reset-button" variant="contained" color="primary" fullWidth onClick={handleResetClick}>
|
||||
Reset
|
||||
{translate("Reset")}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
|
@ -71,7 +73,7 @@ const ResetPasswordStep1 = function () {
|
|||
fullWidth
|
||||
onClick={handleCancelClick}
|
||||
>
|
||||
Cancel
|
||||
{translate("Cancel")}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
|
|
@ -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 (
|
||||
<LoginLayout title="Enter new password" id="reset-password-step2-stage">
|
||||
<LoginLayout title={translate("Enter new password")} id="reset-password-step2-stage">
|
||||
<Grid container className={style.root} spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<FixedTextField
|
||||
id="password1-textfield"
|
||||
label="New password"
|
||||
label={translate("New password")}
|
||||
variant="outlined"
|
||||
type="password"
|
||||
value={password1}
|
||||
|
@ -105,7 +109,7 @@ const ResetPasswordStep2 = function () {
|
|||
<Grid item xs={12}>
|
||||
<FixedTextField
|
||||
id="password2-textfield"
|
||||
label="Repeat new password"
|
||||
label={translate("Repeat new password")}
|
||||
variant="outlined"
|
||||
type="password"
|
||||
disabled={formDisabled}
|
||||
|
@ -132,7 +136,7 @@ const ResetPasswordStep2 = function () {
|
|||
onClick={handleResetClick}
|
||||
className={style.fullWidth}
|
||||
>
|
||||
Reset
|
||||
{translate("Reset")}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
|
@ -144,7 +148,7 @@ const ResetPasswordStep2 = function () {
|
|||
onClick={handleCancelClick}
|
||||
className={style.fullWidth}
|
||||
>
|
||||
Cancel
|
||||
{translate("Cancel")}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
"@components/*": ["components/*"],
|
||||
"@constants/*": ["constants/*"],
|
||||
"@hooks/*": ["hooks/*"],
|
||||
"@i18n/*": ["i18n/*"],
|
||||
"@layouts/*": ["layouts/*"],
|
||||
"@models/*": ["models/*"],
|
||||
"@services/*": ["services/*"],
|
||||
|
|
Loading…
Reference in New Issue