diff options
Diffstat (limited to 'web/src')
41 files changed, 1299 insertions, 418 deletions
diff --git a/web/src/App.tsx b/web/src/App.tsx index 21c99a540..8298b6b22 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,7 +11,6 @@ import { ConsentRoute, IndexRoute, LogoutRoute, - RegisterOneTimePasswordRoute, ResetPasswordStep1Route, ResetPasswordStep2Route, SettingsRoute, @@ -27,7 +26,6 @@ import { getResetPasswordCustomURL, getTheme, } from "@utils/Configuration"; -import RegisterOneTimePassword from "@views/DeviceRegistration/RegisterOneTimePassword"; import BaseLoadingPage from "@views/LoadingPage/BaseLoadingPage"; import ConsentView from "@views/LoginPortal/ConsentView/ConsentView"; import LoginPortal from "@views/LoginPortal/LoginPortal"; @@ -78,6 +76,7 @@ const App: React.FC<Props> = (props: Props) => { } } }, []); + return ( <CacheProvider value={cache}> <ThemeProvider theme={theme}> @@ -89,7 +88,6 @@ const App: React.FC<Props> = (props: Props) => { <Routes> <Route path={ResetPasswordStep1Route} element={<ResetPasswordStep1 />} /> <Route path={ResetPasswordStep2Route} element={<ResetPasswordStep2 />} /> - <Route path={RegisterOneTimePasswordRoute} element={<RegisterOneTimePassword />} /> <Route path={LogoutRoute} element={<SignOut />} /> <Route path={ConsentRoute} element={<ConsentView />} /> <Route path={`${SettingsRoute}/*`} element={<SettingsRouter />} /> diff --git a/web/src/components/Brand.tsx b/web/src/components/Brand.tsx index 797c7f363..0ca13b0b7 100644 --- a/web/src/components/Brand.tsx +++ b/web/src/components/Brand.tsx @@ -37,11 +37,11 @@ const Brand = function (props: Props) { ); }; -export default Brand; - const useStyles = makeStyles((theme: Theme) => ({ links: { fontSize: "0.7em", color: grey[500], }, })); + +export default Brand; diff --git a/web/src/components/ComponentOrLoading.tsx b/web/src/components/ComponentOrLoading.tsx new file mode 100644 index 000000000..2ff6948cf --- /dev/null +++ b/web/src/components/ComponentOrLoading.tsx @@ -0,0 +1,22 @@ +import React, { Fragment, ReactNode } from "react"; + +import LoadingPage from "@views/LoadingPage/LoadingPage"; + +export interface Props { + ready: boolean; + + children: ReactNode; +} + +const ComponentOrLoading = function (props: Props) { + return ( + <Fragment> + <div className={props.ready ? "hidden" : ""}> + <LoadingPage /> + </div> + {props.ready ? props.children : null} + </Fragment> + ); +}; + +export default ComponentOrLoading; diff --git a/web/src/components/CopyButton.tsx b/web/src/components/CopyButton.tsx new file mode 100644 index 000000000..d74173d06 --- /dev/null +++ b/web/src/components/CopyButton.tsx @@ -0,0 +1,80 @@ +import React, { ReactNode, useState } from "react"; + +import { Check, ContentCopy } from "@mui/icons-material"; +import { Button, CircularProgress, SxProps, Tooltip } from "@mui/material"; + +export interface Props { + variant?: "contained" | "outlined" | "text"; + tooltip: string; + children: ReactNode; + childrenCopied?: ReactNode; + value: string | null; + xs?: number; + msTimeoutCopying?: number; + msTimeoutCopied?: number; + sx?: SxProps; + fullWidth?: boolean; +} + +const msTmeoutDefaultCopying = 500; +const msTmeoutDefaultCopied = 2000; + +const CopyButton = function (props: Props) { + const [isCopied, setIsCopied] = useState(false); + const [isCopying, setIsCopying] = useState(false); + const msTimeoutCopying = props.msTimeoutCopying ? props.msTimeoutCopying : msTmeoutDefaultCopying; + const msTimeoutCopied = props.msTimeoutCopied ? props.msTimeoutCopied : msTmeoutDefaultCopied; + + const handleCopyToClipboard = () => { + if (isCopied || !props.value || props.value === "") { + return; + } + + (async (value: string) => { + setIsCopying(true); + + await navigator.clipboard.writeText(value); + + setTimeout(() => { + setIsCopying(false); + setIsCopied(true); + }, msTimeoutCopying); + + setTimeout(() => { + setIsCopied(false); + }, msTimeoutCopied); + })(props.value); + }; + + return props.value === null || props.value === "" ? ( + <Button + variant={props.variant ? props.variant : "outlined"} + color={isCopied ? "success" : "primary"} + disabled + sx={props.sx} + fullWidth={props.fullWidth} + startIcon={ + isCopying ? <CircularProgress color="inherit" size={20} /> : isCopied ? <Check /> : <ContentCopy /> + } + > + {isCopied && props.childrenCopied ? props.childrenCopied : props.children} + </Button> + ) : ( + <Tooltip title={props.tooltip}> + <Button + variant={props.variant ? props.variant : "outlined"} + color={isCopied ? "success" : "primary"} + onClick={isCopying ? undefined : handleCopyToClipboard} + sx={props.sx} + fullWidth={props.fullWidth} + startIcon={ + isCopying ? <CircularProgress color="inherit" size={20} /> : isCopied ? <Check /> : <ContentCopy /> + } + > + {isCopied && props.childrenCopied ? props.childrenCopied : props.children} + </Button> + </Tooltip> + ); +}; + +export default CopyButton; diff --git a/web/src/components/FixedTextField.tsx b/web/src/components/FixedTextField.tsx index f877ccab9..5ca6bb1e6 100644 --- a/web/src/components/FixedTextField.tsx +++ b/web/src/components/FixedTextField.tsx @@ -27,8 +27,6 @@ const FixedTextField = function (props: TextFieldProps) { ); }; -export default FixedTextField; - const useStyles = makeStyles((theme: Theme) => ({ label: { backgroundColor: theme.palette.background.default, @@ -36,3 +34,5 @@ const useStyles = makeStyles((theme: Theme) => ({ paddingRight: theme.spacing(0.1), }, })); + +export default FixedTextField; diff --git a/web/src/components/TypographyWithTooltip.tsx b/web/src/components/TypographyWithTooltip.tsx index 1a876bf8a..340a261c7 100644 --- a/web/src/components/TypographyWithTooltip.tsx +++ b/web/src/components/TypographyWithTooltip.tsx @@ -11,7 +11,7 @@ export interface Props { tooltip?: string; } -export default function TypographyWithTooltip(props: Props): JSX.Element { +const TypographyWithTooltip = function (props: Props): JSX.Element { return ( <Fragment> {props.tooltip ? ( @@ -23,4 +23,6 @@ export default function TypographyWithTooltip(props: Props): JSX.Element { )} </Fragment> ); -} +}; + +export default TypographyWithTooltip; diff --git a/web/src/components/WebAuthnRegisterIcon.tsx b/web/src/components/WebAuthnRegisterIcon.tsx index 76ab9f502..add960937 100644 --- a/web/src/components/WebAuthnRegisterIcon.tsx +++ b/web/src/components/WebAuthnRegisterIcon.tsx @@ -12,7 +12,7 @@ interface Props { timeout: number; } -export default function WebAuthnRegisterIcon(props: Props) { +const WebAuthnRegisterIcon = function (props: Props) { const theme = useTheme(); const [timerPercent, triggerTimer] = useTimer(props.timeout); @@ -36,4 +36,6 @@ export default function WebAuthnRegisterIcon(props: Props) { </IconWithContext> </Box> ); -} +}; + +export default WebAuthnRegisterIcon; diff --git a/web/src/components/WebAuthnTryIcon.tsx b/web/src/components/WebAuthnTryIcon.tsx index 42bacacf0..be243a24e 100644 --- a/web/src/components/WebAuthnTryIcon.tsx +++ b/web/src/components/WebAuthnTryIcon.tsx @@ -15,7 +15,7 @@ interface Props { webauthnTouchState: WebAuthnTouchState; } -export default function WebAuthnTryIcon(props: Props) { +const WebAuthnTryIcon = function (props: Props) { const touchTimeout = 30; const theme = useTheme(); const [timerPercent, triggerTimer, clearTimer] = useTimer(touchTimeout * 1000 - 500); @@ -65,4 +65,6 @@ export default function WebAuthnTryIcon(props: Props) { {failure} </Box> ); -} +}; + +export default WebAuthnTryIcon; diff --git a/web/src/constants/Routes.ts b/web/src/constants/Routes.ts index c9ea0cdd9..a58ab6b95 100644 --- a/web/src/constants/Routes.ts +++ b/web/src/constants/Routes.ts @@ -9,7 +9,6 @@ export const SecondFactorPushSubRoute: string = "/push-notification"; export const ResetPasswordStep1Route: string = "/reset-password/step1"; export const ResetPasswordStep2Route: string = "/reset-password/step2"; -export const RegisterOneTimePasswordRoute: string = "/one-time-password/register"; export const LogoutRoute: string = "/logout"; export const SettingsRoute: string = "/settings"; diff --git a/web/src/hooks/NotificationsContext.ts b/web/src/hooks/NotificationsContext.ts index 06d512415..4d470432a 100644 --- a/web/src/hooks/NotificationsContext.ts +++ b/web/src/hooks/NotificationsContext.ts @@ -15,8 +15,6 @@ interface NotificationContextProps { const NotificationsContext = createContext<NotificationContextProps>({ notification: null, setNotification: () => {} }); -export default NotificationsContext; - export function useNotifications() { let useNotificationsProps = useContext(NotificationsContext); @@ -47,3 +45,5 @@ export function useNotifications() { isActive, }; } + +export default NotificationsContext; diff --git a/web/src/hooks/UserInfoTOTPConfiguration.ts b/web/src/hooks/UserInfoTOTPConfiguration.ts index dba45ab0b..38bebe814 100644 --- a/web/src/hooks/UserInfoTOTPConfiguration.ts +++ b/web/src/hooks/UserInfoTOTPConfiguration.ts @@ -1,6 +1,13 @@ import { useRemoteCall } from "@hooks/RemoteCall"; -import { getUserInfoTOTPConfiguration } from "@services/UserInfoTOTPConfiguration"; +import { + getUserInfoTOTPConfiguration, + getUserInfoTOTPConfigurationOptional, +} from "@services/UserInfoTOTPConfiguration"; export function useUserInfoTOTPConfiguration() { return useRemoteCall(getUserInfoTOTPConfiguration, []); } + +export function useUserInfoTOTPConfigurationOptional() { + return useRemoteCall(getUserInfoTOTPConfigurationOptional, []); +} diff --git a/web/src/layouts/LoginLayout.tsx b/web/src/layouts/LoginLayout.tsx index 31e961d71..4ed0c5dcf 100644 --- a/web/src/layouts/LoginLayout.tsx +++ b/web/src/layouts/LoginLayout.tsx @@ -108,8 +108,6 @@ const LoginLayout = function (props: Props) { ); }; -export default LoginLayout; - const useStyles = makeStyles((theme: Theme) => ({ root: { minHeight: "90vh", @@ -132,3 +130,5 @@ const useStyles = makeStyles((theme: Theme) => ({ paddingBottom: theme.spacing(), }, })); + +export default LoginLayout; diff --git a/web/src/layouts/SettingsLayout.tsx b/web/src/layouts/SettingsLayout.tsx index c5d91597d..2f9fbea67 100644 --- a/web/src/layouts/SettingsLayout.tsx +++ b/web/src/layouts/SettingsLayout.tsx @@ -1,21 +1,22 @@ -import React, { ReactNode, useEffect } from "react"; +import React, { ReactNode, SyntheticEvent, useCallback, useEffect, useState } from "react"; -import { Dashboard } from "@mui/icons-material"; +import { Close, Dashboard } from "@mui/icons-material"; +import MenuIcon from "@mui/icons-material/Menu"; import SystemSecurityUpdateGoodIcon from "@mui/icons-material/SystemSecurityUpdateGood"; import { AppBar, Box, - Button, - Drawer, - Grid, + Divider, List, ListItem, ListItemButton, ListItemIcon, ListItemText, + SwipeableDrawer, Toolbar, Typography, } from "@mui/material"; +import IconButton from "@mui/material/IconButton"; import { useTranslation } from "react-i18next"; import { IndexRoute, SettingsRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes"; @@ -33,8 +34,7 @@ const defaultDrawerWidth = 240; const SettingsLayout = function (props: Props) { const { t: translate } = useTranslation("settings"); - - const navigate = useRouterNavigate(); + const [drawerOpen, setDrawerOpen] = useState(false); useEffect(() => { if (props.title) { @@ -54,72 +54,120 @@ const SettingsLayout = function (props: Props) { const drawerWidth = props.drawerWidth === undefined ? defaultDrawerWidth : props.drawerWidth; + const handleToggleDrawer = (event: SyntheticEvent) => { + if ( + event.nativeEvent instanceof KeyboardEvent && + event.nativeEvent.type === "keydown" && + (event.nativeEvent.key === "Tab" || event.nativeEvent.key === "Shift") + ) { + return; + } + + setDrawerOpen((state) => !state); + }; + + const container = window !== undefined ? () => window.document.body : undefined; + + const drawer = ( + <Box onClick={handleToggleDrawer} sx={{ textAlign: "center" }}> + <Typography variant="h6" sx={{ my: 2 }}> + {translate("Settings")} + </Typography> + <Divider /> + <List> + {navItems.map((item) => ( + <DrawerNavItem key={item.keyname} text={item.text} pathname={item.pathname} icon={item.icon} /> + ))} + </List> + </Box> + ); + return ( <Box sx={{ display: "flex" }}> - <AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}> - <Toolbar variant="dense"> - <Typography style={{ flexGrow: 1 }}>Authelia {translate("Settings")}</Typography> - <Button - variant="contained" - color="success" - onClick={() => { - navigate(IndexRoute); - }} + <AppBar component={"nav"}> + <Toolbar> + <IconButton + edge="start" + color="inherit" + aria-label="open drawer" + onClick={handleToggleDrawer} + sx={{ mr: 2 }} + > + <MenuIcon /> + </IconButton> + <Typography + variant="h6" + component={"div"} + sx={{ flexGrow: 1, display: { xs: "none", sm: drawerOpen ? "none" : "block" } }} > - {translate("Close")} - </Button> + {translate("Settings")} + </Typography> </Toolbar> </AppBar> - <Drawer - variant="permanent" - sx={{ - width: drawerWidth, - flexShrink: 0, - [`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: "border-box" }, - }} - > - <Toolbar variant="dense" /> - <Box sx={{ overflow: "auto" }}> - <List> - <SettingsMenuItem pathname={SettingsRoute} text={translate("Overview")} icon={<Dashboard />} /> - <SettingsMenuItem - pathname={`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`} - text={translate("Two-Factor Authentication")} - icon={<SystemSecurityUpdateGoodIcon />} - /> - </List> - </Box> - </Drawer> - <Grid container id={props.id} spacing={0}> - <Grid item xs={12}> - <Box component="main" sx={{ flexGrow: 1, p: 3 }}> - <Toolbar variant="dense" /> - {props.children} - </Box> - </Grid> - </Grid> + <Box component={"nav"}> + <SwipeableDrawer + container={container} + anchor={"left"} + open={drawerOpen} + onOpen={handleToggleDrawer} + onClose={handleToggleDrawer} + ModalProps={{ + keepMounted: true, + }} + sx={{ + display: { xs: "block" }, + "& .MuiDrawer-paper": { boxSizing: "border-box", width: drawerWidth }, + }} + > + {drawer} + </SwipeableDrawer> + </Box> + <Box component="main" sx={{ flexGrow: 1, p: 3 }}> + <Toolbar /> + {props.children} + </Box> </Box> ); }; -export default SettingsLayout; - -interface SettingsMenuItemProps { - pathname: string; +interface NavItem { + keyname?: string; text: string; - icon: ReactNode; + pathname: string; + icon?: ReactNode; } -const SettingsMenuItem = function (props: SettingsMenuItemProps) { +const navItems: NavItem[] = [ + { keyname: "overview", text: "Overview", pathname: SettingsRoute, icon: <Dashboard color={"primary"} /> }, + { + keyname: "twofactor", + text: "Two-Factor Authentication", + pathname: `${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`, + icon: <SystemSecurityUpdateGoodIcon color={"primary"} />, + }, + { keyname: "close", text: "Close", pathname: IndexRoute, icon: <Close color={"error"} /> }, +]; + +const DrawerNavItem = function (props: NavItem) { const selected = window.location.pathname === props.pathname || window.location.pathname === props.pathname + "/"; const navigate = useRouterNavigate(); + const handleOnClick = useCallback(() => { + if (selected) { + return; + } + + navigate(props.pathname); + }, [navigate, props, selected]); + return ( - <ListItem disablePadding onClick={!selected ? () => navigate(props.pathname) : undefined}> + <ListItem disablePadding onClick={handleOnClick}> <ListItemButton selected={selected}> - <ListItemIcon>{props.icon}</ListItemIcon> + {props.icon ? <ListItemIcon>{props.icon}</ListItemIcon> : null} <ListItemText primary={props.text} /> </ListItemButton> </ListItem> ); }; + +export default SettingsLayout; diff --git a/web/src/models/TOTPConfiguration.ts b/web/src/models/TOTPConfiguration.ts new file mode 100644 index 000000000..aba289312 --- /dev/null +++ b/web/src/models/TOTPConfiguration.ts @@ -0,0 +1,48 @@ +export interface UserInfoTOTPConfiguration { + created_at: Date; + last_used_at?: Date; + issuer: string; + algorithm: TOTPAlgorithm; + digits: TOTPDigits; + period: number; +} + +export interface TOTPOptions { + algorithm: TOTPAlgorithm; + algorithms: TOTPAlgorithm[]; + length: TOTPDigits; + lengths: TOTPDigits[]; + period: number; + periods: number[]; +} + +export enum TOTPAlgorithm { + SHA1 = 0, + SHA256, + SHA512, +} + +export type TOTPDigits = 6 | 8; +export type TOTPAlgorithmPayload = "SHA1" | "SHA256" | "SHA512"; + +export function toAlgorithmString(alg: TOTPAlgorithm): TOTPAlgorithmPayload { + switch (alg) { + case TOTPAlgorithm.SHA1: + return "SHA1"; + case TOTPAlgorithm.SHA256: + return "SHA256"; + case TOTPAlgorithm.SHA512: + return "SHA512"; + } +} + +export function toEnum(alg: TOTPAlgorithmPayload): TOTPAlgorithm { + switch (alg) { + case "SHA1": + return TOTPAlgorithm.SHA1; + case "SHA256": + return TOTPAlgorithm.SHA256; + case "SHA512": + return TOTPAlgorithm.SHA512; + } +} diff --git a/web/src/models/UserInfoTOTPConfiguration.ts b/web/src/models/UserInfoTOTPConfiguration.ts deleted file mode 100644 index adbcea5e9..000000000 --- a/web/src/models/UserInfoTOTPConfiguration.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface UserInfoTOTPConfiguration { - period: number; - digits: number; -} diff --git a/web/src/services/Api.ts b/web/src/services/Api.ts index 745c5a326..29b2af087 100644 --- a/web/src/services/Api.ts +++ b/web/src/services/Api.ts @@ -8,13 +8,12 @@ const basePath = getBasePath(); export const ConsentPath = basePath + "/api/oidc/consent"; export const FirstFactorPath = basePath + "/api/firstfactor"; -export const InitiateTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/start"; -export const CompleteTOTPRegistrationPath = basePath + "/api/secondfactor/totp/identity/finish"; -export const WebAuthnRegistrationPath = basePath + "/api/secondfactor/webauthn/credential/register"; +export const TOTPRegistrationOptionsPath = basePath + "/api/secondfactor/totp/register/options"; +export const TOTPRegistrationPath = basePath + "/api/secondfactor/totp/register"; +export const WebAuthnRegistrationPath = basePath + "/api/secondfactor/webauthn/credential/register"; export const WebAuthnAssertionPath = basePath + "/api/secondfactor/webauthn"; - export const WebAuthnDevicesPath = basePath + "/api/secondfactor/webauthn/credentials"; export const WebAuthnDevicePath = basePath + "/api/secondfactor/webauthn/credential"; diff --git a/web/src/services/Client.ts b/web/src/services/Client.ts index cffc08d2a..83d82f9ac 100644 --- a/web/src/services/Client.ts +++ b/web/src/services/Client.ts @@ -2,6 +2,16 @@ import axios from "axios"; import { ServiceResponse, hasServiceError, toData } from "@services/Api"; +export async function PutWithOptionalResponse<T = undefined>(path: string, body?: any): Promise<T | undefined> { + const res = await axios.put<ServiceResponse<T>>(path, body); + + if (res.status !== 200 || hasServiceError(res).errored) { + throw new Error(`Failed POST to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`); + } + + return toData<T>(res); +} + export async function PostWithOptionalResponse<T = undefined>(path: string, body?: any): Promise<T | undefined> { const res = await axios.post<ServiceResponse<T>>(path, body); @@ -12,6 +22,16 @@ export async function PostWithOptionalResponse<T = undefined>(path: string, body return toData<T>(res); } +export async function DeleteWithOptionalResponse<T = undefined>(path: string, body?: any): Promise<T | undefined> { + const res = await axios.delete<ServiceResponse<T>>(path, body); + + if (res.status !== 200 || hasServiceError(res).errored) { + throw new Error(`Failed DELETE to ${path}. Code: ${res.status}. Message: ${hasServiceError(res).message}`); + } + + return toData<T>(res); +} + export async function Post<T>(path: string, body?: any) { const res = await PostWithOptionalResponse<T>(path, body); if (!res) { @@ -20,6 +40,14 @@ export async function Post<T>(path: string, body?: any) { return res; } +export async function Put<T>(path: string, body?: any) { + const res = await PutWithOptionalResponse<T>(path, body); + if (!res) { + throw new Error("unexpected type of response"); + } + return res; +} + export async function Get<T = undefined>(path: string): Promise<T> { const res = await axios.get<ServiceResponse<T>>(path); diff --git a/web/src/services/OneTimePassword.ts b/web/src/services/OneTimePassword.ts index ec66e9e83..110821aba 100644 --- a/web/src/services/OneTimePassword.ts +++ b/web/src/services/OneTimePassword.ts @@ -1,5 +1,5 @@ -import { CompleteTOTPSignInPath } from "@services/Api"; -import { PostWithOptionalResponse } from "@services/Client"; +import { CompleteTOTPSignInPath, TOTPRegistrationPath } from "@services/Api"; +import { DeleteWithOptionalResponse, PostWithOptionalResponse } from "@services/Client"; import { SignInResponse } from "@services/SignIn"; interface CompleteTOTPSignInBody { @@ -19,3 +19,15 @@ export function completeTOTPSignIn(passcode: string, targetURL?: string, workflo return PostWithOptionalResponse<SignInResponse>(CompleteTOTPSignInPath, body); } + +export function completeTOTPRegister(passcode: string) { + const body: CompleteTOTPSignInBody = { + token: `${passcode}`, + }; + + return PostWithOptionalResponse(TOTPRegistrationPath, body); +} + +export function stopTOTPRegister() { + return DeleteWithOptionalResponse(TOTPRegistrationPath); +} diff --git a/web/src/services/RegisterDevice.ts b/web/src/services/RegisterDevice.ts index 0524f6235..1db7844d4 100644 --- a/web/src/services/RegisterDevice.ts +++ b/web/src/services/RegisterDevice.ts @@ -1,15 +1,15 @@ -import { CompleteTOTPRegistrationPath, InitiateTOTPRegistrationPath } from "@services/Api"; -import { Post, PostWithOptionalResponse } from "@services/Client"; - -export async function initiateTOTPRegistrationProcess() { - await PostWithOptionalResponse(InitiateTOTPRegistrationPath); -} +import { TOTPRegistrationPath } from "@services/Api"; +import { Put } from "@services/Client"; interface CompleteTOTPRegistrationResponse { base32_secret: string; otpauth_url: string; } -export async function completeTOTPRegistrationProcess(processToken: string) { - return Post<CompleteTOTPRegistrationResponse>(CompleteTOTPRegistrationPath, { token: processToken }); +export async function getTOTPSecret(algorithm: string, length: number, period: number) { + return Put<CompleteTOTPRegistrationResponse>(TOTPRegistrationPath, { + algorithm: algorithm, + length: length, + period: period, + }); } diff --git a/web/src/services/UserInfoTOTPConfiguration.ts b/web/src/services/UserInfoTOTPConfiguration.ts index f32a431d5..55829c7a5 100644 --- a/web/src/services/UserInfoTOTPConfiguration.ts +++ b/web/src/services/UserInfoTOTPConfiguration.ts @@ -1,15 +1,88 @@ -import { UserInfoTOTPConfiguration } from "@models/UserInfoTOTPConfiguration"; -import { UserInfoTOTPConfigurationPath } from "@services/Api"; -import { Get } from "@services/Client"; +import axios from "axios"; -export type TOTPDigits = 6 | 8; +import { + TOTPAlgorithmPayload, + TOTPDigits, + TOTPOptions, + UserInfoTOTPConfiguration, + toEnum, +} from "@models/TOTPConfiguration"; +import { + AuthenticationOKResponse, + CompleteTOTPSignInPath, + ServiceResponse, + TOTPRegistrationOptionsPath, + UserInfoTOTPConfigurationPath, + validateStatusAuthentication, +} from "@services/Api"; +import { Get } from "@services/Client"; export interface UserInfoTOTPConfigurationPayload { - period: number; + created_at: string; + last_used_at?: string; + issuer: string; + algorithm: TOTPAlgorithmPayload; digits: TOTPDigits; + period: number; +} + +function toUserInfoTOTPConfiguration(payload: UserInfoTOTPConfigurationPayload): UserInfoTOTPConfiguration { + return { + created_at: new Date(payload.created_at), + last_used_at: payload.last_used_at ? new Date(payload.last_used_at) : undefined, + issuer: payload.issuer, + algorithm: toEnum(payload.algorithm), + digits: payload.digits, + period: payload.period, + }; } export async function getUserInfoTOTPConfiguration(): Promise<UserInfoTOTPConfiguration> { const res = await Get<UserInfoTOTPConfigurationPayload>(UserInfoTOTPConfigurationPath); - return { ...res }; + + return toUserInfoTOTPConfiguration(res); +} + +export async function getUserInfoTOTPConfigurationOptional(): Promise<UserInfoTOTPConfiguration | null> { + const res = await axios.get<ServiceResponse<UserInfoTOTPConfigurationPayload>>(UserInfoTOTPConfigurationPath, { + validateStatus: function (status) { + return status < 300 || status === 404; + }, + }); + + if (res === null || res.status === 404 || res.data.status === "KO") { + return null; + } + + return toUserInfoTOTPConfiguration(res.data.data); +} + +export interface TOTPOptionsPayload { + algorithm: TOTPAlgorithmPayload; + algorithms: TOTPAlgorithmPayload[]; + length: TOTPDigits; + lengths: TOTPDigits[]; + period: number; + periods: number[]; +} + +export async function getTOTPOptions(): Promise<TOTPOptions> { + const res = await Get<TOTPOptionsPayload>(TOTPRegistrationOptionsPath); + + return { + algorithm: toEnum(res.algorithm), + algorithms: res.algorithms.map((alg) => toEnum(alg)), + length: res.length, + lengths: res.lengths, + period: res.period, + periods: res.periods, + }; +} + +export async function deleteUserTOTPConfiguration() { + return await axios<AuthenticationOKResponse>({ + method: "DELETE", + url: CompleteTOTPSignInPath, + validateStatus: validateStatusAuthentication, + }); } diff --git a/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx b/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx deleted file mode 100644 index 9fbe349f2..000000000 --- a/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import React, { useCallback, useEffect, useState } from "react"; - -import { IconDefinition, faCopy, faKey, faTimesCircle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Button, CircularProgress, IconButton, Link, TextField, Theme, Typography } from "@mui/material"; -import { red } from "@mui/material/colors"; -import makeStyles from "@mui/styles/makeStyles"; -import classnames from "classnames"; -import { QRCodeSVG } from "qrcode.react"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; - -import AppStoreBadges from "@components/AppStoreBadges"; -import { GoogleAuthenticator } from "@constants/constants"; -import { IndexRoute } from "@constants/Routes"; -import { IdentityToken } from "@constants/SearchParams"; -import { useNotifications } from "@hooks/NotificationsContext"; -import { useQueryParam } from "@hooks/QueryParam"; -import LoginLayout from "@layouts/LoginLayout"; -import { completeTOTPRegistrationProcess } from "@services/RegisterDevice"; - -const RegisterOneTimePassword = function () { - const { t: translate } = useTranslation(); - - const styles = useStyles(); - - const navigate = useNavigate(); - const { createSuccessNotification, createErrorNotification } = useNotifications(); - - // The secret retrieved from the API is all is ok. - const [secretURL, setSecretURL] = useState("empty"); - const [secretBase32, setSecretBase32] = useState(undefined as string | undefined); - const [hasErrored, setHasErrored] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - // Get the token from the query param to give it back to the API when requesting - // the secret for OTP. - const processToken = useQueryParam(IdentityToken); - - const handleDoneClick = () => { - navigate(IndexRoute); - }; - - const completeRegistrationProcess = useCallback(async () => { - if (!processToken) { - return; - } - - setIsLoading(true); - try { - const secret = await completeTOTPRegistrationProcess(processToken); - setSecretURL(secret.otpauth_url); - setSecretBase32(secret.base32_secret); - } catch (err) { - 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( - translate("Failed to register device, the provided link is expired or has already been used"), - ); - } - setHasErrored(true); - } - setIsLoading(false); - }, [processToken, createErrorNotification, translate]); - - useEffect(() => { - completeRegistrationProcess(); - }, [completeRegistrationProcess]); - - function SecretButton(text: string | undefined, action: string, icon: IconDefinition) { - return ( - <IconButton - className={styles.secretButtons} - color="primary" - onClick={() => { - navigator.clipboard.writeText(`${text}`); - createSuccessNotification(`${action}`); - }} - size="large" - > - <FontAwesomeIcon icon={icon} /> - </IconButton> - ); - } - const qrcodeFuzzyStyle = isLoading || hasErrored ? styles.fuzzy : undefined; - - return ( - <LoginLayout title={translate("Scan QR Code")}> - <div className={styles.root}> - <div className={styles.googleAuthenticator}> - <Typography className={styles.googleAuthenticatorText}> - {translate("Need Google Authenticator?")} - </Typography> - <AppStoreBadges - iconSize={128} - targetBlank - className={styles.googleAuthenticatorBadges} - googlePlayLink={GoogleAuthenticator.googlePlay} - appleStoreLink={GoogleAuthenticator.appleStore} - /> - </div> - <div className={classnames(qrcodeFuzzyStyle, styles.qrcodeContainer)}> - <Link href={secretURL} underline="hover"> - <QRCodeSVG value={secretURL} className={styles.qrcode} size={256} /> - {!hasErrored && isLoading ? <CircularProgress className={styles.loader} size={128} /> : null} - {hasErrored ? <FontAwesomeIcon className={styles.failureIcon} icon={faTimesCircle} /> : null} - </Link> - </div> - <div> - {secretURL !== "empty" ? ( - <TextField - id="secret-url" - label={translate("Secret")} - className={styles.secret} - value={secretURL} - InputProps={{ - readOnly: true, - }} - /> - ) : 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" - color="primary" - className={styles.doneButton} - onClick={handleDoneClick} - disabled={isLoading} - > - {translate("Done")} - </Button> - </div> - </LoginLayout> - ); -}; - -export default RegisterOneTimePassword; - -const useStyles = makeStyles((theme: Theme) => ({ - root: { - paddingTop: theme.spacing(4), - paddingBottom: theme.spacing(4), - }, - qrcode: { - marginTop: theme.spacing(2), - marginBottom: theme.spacing(2), - padding: theme.spacing(), - backgroundColor: "white", - }, - fuzzy: { - filter: "blur(10px)", - }, - secret: { - marginTop: theme.spacing(1), - marginBottom: theme.spacing(1), - width: "256px", - }, - googleAuthenticator: {}, - googleAuthenticatorText: { - fontSize: theme.typography.fontSize * 0.8, - }, - googleAuthenticatorBadges: {}, - secretButtons: { - width: "128px", - }, - doneButton: { - width: "256px", - }, - qrcodeContainer: { - position: "relative", - display: "inline-block", - }, - loader: { - position: "absolute", - top: "calc(128px - 64px)", - left: "calc(128px - 64px)", - color: "rgba(255, 255, 255, 0.5)", - }, - failureIcon: { - position: "absolute", - top: "calc(128px - 64px)", - left: "calc(128px - 64px)", - color: red[400], - fontSize: "128px", - }, -})); diff --git a/web/src/views/LoadingPage/BaseLoadingPage.tsx b/web/src/views/LoadingPage/BaseLoadingPage.tsx index cf53cb917..7ef83356e 100644 --- a/web/src/views/LoadingPage/BaseLoadingPage.tsx +++ b/web/src/views/LoadingPage/BaseLoadingPage.tsx @@ -22,8 +22,6 @@ const BaseLoadingPage = function (props: Props) { ); }; -export default BaseLoadingPage; - const useStyles = makeStyles((theme: Theme) => ({ gridOuter: { alignItems: "center", @@ -35,3 +33,5 @@ const useStyles = makeStyles((theme: Theme) => ({ display: "inline-block", }, })); + +export default BaseLoadingPage; diff --git a/web/src/views/LoginPortal/Authenticated.tsx b/web/src/views/LoginPortal/Authenticated.tsx index 78ce21f4a..c841a4945 100644 --- a/web/src/views/LoginPortal/Authenticated.tsx +++ b/web/src/views/LoginPortal/Authenticated.tsx @@ -21,11 +21,11 @@ const Authenticated = function () { ); }; -export default Authenticated; - const useStyles = makeStyles((theme: Theme) => ({ iconContainer: { marginBottom: theme.spacing(2), flex: "0 0 100%", }, })); + +export default Authenticated; diff --git a/web/src/views/LoginPortal/AuthenticatedView/AuthenticatedView.tsx b/web/src/views/LoginPortal/AuthenticatedView/AuthenticatedView.tsx index d17f570e1..3930f304b 100644 --- a/web/src/views/LoginPortal/AuthenticatedView/AuthenticatedView.tsx +++ b/web/src/views/LoginPortal/AuthenticatedView/AuthenticatedView.tsx @@ -40,8 +40,6 @@ const AuthenticatedView = function (props: Props) { ); }; -export default AuthenticatedView; - const useStyles = makeStyles((theme: Theme) => ({ mainContainer: { border: "1px solid #d6d6d6", @@ -51,3 +49,5 @@ const useStyles = makeStyles((theme: Theme) => ({ marginBottom: theme.spacing(2), }, })); + +export default AuthenticatedView; diff --git a/web/src/views/LoginPortal/ConsentView/ConsentView.tsx b/web/src/views/LoginPortal/ConsentView/ConsentView.tsx index 8325bf59e..3956b4d47 100644 --- a/web/src/views/LoginPortal/ConsentView/ConsentView.tsx +++ b/web/src/views/LoginPortal/ConsentView/ConsentView.tsx @@ -276,8 +276,6 @@ const useStyles = makeStyles((theme: Theme) => ({ preConfigure: {}, })); -export default ConsentView; - interface ComponentOrLoadingProps { ready: boolean; @@ -294,3 +292,5 @@ function ComponentOrLoading(props: ComponentOrLoadingProps) { </Fragment> ); } + +export default ConsentView; diff --git a/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx b/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx index 6b3322bdf..4da9e5805 100644 --- a/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx +++ b/web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx @@ -227,8 +227,6 @@ const FirstFactorForm = function (props: Props) { ); }; -export default FirstFactorForm; - const useStyles = makeStyles((theme: Theme) => ({ actionRow: { display: "flex", @@ -248,3 +246,5 @@ const useStyles = makeStyles((theme: Theme) => ({ justifyContent: "flex-end", }, })); + +export default FirstFactorForm; diff --git a/web/src/views/LoginPortal/LoginPortal.tsx b/web/src/views/LoginPortal/LoginPortal.tsx index e85c460ba..ed710ee00 100644 --- a/web/src/views/LoginPortal/LoginPortal.tsx +++ b/web/src/views/LoginPortal/LoginPortal.tsx @@ -211,8 +211,6 @@ const LoginPortal = function (props: Props) { ); }; -export default LoginPortal; - interface ComponentOrLoadingProps { ready: boolean; @@ -229,3 +227,5 @@ function ComponentOrLoading(props: ComponentOrLoadingProps) { </Fragment> ); } + +export default LoginPortal; diff --git a/web/src/views/LoginPortal/SecondFactor/DeviceSelectionContainer.tsx b/web/src/views/LoginPortal/SecondFactor/DeviceSelectionContainer.tsx index 4397eb7c4..773da7513 100644 --- a/web/src/views/LoginPortal/SecondFactor/DeviceSelectionContainer.tsx +++ b/web/src/views/LoginPortal/SecondFactor/DeviceSelectionContainer.tsx @@ -91,8 +91,6 @@ const DefaultDeviceSelectionContainer = function (props: Props) { ); }; -export default DefaultDeviceSelectionContainer; - interface DeviceItemProps { id: number; device: SelectableDevice; @@ -100,7 +98,7 @@ interface DeviceItemProps { onSelect: () => void; } -function DeviceItem(props: DeviceItemProps) { +const DeviceItem = function (props: DeviceItemProps) { const className = "device-option-" + props.id; const idName = "device-" + props.device.id; const style = makeStyles((theme: Theme) => ({ @@ -136,7 +134,7 @@ function DeviceItem(props: DeviceItemProps) { </Button> </Grid> ); -} +}; interface MethodItemProps { id: number; @@ -145,7 +143,7 @@ interface MethodItemProps { onSelect: () => void; } -function MethodItem(props: MethodItemProps) { +const MethodItem = function (props: MethodItemProps) { const className = "method-option-" + props.id; const idName = "method-" + props.method; const style = makeStyles((theme: Theme) => ({ @@ -181,4 +179,6 @@ function MethodItem(props: MethodItemProps) { </Button> </Grid> ); -} +}; + +export default DefaultDeviceSelectionContainer; diff --git a/web/src/views/LoginPortal/SecondFactor/OTPDial.tsx b/web/src/views/LoginPortal/SecondFactor/OTPDial.tsx index 5de55beab..0f046f536 100644 --- a/web/src/views/LoginPortal/SecondFactor/OTPDial.tsx +++ b/web/src/views/LoginPortal/SecondFactor/OTPDial.tsx @@ -45,8 +45,6 @@ const OTPDial = function (props: Props) { ); }; -export default OTPDial; - const useStyles = makeStyles((theme: Theme) => ({ timeProgress: {}, register: { @@ -85,3 +83,5 @@ function Icon(props: IconProps) { </Fragment> ); } + +export default OTPDial; diff --git a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx index 93c0ba221..c3531e10a 100644 --- a/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx +++ b/web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx @@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next"; import { Route, Routes, useNavigate } from "react-router-dom"; import { - RegisterOneTimePasswordRoute, SecondFactorPushSubRoute, SecondFactorTOTPSubRoute, SecondFactorWebAuthnSubRoute, @@ -20,7 +19,6 @@ import LoginLayout from "@layouts/LoginLayout"; import { Configuration } from "@models/Configuration"; import { SecondFactorMethod } from "@models/Methods"; import { UserInfo } from "@models/UserInfo"; -import { initiateTOTPRegistrationProcess } from "@services/RegisterDevice"; import { AuthenticationLevel } from "@services/State"; import { setPreferred2FAMethod } from "@services/UserInfo"; import MethodSelectionDialog from "@views/LoginPortal/SecondFactor/MethodSelectionDialog"; @@ -42,8 +40,7 @@ const SecondFactorForm = function (props: Props) { const styles = useStyles(); const navigate = useNavigate(); const [methodSelectionOpen, setMethodSelectionOpen] = useState(false); - const { createInfoNotification, createErrorNotification } = useNotifications(); - const [registrationInProgress, setRegistrationInProgress] = useState(false); + const { createErrorNotification } = useNotifications(); const [stateWebAuthnSupported, setStateWebAuthnSupported] = useState(false); const { t: translate } = useTranslation(); @@ -51,27 +48,6 @@ const SecondFactorForm = function (props: Props) { setStateWebAuthnSupported(browserSupportsWebAuthn()); }, [setStateWebAuthnSupported]); - const initiateRegistration = (initiateRegistrationFunc: () => Promise<void>, redirectRoute: string) => { - return async () => { - if (props.authenticationLevel >= AuthenticationLevel.TwoFactor) { - navigate(redirectRoute); - } else { - if (registrationInProgress) { - return; - } - setRegistrationInProgress(true); - try { - await initiateRegistrationFunc(); - createInfoNotification(translate("An email has been sent to your address to complete the process")); - } catch (err) { - console.error(err); - createErrorNotification(translate("There was a problem initiating the registration process")); - } - setRegistrationInProgress(false); - } - }; - }; - const handleMethodSelectionClick = () => { setMethodSelectionOpen(true); }; @@ -129,10 +105,9 @@ const SecondFactorForm = function (props: Props) { authenticationLevel={props.authenticationLevel} // Whether the user has a TOTP secret registered already registered={props.userInfo.has_totp} - onRegisterClick={initiateRegistration( - initiateTOTPRegistrationProcess, - RegisterOneTimePasswordRoute, - )} + onRegisterClick={() => { + navigate(`${SettingsRoute}${SettingsTwoFactorAuthenticationSubRoute}`); + }} onSignInError={(err) => createErrorNotification(err.message)} onSignInSuccess={props.onAuthenticationSuccess} /> diff --git a/web/src/views/Settings/SettingsRouter.tsx b/web/src/views/Settings/SettingsRouter.tsx index 4082adb94..ba8fdf469 100644 --- a/web/src/views/Settings/SettingsRouter.tsx +++ b/web/src/views/Settings/SettingsRouter.tsx @@ -16,7 +16,6 @@ const SettingsRouter = function (props: Props) { const navigate = useRouterNavigate(); const [state, fetchState, , fetchStateError] = useAutheliaState(); - // Fetch the state on page load useEffect(() => { fetchState(); }, [fetchState]); diff --git a/web/src/views/Settings/TwoFactorAuthentication/DeleteDialog.tsx b/web/src/views/Settings/TwoFactorAuthentication/DeleteDialog.tsx index 2aa8b2aef..fc5159825 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/DeleteDialog.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/DeleteDialog.tsx @@ -10,7 +10,7 @@ interface Props { handleClose: (ok: boolean) => void; } -export default function DeleteDialog(props: Props) { +const DeleteDialog = function (props: Props) { const { t: translate } = useTranslation("settings"); const handleCancel = () => { @@ -35,4 +35,6 @@ export default function DeleteDialog(props: Props) { </DialogActions> </Dialog> ); -} +}; + +export default DeleteDialog; diff --git a/web/src/views/Settings/TwoFactorAuthentication/TOTPDevice.tsx b/web/src/views/Settings/TwoFactorAuthentication/TOTPDevice.tsx new file mode 100644 index 000000000..b8ba62e2e --- /dev/null +++ b/web/src/views/Settings/TwoFactorAuthentication/TOTPDevice.tsx @@ -0,0 +1,138 @@ +import React, { Fragment, useState } from "react"; + +import { QrCode2 } from "@mui/icons-material"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { Box, Button, CircularProgress, Paper, Stack, Tooltip, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import { useNotifications } from "@hooks/NotificationsContext"; +import { UserInfoTOTPConfiguration, toAlgorithmString } from "@models/TOTPConfiguration"; +import { deleteUserTOTPConfiguration } from "@services/UserInfoTOTPConfiguration"; +import DeleteDialog from "@views/Settings/TwoFactorAuthentication/DeleteDialog"; + +interface Props { + config: UserInfoTOTPConfiguration; + handleRefresh: () => void; +} + +const TOTPDevice = function (props: Props) { + const { t: translate } = useTranslation("settings"); + + const { createSuccessNotification, createErrorNotification } = useNotifications(); + + const [showDialogDelete, setShowDialogDelete] = useState<boolean>(false); + + const [loadingDelete, setLoadingDelete] = useState<boolean>(false); + + const handleDelete = async (ok: boolean) => { + setShowDialogDelete(false); + + if (!ok) { + return; + } + + setLoadingDelete(true); + + const response = await deleteUserTOTPConfiguration(); + + setLoadingDelete(false); + + if (response.data.status === "KO") { + if (response.data.elevation) { + createErrorNotification(translate("You must be elevated to delete the One-Time Password")); + } else if (response.data.authentication) { + createErrorNotification( + translate("You must have a higher authentication level to delete the One-Time Password"), + ); + } else { + createErrorNotification(translate("There was a problem deleting the One-Time Password")); + } + + return; + } + + createSuccessNotification(translate("Successfully deleted the One Time Password configuration")); + + props.handleRefresh(); + }; + + return ( + <Fragment> + <Paper variant="outlined"> + <Box sx={{ p: 3 }}> + <DeleteDialog + open={showDialogDelete} + handleClose={handleDelete} + title={translate("Remove One Time Password")} + text={translate( + "Are you sure you want to remove the Time-based One Time Password from from your account", + )} + /> + <Stack direction={"row"} spacing={1} alignItems={"center"}> + <QrCode2 fontSize="large" /> + <Stack spacing={0} sx={{ minWidth: 400 }}> + <Box> + <Typography display={"inline"} sx={{ fontWeight: "bold" }}> + {props.config.issuer} + </Typography> + <Typography display={"inline"} variant={"body2"}> + {" (" + + translate("{{algorithm}}, {{digits}} digits, {{seconds}} seconds", { + algorithm: toAlgorithmString(props.config.algorithm), + digits: props.config.digits, + seconds: props.config.period, + }) + + ")"} + </Typography> + </Box> + <Typography variant={"caption"}> + {translate("Added when", { + when: props.config.created_at, + formatParams: { + when: { + hour: "numeric", + minute: "numeric", + year: "numeric", + month: "long", + day: "numeric", + }, + }, + })} + </Typography> + <Typography variant={"caption"}> + {props.config.last_used_at === undefined + ? translate("Never used") + : translate("Last Used when", { + when: props.config.last_used_at, + formatParams: { + when: { + hour: "numeric", + minute: "numeric", + year: "numeric", + month: "long", + day: "numeric", + }, + }, + })} + </Typography> + </Stack> + <Tooltip title={translate("Remove the Time-based One Time Password configuration")}> + <Button + variant={"outlined"} + color={"error"} + startIcon={ + loadingDelete ? <CircularProgress color="inherit" size={20} /> : <DeleteIcon /> + } + onClick={loadingDelete ? undefined : () => setShowDialogDelete(true)} + > + {translate("Remove")} + </Button> + </Tooltip> + </Stack> + </Box> + </Paper> + </Fragment> + ); +}; + +export default TOTPDevice; diff --git a/web/src/views/Settings/TwoFactorAuthentication/TOTPPanel.tsx b/web/src/views/Settings/TwoFactorAuthentication/TOTPPanel.tsx new file mode 100644 index 000000000..3f14c94b8 --- /dev/null +++ b/web/src/views/Settings/TwoFactorAuthentication/TOTPPanel.tsx @@ -0,0 +1,73 @@ +import React, { Fragment, useState } from "react"; + +import { Button, Paper, Stack, Tooltip, Typography } from "@mui/material"; +import Grid from "@mui/material/Unstable_Grid2"; +import { useTranslation } from "react-i18next"; + +import { UserInfoTOTPConfiguration } from "@models/TOTPConfiguration"; +import TOTPDevice from "@views/Settings/TwoFactorAuthentication/TOTPDevice"; +import TOTPRegisterDialogController from "@views/Settings/TwoFactorAuthentication/TOTPRegisterDialogController"; + +interface Props { + config: UserInfoTOTPConfiguration | undefined | null; + handleRefreshState: () => void; +} + +const TOTPPanel = function (props: Props) { + const { t: translate } = useTranslation("settings"); + + const [showRegisterDialog, setShowRegisterDialog] = useState<boolean>(false); + + return ( + <Fragment> + <TOTPRegisterDialogController + open={showRegisterDialog} + setClosed={() => { + setShowRegisterDialog(false); + props.handleRefreshState(); + }} + /> + <Paper variant={"outlined"}> + <Grid container spacing={2} padding={2}> + <Grid xs={12} lg={8}> + <Typography variant={"h5"}>{translate("One Time Password")}</Typography> + </Grid> + {props.config === undefined || props.config === null ? ( + <Fragment> + <Grid xs={2}> + <Tooltip + title={translate("Click to add a Time-based One Time Password to your account")} + > + <Button + variant="outlined" + color="primary" + onClick={() => { + setShowRegisterDialog(true); + }} + > + {translate("Add")} + </Button> + </Tooltip> + </Grid> + <Grid xs={12}> + <Typography variant={"subtitle2"}> + {translate( + "The One Time Password has not been registered. If you'd like to register it click add.", + )} + </Typography> + </Grid> + </Fragment> + ) : ( + <Grid xs={12}> + <Stack spacing={3}> + <TOTPDevice config={props.config} handleRefresh={props.handleRefreshState} /> + </Stack> + </Grid> + )} + </Grid> + </Paper> + </Fragment> + ); +}; + +export default TOTPPanel; diff --git a/web/src/views/Settings/TwoFactorAuthentication/TOTPRegisterDialogController.tsx b/web/src/views/Settings/TwoFactorAuthentication/TOTPRegisterDialogController.tsx new file mode 100644 index 000000000..45e78b131 --- /dev/null +++ b/web/src/views/Settings/TwoFactorAuthentication/TOTPRegisterDialogController.tsx @@ -0,0 +1,579 @@ +import React, { Fragment, useCallback, useEffect, useState } from "react"; + +import { faTimesCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + FormControl, + FormControlLabel, + FormLabel, + Link, + Radio, + RadioGroup, + Step, + StepLabel, + Stepper, + Switch, + TextField, + Theme, + Typography, +} from "@mui/material"; +import { red } from "@mui/material/colors"; +import Grid from "@mui/material/Unstable_Grid2"; +import makeStyles from "@mui/styles/makeStyles"; +import classnames from "classnames"; +import { QRCodeSVG } from "qrcode.react"; +import { useTranslation } from "react-i18next"; + +import AppStoreBadges from "@components/AppStoreBadges"; +import CopyButton from "@components/CopyButton"; +import SuccessIcon from "@components/SuccessIcon"; +import { GoogleAuthenticator } from "@constants/constants"; +import { useNotifications } from "@hooks/NotificationsContext"; +import { toAlgorithmString } from "@models/TOTPConfiguration"; +import { completeTOTPRegister, stopTOTPRegister } from "@services/OneTimePassword"; +import { getTOTPSecret } from "@services/RegisterDevice"; +import { getTOTPOptions } from "@services/UserInfoTOTPConfiguration"; +import { State } from "@views/LoginPortal/SecondFactor/OneTimePasswordMethod"; +import OTPDial from "@views/LoginPortal/SecondFactor/OTPDial"; + +const steps = ["Start", "Register", "Confirm"]; + +interface Props { + open: boolean; + setClosed: () => void; +} + +interface Options { + algorithm: string; + length: number; + period: number; +} + +interface AvailableOptions { + algorithms: string[]; + lengths: number[]; + periods: number[]; +} + +const TOTPRegisterDialogController = function (props: Props) { + const { t: translate } = useTranslation("settings"); + + const styles = useStyles(); + const { createErrorNotification } = useNotifications(); + + const [selected, setSelected] = useState<Options>({ algorithm: "", length: 6, period: 30 }); + const [defaults, setDefaults] = useState<Options | null>(null); + const [available, setAvailable] = useState<AvailableOptions>({ + algorithms: [], + lengths: [], + periods: [], + }); + + const [activeStep, setActiveStep] = useState(0); + + const [secretURL, setSecretURL] = useState<string | null>(null); + const [secretValue, setSecretValue] = useState<string | null>(null); + const [isLoading, setIsLoading] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const [hasErrored, setHasErrored] = useState(false); + const [dialValue, setDialValue] = useState(""); + const [dialState, setDialState] = useState(State.Idle); + const [showQRCode, setShowQRCode] = useState(true); + const [success, setSuccess] = useState(false); + + const resetStates = useCallback(() => { + if (defaults) { + setSelected(defaults); + } + + setSecretURL(null); + setSecretValue(null); + setIsLoading(false); + setShowAdvanced(false); + setActiveStep(0); + setDialValue(""); + setDialState(State.Idle); + setShowQRCode(true); + }, [defaults]); + + const handleClose = useCallback(() => { + (async () => { + props.setClosed(); + + if (secretURL !== "") { + try { + await stopTOTPRegister(); + } catch (err) { + console.error(err); + } + } + + resetStates(); + })(); + }, [props, secretURL, resetStates]); + + const handleFinished = useCallback(() => { + setSuccess(true); + + setTimeout(() => { + props.setClosed(); + resetStates(); + }, 750); + }, [props, resetStates]); + + const handleOnClose = () => { + if (!props.open) { + return; + } + + handleClose(); + }; + + useEffect(() => { + if (!props.open || activeStep !== 0 || defaults !== null) { + return; + } + + (async () => { + const opts = await getTOTPOptions(); + + const decoded = { + algorithm: toAlgorithmString(opts.algorithm), + length: opts.length, + period: opts.period, + }; + + setAvailable({ + algorithms: opts.algorithms.map((algorithm) => toAlgorithmString(algorithm)), + lengths: opts.lengths, + periods: opts.periods, + }); + + setDefaults(decoded); + setSelected(decoded); + })(); + }, [props.open, activeStep, defaults, selected]); + + const handleSetStepPrevious = useCallback(() => { + if (activeStep === 0) { + return; + } + + setShowAdvanced(false); + setActiveStep((prevState) => { + return prevState - 1; + }); + }, [activeStep]); + + const handleSetStepNext = useCallback(() => { + if (activeStep === steps.length - 1) { + return; + } + + setShowAdvanced(false); + setActiveStep((prevState) => { + return prevState + 1; + }); + }, [activeStep]); + + useEffect(() => { + if (!props.open || activeStep !== 1) { + return; + } + + (async () => { + setIsLoading(true); + + try { + const secret = await getTOTPSecret(selected.algorithm, selected.length, selected.period); + setSecretURL(secret.otpauth_url); + setSecretValue(secret.base32_secret); + } catch (err) { + 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( + translate("Failed to register device, the provided link is expired or has already been used"), + ); + } + setHasErrored(true); + } + + setIsLoading(false); + })(); + }, [activeStep, createErrorNotification, selected, props.open, translate]); + + useEffect(() => { + if (!props.open || activeStep !== 2 || dialState === State.InProgress || dialValue.length !== selected.length) { + return; + } + + (async () => { + setDialState(State.InProgress); + + try { + const registerValue = dialValue; + setDialValue(""); + + await completeTOTPRegister(registerValue); + + handleFinished(); + } catch (err) { + console.error(err); + setDialState(State.Failure); + } + })(); + }, [activeStep, dialState, dialValue, dialValue.length, handleFinished, props.open, selected.length]); + + const toggleAdvanced = () => { + setShowAdvanced((prevState) => !prevState); + }; + + const advanced = + defaults !== null && + (available.algorithms.length !== 1 || available.lengths.length !== 1 || available.periods.length !== 1); + + const disableAdvanced = + defaults === null || + (available.algorithms.length <= 1 && available.lengths.length <= 1 && available.periods.length <= 1); + + const hideAlgorithms = advanced && available.algorithms.length <= 1; + const hideLengths = advanced && available.lengths.length <= 1; + const hidePeriods = advanced && available.periods.length <= 1; + const qrcodeFuzzyStyle = isLoading || hasErrored ? styles.fuzzy : undefined; + + function renderStep(step: number) { + switch (step) { + case 0: + return ( + <Fragment> + {defaults === null ? ( + <Grid xs={12} my={3}> + <Typography>Loading...</Typography> + </Grid> + ) : ( + <Grid container> + <Grid xs={12} my={3}> + <Typography>{translate("To begin select next")}</Typography> + </Grid> + <Grid xs={12} hidden={disableAdvanced}> + <FormControlLabel + disabled={disableAdvanced} + control={<Switch checked={showAdvanced} onChange={toggleAdvanced} />} + label={translate("Advanced")} + /> + </Grid> + <Grid + xs={12} + hidden={disableAdvanced || !showAdvanced} + justifyContent={"center"} + alignItems={"center"} + > + <FormControl fullWidth> + <FormLabel id={"lbl-adv-algorithms"} hidden={hideAlgorithms}> + {translate("Algorithm")} + </FormLabel> + <RadioGroup + row + aria-labelledby={"lbl-adv-algorithms"} + value={selected.algorithm} + hidden={hideAlgorithms} + style={{ + justifyContent: "center", + }} + onChange={(e, value) => { + setSelected((prevState) => { + return { + ...prevState, + algorithm: value, + }; + }); + + e.preventDefault(); + }} + > + {available.algorithms.map((algorithm) => ( + <FormControlLabel + key={algorithm} + value={algorithm} + control={<Radio />} + label={algorithm} + /> + ))} + </RadioGroup> + <FormLabel id={"lbl-adv-lengths"} hidden={hideLengths}> + {translate("Length")} + </FormLabel> + <RadioGroup + row + aria-labelledby={"lbl-adv-lengths"} + value={selected.length.toString()} + hidden={hideLengths} + style={{ + justifyContent: "center", + }} + onChange={(e, value) => { + setSelected((prevState) => { + return { + ...prevState, + length: parseInt(value), + }; + }); + + e.preventDefault(); + }} + > + {available.lengths.map((length) => ( + <FormControlLabel + key={length.toString()} + value={length.toString()} + control={<Radio />} + label={length.toString()} + /> + ))} + </RadioGroup> + <FormLabel id={"lbl-adv-periods"} hidden={hidePeriods}> + {translate("Seconds")} + </FormLabel> + <RadioGroup + row + aria-labelledby={"lbl-adv-periods"} + value={selected.period.toString()} + hidden={hidePeriods} + style={{ + justifyContent: "center", + }} + onChange={(e, value) => { + setSelected((prevState) => { + return { + ...prevState, + period: parseInt(value), + }; + }); + + e.preventDefault(); + }} + > + {available.periods.map((period) => ( + <FormControlLabel + key={period.toString()} + value={period.toString()} + control={<Radio />} + label={period.toString()} + /> + ))} + </RadioGroup> + </FormControl> + </Grid> + </Grid> + )} + </Fragment> + ); + case 1: + return ( + <Fragment> + <Grid xs={12} my={2}> + <FormControlLabel + disabled={disableAdvanced} + control={ + <Switch + checked={showQRCode} + onChange={() => { + setShowQRCode((value) => !value); + }} + /> + } + label={translate("QR Code")} + /> + </Grid> + <Grid xs={12} hidden={!showQRCode}> + <Box className={classnames(qrcodeFuzzyStyle, styles.qrcodeContainer)}> + {secretURL !== null ? ( + <Link href={secretURL} underline="hover"> + <QRCodeSVG value={secretURL} className={styles.qrcode} size={200} /> + {!hasErrored && isLoading ? ( + <CircularProgress className={styles.loader} size={128} /> + ) : null} + {hasErrored ? ( + <FontAwesomeIcon className={styles.failureIcon} icon={faTimesCircle} /> + ) : null} + </Link> + ) : null} + </Box> + </Grid> + <Grid xs={12} hidden={showQRCode}> + <Grid container spacing={2} justifyContent={"center"}> + <Grid xs={4}> + <CopyButton + tooltip={translate("Click to Copy")} + value={secretURL} + childrenCopied={translate("Copied")} + fullWidth={true} + > + {translate("OTP URL")} + </CopyButton> + </Grid> + <Grid xs={4}> + <CopyButton + tooltip={translate("Click to Copy")} + value={secretValue} + childrenCopied={translate("Copied")} + fullWidth={true} + > + {translate("Secret")} + </CopyButton> + </Grid> + <Grid xs={12}> + <TextField + id="secret-url" + label={translate("Secret")} + className={styles.secret} + value={secretURL === null ? "" : secretURL} + multiline={true} + InputProps={{ + readOnly: true, + }} + /> + </Grid> + </Grid> + </Grid> + <Grid xs={12} sx={{ display: { xs: "none", md: "block" } }}> + <Box> + <Typography className={styles.googleAuthenticatorText}> + {translate("Need Google Authenticator?")} + </Typography> + <AppStoreBadges + iconSize={110} + targetBlank + className={styles.googleAuthenticatorBadges} + googlePlayLink={GoogleAuthenticator.googlePlay} + appleStoreLink={GoogleAuthenticator.appleStore} + /> + </Box> + </Grid> + </Fragment> + ); + case 2: + return ( + <Fragment> + <Grid xs={12} paddingY={4}> + {success ? ( + <Box className={styles.success}> + <SuccessIcon /> + </Box> + ) : ( + <OTPDial + passcode={dialValue} + state={dialState} + digits={selected.length} + period={selected.period} + onChange={setDialValue} + /> + )} + </Grid> + </Fragment> + ); + } + } + + return ( + <Dialog open={props.open} onClose={handleOnClose} maxWidth={"xs"} fullWidth={true}> + <DialogTitle>{translate("Register One Time Password (TOTP)")}</DialogTitle> + <DialogContent> + <DialogContentText sx={{ mb: 3 }}> + {translate("This dialog allows registration of the One-Time Password.")} + </DialogContentText> + <Grid container spacing={0} alignItems={"center"} justifyContent={"center"} textAlign={"center"}> + <Grid xs={12}> + <Stepper activeStep={activeStep}> + {steps.map((label, index) => { + const stepProps: { completed?: boolean } = {}; + const labelProps: { + optional?: React.ReactNode; + } = {}; + return ( + <Step key={label} {...stepProps}> + <StepLabel {...labelProps}>{translate(label)}</StepLabel> + </Step> + ); + })} + </Stepper> + </Grid> + <Grid xs={12}> + <Grid container spacing={1} justifyContent={"center"}> + {renderStep(activeStep)} + </Grid> + </Grid> + </Grid> + </DialogContent> + <DialogActions> + <Button color={"primary"} onClick={handleSetStepPrevious} disabled={activeStep === 0}> + {translate("Previous")} + </Button> + <Button color={"error"} onClick={handleClose}> + {translate("Cancel")} + </Button> + <Button color={"primary"} onClick={handleSetStepNext} disabled={activeStep === steps.length - 1}> + {translate("Next")} + </Button> + </DialogActions> + </Dialog> + ); +}; + +const useStyles = makeStyles((theme: Theme) => ({ + qrcode: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + padding: theme.spacing(), + backgroundColor: "white", + }, + fuzzy: { + filter: "blur(10px)", + }, + secret: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + width: "256px", + }, + googleAuthenticatorText: { + fontSize: theme.typography.fontSize * 0.8, + }, + googleAuthenticatorBadges: {}, + qrcodeContainer: { + position: "relative", + display: "inline-block", + }, + loader: { + position: "absolute", + top: "calc(128px - 64px)", + left: "calc(128px - 64px)", + color: "rgba(255, 255, 255, 0.5)", + }, + failureIcon: { + position: "absolute", + top: "calc(128px - 64px)", + left: "calc(128px - 64px)", + color: red[400], + fontSize: "128px", + }, + success: { + marginBottom: theme.spacing(2), + flex: "0 0 100%", + }, +})); + +export default TOTPRegisterDialogController; diff --git a/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx b/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx index fafd150de..65b87e149 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx @@ -4,15 +4,18 @@ import Grid from "@mui/material/Unstable_Grid2"; import { useNotifications } from "@hooks/NotificationsContext"; import { useUserInfoPOST } from "@hooks/UserInfo"; +import { useUserInfoTOTPConfigurationOptional } from "@hooks/UserInfoTOTPConfiguration"; import { useUserWebAuthnDevices } from "@hooks/WebAuthnDevices"; +import TOTPPanel from "@views/Settings/TwoFactorAuthentication/TOTPPanel"; import WebAuthnDevicesPanel from "@views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel"; interface Props {} -export default function TwoFactorAuthSettings(props: Props) { +const TwoFactorAuthSettings = function (props: Props) { const [refreshState, setRefreshState] = useState(0); const { createErrorNotification } = useNotifications(); const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoPOST(); + const [userTOTPConfig, fetchUserTOTPConfig, , fetchUserTOTPConfigError] = useUserInfoTOTPConfigurationOptional(); const [userWebAuthnDevices, fetchUserWebAuthnDevices, , fetchUserWebAuthnDevicesError] = useUserWebAuthnDevices(); const [hasTOTP, setHasTOTP] = useState(false); const [hasWebAuthn, setHasWebAuthn] = useState(false); @@ -40,6 +43,10 @@ export default function TwoFactorAuthSettings(props: Props) { }, [hasTOTP, hasWebAuthn, userInfo]); useEffect(() => { + fetchUserTOTPConfig(); + }, [fetchUserTOTPConfig, hasTOTP]); + + useEffect(() => { fetchUserWebAuthnDevices(); }, [fetchUserWebAuthnDevices, hasWebAuthn]); @@ -50,6 +57,12 @@ export default function TwoFactorAuthSettings(props: Props) { }, [fetchUserInfoError, createErrorNotification]); useEffect(() => { + if (fetchUserTOTPConfigError) { + createErrorNotification("There was an issue retrieving One Time Password Configuration"); + } + }, [fetchUserTOTPConfigError, createErrorNotification]); + + useEffect(() => { if (fetchUserWebAuthnDevicesError) { createErrorNotification("There was an issue retrieving One Time Password Configuration"); } @@ -58,8 +71,13 @@ export default function TwoFactorAuthSettings(props: Props) { return ( <Grid container spacing={2}> <Grid xs={12}> + <TOTPPanel config={userTOTPConfig} handleRefreshState={handleRefreshState} /> + </Grid> + <Grid xs={12}> <WebAuthnDevicesPanel devices={userWebAuthnDevices} handleRefreshState={handleRefreshState} /> </Grid> </Grid> ); -} +}; + +export default TwoFactorAuthSettings; diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceDetailsDialog.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceDetailsDialog.tsx index 255dd89b8..a8b5974db 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceDetailsDialog.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceDetailsDialog.tsx @@ -1,21 +1,19 @@ -import React, { Fragment, useState } from "react"; +import React, { Fragment } from "react"; -import { Check, ContentCopy } from "@mui/icons-material"; import { Button, - CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Divider, - Tooltip, Typography, } from "@mui/material"; import Grid from "@mui/material/Unstable_Grid2"; import { useTranslation } from "react-i18next"; +import CopyButton from "@components/CopyButton"; import { WebAuthnDevice, toTransportName } from "@models/WebAuthn"; interface Props { @@ -24,12 +22,14 @@ interface Props { handleClose: () => void; } -export default function WebAuthnDeviceDetailsDialog(props: Props) { +const WebAuthnDeviceDetailsDialog = function (props: Props) { const { t: translate } = useTranslation("settings"); return ( - <Dialog open={props.open} onClose={props.handleClose}> - <DialogTitle>{translate("WebAuthn Credential Details")}</DialogTitle> + <Dialog open={props.open} onClose={props.handleClose} aria-labelledby="webauthn-device-details-dialog-title"> + <DialogTitle id="webauthn-device-details-dialog-title"> + {translate("WebAuthn Credential Details")} + </DialogTitle> <DialogContent> <DialogContentText sx={{ mb: 3 }}> {translate("Extended WebAuthn credential information for security key", { @@ -40,12 +40,6 @@ export default function WebAuthnDeviceDetailsDialog(props: Props) { <Grid md={3} sx={{ display: { xs: "none", md: "block" } }}> <Fragment /> </Grid> - <Grid xs={4} md={2}> - <PropertyCopyButton name={translate("KID")} value={props.device.kid.toString()} /> - </Grid> - <Grid xs={8} md={4}> - <PropertyCopyButton name={translate("Public Key")} value={props.device.public_key.toString()} /> - </Grid> <Grid xs={12}> <Divider /> </Grid> @@ -128,11 +122,29 @@ export default function WebAuthnDeviceDetailsDialog(props: Props) { </Grid> </DialogContent> <DialogActions> + <CopyButton + variant={"contained"} + tooltip={`${translate("Click to copy the")} ${translate("KID")}`} + value={props.device.kid.toString()} + fullWidth={false} + childrenCopied={translate("Copied")} + > + {translate("KID")} + </CopyButton> + <CopyButton + variant={"contained"} + tooltip={`${translate("Click to copy the")} ${translate("Public Key")}`} + value={props.device.public_key.toString()} + fullWidth={false} + childrenCopied={translate("Copied")} + > + {translate("Public Key")} + </CopyButton> <Button onClick={props.handleClose}>{translate("Close")}</Button> </DialogActions> </Dialog> ); -} +}; interface PropertyTextProps { name: string; @@ -140,49 +152,6 @@ interface PropertyTextProps { xs?: number; } -function PropertyCopyButton(props: PropertyTextProps) { - const { t: translate } = useTranslation("settings"); - - const [copied, setCopied] = useState(false); - const [copying, setCopying] = useState(false); - - const handleCopyToClipboard = () => { - if (copied) { - return; - } - - (async () => { - setCopying(true); - - await navigator.clipboard.writeText(props.value); - - setTimeout(() => { - setCopying(false); - setCopied(true); - }, 500); - - setTimeout(() => { - setCopied(false); - }, 2000); - })(); - }; - - return ( - <Tooltip title={`${translate("Click to copy the")} ${props.name}`}> - <Button - variant="outlined" - color={copied ? "success" : "primary"} - onClick={copying ? undefined : handleCopyToClipboard} - startIcon={ - copying ? <CircularProgress color="inherit" size={20} /> : copied ? <Check /> : <ContentCopy /> - } - > - {copied ? translate("Copied") : props.name} - </Button> - </Tooltip> - ); -} - function PropertyText(props: PropertyTextProps) { return ( <Grid xs={props.xs !== undefined ? props.xs : 12}> @@ -193,3 +162,5 @@ function PropertyText(props: PropertyTextProps) { </Grid> ); } + +export default WebAuthnDeviceDetailsDialog; diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceEditDialog.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceEditDialog.tsx index a76041710..a3fc79f8d 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceEditDialog.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceEditDialog.tsx @@ -10,8 +10,7 @@ interface Props { device: WebAuthnDevice; handleClose: (ok: boolean, name: string) => void; } - -export default function WebAuthnDeviceEditDialog(props: Props) { +const WebAuthnDeviceEditDialog = function (props: Props) { const { t: translate } = useTranslation("settings"); const [deviceName, setName] = useState(""); @@ -36,12 +35,14 @@ export default function WebAuthnDeviceEditDialog(props: Props) { <Dialog open={props.open} onClose={handleCancel}> <DialogTitle>{translate("Edit WebAuthn Credential")}</DialogTitle> <DialogContent> - <DialogContentText>{translate("Enter a new name for this WebAuthn credential")}</DialogContentText> + <DialogContentText> + {translate("Enter a new description for this WebAuthn credential")} + </DialogContentText> <TextField autoFocus inputRef={nameRef} id="name-textfield" - label={translate("Name")} + label={translate("Description")} variant="standard" required value={deviceName} @@ -68,4 +69,6 @@ export default function WebAuthnDeviceEditDialog(props: Props) { </DialogActions> </Dialog> ); -} +}; + +export default WebAuthnDeviceEditDialog; diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceItem.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceItem.tsx index bdccb1cc4..5f136cde5 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceItem.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceItem.tsx @@ -21,8 +21,7 @@ interface Props { device: WebAuthnDevice; handleEdit: () => void; } - -export default function WebAuthnDeviceItem(props: Props) { +const WebAuthnDeviceItem = function (props: Props) { const { t: translate } = useTranslation("settings"); const { createSuccessNotification, createErrorNotification } = useNotifications(); @@ -197,4 +196,6 @@ export default function WebAuthnDeviceItem(props: Props) { </Paper> </Grid> ); -} +}; + +export default WebAuthnDeviceItem; diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel.tsx index 233d94b24..a6ffa4c3c 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel.tsx @@ -12,8 +12,7 @@ interface Props { devices: WebAuthnDevice[] | undefined; handleRefreshState: () => void; } - -export default function WebAuthnDevicesPanel(props: Props) { +const WebAuthnDevicesPanel = function (props: Props) { const { t: translate } = useTranslation("settings"); const [showRegisterDialog, setShowRegisterDialog] = useState<boolean>(false); @@ -63,4 +62,6 @@ export default function WebAuthnDevicesPanel(props: Props) { </Paper> </Fragment> ); -} +}; + +export default WebAuthnDevicesPanel; diff --git a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesStack.tsx b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesStack.tsx index 3d9463451..282331a14 100644 --- a/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesStack.tsx +++ b/web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesStack.tsx @@ -10,7 +10,7 @@ interface Props { handleRefreshState: () => void; } -export default function WebAuthnDevicesStack(props: Props) { +const WebAuthnDevicesStack = function (props: Props) { return ( <Grid container spacing={3}> {props.devices.map((x, idx) => ( @@ -18,4 +18,6 @@ export default function WebAuthnDevicesStack(props: Props) { ))} </Grid> ); -} +}; + +export default WebAuthnDevicesStack; |
