feat: backport changes

Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
feat-otp-verification
James Elliott 2023-04-15 09:26:54 +10:00
parent 6c89ee1f9c
commit 53668e0c60
No known key found for this signature in database
GPG Key ID: 0F1C4A096E857E49
12 changed files with 361 additions and 280 deletions

View File

@ -1,6 +1,9 @@
ALTER TABLE webauthn_devices ALTER TABLE webauthn_devices
RENAME TO _bkp_DOWN_V0008_webauthn_devices; RENAME TO _bkp_DOWN_V0008_webauthn_devices;
DROP INDEX IF EXISTS webauthn_devices_kid_key;
DROP INDEX IF EXISTS webauthn_devices_lookup_key;
CREATE TABLE IF NOT EXISTS webauthn_devices ( CREATE TABLE IF NOT EXISTS webauthn_devices (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

View File

@ -0,0 +1,6 @@
import { useRemoteCall } from "@hooks/RemoteCall";
import { getUserWebAuthnDevices } from "@services/UserWebAuthnDevices";
export function useUserWebAuthnDevices() {
return useRemoteCall(getUserWebAuthnDevices, []);
}

View File

@ -2,7 +2,12 @@ import { WebAuthnDevice } from "@models/WebAuthn";
import { WebAuthnDevicesPath } from "@services/Api"; import { WebAuthnDevicesPath } from "@services/Api";
import { GetWithOptionalData } from "@services/Client"; import { GetWithOptionalData } from "@services/Client";
// getWebAuthnDevices returns the list of webauthn devices for the authenticated user. export async function getUserWebAuthnDevices(): Promise<WebAuthnDevice[]> {
export async function getWebAuthnDevices(): Promise<WebAuthnDevice[] | null> { const res = await GetWithOptionalData<WebAuthnDevice[] | null>(WebAuthnDevicesPath);
return GetWithOptionalData<WebAuthnDevice[] | null>(WebAuthnDevicesPath);
if (res === null) {
return [];
}
return res;
} }

View File

@ -245,7 +245,7 @@ export async function finishRegistration(response: RegistrationResponseJSON) {
return result; return result;
} }
export async function deleteDevice(deviceID: string) { export async function deleteUserWebAuthnDevice(deviceID: string) {
return await axios<AuthenticationOKResponse>({ return await axios<AuthenticationOKResponse>({
method: "DELETE", method: "DELETE",
url: `${WebAuthnDevicePath}/${deviceID}`, url: `${WebAuthnDevicePath}/${deviceID}`,
@ -253,7 +253,7 @@ export async function deleteDevice(deviceID: string) {
}); });
} }
export async function updateDevice(deviceID: string, description: string) { export async function updateUserWebAuthnDevice(deviceID: string, description: string) {
return await axios<AuthenticationOKResponse>({ return await axios<AuthenticationOKResponse>({
method: "PUT", method: "PUT",
url: `${WebAuthnDevicePath}/${deviceID}`, url: `${WebAuthnDevicePath}/${deviceID}`,

View File

@ -3,39 +3,33 @@ import React from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { WebAuthnDevice } from "@models/WebAuthn";
interface Props { interface Props {
open: boolean; open: boolean;
device: WebAuthnDevice; title: string;
text: string;
handleClose: (ok: boolean) => void; handleClose: (ok: boolean) => void;
} }
export default function WebAuthnDeviceDeleteDialog(props: Props) { export default function DeleteDialog(props: Props) {
const { t: translate } = useTranslation("settings"); const { t: translate } = useTranslation("settings");
const handleCancel = () => { const handleCancel = () => {
props.handleClose(false); props.handleClose(false);
}; };
const handleRemove = () => {
props.handleClose(true);
};
return ( return (
<Dialog open={props.open} onClose={handleCancel}> <Dialog open={props.open} onClose={handleCancel}>
<DialogTitle>{translate("Remove WebAuthn Credential")}</DialogTitle> <DialogTitle>{props.title}</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText> <DialogContentText>{props.text}</DialogContentText>
{translate("Are you sure you want to remove the WebAuthn credential from from your account", {
description: props.device.description,
})}
</DialogContentText>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleCancel}>{translate("Cancel")}</Button> <Button onClick={handleCancel}>{translate("Cancel")}</Button>
<Button <Button onClick={handleRemove} color={"error"}>
onClick={() => {
props.handleClose(true);
}}
autoFocus
>
{translate("Remove")} {translate("Remove")}
</Button> </Button>
</DialogActions> </DialogActions>

View File

@ -1,19 +1,64 @@
import React from "react"; import React, { useEffect, useState } from "react";
import { Grid } from "@mui/material"; import Grid from "@mui/material/Unstable_Grid2";
import { AutheliaState } from "@services/State"; import { useNotifications } from "@hooks/NotificationsContext";
import WebAuthnDevices from "@views/Settings/TwoFactorAuthentication/WebAuthnDevices"; import { useUserInfoPOST } from "@hooks/UserInfo";
import { useUserWebAuthnDevices } from "@hooks/WebAuthnDevices";
import WebAuthnDevicesPanel from "@views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel";
interface Props { interface Props {}
state: AutheliaState;
}
export default function TwoFactorAuthSettings(props: Props) { export default function TwoFactorAuthSettings(props: Props) {
const [refreshState, setRefreshState] = useState(0);
const { createErrorNotification } = useNotifications();
const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoPOST();
const [userWebAuthnDevices, fetchUserWebAuthnDevices, , fetchUserWebAuthnDevicesError] = useUserWebAuthnDevices();
const [hasTOTP, setHasTOTP] = useState(false);
const [hasWebAuthn, setHasWebAuthn] = useState(false);
const handleRefreshState = () => {
setRefreshState((refreshState) => refreshState + 1);
};
useEffect(() => {
fetchUserInfo();
}, [fetchUserInfo, refreshState]);
useEffect(() => {
if (userInfo === undefined) {
return;
}
if (userInfo.has_webauthn !== hasWebAuthn) {
setHasWebAuthn(userInfo.has_webauthn);
}
if (userInfo.has_totp !== hasTOTP) {
setHasTOTP(userInfo.has_totp);
}
}, [hasTOTP, hasWebAuthn, userInfo]);
useEffect(() => {
fetchUserWebAuthnDevices();
}, [fetchUserWebAuthnDevices, hasWebAuthn]);
useEffect(() => {
if (fetchUserInfoError) {
createErrorNotification("There was an issue retrieving user preferences");
}
}, [fetchUserInfoError, createErrorNotification]);
useEffect(() => {
if (fetchUserWebAuthnDevicesError) {
createErrorNotification("There was an issue retrieving One Time Password Configuration");
}
}, [fetchUserWebAuthnDevicesError, createErrorNotification]);
return ( return (
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12}> <Grid xs={12}>
<WebAuthnDevices state={props.state} /> <WebAuthnDevicesPanel devices={userWebAuthnDevices} handleRefreshState={handleRefreshState} />
</Grid> </Grid>
</Grid> </Grid>
); );

View File

@ -1,8 +1,7 @@
import React, { useState } from "react"; import React, { Fragment, useState } from "react";
import { Check, ContentCopy } from "@mui/icons-material"; import { Check, ContentCopy } from "@mui/icons-material";
import { import {
Box,
Button, Button,
CircularProgress, CircularProgress,
Dialog, Dialog,
@ -10,10 +9,11 @@ import {
DialogContent, DialogContent,
DialogContentText, DialogContentText,
DialogTitle, DialogTitle,
Stack, Divider,
Tooltip, Tooltip,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import Grid from "@mui/material/Unstable_Grid2";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { WebAuthnDevice, toTransportName } from "@models/WebAuthn"; import { WebAuthnDevice, toTransportName } from "@models/WebAuthn";
@ -24,7 +24,7 @@ interface Props {
handleClose: () => void; handleClose: () => void;
} }
export default function WebAuthnDetailsDeleteDialog(props: Props) { export default function WebAuthnDeviceDetailsDialog(props: Props) {
const { t: translate } = useTranslation("settings"); const { t: translate } = useTranslation("settings");
return ( return (
@ -36,16 +36,19 @@ export default function WebAuthnDetailsDeleteDialog(props: Props) {
description: props.device.description, description: props.device.description,
})} })}
</DialogContentText> </DialogContentText>
<Stack spacing={0} sx={{ minWidth: 400 }}> <Grid container spacing={2}>
<Box paddingBottom={2}> <Grid md={3} sx={{ display: { xs: "none", md: "block" } }}>
<Stack direction="row" spacing={1} alignItems="center"> <Fragment />
<PropertyCopyButton name={translate("Identifier")} value={props.device.kid.toString()} /> </Grid>
<PropertyCopyButton <Grid xs={4} md={2}>
name={translate("Public Key")} <PropertyCopyButton name={translate("KID")} value={props.device.kid.toString()} />
value={props.device.public_key.toString()} </Grid>
/> <Grid xs={8} md={4}>
</Stack> <PropertyCopyButton name={translate("Public Key")} value={props.device.public_key.toString()} />
</Box> </Grid>
<Grid xs={12}>
<Divider />
</Grid>
<PropertyText name={translate("Description")} value={props.device.description} /> <PropertyText name={translate("Description")} value={props.device.description} />
<PropertyText name={translate("Relying Party ID")} value={props.device.rpid} /> <PropertyText name={translate("Relying Party ID")} value={props.device.rpid} />
<PropertyText <PropertyText
@ -53,7 +56,10 @@ export default function WebAuthnDetailsDeleteDialog(props: Props) {
value={props.device.aaguid === undefined ? "N/A" : props.device.aaguid} value={props.device.aaguid === undefined ? "N/A" : props.device.aaguid}
/> />
<PropertyText name={translate("Attestation Type")} value={props.device.attestation_type} /> <PropertyText name={translate("Attestation Type")} value={props.device.attestation_type} />
<PropertyText name={translate("Attachment")} value={props.device.attachment} /> <PropertyText
name={translate("Attachment")}
value={props.device.attachment === "" ? translate("Unknown") : props.device.attachment}
/>
<PropertyText <PropertyText
name={translate("Discoverable")} name={translate("Discoverable")}
value={props.device.discoverable ? translate("Yes") : translate("No")} value={props.device.discoverable ? translate("Yes") : translate("No")}
@ -85,7 +91,41 @@ export default function WebAuthnDetailsDeleteDialog(props: Props) {
value={props.device.clone_warning ? translate("Yes") : translate("No")} value={props.device.clone_warning ? translate("Yes") : translate("No")}
/> />
<PropertyText name={translate("Usage Count")} value={`${props.device.sign_count}`} /> <PropertyText name={translate("Usage Count")} value={`${props.device.sign_count}`} />
</Stack> <PropertyText
name={translate("Added")}
value={translate("{{when, datetime}}", {
when: new Date(props.device.created_at),
formatParams: {
when: {
hour: "numeric",
minute: "numeric",
year: "numeric",
month: "long",
day: "numeric",
},
},
})}
/>
<PropertyText
name={translate("Last Used")}
value={
props.device.last_used_at
? translate("{{when, datetime}}", {
when: new Date(props.device.last_used_at),
formatParams: {
when: {
hour: "numeric",
minute: "numeric",
year: "numeric",
month: "long",
day: "numeric",
},
},
})
: translate("Never")
}
/>
</Grid>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={props.handleClose}>{translate("Close")}</Button> <Button onClick={props.handleClose}>{translate("Close")}</Button>
@ -97,6 +137,7 @@ export default function WebAuthnDetailsDeleteDialog(props: Props) {
interface PropertyTextProps { interface PropertyTextProps {
name: string; name: string;
value: string; value: string;
xs?: number;
} }
function PropertyCopyButton(props: PropertyTextProps) { function PropertyCopyButton(props: PropertyTextProps) {
@ -144,11 +185,11 @@ function PropertyCopyButton(props: PropertyTextProps) {
function PropertyText(props: PropertyTextProps) { function PropertyText(props: PropertyTextProps) {
return ( return (
<Box> <Grid xs={props.xs !== undefined ? props.xs : 12}>
<Typography display="inline" sx={{ fontWeight: "bold" }}> <Typography display="inline" sx={{ fontWeight: "bold" }}>
{`${props.name}: `} {`${props.name}: `}
</Typography> </Typography>
<Typography display="inline">{props.value}</Typography> <Typography display="inline">{props.value}</Typography>
</Box> </Grid>
); );
} }

View File

@ -1,16 +1,18 @@
import React, { Fragment, useState } from "react"; import React, { useState } from "react";
import { Fingerprint } from "@mui/icons-material"; import { Fingerprint } from "@mui/icons-material";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import { Box, Button, CircularProgress, Paper, Stack, Tooltip, Typography } from "@mui/material"; import { CircularProgress, Paper, Stack, Tooltip, Typography } from "@mui/material";
import IconButton from "@mui/material/IconButton";
import Grid from "@mui/material/Unstable_Grid2";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNotifications } from "@hooks/NotificationsContext"; import { useNotifications } from "@hooks/NotificationsContext";
import { WebAuthnDevice } from "@models/WebAuthn"; import { WebAuthnDevice } from "@models/WebAuthn";
import { deleteDevice, updateDevice } from "@services/WebAuthn"; import { deleteUserWebAuthnDevice, updateUserWebAuthnDevice } from "@services/WebAuthn";
import WebAuthnDeviceDeleteDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceDeleteDialog"; import DeleteDialog from "@views/Settings/TwoFactorAuthentication/DeleteDialog";
import WebAuthnDeviceDetailsDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceDetailsDialog"; import WebAuthnDeviceDetailsDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceDetailsDialog";
import WebAuthnDeviceEditDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceEditDialog"; import WebAuthnDeviceEditDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceEditDialog";
@ -41,7 +43,7 @@ export default function WebAuthnDeviceItem(props: Props) {
setLoadingEdit(true); setLoadingEdit(true);
const response = await updateDevice(props.device.id, name); const response = await updateUserWebAuthnDevice(props.device.id, name);
setLoadingEdit(false); setLoadingEdit(false);
@ -73,7 +75,7 @@ export default function WebAuthnDeviceItem(props: Props) {
setLoadingDelete(true); setLoadingDelete(true);
const response = await deleteDevice(props.device.id); const response = await deleteUserWebAuthnDevice(props.device.id);
setLoadingDelete(false); setLoadingDelete(false);
@ -97,101 +99,102 @@ export default function WebAuthnDeviceItem(props: Props) {
}; };
return ( return (
<Fragment> <Grid xs={12} md={6} xl={3}>
<WebAuthnDeviceDetailsDialog
device={props.device}
open={showDialogDetails}
handleClose={() => {
setShowDialogDetails(false);
}}
/>
<WebAuthnDeviceEditDialog device={props.device} open={showDialogEdit} handleClose={handleEdit} />
<DeleteDialog
open={showDialogDelete}
handleClose={handleDelete}
title={translate("Remove WebAuthn Credential")}
text={translate("Are you sure you want to remove the WebAuthn credential from from your account", {
description: props.device.description,
})}
/>
<Paper variant="outlined"> <Paper variant="outlined">
<Box sx={{ p: 3 }}> <Grid container spacing={1} alignItems="center" padding={3}>
<WebAuthnDeviceDetailsDialog <Grid xs={12} sm={6} md={6}>
device={props.device} <Grid container>
open={showDialogDetails} <Grid xs={12}>
handleClose={() => { <Stack direction={"row"} spacing={1} alignItems={"center"}>
setShowDialogDetails(false); <Fingerprint fontSize="large" color={"warning"} />
}} <Typography display="inline" sx={{ fontWeight: "bold" }}>
/> {props.device.description}
<WebAuthnDeviceEditDialog device={props.device} open={showDialogEdit} handleClose={handleEdit} /> </Typography>
<WebAuthnDeviceDeleteDialog <Typography
device={props.device} display="inline"
open={showDialogDelete} variant="body2"
handleClose={handleDelete} >{` (${props.device.attestation_type.toUpperCase()})`}</Typography>
/> </Stack>
<Stack direction="row" spacing={1} alignItems="center"> </Grid>
<Fingerprint fontSize="large" color={"warning"} /> <Grid xs={12}>
<Stack spacing={0} sx={{ minWidth: 400 }}> <Typography variant={"caption"} sx={{ display: { xs: "none", md: "block" } }}>
<Box> {translate("Added when", {
<Typography display="inline" sx={{ fontWeight: "bold" }}> when: new Date(props.device.created_at),
{props.device.description} formatParams: {
</Typography> when: {
<Typography hour: "numeric",
display="inline" minute: "numeric",
variant="body2" year: "numeric",
>{` (${props.device.attestation_type.toUpperCase()})`}</Typography> month: "long",
</Box> day: "numeric",
<Typography variant={"caption"}> },
{translate("Added", {
when: new Date(props.device.created_at),
formatParams: {
when: {
hour: "numeric",
minute: "numeric",
year: "numeric",
month: "long",
day: "numeric",
}, },
}, })}
})} </Typography>
</Typography> </Grid>
<Typography variant={"caption"}> <Grid xs={12}>
{props.device.last_used_at === undefined <Typography variant={"caption"} sx={{ display: { xs: "none", md: "block" } }}>
? translate("Never used") {props.device.last_used_at === undefined
: translate("Last Used", { ? translate("Never used")
when: new Date(props.device.last_used_at), : translate("Last Used when", {
formatParams: { when: new Date(props.device.last_used_at),
when: { formatParams: {
hour: "numeric", when: {
minute: "numeric", hour: "numeric",
year: "numeric", minute: "numeric",
month: "long", year: "numeric",
day: "numeric", month: "long",
day: "numeric",
},
}, },
}, })}
})} </Typography>
</Typography> </Grid>
</Grid>
</Grid>
<Grid xs={12} md={7} xl={5}>
<Stack direction={"row"} spacing={1}>
<Tooltip title={translate("Display extended information for this WebAuthn credential")}>
<IconButton color="primary" onClick={() => setShowDialogDetails(true)}>
<InfoOutlinedIcon />
</IconButton>
</Tooltip>
<Tooltip title={translate("Edit information for this WebAuthn credential")}>
<IconButton
color="primary"
onClick={loadingEdit ? undefined : () => setShowDialogEdit(true)}
>
{loadingEdit ? <CircularProgress color="inherit" size={20} /> : <EditIcon />}
</IconButton>
</Tooltip>
<Tooltip title={translate("Remove this WebAuthn credential")}>
<IconButton
color="primary"
onClick={loadingDelete ? undefined : () => setShowDialogDelete(true)}
>
{loadingDelete ? <CircularProgress color="inherit" size={20} /> : <DeleteIcon />}
</IconButton>
</Tooltip>
</Stack> </Stack>
</Grid>
<Tooltip title={translate("Display extended information for this WebAuthn credential")}> </Grid>
<Button
variant="outlined"
color="primary"
startIcon={<InfoOutlinedIcon />}
onClick={() => setShowDialogDetails(true)}
>
{translate("Info")}
</Button>
</Tooltip>
<Tooltip title={translate("Edit information for this WebAuthn credential")}>
<Button
variant="outlined"
color="primary"
startIcon={loadingEdit ? <CircularProgress color="inherit" size={20} /> : <EditIcon />}
onClick={loadingEdit ? undefined : () => setShowDialogEdit(true)}
>
{translate("Edit")}
</Button>
</Tooltip>
<Tooltip title={translate("Remove this WebAuthn credential")}>
<Button
variant="outlined"
color="primary"
startIcon={
loadingDelete ? <CircularProgress color="inherit" size={20} /> : <DeleteIcon />
}
onClick={loadingDelete ? undefined : () => setShowDialogDelete(true)}
>
{translate("Remove")}
</Button>
</Tooltip>
</Stack>
</Box>
</Paper> </Paper>
</Fragment> </Grid>
); );
} }

View File

@ -8,7 +8,6 @@ import {
DialogContent, DialogContent,
DialogContentText, DialogContentText,
DialogTitle, DialogTitle,
Grid,
Step, Step,
StepLabel, StepLabel,
Stepper, Stepper,
@ -16,6 +15,7 @@ import {
Theme, Theme,
Typography, Typography,
} from "@mui/material"; } from "@mui/material";
import Grid from "@mui/material/Unstable_Grid2";
import makeStyles from "@mui/styles/makeStyles"; import makeStyles from "@mui/styles/makeStyles";
import { PublicKeyCredentialCreationOptionsJSON } from "@simplewebauthn/typescript-types"; import { PublicKeyCredentialCreationOptionsJSON } from "@simplewebauthn/typescript-types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -30,7 +30,6 @@ const steps = ["Description", "Verification"];
interface Props { interface Props {
open: boolean; open: boolean;
onClose: () => void;
setCancelled: () => void; setCancelled: () => void;
} }
@ -65,7 +64,7 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
}, [props]); }, [props]);
const performCredentialCreation = useCallback(async () => { const performCredentialCreation = useCallback(async () => {
if (options === null) { if (!props.open || options === null) {
return; return;
} }
@ -106,10 +105,10 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
"Failed to register your device. The identity verification process might have timed out.", "Failed to register your device. The identity verification process might have timed out.",
); );
} }
}, [options, createErrorNotification, handleClose]); }, [props.open, options, createErrorNotification, handleClose]);
useEffect(() => { useEffect(() => {
if (state !== WebAuthnTouchState.Failure || activeStep !== 0 || !props.open) { if (!props.open || state !== WebAuthnTouchState.Failure || activeStep !== 0) {
return; return;
} }
@ -126,40 +125,48 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
})(); })();
}, [props.open, activeStep, options, performCredentialCreation]); }, [props.open, activeStep, options, performCredentialCreation]);
const handleNext = useCallback(async () => { const handleNext = useCallback(() => {
if (credentialDescription.length === 0 || credentialDescription.length > 64) { if (!props.open) {
setErrorDescription(true);
createErrorNotification(
translate("The Description must be more than 1 character and less than 64 characters."),
);
return; return;
} }
const res = await getAttestationCreationOptions(credentialDescription); (async function () {
if (credentialDescription.length === 0 || credentialDescription.length > 64) {
switch (res.status) {
case 200:
if (res.options) {
setOptions(res.options);
} else {
throw new Error(
"Credential Creation Options Request succeeded but Credential Creation Options is empty.",
);
}
break;
case 409:
setErrorDescription(true); setErrorDescription(true);
createErrorNotification(translate("A WebAuthn Credential with that Description already exists."));
break;
default:
createErrorNotification( createErrorNotification(
translate("Error occurred obtaining the WebAuthn Credential creation options."), translate("The Description must be more than 1 character and less than 64 characters."),
); );
}
}, [createErrorNotification, credentialDescription, translate]); return;
}
const res = await getAttestationCreationOptions(credentialDescription);
switch (res.status) {
case 200:
if (res.options) {
setOptions(res.options);
} else {
throw new Error(
"Credential Creation Options Request succeeded but Credential Creation Options is empty.",
);
}
break;
case 409:
setErrorDescription(true);
createErrorNotification(translate("A WebAuthn Credential with that Description already exists."));
break;
default:
createErrorNotification(
translate("Error occurred obtaining the WebAuthn Credential creation options."),
);
}
await performCredentialCreation();
})();
}, [createErrorNotification, credentialDescription, performCredentialCreation, props.open, translate]);
const handleCredentialDescription = useCallback( const handleCredentialDescription = useCallback(
(description: string) => { (description: string) => {
@ -184,7 +191,7 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
{translate("Enter a description for this credential")} {translate("Enter a description for this credential")}
</Typography> </Typography>
<Grid container spacing={1}> <Grid container spacing={1}>
<Grid item xs={12}> <Grid xs={12}>
<TextField <TextField
inputRef={nameRef} inputRef={nameRef}
id="name-textfield" id="name-textfield"
@ -225,7 +232,7 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
} }
const handleOnClose = () => { const handleOnClose = () => {
if (activeStep === 0 || !props.open) { if (!props.open || activeStep === 1) {
return; return;
} }
@ -242,7 +249,7 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
)} )}
</DialogContentText> </DialogContentText>
<Grid container spacing={0} alignItems={"center"} justifyContent={"center"} textAlign={"center"}> <Grid container spacing={0} alignItems={"center"} justifyContent={"center"} textAlign={"center"}>
<Grid item xs={12}> <Grid xs={12}>
<Stepper activeStep={activeStep}> <Stepper activeStep={activeStep}>
{steps.map((label, index) => { {steps.map((label, index) => {
const stepProps: { completed?: boolean } = {}; const stepProps: { completed?: boolean } = {};
@ -257,9 +264,7 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
})} })}
</Stepper> </Stepper>
</Grid> </Grid>
<Grid item xs={12}> <Grid xs={12}>{renderStep(activeStep)}</Grid>
{renderStep(activeStep)}
</Grid>
</Grid> </Grid>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>

View File

@ -1,67 +0,0 @@
import React, { Fragment, Suspense, useState } from "react";
import { Box, Button, Paper, Stack, Tooltip, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import { AutheliaState } from "@services/State";
import LoadingPage from "@views/LoadingPage/LoadingPage";
import WebAuthnDeviceRegisterDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceRegisterDialog";
import WebAuthnDevicesStack from "@views/Settings/TwoFactorAuthentication/WebAuthnDevicesStack";
interface Props {
state: AutheliaState;
}
export default function WebAuthnDevices(props: Props) {
const { t: translate } = useTranslation("settings");
const [showWebAuthnDeviceRegisterDialog, setShowWebAuthnDeviceRegisterDialog] = useState<boolean>(false);
const [refreshState, setRefreshState] = useState<number>(0);
const handleIncrementRefreshState = () => {
setRefreshState((refreshState) => refreshState + 1);
};
return (
<Fragment>
<WebAuthnDeviceRegisterDialog
open={showWebAuthnDeviceRegisterDialog}
onClose={() => {
handleIncrementRefreshState();
}}
setCancelled={() => {
setShowWebAuthnDeviceRegisterDialog(false);
handleIncrementRefreshState();
}}
/>
<Paper variant="outlined">
<Box sx={{ p: 3 }}>
<Stack spacing={2}>
<Box>
<Typography variant="h5">{translate("WebAuthn Credentials")}</Typography>
</Box>
<Box>
<Tooltip title={translate("Click to add a WebAuthn credential to your account")}>
<Button
variant="outlined"
color="primary"
onClick={() => {
setShowWebAuthnDeviceRegisterDialog(true);
}}
>
{translate("Add Credential")}
</Button>
</Tooltip>
</Box>
<Suspense fallback={<LoadingPage />}>
<WebAuthnDevicesStack
refreshState={refreshState}
incrementRefreshState={handleIncrementRefreshState}
/>
</Suspense>
</Stack>
</Box>
</Paper>
</Fragment>
);
}

View File

@ -0,0 +1,66 @@
import React, { Fragment, useState } from "react";
import { Button, Paper, Tooltip, Typography } from "@mui/material";
import Grid from "@mui/material/Unstable_Grid2";
import { useTranslation } from "react-i18next";
import { WebAuthnDevice } from "@models/WebAuthn";
import WebAuthnDeviceRegisterDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceRegisterDialog";
import WebAuthnDevicesStack from "@views/Settings/TwoFactorAuthentication/WebAuthnDevicesStack";
interface Props {
devices: WebAuthnDevice[] | undefined;
handleRefreshState: () => void;
}
export default function WebAuthnDevicesPanel(props: Props) {
const { t: translate } = useTranslation("settings");
const [showRegisterDialog, setShowRegisterDialog] = useState<boolean>(false);
return (
<Fragment>
<WebAuthnDeviceRegisterDialog
open={showRegisterDialog}
setCancelled={() => {
setShowRegisterDialog(false);
props.handleRefreshState();
}}
/>
<Paper variant={"outlined"}>
<Grid container spacing={2} padding={2}>
<Grid xs={12}>
<Typography variant="h5">{translate("WebAuthn Credentials")}</Typography>
</Grid>
<Grid xs={4} md={2}>
<Tooltip title={translate("Click to add a WebAuthn credential to your account")}>
<Button
variant="outlined"
color="primary"
onClick={() => {
setShowRegisterDialog(true);
}}
>
{translate("Add")}
</Button>
</Tooltip>
</Grid>
<Grid xs={12}>
{props.devices === undefined || props.devices.length === 0 ? (
<Typography variant={"subtitle2"}>
{translate(
"No WebAuthn Credentials have been registered. If you'd like to register one click add.",
)}
</Typography>
) : (
<WebAuthnDevicesStack
devices={props.devices}
handleRefreshState={props.handleRefreshState}
/>
)}
</Grid>
</Grid>
</Paper>
</Fragment>
);
}

View File

@ -1,41 +1,21 @@
import React, { Fragment, useEffect, useState } from "react"; import React from "react";
import { Stack, Typography } from "@mui/material"; import Grid from "@mui/material/Unstable_Grid2";
import { useTranslation } from "react-i18next";
import { WebAuthnDevice } from "@models/WebAuthn"; import { WebAuthnDevice } from "@models/WebAuthn";
import { getWebAuthnDevices } from "@services/UserWebAuthnDevices";
import WebAuthnDeviceItem from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceItem"; import WebAuthnDeviceItem from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceItem";
interface Props { interface Props {
refreshState: number; devices: WebAuthnDevice[];
incrementRefreshState: () => void; handleRefreshState: () => void;
} }
export default function WebAuthnDevicesStack(props: Props) { export default function WebAuthnDevicesStack(props: Props) {
const { t: translate } = useTranslation("settings");
const [devices, setDevices] = useState<WebAuthnDevice[] | null>(null);
useEffect(() => {
(async function () {
setDevices(null);
const devices = await getWebAuthnDevices();
setDevices(devices);
})();
}, [props.refreshState]);
return ( return (
<Fragment> <Grid container spacing={3}>
{devices !== null && devices.length !== 0 ? ( {props.devices.map((x, idx) => (
<Stack spacing={3}> <WebAuthnDeviceItem key={idx} index={idx} device={x} handleEdit={props.handleRefreshState} />
{devices.map((x, idx) => ( ))}
<WebAuthnDeviceItem key={idx} index={idx} device={x} handleEdit={props.incrementRefreshState} /> </Grid>
))}
</Stack>
) : (
<Typography>{translate("No Registered WebAuthn Credentials")}</Typography>
)}
</Fragment>
); );
} }