feat: implement webauthn device rename with dialog (#4427)

* feat: add loading skeleton to webauthn devices list in settings ui

* 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

* feat: implement webauthn device rename with dialog

* refactor: remove `@root` from import paths

* refactor: remove `@root` from import paths
pull/4435/head^2
Stephen Kent 2022-11-26 16:08:13 -08:00 committed by GitHub
parent 24d947624b
commit 33520daa10
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 133 additions and 7 deletions

View File

@ -156,3 +156,7 @@ export enum WebauthnTouchState {
InProgress = 2, InProgress = 2,
Failure = 3, Failure = 3,
} }
export interface WebauthnDeviceUpdateRequest {
description: string;
}

View File

@ -17,6 +17,7 @@ import {
PublicKeyCredentialJSON, PublicKeyCredentialJSON,
PublicKeyCredentialRequestOptionsJSON, PublicKeyCredentialRequestOptionsJSON,
PublicKeyCredentialRequestOptionsStatus, PublicKeyCredentialRequestOptionsStatus,
WebauthnDeviceUpdateRequest,
} from "@models/Webauthn"; } from "@models/Webauthn";
import { import {
OptionalDataServiceResponse, OptionalDataServiceResponse,
@ -401,3 +402,10 @@ 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;
} }
export async function updateDevice(deviceID: string, description: string): Promise<number> {
let response = await axios.put<ServiceResponse<WebauthnDeviceUpdateRequest>>(`${WebauthnDevicesPath}/${deviceID}`, {
description: description,
});
return response.status;
}

View File

@ -181,7 +181,7 @@ const RegisterWebauthn = function (props: Props) {
onChange={(v) => setName(v.target.value.substring(0, 30))} onChange={(v) => setName(v.target.value.substring(0, 30))}
onFocus={() => setNameError(false)} onFocus={() => setNameError(false)}
autoCapitalize="none" autoCapitalize="none"
autoComplete="username" autoComplete="webauthn-name"
onKeyPress={(ev) => { onKeyPress={(ev) => {
if (ev.key === "Enter") { if (ev.key === "Enter") {
if (!deviceName.length) { if (!deviceName.length) {

View File

@ -0,0 +1,72 @@
import React, { MutableRefObject, useRef, useState } from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
import { useTranslation } from "react-i18next";
import FixedTextField from "@components/FixedTextField";
import { WebauthnDevice } from "@models/Webauthn";
interface Props {
open: boolean;
device: WebauthnDevice | undefined;
handleClose: (ok: boolean, name: string) => void;
}
export default function WebauthnDeviceEditDialog(props: Props) {
const { t: translate } = useTranslation();
const [deviceName, setName] = useState("");
const nameRef = useRef() as MutableRefObject<HTMLInputElement>;
const [nameError, setNameError] = useState(false);
const handleConfirm = () => {
if (!deviceName.length) {
setNameError(true);
} else {
props.handleClose(true, deviceName);
}
setName("");
};
const handleCancel = () => {
props.handleClose(false, "");
setName("");
};
return (
<Dialog open={props.open} onClose={handleCancel}>
<DialogTitle>{`Edit ${props.device ? props.device.description : "(unknown)"}`}</DialogTitle>
<DialogContent>
<DialogContentText>Enter a new name for this device:</DialogContentText>
<FixedTextField
// TODO (PR: #806, Issue: #511) potentially refactor
autoFocus
inputRef={nameRef}
id="name-textfield"
label={translate("Name")}
variant="standard"
required
value={deviceName}
error={nameError}
fullWidth
disabled={false}
onChange={(v) => setName(v.target.value.substring(0, 30))}
onFocus={() => {
setNameError(false);
}}
autoCapitalize="none"
autoComplete="webauthn-name"
onKeyPress={(ev) => {
if (ev.key === "Enter") {
handleConfirm();
ev.preventDefault();
}
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleCancel}>Cancel</Button>
<Button onClick={handleConfirm}>Update</Button>
</DialogActions>
</Dialog>
);
}

View File

@ -25,9 +25,11 @@ import { WebauthnDevice } from "@models/Webauthn";
interface Props { interface Props {
device: WebauthnDevice; device: WebauthnDevice;
deleting: boolean; deleting: boolean;
editing: boolean;
webauthnShowDetails: boolean; webauthnShowDetails: boolean;
handleWebAuthnDetailsChange: () => void; handleWebAuthnDetailsChange: () => void;
handleDelete: () => void; handleDelete: () => void;
handleEdit: () => void;
} }
export default function WebauthnDeviceItem(props: Props) { export default function WebauthnDeviceItem(props: Props) {
@ -51,11 +53,15 @@ export default function WebauthnDeviceItem(props: Props) {
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center"> <Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
{props.editing ? (
<CircularProgress color="inherit" size={24} />
) : (
<Tooltip title={translate("Edit")} placement="bottom"> <Tooltip title={translate("Edit")} placement="bottom">
<IconButton aria-label="edit"> <IconButton aria-label="edit" onClick={props.handleEdit}>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)}
{props.deleting ? ( {props.deleting ? (
<CircularProgress color="inherit" size={24} /> <CircularProgress color="inherit" size={24} />
) : ( ) : (

View File

@ -22,9 +22,10 @@ import { WebauthnDevice } from "@models/Webauthn";
import { initiateWebauthnRegistrationProcess } from "@services/RegisterDevice"; import { initiateWebauthnRegistrationProcess } from "@services/RegisterDevice";
import { AutheliaState, AuthenticationLevel } from "@services/State"; import { AutheliaState, AuthenticationLevel } from "@services/State";
import { getWebauthnDevices } from "@services/UserWebauthnDevices"; import { getWebauthnDevices } from "@services/UserWebauthnDevices";
import { deleteDevice } from "@services/Webauthn"; import { deleteDevice, updateDevice } from "@services/Webauthn";
import WebauthnDeviceDeleteDialog from "./WebauthnDeviceDeleteDialog"; import WebauthnDeviceDeleteDialog from "./WebauthnDeviceDeleteDialog";
import WebauthnDeviceEditDialog from "./WebauthnDeviceEditDialog";
import WebauthnDeviceItem from "./WebauthnDeviceItem"; import WebauthnDeviceItem from "./WebauthnDeviceItem";
interface Props { interface Props {
@ -33,6 +34,7 @@ interface Props {
interface WebauthnDeviceDisplay extends WebauthnDevice { interface WebauthnDeviceDisplay extends WebauthnDevice {
deleting: boolean; deleting: boolean;
editing: boolean;
} }
export default function WebauthnDevices(props: Props) { export default function WebauthnDevices(props: Props) {
@ -42,11 +44,13 @@ export default function WebauthnDevices(props: Props) {
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 [deletingIdx, setDeletingIdx] = useState<number>(-1);
const [editingIdx, setEditingIdx] = 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<WebauthnDeviceDisplay[]>([]); const [webauthnDevices, setWebauthnDevices] = useState<WebauthnDeviceDisplay[]>([]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
useEffect(() => { useEffect(() => {
(async function () { (async function () {
@ -90,6 +94,29 @@ export default function WebauthnDevices(props: Props) {
setWebauthnDevices(updatedDevices); setWebauthnDevices(updatedDevices);
}; };
const handleEditItem = async (idx: number) => {
setEditingIdx(idx);
setEditDialogOpen(true);
};
const handleEditItemConfirm = async (ok: boolean, name: string) => {
setEditDialogOpen(false);
const idx = editingIdx;
if (ok !== true) {
return;
}
webauthnDevices[idx].editing = true;
const status = await updateDevice(webauthnDevices[idx].id, name);
webauthnDevices[idx].editing = false;
if (status !== 200) {
createErrorNotification(translate("There was a problem updating the device"));
return;
}
let updatedDevices = [...webauthnDevices];
updatedDevices[idx].description = name;
setWebauthnDevices(updatedDevices);
};
const initiateRegistration = async (initiateRegistrationFunc: () => Promise<void>, redirectRoute: string) => { const initiateRegistration = async (initiateRegistrationFunc: () => Promise<void>, redirectRoute: string) => {
if (props.state.authentication_level >= AuthenticationLevel.TwoFactor) { if (props.state.authentication_level >= AuthenticationLevel.TwoFactor) {
navigate(redirectRoute); navigate(redirectRoute);
@ -115,6 +142,11 @@ export default function WebauthnDevices(props: Props) {
return ( return (
<> <>
<WebauthnDeviceEditDialog
device={editingIdx > -1 ? webauthnDevices[editingIdx] : undefined}
open={editDialogOpen}
handleClose={handleEditItemConfirm}
/>
<WebauthnDeviceDeleteDialog <WebauthnDeviceDeleteDialog
device={deletingIdx > -1 ? webauthnDevices[deletingIdx] : undefined} device={deletingIdx > -1 ? webauthnDevices[deletingIdx] : undefined}
open={deleteDialogOpen} open={deleteDialogOpen}
@ -150,10 +182,14 @@ export default function WebauthnDevices(props: Props) {
<WebauthnDeviceItem <WebauthnDeviceItem
device={x} device={x}
deleting={x.deleting} deleting={x.deleting}
editing={x.editing}
webauthnShowDetails={webauthnShowDetails === idx} webauthnShowDetails={webauthnShowDetails === idx}
handleWebAuthnDetailsChange={() => { handleWebAuthnDetailsChange={() => {
handleWebAuthnDetailsChange(idx); handleWebAuthnDetailsChange(idx);
}} }}
handleEdit={() => {
handleEditItem(idx);
}}
handleDelete={() => { handleDelete={() => {
handleDeleteItem(idx); handleDeleteItem(idx);
}} }}