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 pathspull/4427/head^2
parent
b842a22236
commit
24d947624b
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 }}>
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue