diff --git a/frontend/.gitignore b/frontend/.gitignore index 98e16bb..bfe5e57 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -13,6 +13,7 @@ dist dist-ssr *.local .env +.yarn # Editor directories and files .vscode/* diff --git a/frontend/package.json b/frontend/package.json index 5f6513c..119b2f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "sheet2i18n": "sheet2i18n src/sheet2i18n.config.cjs", + "sheet2i18n": "node ./sheet2i18n/index.cjs src/sheet2i18n.config.cjs", "lint": "yarn lint:scripts && yarn lint:styles && yarn lint:editor && yarn prettier --write .", "lint:scripts": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint:styles": "stylelint \"./src/**/*.(scss)\"", @@ -48,16 +48,17 @@ "@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/parser": "^7.13.1", "@vitejs/plugin-react": "^4.3.1", + "csv-parse": "^5.6.0", "eclint": "^2.8.1", "eslint": "^8.57.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.34.3", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.7", + "node-fetch": "^3.3.2", "postcss": "^8.4.29", "prettier": "^3.3.2", "sass": "^1.77.6", - "sheet2i18n": "^1.1.2", "stylelint": "^16.6.1", "stylelint-config-prettier-scss": "^1.0.0", "stylelint-config-standard-scss": "^13.1.0", diff --git a/frontend/sheet2i18n/index.cjs b/frontend/sheet2i18n/index.cjs new file mode 100644 index 0000000..a51eae6 --- /dev/null +++ b/frontend/sheet2i18n/index.cjs @@ -0,0 +1,20 @@ +"use strict"; + +const path = require("path"); +const sheet2i18n = require("./sheet2i18n.cjs"); + +const [, , configPathArg] = process.argv; + +const configPath = configPathArg || "sheet2i18n.config"; +const currentDir = process.cwd(); +const resolvedConfigPath = path.resolve(currentDir, configPath); + +try { + sheet2i18n(resolvedConfigPath, currentDir); +} catch (error) { + console.error( + `Failed to execute sheet2i18n with config path: ${resolvedConfigPath}`, + ); + console.error(error); + process.exit(1); +} diff --git a/frontend/sheet2i18n/sheet2i18n.cjs b/frontend/sheet2i18n/sheet2i18n.cjs new file mode 100644 index 0000000..d378de9 --- /dev/null +++ b/frontend/sheet2i18n/sheet2i18n.cjs @@ -0,0 +1,102 @@ +const fs = require("fs").promises; +const path = require("path"); +const parseCSV = require("csv-parse/sync"); + +/** + * Load CSV files, parse them, and generate JSON files for localization. + * @param {string} configPath - Path to the configuration file. + * @param {string} currentDir - Path to the root folder. + */ +async function loadCsv(configPath, currentDir) { + const { exportPath, tabsUrl, localesKey } = require(configPath); + + console.log("[+] IMPORTING LOCALES"); + + try { + const fetch = (await import("node-fetch")).default; + + const responses = await Promise.all( + tabsUrl.map(async (urltab) => { + const response = await fetch(urltab); + return await response.text(); + }), + ); + + const resolvedExportPath = path.resolve(currentDir, exportPath); + + const rows = responses.flatMap((response) => getParsedCSV(response)); + await handleResponse(localesKey, rows, resolvedExportPath); + } catch (error) { + console.error("Error fetching or processing CSV files:", error); + } +} + +/** + * Parse a CSV string into an array of objects. + * @param {string} file - CSV file content as a string. + * @returns {Object[]} Parsed CSV data. + */ +function getParsedCSV(file) { + return parseCSV.parse(file, { + columns: (header) => header.map((col) => col.split(" ")[0].toLowerCase()), + }); +} + +/** + * Handle the parsed CSV data and generate JSON files for each locale. + * @param {string[]} localesKey - Array of locale keys. + * @param {Object[]} rows - Parsed CSV data. + * @param {string} exportPath - Path to export JSON files. + */ +async function handleResponse(localesKey, rows, exportPath) { + await localesKey.forEach(async (localeKey) => { + const content = writeTranslation(localesKey, rows, localeKey); + await createJson(exportPath, localeKey, `{\n${content}\n}\n`); + }); +} + +/** + * Write translation content for a specific locale. + * @param {string[]} localesKey - Array of locale keys. + * @param {Object[]} rows - Parsed CSV data. + * @param {string} locale - Current locale key. + * @returns {string} Translation content as a JSON string. + */ +function writeTranslation(localesKey, rows, locale) { + const fallback = + localesKey[(localesKey.indexOf(locale) + 1) % localesKey.length]; + + return rows + .map((row) => { + let { key } = row; + if (!key) return; + + key = key.replace(/\s+/g, ""); + + const newRow = + row[locale]?.replace(/"/g, "'").replace(/(?:\r\n|\r|\n)/g, "
") || + row[fallback]; + + return newRow ? ` "${key}": "${newRow}"` : undefined; + }) + .filter(Boolean) + .join(",\n"); +} + +/** + * Create a JSON file with the given content. + * @param {string} exportPath - Path to export JSON files. + * @param {string} locale - Locale key. + * @param {string} content - JSON content as a string. + */ +async function createJson(exportPath, locale, content) { + try { + const filePath = `${exportPath}/${locale}.json`; + await fs.writeFile(filePath, content); + console.log(`JSON in ${locale} is saved.`); + } catch (error) { + console.error(`Error saving JSON for ${locale}:`, error); + } +} + +module.exports = loadCsv; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b6f9766..9030b64 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import Loading from "@components/loading/Loading"; -import CookieConsent from "@containers/cookieConsent/CookieConsent"; -import { hasConsent } from "@containers/cookieConsent/cookieConsentHelper"; +import CookieConsent from "@components/cookieConsent/CookieConsent"; +import { hasConsent } from "@components/cookieConsent/cookieConsentHelper"; import Router from "@routes/Router"; import "@shared/i18n"; import "@styles/index.scss"; diff --git a/frontend/src/app/containers/cookieConsent/CookieConsent.tsx b/frontend/src/app/components/cookieConsent/CookieConsent.tsx similarity index 90% rename from frontend/src/app/containers/cookieConsent/CookieConsent.tsx rename to frontend/src/app/components/cookieConsent/CookieConsent.tsx index b8633bf..36ba905 100644 --- a/frontend/src/app/containers/cookieConsent/CookieConsent.tsx +++ b/frontend/src/app/components/cookieConsent/CookieConsent.tsx @@ -1,10 +1,10 @@ -import cookieTypes from "@containers/cookieConsent/cookieConsent.config"; +import cookieTypes from "./cookieConsent.config"; import { COOKIE_CONSENT_DURATION, getCookieConsentPreferences, setCookiePreferencesInStorage, -} from "@containers/cookieConsent/cookieConsentHelper"; -import ICookiePreferences from "@containers/cookieConsent/interfaces/ICookiePreferences"; +} from "./cookieConsentHelper"; +import ICookiePreferences from "./interfaces/ICookiePreferences"; import { useCallback, useEffect, useState } from "react"; import CookieBanner from "./cookieBanner/CookieBanner"; import CookieModal from "./cookieModal/CookieModal"; @@ -53,6 +53,7 @@ export default function CookieConsent() { cookiePreferences={cookiePreferences} setCookiePreferences={setCookiePreferences} /> + { diff --git a/frontend/src/app/containers/cookieConsent/cookieBanner/CookieBanner.tsx b/frontend/src/app/components/cookieConsent/cookieBanner/CookieBanner.tsx similarity index 88% rename from frontend/src/app/containers/cookieConsent/cookieBanner/CookieBanner.tsx rename to frontend/src/app/components/cookieConsent/cookieBanner/CookieBanner.tsx index 35475e1..df0eb23 100644 --- a/frontend/src/app/containers/cookieConsent/cookieBanner/CookieBanner.tsx +++ b/frontend/src/app/components/cookieConsent/cookieBanner/CookieBanner.tsx @@ -60,7 +60,10 @@ export default function CookieBanner({
({ bottom: theme.spacing(2) })} + sx={(theme) => ({ + bottom: theme.spacing(2), + zIndex: theme.zIndex.cookieBanner, + })} >
-
- +
+ {t("cookie_banner__description")} -
+
-
-
-
+ +
({ margin: theme.customProperties.spacing.lg })}> +
({ + display: "flex", + flexDirection: "column", + gap: theme.customProperties.spacing.md, + marginBottom: theme.customProperties.spacing.md, + })} + > +
{t("cookie_modal__title")} @@ -80,7 +93,7 @@ export default function CookieModal({ {t("cookie_modal__description_2")} ({ marginRight: theme.customProperties.spacing.a })} href={t("cookie_consent_link")} underline="always" external @@ -90,7 +103,13 @@ export default function CookieModal({
-
+
({ + display: "flex", + flexDirection: "column", + gap: theme.customProperties.spacing.lg, + })} + > {t("cookie_modal__description_3")} @@ -103,7 +122,12 @@ export default function CookieModal({ onChange={handleExpand(index)} > - + ({ + marginRight: theme.customProperties.spacing.a, + })} + > {t(cookieType.title)} ({ + marginX: theme.customProperties.spacing.md, + marginBottom: theme.customProperties.spacing.md, + })} > {t(description)} ))} {cookieType.cookies && ( -
+
({ + margin: theme.customProperties.spacing.md, + })} + > -
+
({ + display: "flex", + flexDirection: "column", + gap: theme.customProperties.spacing.sm, + justifyContent: "center", + })} + > diff --git a/frontend/src/app/containers/cookieConsent/interfaces/ICookieInfo.ts b/frontend/src/app/components/cookieConsent/interfaces/ICookieInfo.ts similarity index 100% rename from frontend/src/app/containers/cookieConsent/interfaces/ICookieInfo.ts rename to frontend/src/app/components/cookieConsent/interfaces/ICookieInfo.ts diff --git a/frontend/src/app/containers/cookieConsent/interfaces/ICookiePreferences.ts b/frontend/src/app/components/cookieConsent/interfaces/ICookiePreferences.ts similarity index 100% rename from frontend/src/app/containers/cookieConsent/interfaces/ICookiePreferences.ts rename to frontend/src/app/components/cookieConsent/interfaces/ICookiePreferences.ts diff --git a/frontend/src/app/containers/cookieConsent/interfaces/ICookieSection.ts b/frontend/src/app/components/cookieConsent/interfaces/ICookieSection.ts similarity index 100% rename from frontend/src/app/containers/cookieConsent/interfaces/ICookieSection.ts rename to frontend/src/app/components/cookieConsent/interfaces/ICookieSection.ts diff --git a/frontend/src/app/containers/debugBanner/DebugBanner.tsx b/frontend/src/app/components/debugBanner/DebugBanner.tsx similarity index 86% rename from frontend/src/app/containers/debugBanner/DebugBanner.tsx rename to frontend/src/app/components/debugBanner/DebugBanner.tsx index 6598590..b97b39f 100644 --- a/frontend/src/app/containers/debugBanner/DebugBanner.tsx +++ b/frontend/src/app/components/debugBanner/DebugBanner.tsx @@ -21,7 +21,7 @@ export default function DebugBanner() { const pages = [ { name: t(homeRoute.name), - to: homeRoute.paths[t("locale__key")], + to: `${homeRoute.paths[t("locale__key")]}`, }, { name: t(uikitRoute.name), @@ -42,11 +42,13 @@ export default function DebugBanner() { } return ( -
+
({ + zIndex: theme.zIndex.debugBanner, + })} + >
({ - zIndex: theme.zIndex.debugBanner, - })} className={classNames(classes["content"], { [classes["local"]]: __ENV__ === "local", [classes["dev"]]: __ENV__ === "dev", @@ -63,9 +65,7 @@ export default function DebugBanner() { ))}
- +
); diff --git a/frontend/src/app/containers/debugBanner/debug-banner.module.css b/frontend/src/app/components/debugBanner/debug-banner.module.css similarity index 100% rename from frontend/src/app/containers/debugBanner/debug-banner.module.css rename to frontend/src/app/components/debugBanner/debug-banner.module.css diff --git a/frontend/src/app/components/fieldHelperText/FieldHelperText.tsx b/frontend/src/app/components/fieldHelperText/FieldHelperText.tsx index 73d2762..ebea487 100644 --- a/frontend/src/app/components/fieldHelperText/FieldHelperText.tsx +++ b/frontend/src/app/components/fieldHelperText/FieldHelperText.tsx @@ -44,7 +44,13 @@ export default function FieldHelperText({ } return ( - + ({ + marginTop: theme.customProperties.spacing.xxs, + marginLeft: theme.customProperties.spacing.md, + })} + > {helperText} ); diff --git a/frontend/src/app/components/layout/Layout.tsx b/frontend/src/app/components/layout/Layout.tsx deleted file mode 100644 index 377555e..0000000 --- a/frontend/src/app/components/layout/Layout.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { ReactNode } from "react"; -import { styled } from "@mui/material-pigment-css"; - -interface ILayout { - children: ReactNode; - className?: string; -} - -const LayoutContainer = styled("main")(({ theme }) => ({ - width: "100%", - display: "flex", - flexDirection: "column", - flexGrow: 1, - padding: theme.spacing(2), - alignItems: "center", - backgroundColor: "#fafafb", // TODO: get this from theme - - [theme.breakpoints.up("md")]: { - padding: theme.spacing(4), - }, - - [theme.breakpoints.up("lg")]: { - padding: theme.spacing(6), - }, - - [theme.breakpoints.up("xl")]: { - padding: theme.spacing(8), - }, - - "> .content": { - maxWidth: "82.5rem", - width: "100%", - }, -})); - -function Container({ children, className }: ILayout) { - return ( - -
{children}
-
- ); -} - -const LayoutAuth = styled("main")(({ theme }) => ({ - width: "100%", - display: "flex", - flexDirection: "column", - justifyContent: "center", - alignItems: "center", - backgroundColor: "#fafafb", // TODO: get this from theme - - [theme.breakpoints.up("xs")]: { - flex: "1 1 auto", - }, - - "> .content": { - maxWidth: "82.5rem", - width: "100%", - - [theme.breakpoints.up("xs")]: { - maxWidth: 442, - padding: theme.spacing(2), - }, - }, -})); - -function Auth({ children, className }: ILayout) { - return ( - -
{children}
-
- ); -} - -const Layout = { - Container, - Auth, -}; - -export default Layout; diff --git a/frontend/src/app/components/layouts/Centered.tsx b/frontend/src/app/components/layouts/Centered.tsx new file mode 100644 index 0000000..f89b3ab --- /dev/null +++ b/frontend/src/app/components/layouts/Centered.tsx @@ -0,0 +1,38 @@ +import { styled } from "@mui/material-pigment-css"; + +interface ILayout { + className?: string; + children: React.ReactNode; +} + +const LayoutAuth = styled("main")(({ theme }) => ({ + width: "100%", + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + padding: theme.spacing(2), + + [theme.breakpoints.up("xs")]: { + flex: "1 1 auto", + }, + + "> .content": { + maxWidth: 442, + width: "100%", + + [theme.breakpoints.up("sm")]: { + maxWidth: 442, + }, + }, +})); + +function Centered({ className, children }: ILayout) { + return ( + +
{children}
+
+ ); +} + +export default Centered; diff --git a/frontend/src/app/components/layouts/Dashboard.tsx b/frontend/src/app/components/layouts/Dashboard.tsx new file mode 100644 index 0000000..c31e726 --- /dev/null +++ b/frontend/src/app/components/layouts/Dashboard.tsx @@ -0,0 +1,162 @@ +import { useState } from "react"; +import AppBar from "@mui/material/AppBar"; +import Divider from "@mui/material/Divider"; +import Drawer from "@mui/material/Drawer"; +import IconButton from "@mui/material/IconButton"; +import InboxIcon from "@mui/icons-material/MoveToInbox"; +import List from "@mui/material/List"; +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; +import MailIcon from "@mui/icons-material/Mail"; +import MenuIcon from "@mui/icons-material/Menu"; +import Toolbar from "@mui/material/Toolbar"; +import Typography from "@mui/material/Typography"; +import { Link, Outlet } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import homeRoute from "@pages/home/home.route"; +import myAccountRoute from "@pages/myAccount/myAccount.route"; + +const drawerWidth = 200; + +interface Props { + /** + * Injected by the documentation to work in an iframe. + * Remove this when copying and pasting into your project. + */ + window?: () => Window; +} + +export default function ResponsiveDrawer(props: Props) { + const { t } = useTranslation(); + const { window } = props; + const [mobileOpen, setMobileOpen] = useState(false); + const [isClosing, setIsClosing] = useState(false); + + const handleDrawerClose = () => { + setIsClosing(true); + setMobileOpen(false); + }; + + const handleDrawerTransitionEnd = () => { + setIsClosing(false); + }; + + const handleDrawerToggle = () => { + if (!isClosing) { + setMobileOpen(!mobileOpen); + } + }; + + const drawer = ( +
+ + + + {[ + { + text: t("home__page_title"), + to: homeRoute.paths[t("locale__key")], + }, + { + text: t("my_account__page_title"), + to: myAccountRoute.paths[t("locale__key")], + }, + ].map(({ text, to }, index) => ( + + + + {index % 2 === 0 ? : } + + + + + ))} + +
+ ); + + // Remove this const when copying and pasting into your project. + const container = + window !== undefined ? () => window().document.body : undefined; + + return ( +
+ + + + + + + + nventive + + + + + + +
+ + +
+
+ ); +} diff --git a/frontend/src/app/components/link/Link.tsx b/frontend/src/app/components/link/Link.tsx index f60faa7..946b16a 100644 --- a/frontend/src/app/components/link/Link.tsx +++ b/frontend/src/app/components/link/Link.tsx @@ -4,9 +4,14 @@ import { styled } from "@mui/material-pigment-css"; const StyledMuiLink = styled(MuiLink)(({ theme }) => ({ display: "flex", + alignItems: "center", color: theme.palette.primary.main, textDecorationColor: "unset", cursor: "pointer", + + ".external-link": { + marginLeft: theme.customProperties.spacing.xxs, + }, })); interface ILink extends LinkProps { @@ -29,7 +34,7 @@ export default function Link({ target={target || external ? "_blank" : undefined} > {children} - {external && } + {external && } ); } diff --git a/frontend/src/app/components/uikit/uikitBlock/UikitBlock.tsx b/frontend/src/app/components/uikit/uikitBlock/UikitBlock.tsx index aabe4e4..ba4a1db 100644 --- a/frontend/src/app/components/uikit/uikitBlock/UikitBlock.tsx +++ b/frontend/src/app/components/uikit/uikitBlock/UikitBlock.tsx @@ -34,9 +34,12 @@ export default function UikitBlock({ display: "flex", flexDirection: "column", gap: theme.spacing(1), + marginBottom: theme.spacing(8), })} > - {title} + + {title} + {children} {codeBlock && ( diff --git a/frontend/src/app/components/uikit/uikitColor/UikitColor.tsx b/frontend/src/app/components/uikit/uikitColor/UikitColor.tsx index 23adc9c..2f5a9ed 100644 --- a/frontend/src/app/components/uikit/uikitColor/UikitColor.tsx +++ b/frontend/src/app/components/uikit/uikitColor/UikitColor.tsx @@ -17,7 +17,11 @@ export default function UikitColor({ color }: IUikitColor) { const colorItem = useCallback((bgColor: string, label: string) => { return ( Components -
    +
      ({ + display: "flex", + flexDirection: "column", + gap: theme.customProperties.spacing.xs, + })} + > {items.map((item) => (
    • {item.text} diff --git a/frontend/src/app/containers/authGuard/AuthGuard.tsx b/frontend/src/app/containers/authGuard/AuthGuard.tsx new file mode 100644 index 0000000..e74e794 --- /dev/null +++ b/frontend/src/app/containers/authGuard/AuthGuard.tsx @@ -0,0 +1,13 @@ +import Loading from "@components/loading/Loading"; +import { useUserStore } from "@stores/userStore"; +import { ReactNode } from "react"; +import { useLoadUser, useRedirectToLoginIfNoToken } from "./hooks"; + +export default function AuthGuard({ children }: { children: ReactNode }) { + const { user } = useUserStore(); + + useRedirectToLoginIfNoToken(); + useLoadUser(); + + return !user ? : children; +} diff --git a/frontend/src/app/containers/authGuard/hooks.ts b/frontend/src/app/containers/authGuard/hooks.ts new file mode 100644 index 0000000..46d0fd1 --- /dev/null +++ b/frontend/src/app/containers/authGuard/hooks.ts @@ -0,0 +1,56 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { useUserStore } from "@stores/userStore"; +import { toast } from "react-toastify"; +import { ACCESS_TOKEN } from "@shared/constants"; +import { getMe } from "@services/users/userService"; +import loginRoute from "@pages/login/login.route"; + +export const useRedirectToLoginIfNoToken = () => { + const [t] = useTranslation(); + const navigate = useNavigate(); + + useEffect(() => { + const accessToken = localStorage.getItem(ACCESS_TOKEN); + if (!accessToken) { + navigate(loginRoute.paths[t("locale__key")], { replace: true }); + } + }, [navigate, t]); +}; + +export const useLoadUser = () => { + const [t] = useTranslation(); + const navigate = useNavigate(); + const { setUser, user } = useUserStore(); + + useEffect(() => { + if (user) { + return; + } + + getMe() + .then(({ data }) => { + setUser(data); + }) + .catch((error) => { + if (error.response?.status === 401) { + toast.error(t("errors__expired_session"), { + toastId: "expired-session", + }); + localStorage.removeItem(ACCESS_TOKEN); + } else { + toast.error(t("errors__generic"), { + toastId: "generic", + }); + } + navigate(loginRoute.paths[t("locale__key")], { replace: true }); + }); + }, [navigate, setUser, t, user]); +}; + +export const useCheckPermission = (_permission: string) => { + // const { user } = useUserStore(); + // TODO: validate permission + return true; +}; diff --git a/frontend/src/app/containers/authProvider/AuthProvider.tsx b/frontend/src/app/containers/authProvider/AuthProvider.tsx deleted file mode 100644 index 9968b6d..0000000 --- a/frontend/src/app/containers/authProvider/AuthProvider.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import Loading from "@components/loading/Loading"; -import EPermission from "@enums/EPermission"; -import loginRoute from "@pages/login/login.route"; -import { getMe } from "@services/users/userService"; -import { ACCESS_TOKEN } from "@shared/constants"; -import { useUserStore } from "@stores/userStore"; -import { ReactNode, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; -import { toast } from "react-toastify"; - -export default function AuthProvider({ - children, - permission, -}: { - children: ReactNode; - permission: EPermission; -}) { - const [t] = useTranslation(); - const navigate = useNavigate(); - const { setUser, user } = useUserStore(); - - useEffect(() => { - const accessToken = localStorage.getItem(ACCESS_TOKEN); - if (!accessToken) { - navigate(loginRoute.paths[t("locale__key")], { replace: true }); - } else if (!user) { - getMe() - .then(({ data }) => { - setUser(data); - }) - .catch((error) => { - if (error.response?.status === 401) { - toast.error(t("errors__expired_session"), { - toastId: "expired-session", - }); - localStorage.removeItem(ACCESS_TOKEN); - } else { - toast.error(t("errors__generic"), { - toastId: "generic", - }); - } - navigate(loginRoute.paths[t("locale__key")], { replace: true }); - }); - } - - // TODO: validate permission - }, [navigate, permission, setUser, t, user]); - - return !user ? : children; -} diff --git a/frontend/src/app/containers/permissionGuard/PermissionGuard.tsx b/frontend/src/app/containers/permissionGuard/PermissionGuard.tsx new file mode 100644 index 0000000..3902fff --- /dev/null +++ b/frontend/src/app/containers/permissionGuard/PermissionGuard.tsx @@ -0,0 +1,21 @@ +import Loading from "@components/loading/Loading"; +import EPermission from "@enums/EPermission"; +import { useUserStore } from "@stores/userStore"; +import { ReactNode } from "react"; +import { useCheckPermission } from "./hooks"; + +export default function AuthProvider({ + children, + permission, +}: { + children: ReactNode; + permission: EPermission; +}) { + const { user } = useUserStore(); + const allowed = useCheckPermission(permission); + + if (!user || allowed === undefined) return ; + if (!allowed) return
      Permission Denied
      ; // TODO: throw an error? + + return children; +} diff --git a/frontend/src/app/containers/permissionGuard/hooks.ts b/frontend/src/app/containers/permissionGuard/hooks.ts new file mode 100644 index 0000000..28a6db8 --- /dev/null +++ b/frontend/src/app/containers/permissionGuard/hooks.ts @@ -0,0 +1,5 @@ +export const useCheckPermission = (_permission: string) => { + // const { user } = useUserStore(); + // TODO: validate permission + return true; +}; diff --git a/frontend/src/app/forms/auth/loginForm/LoginForm.tsx b/frontend/src/app/forms/auth/loginForm/LoginForm.tsx index c088a0c..9f3403a 100644 --- a/frontend/src/app/forms/auth/loginForm/LoginForm.tsx +++ b/frontend/src/app/forms/auth/loginForm/LoginForm.tsx @@ -18,6 +18,7 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; import { ValidationError } from "yup"; +import classes from "./loginForm.module.css"; interface ILoginForm { setIsLoading: Dispatch>; @@ -46,8 +47,8 @@ export default function LoginForm({ setIsLoading }: ILoginForm) { setIsLoading(true); postLogin(loginForm) .then(({ data }) => { - if (data.token && data.refreshToken) { - localStorage.setItem(ACCESS_TOKEN, data.token); + if (data.accessToken && data.refreshToken) { + localStorage.setItem(ACCESS_TOKEN, data.accessToken); localStorage.setItem(REFRESH_TOKEN, data.refreshToken); } setUser(data); @@ -92,8 +93,8 @@ export default function LoginForm({ setIsLoading }: ILoginForm) { }, [loginForm, loginFormValidated]); return ( -
      -
      + +
      -
      + +
      - diff --git a/frontend/src/app/forms/auth/loginForm/loginForm.module.css b/frontend/src/app/forms/auth/loginForm/loginForm.module.css new file mode 100644 index 0000000..d44c3a2 --- /dev/null +++ b/frontend/src/app/forms/auth/loginForm/loginForm.module.css @@ -0,0 +1,5 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--mui-customProperties-spacing-xl); +} diff --git a/frontend/src/app/hocs/withAuth.tsx b/frontend/src/app/hocs/withAuth.tsx deleted file mode 100644 index 0f8d279..0000000 --- a/frontend/src/app/hocs/withAuth.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import AuthProvider from "@containers/authProvider/AuthProvider"; -import EPermission from "@enums/EPermission"; -import { ComponentType } from "react"; - -export default function withAuth( - WrappedComponent: ComponentType, - permission: EPermission, -) { - return function WrappedWithAuth() { - return ( - - - - ); - }; -} diff --git a/frontend/src/app/hocs/withPermissionGuard.tsx b/frontend/src/app/hocs/withPermissionGuard.tsx new file mode 100644 index 0000000..fa934c0 --- /dev/null +++ b/frontend/src/app/hocs/withPermissionGuard.tsx @@ -0,0 +1,16 @@ +import PermissionGuard from "@containers/permissionGuard/PermissionGuard"; +import EPermission from "@enums/EPermission"; +import { ComponentType } from "react"; + +export default function withPermissionGuard( + WrappedComponent: ComponentType, + permission: EPermission, +) { + return function WrappedWithPermissionGuard() { + return ( + + + + ); + }; +} diff --git a/frontend/src/app/pages/dashbaord/Dashboard.tsx b/frontend/src/app/pages/dashbaord/Dashboard.tsx deleted file mode 100644 index a968fe7..0000000 --- a/frontend/src/app/pages/dashbaord/Dashboard.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import Layout from "@components/layout/Layout"; - -function Home() { - return DASHBOARD; -} - -export default Home; diff --git a/frontend/src/app/pages/dashbaord/dashboard.route.tsx b/frontend/src/app/pages/dashbaord/dashboard.route.tsx deleted file mode 100755 index 5bb90e7..0000000 --- a/frontend/src/app/pages/dashbaord/dashboard.route.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import en from "@assets/locales/en.json"; -import fr from "@assets/locales/fr.json"; -import { IRoute } from "@routes/interfaces/IRoute"; -import { lazy } from "react"; - -const dashboardRoute: IRoute = { - name: "dashboard__page_title", - component: lazy(() => import("./withAuthDashboard")), - paths: { - en: `/${en.locale__key}/${en.routes__dashboard}`, - fr: `/${fr.locale__key}/${fr.routes__dashboard}`, - }, -}; - -export default dashboardRoute; diff --git a/frontend/src/app/pages/dashbaord/withAuthDashboard.tsx b/frontend/src/app/pages/dashbaord/withAuthDashboard.tsx deleted file mode 100644 index 4cdd944..0000000 --- a/frontend/src/app/pages/dashbaord/withAuthDashboard.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import EPermission from "@enums/EPermission"; -import withAuth from "@hocs/withAuth"; -import Dashboard from "@pages/dashbaord/Dashboard"; - -const withAuthDashboard = withAuth(Dashboard, EPermission.DashboardRead); - -export default withAuthDashboard; diff --git a/frontend/src/app/pages/home/Home.tsx b/frontend/src/app/pages/home/Home.tsx index a968ac9..cdb73d0 100644 --- a/frontend/src/app/pages/home/Home.tsx +++ b/frontend/src/app/pages/home/Home.tsx @@ -1,92 +1,37 @@ import logo from "@assets/images/logo.png"; import reactLogo from "@assets/react.svg"; import Button from "@components/button/Button"; -import Layout from "@components/layout/Layout"; import Typography from "@mui/material/Typography"; -import AddRounded from "@icons/AddRounded"; -import LogoutRounded from "@icons/LogoutRounded"; -import loginRoute from "@pages/login/login.route"; -import { ACCESS_TOKEN, REFRESH_TOKEN } from "@shared/constants"; import { useUserStore } from "@stores/userStore"; -import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; import viteLogo from "/vite.svg"; +import styles from "./home.module.css"; function Home() { const { t } = useTranslation(); - const { user, setUser } = useUserStore(); - const navigate = useNavigate(); - const [count, setCount] = useState(0); - - const onLogout = useCallback(() => { - localStorage.removeItem(ACCESS_TOKEN); - localStorage.removeItem(REFRESH_TOKEN); - setUser(undefined); - navigate(loginRoute.paths[t("locale__key")]); - }, [navigate, setUser, t]); + const { user } = useUserStore(); return ( - -
      - + +
      + -
      - - -
      - {`${t("home__welcome")} ${user?.firstName} ${user?.lastName}`} + +
      - - VERSION: {__VERSION_NUMBER__} - - - API_URL: {__API_URL__} - + {`${t("home__welcome")} ${user?.firstName} ${user?.lastName}`} -
      - -
      -
      - -
      -
      -
      + VERSION: {__VERSION_NUMBER__} + API_URL: {__API_URL__} +
      ); } diff --git a/frontend/src/app/pages/home/home.module.css b/frontend/src/app/pages/home/home.module.css new file mode 100644 index 0000000..a17f499 --- /dev/null +++ b/frontend/src/app/pages/home/home.module.css @@ -0,0 +1,6 @@ +.home { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--mui-customProperties-spacing-xl); +} diff --git a/frontend/src/app/pages/home/home.route.tsx b/frontend/src/app/pages/home/home.route.tsx index 973087a..a37cfff 100755 --- a/frontend/src/app/pages/home/home.route.tsx +++ b/frontend/src/app/pages/home/home.route.tsx @@ -5,10 +5,10 @@ import { lazy } from "react"; const homeRoute: IRoute = { name: "home__page_title", - component: lazy(() => import("./withAuthHome")), + component: lazy(() => import("./withPermissionHome")), paths: { - en: `/${en.locale__key}/${en.routes__home}`, - fr: `/${fr.locale__key}/${fr.routes__home}`, + en: `/app/${en.locale__key}/${en.routes__home}`, + fr: `/app/${fr.locale__key}/${fr.routes__home}`, }, }; diff --git a/frontend/src/app/pages/home/withAuthHome.tsx b/frontend/src/app/pages/home/withAuthHome.tsx deleted file mode 100644 index ed0c377..0000000 --- a/frontend/src/app/pages/home/withAuthHome.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import EPermission from "@enums/EPermission"; -import withAuth from "@hocs/withAuth"; -import Home from "@pages/home/Home"; - -const withAuthHome = withAuth(Home, EPermission.HomeRead); - -export default withAuthHome; diff --git a/frontend/src/app/pages/home/withPermissionHome.tsx b/frontend/src/app/pages/home/withPermissionHome.tsx new file mode 100644 index 0000000..41b3a7c --- /dev/null +++ b/frontend/src/app/pages/home/withPermissionHome.tsx @@ -0,0 +1,7 @@ +import EPermission from "@enums/EPermission"; +import withPermissionGuard from "@hocs/withPermissionGuard"; +import Home from "@pages/home/Home"; + +const withPermissionHome = withPermissionGuard(Home, EPermission.HomeRead); + +export default withPermissionHome; diff --git a/frontend/src/app/pages/login/Login.tsx b/frontend/src/app/pages/login/Login.tsx index 0837d47..5d6a7b3 100644 --- a/frontend/src/app/pages/login/Login.tsx +++ b/frontend/src/app/pages/login/Login.tsx @@ -4,51 +4,77 @@ import Typography from "@mui/material/Typography"; import LoginForm from "@forms/auth/loginForm/LoginForm"; import findRoute from "@routes/findRoute"; import i18n from "@shared/i18n"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import Layout from "@components/layout/Layout"; -// import Container from "@mui/material-pigment-css/Container"; +import Centered from "@components/layouts/Centered"; export default function Login() { const { t } = useTranslation(); const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(false); - const onChangeLanguage = useCallback(() => { + const onChangeLanguage = () => { navigate(findRoute(location.pathname, t("locale__switch_key"))); - i18n.changeLanguage(t("locale__switch_key")); - }, [navigate, t]); + void i18n.changeLanguage(t("locale__switch_key")); + }; return ( <> - -
      - + +
      ({ + display: "flex", + flexDirection: "column", + marginBottom: theme.customProperties.spacing.lg, + })} + > + ({ + marginBottom: theme.customProperties.spacing.md, + })} + > {t("login__page_title")} - - User: oliviaw - - + User: oliviaw + ({ + marginBottom: theme.customProperties.spacing.xxs, + })} + > Password: oliviawpass {t("login__more_user")}
      + -
      - + +
      + ({ + marginBottom: theme.customProperties.spacing.xs, + })} + onClick={onChangeLanguage} + > {t("locale__switch")} {`${t("global__version")}: ${__VERSION_NUMBER__}`}
      - + ); } diff --git a/frontend/src/app/pages/myAccount/MyAccount.tsx b/frontend/src/app/pages/myAccount/MyAccount.tsx new file mode 100644 index 0000000..a1c22ea --- /dev/null +++ b/frontend/src/app/pages/myAccount/MyAccount.tsx @@ -0,0 +1,52 @@ +import findRoute from "@routes/findRoute"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import Button from "@components/button/Button"; +import LogoutRounded from "@icons/LogoutRounded"; +import { Typography } from "@mui/material"; +import loginRoute from "@pages/login/login.route"; +import { ACCESS_TOKEN, REFRESH_TOKEN } from "@shared/constants"; +import i18n from "@shared/i18n"; +import { useUserStore } from "@stores/userStore"; + +function MyAccount() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { setUser } = useUserStore(); + + const onChangeLanguage = () => { + navigate(findRoute(location.pathname, t("locale__switch_key"))); + void i18n.changeLanguage(t("locale__switch_key")); + }; + + const onLogout = () => { + localStorage.removeItem(ACCESS_TOKEN); + localStorage.removeItem(REFRESH_TOKEN); + setUser(undefined); + navigate(loginRoute.paths[t("locale__key")]); + }; + + return ( +
      +

      My Account

      + +
      ({ + display: "flex", + gap: theme.customProperties.spacing.md, + })} + > + + + +
      +
      + ); +} + +export default MyAccount; diff --git a/frontend/src/app/pages/myAccount/myAccount.route.tsx b/frontend/src/app/pages/myAccount/myAccount.route.tsx new file mode 100755 index 0000000..89f3cc4 --- /dev/null +++ b/frontend/src/app/pages/myAccount/myAccount.route.tsx @@ -0,0 +1,15 @@ +import en from "@assets/locales/en.json"; +import fr from "@assets/locales/fr.json"; +import { IRoute } from "@routes/interfaces/IRoute"; +import { lazy } from "react"; + +const myAccountRoute: IRoute = { + name: "my_account__page_title", + component: lazy(() => import("./withPermissionMyAccount")), + paths: { + en: `/app/${en.locale__key}/${en.routes__my_account}`, + fr: `/app/${fr.locale__key}/${fr.routes__my_account}`, + }, +}; + +export default myAccountRoute; diff --git a/frontend/src/app/pages/myAccount/withPermissionMyAccount.tsx b/frontend/src/app/pages/myAccount/withPermissionMyAccount.tsx new file mode 100644 index 0000000..109cda8 --- /dev/null +++ b/frontend/src/app/pages/myAccount/withPermissionMyAccount.tsx @@ -0,0 +1,10 @@ +import EPermission from "@enums/EPermission"; +import withPermissionGuard from "@hocs/withPermissionGuard"; +import MyAccount from "@pages/myAccount/MyAccount"; + +const withPermissionMyAccount = withPermissionGuard( + MyAccount, + EPermission.DashboardRead, +); + +export default withPermissionMyAccount; diff --git a/frontend/src/app/pages/notFound/NotFound.tsx b/frontend/src/app/pages/notFound/NotFound.tsx index 0da0742..7af284c 100644 --- a/frontend/src/app/pages/notFound/NotFound.tsx +++ b/frontend/src/app/pages/notFound/NotFound.tsx @@ -17,21 +17,30 @@ export default function NotFound() { className={classes["not-found"]} >
      - + {t("not_found__title")} - + + ({ + marginBottom: theme.customProperties.spacing.xl, + })} + > {t("not_found__description")} - + + ({ + marginBottom: theme.customProperties.spacing.lg, + })} + > {t("not_found__description_secondary")} +