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
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 (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
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 { GetWithOptionalData } from "@services/Client";
// getWebAuthnDevices returns the list of webauthn devices for the authenticated user.
export async function getWebAuthnDevices(): Promise<WebAuthnDevice[] | null> {
return GetWithOptionalData<WebAuthnDevice[] | null>(WebAuthnDevicesPath);
export async function getUserWebAuthnDevices(): Promise<WebAuthnDevice[]> {
const res = await GetWithOptionalData<WebAuthnDevice[] | null>(WebAuthnDevicesPath);
if (res === null) {
return [];
}
return res;
}

View File

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

View File

@ -3,39 +3,33 @@ import React from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material";
import { useTranslation } from "react-i18next";
import { WebAuthnDevice } from "@models/WebAuthn";
interface Props {
open: boolean;
device: WebAuthnDevice;
title: string;
text: string;
handleClose: (ok: boolean) => void;
}
export default function WebAuthnDeviceDeleteDialog(props: Props) {
export default function DeleteDialog(props: Props) {
const { t: translate } = useTranslation("settings");
const handleCancel = () => {
props.handleClose(false);
};
const handleRemove = () => {
props.handleClose(true);
};
return (
<Dialog open={props.open} onClose={handleCancel}>
<DialogTitle>{translate("Remove WebAuthn Credential")}</DialogTitle>
<DialogTitle>{props.title}</DialogTitle>
<DialogContent>
<DialogContentText>
{translate("Are you sure you want to remove the WebAuthn credential from from your account", {
description: props.device.description,
})}
</DialogContentText>
<DialogContentText>{props.text}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCancel}>{translate("Cancel")}</Button>
<Button
onClick={() => {
props.handleClose(true);
}}
autoFocus
>
<Button onClick={handleRemove} color={"error"}>
{translate("Remove")}
</Button>
</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 WebAuthnDevices from "@views/Settings/TwoFactorAuthentication/WebAuthnDevices";
import { useNotifications } from "@hooks/NotificationsContext";
import { useUserInfoPOST } from "@hooks/UserInfo";
import { useUserWebAuthnDevices } from "@hooks/WebAuthnDevices";
import WebAuthnDevicesPanel from "@views/Settings/TwoFactorAuthentication/WebAuthnDevicesPanel";
interface Props {
state: AutheliaState;
}
interface 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 (
<Grid container spacing={2}>
<Grid item xs={12}>
<WebAuthnDevices state={props.state} />
<Grid xs={12}>
<WebAuthnDevicesPanel devices={userWebAuthnDevices} handleRefreshState={handleRefreshState} />
</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 {
Box,
Button,
CircularProgress,
Dialog,
@ -10,10 +9,11 @@ import {
DialogContent,
DialogContentText,
DialogTitle,
Stack,
Divider,
Tooltip,
Typography,
} from "@mui/material";
import Grid from "@mui/material/Unstable_Grid2";
import { useTranslation } from "react-i18next";
import { WebAuthnDevice, toTransportName } from "@models/WebAuthn";
@ -24,7 +24,7 @@ interface Props {
handleClose: () => void;
}
export default function WebAuthnDetailsDeleteDialog(props: Props) {
export default function WebAuthnDeviceDetailsDialog(props: Props) {
const { t: translate } = useTranslation("settings");
return (
@ -36,16 +36,19 @@ export default function WebAuthnDetailsDeleteDialog(props: Props) {
description: props.device.description,
})}
</DialogContentText>
<Stack spacing={0} sx={{ minWidth: 400 }}>
<Box paddingBottom={2}>
<Stack direction="row" spacing={1} alignItems="center">
<PropertyCopyButton name={translate("Identifier")} value={props.device.kid.toString()} />
<PropertyCopyButton
name={translate("Public Key")}
value={props.device.public_key.toString()}
/>
</Stack>
</Box>
<Grid container spacing={2}>
<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>
<PropertyText name={translate("Description")} value={props.device.description} />
<PropertyText name={translate("Relying Party ID")} value={props.device.rpid} />
<PropertyText
@ -53,7 +56,10 @@ export default function WebAuthnDetailsDeleteDialog(props: Props) {
value={props.device.aaguid === undefined ? "N/A" : props.device.aaguid}
/>
<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
name={translate("Discoverable")}
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")}
/>
<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>
<DialogActions>
<Button onClick={props.handleClose}>{translate("Close")}</Button>
@ -97,6 +137,7 @@ export default function WebAuthnDetailsDeleteDialog(props: Props) {
interface PropertyTextProps {
name: string;
value: string;
xs?: number;
}
function PropertyCopyButton(props: PropertyTextProps) {
@ -144,11 +185,11 @@ function PropertyCopyButton(props: PropertyTextProps) {
function PropertyText(props: PropertyTextProps) {
return (
<Box>
<Grid xs={props.xs !== undefined ? props.xs : 12}>
<Typography display="inline" sx={{ fontWeight: "bold" }}>
{`${props.name}: `}
</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 DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
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 { useNotifications } from "@hooks/NotificationsContext";
import { WebAuthnDevice } from "@models/WebAuthn";
import { deleteDevice, updateDevice } from "@services/WebAuthn";
import WebAuthnDeviceDeleteDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceDeleteDialog";
import { deleteUserWebAuthnDevice, updateUserWebAuthnDevice } from "@services/WebAuthn";
import DeleteDialog from "@views/Settings/TwoFactorAuthentication/DeleteDialog";
import WebAuthnDeviceDetailsDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceDetailsDialog";
import WebAuthnDeviceEditDialog from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceEditDialog";
@ -41,7 +43,7 @@ export default function WebAuthnDeviceItem(props: Props) {
setLoadingEdit(true);
const response = await updateDevice(props.device.id, name);
const response = await updateUserWebAuthnDevice(props.device.id, name);
setLoadingEdit(false);
@ -73,7 +75,7 @@ export default function WebAuthnDeviceItem(props: Props) {
setLoadingDelete(true);
const response = await deleteDevice(props.device.id);
const response = await deleteUserWebAuthnDevice(props.device.id);
setLoadingDelete(false);
@ -97,101 +99,102 @@ export default function WebAuthnDeviceItem(props: Props) {
};
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">
<Box sx={{ p: 3 }}>
<WebAuthnDeviceDetailsDialog
device={props.device}
open={showDialogDetails}
handleClose={() => {
setShowDialogDetails(false);
}}
/>
<WebAuthnDeviceEditDialog device={props.device} open={showDialogEdit} handleClose={handleEdit} />
<WebAuthnDeviceDeleteDialog
device={props.device}
open={showDialogDelete}
handleClose={handleDelete}
/>
<Stack direction="row" spacing={1} alignItems="center">
<Fingerprint fontSize="large" color={"warning"} />
<Stack spacing={0} sx={{ minWidth: 400 }}>
<Box>
<Typography display="inline" sx={{ fontWeight: "bold" }}>
{props.device.description}
</Typography>
<Typography
display="inline"
variant="body2"
>{` (${props.device.attestation_type.toUpperCase()})`}</Typography>
</Box>
<Typography variant={"caption"}>
{translate("Added", {
when: new Date(props.device.created_at),
formatParams: {
when: {
hour: "numeric",
minute: "numeric",
year: "numeric",
month: "long",
day: "numeric",
<Grid container spacing={1} alignItems="center" padding={3}>
<Grid xs={12} sm={6} md={6}>
<Grid container>
<Grid xs={12}>
<Stack direction={"row"} spacing={1} alignItems={"center"}>
<Fingerprint fontSize="large" color={"warning"} />
<Typography display="inline" sx={{ fontWeight: "bold" }}>
{props.device.description}
</Typography>
<Typography
display="inline"
variant="body2"
>{` (${props.device.attestation_type.toUpperCase()})`}</Typography>
</Stack>
</Grid>
<Grid xs={12}>
<Typography variant={"caption"} sx={{ display: { xs: "none", md: "block" } }}>
{translate("Added when", {
when: new Date(props.device.created_at),
formatParams: {
when: {
hour: "numeric",
minute: "numeric",
year: "numeric",
month: "long",
day: "numeric",
},
},
},
})}
</Typography>
<Typography variant={"caption"}>
{props.device.last_used_at === undefined
? translate("Never used")
: translate("Last Used", {
when: new Date(props.device.last_used_at),
formatParams: {
when: {
hour: "numeric",
minute: "numeric",
year: "numeric",
month: "long",
day: "numeric",
})}
</Typography>
</Grid>
<Grid xs={12}>
<Typography variant={"caption"} sx={{ display: { xs: "none", md: "block" } }}>
{props.device.last_used_at === undefined
? translate("Never used")
: translate("Last Used when", {
when: new Date(props.device.last_used_at),
formatParams: {
when: {
hour: "numeric",
minute: "numeric",
year: "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>
<Tooltip title={translate("Display extended information for this WebAuthn credential")}>
<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>
</Grid>
</Grid>
</Paper>
</Fragment>
</Grid>
);
}

View File

@ -8,7 +8,6 @@ import {
DialogContent,
DialogContentText,
DialogTitle,
Grid,
Step,
StepLabel,
Stepper,
@ -16,6 +15,7 @@ import {
Theme,
Typography,
} from "@mui/material";
import Grid from "@mui/material/Unstable_Grid2";
import makeStyles from "@mui/styles/makeStyles";
import { PublicKeyCredentialCreationOptionsJSON } from "@simplewebauthn/typescript-types";
import { useTranslation } from "react-i18next";
@ -30,7 +30,6 @@ const steps = ["Description", "Verification"];
interface Props {
open: boolean;
onClose: () => void;
setCancelled: () => void;
}
@ -65,7 +64,7 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
}, [props]);
const performCredentialCreation = useCallback(async () => {
if (options === null) {
if (!props.open || options === null) {
return;
}
@ -106,10 +105,10 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
"Failed to register your device. The identity verification process might have timed out.",
);
}
}, [options, createErrorNotification, handleClose]);
}, [props.open, options, createErrorNotification, handleClose]);
useEffect(() => {
if (state !== WebAuthnTouchState.Failure || activeStep !== 0 || !props.open) {
if (!props.open || state !== WebAuthnTouchState.Failure || activeStep !== 0) {
return;
}
@ -126,40 +125,48 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
})();
}, [props.open, activeStep, options, performCredentialCreation]);
const handleNext = useCallback(async () => {
if (credentialDescription.length === 0 || credentialDescription.length > 64) {
setErrorDescription(true);
createErrorNotification(
translate("The Description must be more than 1 character and less than 64 characters."),
);
const handleNext = useCallback(() => {
if (!props.open) {
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:
(async function () {
if (credentialDescription.length === 0 || credentialDescription.length > 64) {
setErrorDescription(true);
createErrorNotification(translate("A WebAuthn Credential with that Description already exists."));
break;
default:
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(
(description: string) => {
@ -184,7 +191,7 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
{translate("Enter a description for this credential")}
</Typography>
<Grid container spacing={1}>
<Grid item xs={12}>
<Grid xs={12}>
<TextField
inputRef={nameRef}
id="name-textfield"
@ -225,7 +232,7 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
}
const handleOnClose = () => {
if (activeStep === 0 || !props.open) {
if (!props.open || activeStep === 1) {
return;
}
@ -242,7 +249,7 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
)}
</DialogContentText>
<Grid container spacing={0} alignItems={"center"} justifyContent={"center"} textAlign={"center"}>
<Grid item xs={12}>
<Grid xs={12}>
<Stepper activeStep={activeStep}>
{steps.map((label, index) => {
const stepProps: { completed?: boolean } = {};
@ -257,9 +264,7 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
})}
</Stepper>
</Grid>
<Grid item xs={12}>
{renderStep(activeStep)}
</Grid>
<Grid xs={12}>{renderStep(activeStep)}</Grid>
</Grid>
</DialogContent>
<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 { useTranslation } from "react-i18next";
import Grid from "@mui/material/Unstable_Grid2";
import { WebAuthnDevice } from "@models/WebAuthn";
import { getWebAuthnDevices } from "@services/UserWebAuthnDevices";
import WebAuthnDeviceItem from "@views/Settings/TwoFactorAuthentication/WebAuthnDeviceItem";
interface Props {
refreshState: number;
incrementRefreshState: () => void;
devices: WebAuthnDevice[];
handleRefreshState: () => void;
}
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 (
<Fragment>
{devices !== null && devices.length !== 0 ? (
<Stack spacing={3}>
{devices.map((x, idx) => (
<WebAuthnDeviceItem key={idx} index={idx} device={x} handleEdit={props.incrementRefreshState} />
))}
</Stack>
) : (
<Typography>{translate("No Registered WebAuthn Credentials")}</Typography>
)}
</Fragment>
<Grid container spacing={3}>
{props.devices.map((x, idx) => (
<WebAuthnDeviceItem key={idx} index={idx} device={x} handleEdit={props.handleRefreshState} />
))}
</Grid>
);
}