feat: backport changes
Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>feat-otp-verification
parent
6c89ee1f9c
commit
53668e0c60
|
@ -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,
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { useRemoteCall } from "@hooks/RemoteCall";
|
||||||
|
import { getUserWebAuthnDevices } from "@services/UserWebAuthnDevices";
|
||||||
|
|
||||||
|
export function useUserWebAuthnDevices() {
|
||||||
|
return useRemoteCall(getUserWebAuthnDevices, []);
|
||||||
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}`,
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,9 +99,7 @@ export default function WebAuthnDeviceItem(props: Props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Grid xs={12} md={6} xl={3}>
|
||||||
<Paper variant="outlined">
|
|
||||||
<Box sx={{ p: 3 }}>
|
|
||||||
<WebAuthnDeviceDetailsDialog
|
<WebAuthnDeviceDetailsDialog
|
||||||
device={props.device}
|
device={props.device}
|
||||||
open={showDialogDetails}
|
open={showDialogDetails}
|
||||||
|
@ -108,15 +108,21 @@ export default function WebAuthnDeviceItem(props: Props) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<WebAuthnDeviceEditDialog device={props.device} open={showDialogEdit} handleClose={handleEdit} />
|
<WebAuthnDeviceEditDialog device={props.device} open={showDialogEdit} handleClose={handleEdit} />
|
||||||
<WebAuthnDeviceDeleteDialog
|
<DeleteDialog
|
||||||
device={props.device}
|
|
||||||
open={showDialogDelete}
|
open={showDialogDelete}
|
||||||
handleClose={handleDelete}
|
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,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
<Paper variant="outlined">
|
||||||
|
<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"} />
|
<Fingerprint fontSize="large" color={"warning"} />
|
||||||
<Stack spacing={0} sx={{ minWidth: 400 }}>
|
|
||||||
<Box>
|
|
||||||
<Typography display="inline" sx={{ fontWeight: "bold" }}>
|
<Typography display="inline" sx={{ fontWeight: "bold" }}>
|
||||||
{props.device.description}
|
{props.device.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
@ -124,9 +130,11 @@ export default function WebAuthnDeviceItem(props: Props) {
|
||||||
display="inline"
|
display="inline"
|
||||||
variant="body2"
|
variant="body2"
|
||||||
>{` (${props.device.attestation_type.toUpperCase()})`}</Typography>
|
>{` (${props.device.attestation_type.toUpperCase()})`}</Typography>
|
||||||
</Box>
|
</Stack>
|
||||||
<Typography variant={"caption"}>
|
</Grid>
|
||||||
{translate("Added", {
|
<Grid xs={12}>
|
||||||
|
<Typography variant={"caption"} sx={{ display: { xs: "none", md: "block" } }}>
|
||||||
|
{translate("Added when", {
|
||||||
when: new Date(props.device.created_at),
|
when: new Date(props.device.created_at),
|
||||||
formatParams: {
|
formatParams: {
|
||||||
when: {
|
when: {
|
||||||
|
@ -139,10 +147,12 @@ export default function WebAuthnDeviceItem(props: Props) {
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant={"caption"}>
|
</Grid>
|
||||||
|
<Grid xs={12}>
|
||||||
|
<Typography variant={"caption"} sx={{ display: { xs: "none", md: "block" } }}>
|
||||||
{props.device.last_used_at === undefined
|
{props.device.last_used_at === undefined
|
||||||
? translate("Never used")
|
? translate("Never used")
|
||||||
: translate("Last Used", {
|
: translate("Last Used when", {
|
||||||
when: new Date(props.device.last_used_at),
|
when: new Date(props.device.last_used_at),
|
||||||
formatParams: {
|
formatParams: {
|
||||||
when: {
|
when: {
|
||||||
|
@ -155,43 +165,36 @@ export default function WebAuthnDeviceItem(props: Props) {
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</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")}>
|
<Tooltip title={translate("Display extended information for this WebAuthn credential")}>
|
||||||
<Button
|
<IconButton color="primary" onClick={() => setShowDialogDetails(true)}>
|
||||||
variant="outlined"
|
<InfoOutlinedIcon />
|
||||||
color="primary"
|
</IconButton>
|
||||||
startIcon={<InfoOutlinedIcon />}
|
|
||||||
onClick={() => setShowDialogDetails(true)}
|
|
||||||
>
|
|
||||||
{translate("Info")}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={translate("Edit information for this WebAuthn credential")}>
|
<Tooltip title={translate("Edit information for this WebAuthn credential")}>
|
||||||
<Button
|
<IconButton
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
color="primary"
|
||||||
startIcon={loadingEdit ? <CircularProgress color="inherit" size={20} /> : <EditIcon />}
|
|
||||||
onClick={loadingEdit ? undefined : () => setShowDialogEdit(true)}
|
onClick={loadingEdit ? undefined : () => setShowDialogEdit(true)}
|
||||||
>
|
>
|
||||||
{translate("Edit")}
|
{loadingEdit ? <CircularProgress color="inherit" size={20} /> : <EditIcon />}
|
||||||
</Button>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={translate("Remove this WebAuthn credential")}>
|
<Tooltip title={translate("Remove this WebAuthn credential")}>
|
||||||
<Button
|
<IconButton
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
color="primary"
|
||||||
startIcon={
|
|
||||||
loadingDelete ? <CircularProgress color="inherit" size={20} /> : <DeleteIcon />
|
|
||||||
}
|
|
||||||
onClick={loadingDelete ? undefined : () => setShowDialogDelete(true)}
|
onClick={loadingDelete ? undefined : () => setShowDialogDelete(true)}
|
||||||
>
|
>
|
||||||
{translate("Remove")}
|
{loadingDelete ? <CircularProgress color="inherit" size={20} /> : <DeleteIcon />}
|
||||||
</Button>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Fragment>
|
</Grid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,7 +125,12 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
|
||||||
})();
|
})();
|
||||||
}, [props.open, activeStep, options, performCredentialCreation]);
|
}, [props.open, activeStep, options, performCredentialCreation]);
|
||||||
|
|
||||||
const handleNext = useCallback(async () => {
|
const handleNext = useCallback(() => {
|
||||||
|
if (!props.open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async function () {
|
||||||
if (credentialDescription.length === 0 || credentialDescription.length > 64) {
|
if (credentialDescription.length === 0 || credentialDescription.length > 64) {
|
||||||
setErrorDescription(true);
|
setErrorDescription(true);
|
||||||
createErrorNotification(
|
createErrorNotification(
|
||||||
|
@ -159,7 +163,10 @@ const WebAuthnDeviceRegisterDialog = function (props: Props) {
|
||||||
translate("Error occurred obtaining the WebAuthn Credential creation options."),
|
translate("Error occurred obtaining the WebAuthn Credential creation options."),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [createErrorNotification, credentialDescription, translate]);
|
|
||||||
|
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>
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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} />
|
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Grid>
|
||||||
) : (
|
|
||||||
<Typography>{translate("No Registered WebAuthn Credentials")}</Typography>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue