feat: implement webauthn device delete confirmation (#4426)

* refactor: move webauthn device index knowledge out of webauthndeviceitem

* feat: implement webauthn device delete confirmation

* fix: don't unset deleting idx for dialog on webauthn device delete

* refactor: remove `@root` from import paths
pull/4427/head^2
Stephen Kent 2022-11-26 15:46:24 -08:00 committed by GitHub
parent b842a22236
commit 24d947624b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 150 additions and 93 deletions

View File

@ -397,7 +397,7 @@ export async function performAssertionCeremony(
return AssertionResult.Failure; return AssertionResult.Failure;
} }
export async function deleteDevice(deviceID: number): Promise<number> { export async function deleteDevice(deviceID: string): Promise<number> {
let response = await axios.delete(`${WebauthnDevicesPath}/${deviceID}`); let response = await axios.delete(`${WebauthnDevicesPath}/${deviceID}`);
return response.status; return response.status;
} }

View File

@ -0,0 +1,41 @@
import React from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
import { WebauthnDevice } from "@models/Webauthn";
interface Props {
open: boolean;
device: WebauthnDevice | undefined;
handleClose: (ok: boolean) => void;
}
export default function WebauthnDeviceDeleteDialog(props: Props) {
const handleCancel = () => {
props.handleClose(false);
};
return (
<Dialog open={props.open} onClose={handleCancel}>
<DialogTitle>{`Remove ${props.device ? props.device.description : "(unknown)"}`}</DialogTitle>
<DialogContent>
<DialogContentText>
{`Are you sure you want to remove the device "${
props.device ? props.device.description : "(unknown)"
}" from your account?`}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCancel}>Cancel</Button>
<Button
onClick={() => {
props.handleClose(true);
}}
autoFocus
>
Remove
</Button>
</DialogActions>
</Dialog>
);
}

View File

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React from "react";
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";
@ -20,49 +20,26 @@ import {
} from "@mui/material"; } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNotifications } from "@hooks/NotificationsContext"; import { WebauthnDevice } from "@models/Webauthn";
import { WebauthnDevice } from "@root/models/Webauthn";
import { deleteDevice } from "@root/services/Webauthn";
interface Props { interface Props {
device: WebauthnDevice; device: WebauthnDevice;
webauthnShowDetails: number; deleting: boolean;
idx: number; webauthnShowDetails: boolean;
handleWebAuthnDetailsChange: (idx: number) => void; handleWebAuthnDetailsChange: () => void;
handleDeleteItem: (idx: number) => void; handleDelete: () => void;
} }
export default function WebauthnDeviceItem(props: Props) { export default function WebauthnDeviceItem(props: Props) {
const { t: translate } = useTranslation("settings"); const { t: translate } = useTranslation("settings");
const { createErrorNotification } = useNotifications();
const [deleting, setDeleting] = useState(false);
const handleDelete = async () => {
setDeleting(true);
const status = await deleteDevice(props.device.id);
setDeleting(false);
if (status !== 200) {
createErrorNotification(translate("There was a problem deleting the device"));
return;
}
props.handleDeleteItem(props.idx);
};
return ( return (
<React.Fragment> <React.Fragment>
<TableRow sx={{ "& > *": { borderBottom: "unset" } }} key={props.device.kid.toString()}> <TableRow sx={{ "& > *": { borderBottom: "unset" } }} key={props.device.kid.toString()}>
<TableCell> <TableCell>
<Tooltip title={translate("Show Details")} placement="right"> <Tooltip title={translate("Show Details")} placement="right">
<IconButton <IconButton aria-label="expand row" size="small" onClick={props.handleWebAuthnDetailsChange}>
aria-label="expand row" {props.webauthnShowDetails ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
size="small"
onClick={() => props.handleWebAuthnDetailsChange(props.idx)}
>
{props.webauthnShowDetails === props.idx ? (
<KeyboardArrowUpIcon />
) : (
<KeyboardArrowDownIcon />
)}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</TableCell> </TableCell>
@ -79,11 +56,11 @@ export default function WebauthnDeviceItem(props: Props) {
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{deleting ? ( {props.deleting ? (
<CircularProgress color="inherit" size={24} /> <CircularProgress color="inherit" size={24} />
) : ( ) : (
<Tooltip title={translate("Delete")} placement="bottom"> <Tooltip title={translate("Delete")} placement="bottom">
<IconButton aria-label="delete" onClick={handleDelete}> <IconButton aria-label="delete" onClick={props.handleDelete}>
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@ -93,7 +70,7 @@ export default function WebauthnDeviceItem(props: Props) {
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={4}> <TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={4}>
<Collapse in={props.webauthnShowDetails === props.idx} timeout="auto" unmountOnExit> <Collapse in={props.webauthnShowDetails} timeout="auto" unmountOnExit>
<Grid container spacing={2} sx={{ mb: 3, margin: 1 }}> <Grid container spacing={2} sx={{ mb: 3, margin: 1 }}>
<Grid item xs={12} sm={12} md={12} lg={12} xl={12}> <Grid item xs={12} sm={12} md={12} lg={12} xl={12}>
<Box sx={{ margin: 1 }}> <Box sx={{ margin: 1 }}>

View File

@ -18,32 +18,43 @@ import { useNavigate } from "react-router-dom";
import { RegisterWebauthnRoute } from "@constants/Routes"; import { RegisterWebauthnRoute } from "@constants/Routes";
import { useNotifications } from "@hooks/NotificationsContext"; import { useNotifications } from "@hooks/NotificationsContext";
import { WebauthnDevice } from "@root/models/Webauthn"; import { WebauthnDevice } from "@models/Webauthn";
import { initiateWebauthnRegistrationProcess } from "@root/services/RegisterDevice"; import { initiateWebauthnRegistrationProcess } from "@services/RegisterDevice";
import { AutheliaState, AuthenticationLevel } from "@root/services/State"; import { AutheliaState, AuthenticationLevel } from "@services/State";
import { getWebauthnDevices } from "@root/services/UserWebauthnDevices"; import { getWebauthnDevices } from "@services/UserWebauthnDevices";
import { deleteDevice } from "@services/Webauthn";
import WebauthnDeviceDeleteDialog from "./WebauthnDeviceDeleteDialog";
import WebauthnDeviceItem from "./WebauthnDeviceItem"; import WebauthnDeviceItem from "./WebauthnDeviceItem";
interface Props { interface Props {
state: AutheliaState; state: AutheliaState;
} }
export default function TwoFactorAuthSettings(props: Props) { interface WebauthnDeviceDisplay extends WebauthnDevice {
deleting: boolean;
}
export default function WebauthnDevices(props: Props) {
const { t: translate } = useTranslation("settings"); const { t: translate } = useTranslation("settings");
const navigate = useNavigate(); const navigate = useNavigate();
const { createInfoNotification, createErrorNotification } = useNotifications(); const { createInfoNotification, createErrorNotification } = useNotifications();
const [webauthnShowDetails, setWebauthnShowDetails] = useState<number>(-1); const [webauthnShowDetails, setWebauthnShowDetails] = useState<number>(-1);
const [deletingIdx, setDeletingIdx] = useState<number>(-1);
const [registrationInProgress, setRegistrationInProgress] = useState(false); const [registrationInProgress, setRegistrationInProgress] = useState(false);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [webauthnDevices, setWebauthnDevices] = useState<WebauthnDevice[] | undefined>(); const [webauthnDevices, setWebauthnDevices] = useState<WebauthnDeviceDisplay[]>([]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
useEffect(() => { useEffect(() => {
(async function () { (async function () {
const devices = await getWebauthnDevices(); const devices = await getWebauthnDevices();
setWebauthnDevices(devices); const devicesDisplay = devices.map((x, idx) => {
return { ...x, deleting: false } as WebauthnDeviceDisplay;
});
setWebauthnDevices(devicesDisplay);
setReady(true); setReady(true);
})(); })();
}, []); }, []);
@ -57,6 +68,23 @@ export default function TwoFactorAuthSettings(props: Props) {
}; };
const handleDeleteItem = async (idx: number) => { const handleDeleteItem = async (idx: number) => {
setDeletingIdx(idx);
setDeleteDialogOpen(true);
};
const handleDeleteItemConfirm = async (ok: boolean) => {
setDeleteDialogOpen(false);
const idx = deletingIdx;
if (ok !== true) {
return;
}
webauthnDevices[idx].deleting = true;
const status = await deleteDevice(webauthnDevices[idx].id);
if (status !== 200) {
webauthnDevices[idx].deleting = false;
createErrorNotification(translate("There was a problem deleting the device"));
return;
}
let updatedDevices = [...webauthnDevices]; let updatedDevices = [...webauthnDevices];
updatedDevices.splice(idx, 1); updatedDevices.splice(idx, 1);
setWebauthnDevices(updatedDevices); setWebauthnDevices(updatedDevices);
@ -86,56 +114,67 @@ export default function TwoFactorAuthSettings(props: Props) {
}; };
return ( return (
<Paper variant="outlined"> <>
<Box sx={{ p: 3 }}> <WebauthnDeviceDeleteDialog
<Stack spacing={2}> device={deletingIdx > -1 ? webauthnDevices[deletingIdx] : undefined}
<Box> open={deleteDialogOpen}
<Typography variant="h5">Webauthn Devices</Typography> handleClose={handleDeleteItemConfirm}
</Box> />
<Box> <Paper variant="outlined">
<Button variant="outlined" color="primary" onClick={handleAddKeyButtonClick}> <Box sx={{ p: 3 }}>
{"Add new device"} <Stack spacing={2}>
</Button> <Box>
</Box> <Typography variant="h5">Webauthn Devices</Typography>
<Box> </Box>
{ready ? ( <Box>
<> <Button variant="outlined" color="primary" onClick={handleAddKeyButtonClick}>
{webauthnDevices ? ( {"Add new device"}
<Table> </Button>
<TableHead> </Box>
<TableRow> <Box>
<TableCell /> {ready ? (
<TableCell>{translate("Name")}</TableCell> <>
<TableCell>{translate("Enabled")}</TableCell> {webauthnDevices ? (
<TableCell align="center">{translate("Actions")}</TableCell> <Table>
</TableRow> <TableHead>
</TableHead> <TableRow>
<TableBody> <TableCell />
{webauthnDevices.map((x, idx) => { <TableCell>{translate("Name")}</TableCell>
return ( <TableCell>{translate("Enabled")}</TableCell>
<WebauthnDeviceItem <TableCell align="center">{translate("Actions")}</TableCell>
device={x} </TableRow>
idx={idx} </TableHead>
webauthnShowDetails={webauthnShowDetails} <TableBody>
handleWebAuthnDetailsChange={handleWebAuthnDetailsChange} {webauthnDevices.map((x, idx) => {
handleDeleteItem={handleDeleteItem} return (
key={`webauthn-device-${idx}`} <WebauthnDeviceItem
/> device={x}
); deleting={x.deleting}
})} webauthnShowDetails={webauthnShowDetails === idx}
</TableBody> handleWebAuthnDetailsChange={() => {
</Table> handleWebAuthnDetailsChange(idx);
) : null} }}
</> handleDelete={() => {
) : ( handleDeleteItem(idx);
<> }}
<Skeleton height={20} /> key={`webauthn-device-${idx}`}
<Skeleton height={40} /> />
</> );
)} })}
</Box> </TableBody>
</Stack> </Table>
</Box> ) : null}
</Paper> </>
) : (
<>
<Skeleton height={20} />
<Skeleton height={40} />
</>
)}
</Box>
</Stack>
</Box>
</Paper>
</>
); );
} }