From 0f8de33f2fe21a5eed84a55ece4d9b4dd9389be2 Mon Sep 17 00:00:00 2001 From: James Elliott Date: Mon, 14 Nov 2022 22:13:10 +1100 Subject: [PATCH] feat: settings router (#4377) --- web/src/App.tsx | 4 +- web/src/components/Brand.tsx | 32 ++ web/src/constants/Routes.ts | 2 + web/src/hooks/RouterNavigate.ts | 29 ++ web/src/layouts/LoginLayout.tsx | 20 +- web/src/layouts/SettingsLayout.tsx | 111 ++++++ web/src/views/LoginPortal/LoginPortal.tsx | 40 +- web/src/views/Settings/SettingsRouter.tsx | 20 + web/src/views/Settings/SettingsView.tsx | 365 +----------------- .../AddSecurityDialog.tsx | 0 .../TwoFactorAuthenticationView.tsx | 261 +++++++++++++ 11 files changed, 482 insertions(+), 402 deletions(-) create mode 100644 web/src/components/Brand.tsx create mode 100644 web/src/hooks/RouterNavigate.ts create mode 100644 web/src/layouts/SettingsLayout.tsx create mode 100644 web/src/views/Settings/SettingsRouter.tsx rename web/src/views/Settings/{ => TwoFactorAuthentication}/AddSecurityDialog.tsx (100%) create mode 100644 web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 5c073ae2e..841447b79 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -36,7 +36,7 @@ import LoginPortal from "@views/LoginPortal/LoginPortal"; import SignOut from "@views/LoginPortal/SignOut/SignOut"; import ResetPasswordStep1 from "@views/ResetPassword/ResetPasswordStep1"; import ResetPasswordStep2 from "@views/ResetPassword/ResetPasswordStep2"; -import SettingsView from "@views/Settings/SettingsView"; +import SettingsRouter from "@views/Settings/SettingsRouter"; import "@fortawesome/fontawesome-svg-core/styles.css"; @@ -95,7 +95,7 @@ const App: React.FC = (props: Props) => { } /> } /> } /> - } /> + } /> + + {translate("Powered by")} Authelia + + + ); +}; + +export default Brand; + +const useStyles = makeStyles((theme: Theme) => ({ + poweredBy: { + fontSize: "0.7em", + color: grey[500], + }, +})); diff --git a/web/src/constants/Routes.ts b/web/src/constants/Routes.ts index e882f4dd4..2562a6761 100644 --- a/web/src/constants/Routes.ts +++ b/web/src/constants/Routes.ts @@ -12,4 +12,6 @@ export const ResetPasswordStep2Route: string = "/reset-password/step2"; export const RegisterWebauthnRoute: string = "/webauthn/register"; export const RegisterOneTimePasswordRoute: string = "/one-time-password/register"; export const LogoutRoute: string = "/logout"; + export const SettingsRoute: string = "/settings"; +export const SettingsTwoFactorAuthenticationSubRoute: string = "/two-factor-authentication"; diff --git a/web/src/hooks/RouterNavigate.ts b/web/src/hooks/RouterNavigate.ts new file mode 100644 index 000000000..c582faf24 --- /dev/null +++ b/web/src/hooks/RouterNavigate.ts @@ -0,0 +1,29 @@ +import { useCallback } from "react"; + +import { useNavigate, useSearchParams } from "react-router-dom"; + +export function useRouterNavigate() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + return useCallback( + ( + pathname: string, + preserveSearchParams: boolean = true, + searchParamsOverride: URLSearchParams | undefined = undefined, + ) => { + if (searchParamsOverride && URLSearchParamsHasValues(searchParamsOverride)) { + navigate({ pathname: pathname, search: `?${searchParamsOverride.toString()}` }); + } else if (preserveSearchParams && URLSearchParamsHasValues(searchParams)) { + navigate({ pathname: pathname, search: `?${searchParams.toString()}` }); + } else { + navigate({ pathname: pathname }); + } + }, + [navigate, searchParams], + ); +} + +function URLSearchParamsHasValues(params?: URLSearchParams) { + return params ? !params.entries().next().done : false; +} diff --git a/web/src/layouts/LoginLayout.tsx b/web/src/layouts/LoginLayout.tsx index 9669a12ad..0be58c795 100644 --- a/web/src/layouts/LoginLayout.tsx +++ b/web/src/layouts/LoginLayout.tsx @@ -1,13 +1,13 @@ import React, { ReactNode, useEffect } from "react"; import SettingsIcon from "@mui/icons-material/Settings"; -import { AppBar, Box, Container, Grid, IconButton, Link, Theme, Toolbar, Typography } from "@mui/material"; -import { grey } from "@mui/material/colors"; +import { AppBar, Box, Container, Grid, IconButton, Theme, Toolbar, Typography } from "@mui/material"; import makeStyles from "@mui/styles/makeStyles"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { ReactComponent as UserSvg } from "@assets/images/user.svg"; +import Brand from "@components/Brand"; import TypographyWithTooltip from "@components/TypographyWithTootip"; import { SettingsRoute } from "@root/constants/Routes"; import { getLogoOverride } from "@utils/Configuration"; @@ -23,8 +23,6 @@ export interface Props { showSettings?: boolean; } -const url = "https://www.authelia.com"; - const LoginLayout = function (props: Props) { const navigate = useNavigate(); const styles = useStyles(); @@ -64,9 +62,9 @@ const LoginLayout = function (props: Props) { {props.children} - {props.showBrand ? ( - - - {translate("Powered by")} Authelia - - - ) : null} + {props.showBrand ? : null} @@ -134,8 +126,4 @@ const useStyles = makeStyles((theme: Theme) => ({ paddingTop: theme.spacing(), paddingBottom: theme.spacing(), }, - poweredBy: { - fontSize: "0.7em", - color: grey[500], - }, })); diff --git a/web/src/layouts/SettingsLayout.tsx b/web/src/layouts/SettingsLayout.tsx new file mode 100644 index 000000000..b4d129b73 --- /dev/null +++ b/web/src/layouts/SettingsLayout.tsx @@ -0,0 +1,111 @@ +import React, { ReactNode, useCallback, useEffect } from "react"; + +import SystemSecurityUpdateGoodIcon from "@mui/icons-material/SystemSecurityUpdateGood"; +import { + AppBar, + Box, + Drawer, + Grid, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Toolbar, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import { SettingsRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes"; +import { useRouterNavigate } from "@hooks/RouterNavigate"; + +export interface Props { + id?: string; + children?: ReactNode; + title?: string; + titlePrefix?: string; + drawerWidth?: number; +} + +const defaultDrawerWidth = 240; + +const SettingsLayout = function (props: Props) { + const { t: translate } = useTranslation("settings"); + + useEffect(() => { + if (props.title) { + if (props.titlePrefix) { + document.title = `${props.titlePrefix} - ${props.title} - Authelia`; + } else { + document.title = `${props.title} - Authelia`; + } + } else { + if (props.titlePrefix) { + document.title = `${props.titlePrefix} - ${translate("Settings")} - Authelia`; + } else { + document.title = `${translate("Settings")} - Authelia`; + } + } + }, [props.title, props.titlePrefix, translate]); + + const drawerWidth = props.drawerWidth === undefined ? defaultDrawerWidth : props.drawerWidth; + + return ( + + theme.zIndex.drawer + 1 }}> + + {translate("Settings")} + + + + + + + } + /> + + + + + + + + {props.children} + + + + + ); +}; + +export default SettingsLayout; + +interface SettingsMenuItemProps { + pathname: string; + text: string; + icon: ReactNode; +} + +const SettingsMenuItem = function (props: SettingsMenuItemProps) { + const selected = window.location.pathname === props.pathname; + const navigate = useRouterNavigate(); + + return ( + console.log("selected") : () => navigate(props.pathname)}> + + {props.icon} + + + + ); +}; diff --git a/web/src/views/LoginPortal/LoginPortal.tsx b/web/src/views/LoginPortal/LoginPortal.tsx index bcb66c86b..3ccb37644 100644 --- a/web/src/views/LoginPortal/LoginPortal.tsx +++ b/web/src/views/LoginPortal/LoginPortal.tsx @@ -1,6 +1,6 @@ -import React, { Fragment, ReactNode, useCallback, useEffect, useState } from "react"; +import React, { Fragment, ReactNode, useEffect, useState } from "react"; -import { Route, Routes, useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { Route, Routes, useLocation } from "react-router-dom"; import { AuthenticatedRoute, @@ -14,6 +14,7 @@ import { useConfiguration } from "@hooks/Configuration"; import { useNotifications } from "@hooks/NotificationsContext"; import { useRedirectionURL } from "@hooks/RedirectionURL"; import { useRedirector } from "@hooks/Redirector"; +import { useRouterNavigate } from "@hooks/RouterNavigate"; import { useAutheliaState } from "@hooks/State"; import { useUserInfoPOST } from "@hooks/UserInfo"; import { SecondFactorMethod } from "@models/Methods"; @@ -36,7 +37,6 @@ const RedirectionErrorMessage = "Redirection was determined to be unsafe and aborted. Ensure the redirection URL is correct."; const LoginPortal = function (props: Props) { - const navigate = useNavigate(); const location = useLocation(); const redirectionURL = useRedirectionURL(); const { createErrorNotification } = useNotifications(); @@ -47,24 +47,8 @@ const LoginPortal = function (props: Props) { const [state, fetchState, , fetchStateError] = useAutheliaState(); const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoPOST(); const [configuration, fetchConfiguration, , fetchConfigurationError] = useConfiguration(); - const [searchParams] = useSearchParams(); - const redirect = useCallback( - ( - pathname: string, - preserveSearchParams: boolean = true, - searchParamsOverride: URLSearchParams | undefined = undefined, - ) => { - if (searchParamsOverride && URLSearchParamsHasValues(searchParamsOverride)) { - navigate({ pathname: pathname, search: `?${searchParamsOverride.toString()}` }); - } else if (preserveSearchParams && URLSearchParamsHasValues(searchParams)) { - navigate({ pathname: pathname, search: `?${searchParams.toString()}` }); - } else { - navigate({ pathname: pathname }); - } - }, - [navigate, searchParams], - ); + const navigate = useRouterNavigate(); // Fetch the state when portal is mounted. useEffect(() => { @@ -137,17 +121,17 @@ const LoginPortal = function (props: Props) { if (state.authentication_level === AuthenticationLevel.Unauthenticated) { setFirstFactorDisabled(false); - redirect(IndexRoute); + navigate(IndexRoute); } else if (state.authentication_level >= AuthenticationLevel.OneFactor && userInfo && configuration) { if (configuration.available_methods.size === 0) { - redirect(AuthenticatedRoute, false); + navigate(AuthenticatedRoute, false); } else { if (userInfo.method === SecondFactorMethod.Webauthn) { - redirect(`${SecondFactorRoute}${SecondFactorWebauthnSubRoute}`); + navigate(`${SecondFactorRoute}${SecondFactorWebauthnSubRoute}`); } else if (userInfo.method === SecondFactorMethod.MobilePush) { - redirect(`${SecondFactorRoute}${SecondFactorPushSubRoute}`); + navigate(`${SecondFactorRoute}${SecondFactorPushSubRoute}`); } else { - redirect(`${SecondFactorRoute}${SecondFactorTOTPSubRoute}`); + navigate(`${SecondFactorRoute}${SecondFactorTOTPSubRoute}`); } } } @@ -155,7 +139,7 @@ const LoginPortal = function (props: Props) { }, [ state, redirectionURL, - redirect, + navigate, userInfo, setFirstFactorDisabled, configuration, @@ -244,7 +228,3 @@ function ComponentOrLoading(props: ComponentOrLoadingProps) { ); } - -function URLSearchParamsHasValues(params?: URLSearchParams) { - return params ? !params.entries().next().done : false; -} diff --git a/web/src/views/Settings/SettingsRouter.tsx b/web/src/views/Settings/SettingsRouter.tsx new file mode 100644 index 000000000..77356ca21 --- /dev/null +++ b/web/src/views/Settings/SettingsRouter.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +import { Route, Routes } from "react-router-dom"; + +import { IndexRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes"; +import SettingsView from "@views/Settings/SettingsView"; +import TwoFactorAuthenticationView from "@views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView"; + +export interface Props {} + +const SettingsRouter = function (props: Props) { + return ( + + } /> + } /> + + ); +}; + +export default SettingsRouter; diff --git a/web/src/views/Settings/SettingsView.tsx b/web/src/views/Settings/SettingsView.tsx index dc7618411..bba1c4f90 100644 --- a/web/src/views/Settings/SettingsView.tsx +++ b/web/src/views/Settings/SettingsView.tsx @@ -1,360 +1,17 @@ -import React, { useEffect, useState } from "react"; +import { Box, Typography } from "@mui/material"; -import DeleteIcon from "@mui/icons-material/Delete"; -import EditIcon from "@mui/icons-material/Edit"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; -import SystemSecurityUpdateGoodIcon from "@mui/icons-material/SystemSecurityUpdateGood"; -import { - AppBar, - Box, - Button, - Collapse, - Divider, - Drawer, - Grid, - IconButton, - List, - ListItem, - ListItemButton, - ListItemIcon, - ListItemText, - Paper, - Stack, - Switch, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Toolbar, - Tooltip, - Typography, -} from "@mui/material"; -import { useTranslation } from "react-i18next"; +import SettingsLayout from "@layouts/SettingsLayout"; -import { WebauthnDevice } from "@root/models/Webauthn"; -import { getWebauthnDevices } from "@root/services/UserWebauthnDevices"; - -import AddSecurityKeyDialog from "./AddSecurityDialog"; - -interface Props {} - -const drawerWidth = 240; - -export default function SettingsView(props: Props) { - const { t: translate } = useTranslation("settings"); - - const [webauthnDevices, setWebauthnDevices] = useState(); - const [addKeyOpen, setAddKeyOpen] = useState(false); - const [webauthnShowDetails, setWebauthnShowDetails] = useState(-1); - - const handleWebAuthnDetailsChange = (idx: number) => { - if (webauthnShowDetails === idx) { - setWebauthnShowDetails(-1); - } else { - setWebauthnShowDetails(idx); - } - }; - - useEffect(() => { - (async function () { - const devices = await getWebauthnDevices(); - setWebauthnDevices(devices); - })(); - }, []); - - const handleKeyClose = () => { - setAddKeyOpen(false); - }; - - const handleAddKeyButtonClick = () => { - setAddKeyOpen(true); - }; +export interface Props {} +const SettingsView = function (props: Props) { return ( - - theme.zIndex.drawer + 1 }}> - - {translate("Settings")} - - - - - - - - - - - - - - - - - - - - - - {translate("Manage your security keys")} - - - - - - - - - - - - - {translate("Name")} - {translate("Enabled")} - {translate("Actions")} - - - - {webauthnDevices - ? webauthnDevices.map((x, idx) => { - return ( - - *": { borderBottom: "unset" } }} - key={x.kid.toString()} - > - - - handleWebAuthnDetailsChange(idx)} - > - {webauthnShowDetails === idx ? ( - - ) : ( - - )} - - - - - {x.description} - - - - - - - - - - - - - - - - - - - - - - - - - - - {translate("Details")} - - - - - - - - - {translate( - "Webauthn Credential Identifier", - { - id: x.kid.toString(), - }, - )} - - - - - Public Key: {x.public_key} - {translate("Webauthn Public Key", { - key: x.public_key.toString(), - })} - - - - - - - - {translate("Relying Party ID")} - - {x.rpid} - - - - {translate( - "Authenticator Attestation GUID", - )} - - {x.aaguid} - - - - {translate("Attestation Type")} - - {x.attestation_type} - - - - {translate("Transports")} - - - {x.transports.length === 0 - ? "N/A" - : x.transports.join(", ")} - - - - - {translate("Clone Warning")} - - - {x.clone_warning - ? translate("Yes") - : translate("No")} - - - - - {translate("Created")} - - - {x.created_at.toString()} - - - - - {translate("Last Used")} - - - {x.last_used_at === undefined - ? translate("Never") - : x.last_used_at.toString()} - - - - - {translate("Usage Count")} - - - {x.sign_count === 0 - ? translate("Never") - : x.sign_count} - - - - - - - - - - - ); - }) - : null} - -
-
-
-
+ + + Placeholder - -
+ ); -} +}; + +export default SettingsView; diff --git a/web/src/views/Settings/AddSecurityDialog.tsx b/web/src/views/Settings/TwoFactorAuthentication/AddSecurityDialog.tsx similarity index 100% rename from web/src/views/Settings/AddSecurityDialog.tsx rename to web/src/views/Settings/TwoFactorAuthentication/AddSecurityDialog.tsx diff --git a/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx b/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx new file mode 100644 index 000000000..0471a7b42 --- /dev/null +++ b/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx @@ -0,0 +1,261 @@ +import React, { useEffect, useState } from "react"; + +import DeleteIcon from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import { + Box, + Button, + Collapse, + Divider, + Grid, + IconButton, + Paper, + Stack, + Switch, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import SettingsLayout from "@layouts/SettingsLayout"; +import { WebauthnDevice } from "@root/models/Webauthn"; +import { getWebauthnDevices } from "@root/services/UserWebauthnDevices"; + +import AddSecurityKeyDialog from "./AddSecurityDialog"; + +export interface Props {} + +const TwoFactorAuthenticationView = function (props: Props) { + const { t: translate } = useTranslation("settings"); + + const [webauthnDevices, setWebauthnDevices] = useState(); + const [addKeyOpen, setAddKeyOpen] = useState(false); + const [webauthnShowDetails, setWebauthnShowDetails] = useState(-1); + + const handleWebAuthnDetailsChange = (idx: number) => { + if (webauthnShowDetails === idx) { + setWebauthnShowDetails(-1); + } else { + setWebauthnShowDetails(idx); + } + }; + + useEffect(() => { + (async function () { + const devices = await getWebauthnDevices(); + setWebauthnDevices(devices); + })(); + }, []); + + const handleKeyClose = () => { + setAddKeyOpen(false); + }; + + const handleAddKeyButtonClick = () => { + setAddKeyOpen(true); + }; + + return ( + + + + {translate("Manage your security keys")} + + + + + + + + + + + + + {translate("Name")} + {translate("Enabled")} + {translate("Actions")} + + + + {webauthnDevices + ? webauthnDevices.map((x, idx) => { + return ( + + *": { borderBottom: "unset" } }} + key={x.kid.toString()} + > + + + handleWebAuthnDetailsChange(idx)} + > + {webauthnShowDetails === idx ? ( + + ) : ( + + )} + + + + + {x.description} + + + + + + + + + + + + + + + + + + + + + + + + + + + {translate("Details")} + + + + + + + + + {translate("Webauthn Credential Identifier", { + id: x.kid.toString(), + })} + + + + + Public Key: {x.public_key} + {translate("Webauthn Public Key", { + key: x.public_key.toString(), + })} + + + + + + + + {translate("Relying Party ID")} + + {x.rpid} + + + + {translate("Authenticator Attestation GUID")} + + {x.aaguid} + + + + {translate("Attestation Type")} + + {x.attestation_type} + + + {translate("Transports")} + + {x.transports.length === 0 + ? "N/A" + : x.transports.join(", ")} + + + + + {translate("Clone Warning")} + + + {x.clone_warning + ? translate("Yes") + : translate("No")} + + + + {translate("Created")} + {x.created_at.toString()} + + + {translate("Last Used")} + + {x.last_used_at === undefined + ? translate("Never") + : x.last_used_at.toString()} + + + + + {translate("Usage Count")} + + + {x.sign_count === 0 + ? translate("Never") + : x.sign_count} + + + + + + + + + + + ); + }) + : null} + +
+
+
+
+ +
+ ); +}; + +export default TwoFactorAuthenticationView;