summaryrefslogtreecommitdiff
path: root/web/src
diff options
context:
space:
mode:
authorJames Elliott <james-d-elliott@users.noreply.github.com>2023-10-27 20:24:13 +1100
committerJames Elliott <james-d-elliott@users.noreply.github.com>2024-03-04 20:28:24 +1100
commit87d2a3419d6f29db900bc1aeb4cf5d7769a15ce3 (patch)
treea4efff47cb7be04f9fb1ce344cb9e40f4096dee6 /web/src
parentc0dbdd97ab2ac580e3da07a0137dbc7a1b9c9b83 (diff)
feat(web): user one-time password preferences
This allows administrators to configure a list of Time-based One-Time Password parameters that users can pick from the web UI during registrations. Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
Diffstat (limited to 'web/src')
-rw-r--r--web/src/App.tsx4
-rw-r--r--web/src/components/Brand.tsx4
-rw-r--r--web/src/components/ComponentOrLoading.tsx22
-rw-r--r--web/src/components/CopyButton.tsx80
-rw-r--r--web/src/components/FixedTextField.tsx4
-rw-r--r--web/src/components/TypographyWithTooltip.tsx6
-rw-r--r--web/src/components/WebAuthnRegisterIcon.tsx6
-rw-r--r--web/src/components/WebAuthnTryIcon.tsx6
-rw-r--r--web/src/constants/Routes.ts1
-rw-r--r--web/src/hooks/NotificationsContext.ts4
-rw-r--r--web/src/hooks/UserInfoTOTPConfiguration.ts9
-rw-r--r--web/src/layouts/LoginLayout.tsx4
-rw-r--r--web/src/layouts/SettingsLayout.tsx156
-rw-r--r--web/src/models/TOTPConfiguration.ts48
-rw-r--r--web/src/models/UserInfoTOTPConfiguration.ts4
-rw-r--r--web/src/services/Api.ts7
-rw-r--r--web/src/services/Client.ts28
-rw-r--r--web/src/services/OneTimePassword.ts16
-rw-r--r--web/src/services/RegisterDevice.ts16
-rw-r--r--web/src/services/UserInfoTOTPConfiguration.ts85
-rw-r--r--web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx197
-rw-r--r--web/src/views/LoadingPage/BaseLoadingPage.tsx4
-rw-r--r--web/src/views/LoginPortal/Authenticated.tsx4
-rw-r--r--web/src/views/LoginPortal/AuthenticatedView/AuthenticatedView.tsx4
-rw-r--r--web/src/views/LoginPortal/ConsentView/ConsentView.tsx4
-rw-r--r--web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx4
-rw-r--r--web/src/views/LoginPortal/LoginPortal.tsx4
-rw-r--r--web/src/views/LoginPortal/SecondFactor/DeviceSelectionContainer.tsx12
-rw-r--r--web/src/views/LoginPortal/SecondFactor/OTPDial.tsx4
-rw-r--r--web/src/views/LoginPortal/SecondFactor/SecondFactorForm.tsx33
-rw-r--r--web/src/views/Settings/SettingsRouter.tsx1
-rw-r--r--web/src/views/Settings/TwoFactorAuthentication/DeleteDialog.tsx6
-rw-r--r--web/src/views/Settings/TwoFactorAuthentication/TOTPDevice.tsx138
-rw-r--r--web/src/views/Settings/TwoFactorAuthentication/TOTPPanel.tsx73
-rw-r--r--web/src/views/Settings/TwoFactorAuthentication/TOTPRegisterDialogController.tsx579
-rw-r--r--web/src/views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView.tsx22
-rw-r--r--web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceDetailsDialog.tsx85
-rw-r--r--web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceEditDialog.tsx13
-rw-r--r--web/src/views/Settings/TwoFactorAuthentication/WebAuthnDeviceItem.tsx7
-rw-r--r--web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel.tsx7
-rw-r--r--web/src/views/Settings/TwoFactorAuthentication/WebAuthnDevicesStack.tsx6
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;