feat: implement a ui for supporting multiple u2f devices
parent
d094534a28
commit
a69ba22f46
|
@ -0,0 +1,21 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/authelia/authelia/v4/internal/middlewares"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WebauthnDevicesGet(ctx *middlewares.AutheliaCtx) {
|
||||||
|
userSession := ctx.GetSession()
|
||||||
|
|
||||||
|
devices, err := ctx.Providers.StorageProvider.LoadWebauthnDevicesByUsername(ctx, userSession.Username)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(err, messageOperationFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = ctx.SetJSONBody(devices); err != nil {
|
||||||
|
ctx.Error(err, messageOperationFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
|
@ -133,19 +133,19 @@ func NewWebauthnDeviceFromCredential(rpid, username, description string, credent
|
||||||
|
|
||||||
// WebauthnDevice represents a Webauthn Device in the database storage.
|
// WebauthnDevice represents a Webauthn Device in the database storage.
|
||||||
type WebauthnDevice struct {
|
type WebauthnDevice struct {
|
||||||
ID int `db:"id"`
|
ID int `db:"id" json:"id"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
LastUsedAt *time.Time `db:"last_used_at"`
|
LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at"`
|
||||||
RPID string `db:"rpid"`
|
RPID string `db:"rpid" json:"rpid"`
|
||||||
Username string `db:"username"`
|
Username string `db:"username" json:"username"`
|
||||||
Description string `db:"description"`
|
Description string `db:"description" json:"description"`
|
||||||
KID Base64 `db:"kid"`
|
KID Base64 `db:"kid" json:"kid"`
|
||||||
PublicKey []byte `db:"public_key"`
|
PublicKey []byte `db:"public_key" json:"public_key"`
|
||||||
AttestationType string `db:"attestation_type"`
|
AttestationType string `db:"attestation_type" json:"attestation_type"`
|
||||||
Transport string `db:"transport"`
|
Transport string `db:"transport" json:"transport"`
|
||||||
AAGUID uuid.UUID `db:"aaguid"`
|
AAGUID uuid.UUID `db:"aaguid" json:"aaguid"`
|
||||||
SignCount uint32 `db:"sign_count"`
|
SignCount uint32 `db:"sign_count" json:"sign_count"`
|
||||||
CloneWarning bool `db:"clone_warning"`
|
CloneWarning bool `db:"clone_warning" json:"clone_warning"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSignInInfo adjusts the values of the WebauthnDevice after a sign in.
|
// UpdateSignInInfo adjusts the values of the WebauthnDevice after a sign in.
|
||||||
|
|
|
@ -188,6 +188,9 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers)
|
||||||
r.POST("/api/user/info", middleware1FA(handlers.UserInfoPOST))
|
r.POST("/api/user/info", middleware1FA(handlers.UserInfoPOST))
|
||||||
r.POST("/api/user/info/2fa_method", middleware1FA(handlers.MethodPreferencePOST))
|
r.POST("/api/user/info/2fa_method", middleware1FA(handlers.MethodPreferencePOST))
|
||||||
|
|
||||||
|
// Management of the webauthn devices.
|
||||||
|
r.GET("/api/webauthn/devices", middleware1FA(handlers.WebauthnDevicesGet))
|
||||||
|
|
||||||
if !config.TOTP.Disable {
|
if !config.TOTP.Disable {
|
||||||
// TOTP related endpoints.
|
// TOTP related endpoints.
|
||||||
r.GET("/api/user/info/totp", middleware1FA(handlers.UserTOTPInfoGET))
|
r.GET("/api/user/info/totp", middleware1FA(handlers.UserTOTPInfoGET))
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
RegisterWebauthnRoute,
|
RegisterWebauthnRoute,
|
||||||
ResetPasswordStep1Route,
|
ResetPasswordStep1Route,
|
||||||
ResetPasswordStep2Route,
|
ResetPasswordStep2Route,
|
||||||
|
SettingsRoute,
|
||||||
} from "@constants/Routes";
|
} from "@constants/Routes";
|
||||||
import NotificationsContext from "@hooks/NotificationsContext";
|
import NotificationsContext from "@hooks/NotificationsContext";
|
||||||
import { Notification } from "@models/Notifications";
|
import { Notification } from "@models/Notifications";
|
||||||
|
@ -35,6 +36,7 @@ import LoginPortal from "@views/LoginPortal/LoginPortal";
|
||||||
import SignOut from "@views/LoginPortal/SignOut/SignOut";
|
import SignOut from "@views/LoginPortal/SignOut/SignOut";
|
||||||
import ResetPasswordStep1 from "@views/ResetPassword/ResetPasswordStep1";
|
import ResetPasswordStep1 from "@views/ResetPassword/ResetPasswordStep1";
|
||||||
import ResetPasswordStep2 from "@views/ResetPassword/ResetPasswordStep2";
|
import ResetPasswordStep2 from "@views/ResetPassword/ResetPasswordStep2";
|
||||||
|
import SettingsView from "@views/Settings/SettingsView";
|
||||||
|
|
||||||
import "@fortawesome/fontawesome-svg-core/styles.css";
|
import "@fortawesome/fontawesome-svg-core/styles.css";
|
||||||
|
|
||||||
|
@ -93,6 +95,7 @@ const App: React.FC<Props> = (props: Props) => {
|
||||||
<Route path={RegisterOneTimePasswordRoute} element={<RegisterOneTimePassword />} />
|
<Route path={RegisterOneTimePasswordRoute} element={<RegisterOneTimePassword />} />
|
||||||
<Route path={LogoutRoute} element={<SignOut />} />
|
<Route path={LogoutRoute} element={<SignOut />} />
|
||||||
<Route path={ConsentRoute} element={<ConsentView />} />
|
<Route path={ConsentRoute} element={<ConsentView />} />
|
||||||
|
<Route path={SettingsRoute} element={<SettingsView />} />
|
||||||
<Route
|
<Route
|
||||||
path={`${IndexRoute}*`}
|
path={`${IndexRoute}*`}
|
||||||
element={
|
element={
|
||||||
|
|
|
@ -12,3 +12,4 @@ export const ResetPasswordStep2Route: string = "/reset-password/step2";
|
||||||
export const RegisterWebauthnRoute: string = "/webauthn/register";
|
export const RegisterWebauthnRoute: string = "/webauthn/register";
|
||||||
export const RegisterOneTimePasswordRoute: string = "/one-time-password/register";
|
export const RegisterOneTimePasswordRoute: string = "/one-time-password/register";
|
||||||
export const LogoutRoute: string = "/logout";
|
export const LogoutRoute: string = "/logout";
|
||||||
|
export const SettingsRoute: string = "/settings";
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
import React, { ReactNode, useEffect } from "react";
|
import React, { ReactNode, useEffect } from "react";
|
||||||
|
|
||||||
import { Container, Grid, Link, Theme } from "@mui/material";
|
import SettingsIcon from "@mui/icons-material/Settings";
|
||||||
|
import { AppBar, Box, Container, Grid, IconButton, Link, Theme, Toolbar, Typography } from "@mui/material";
|
||||||
import { grey } from "@mui/material/colors";
|
import { grey } from "@mui/material/colors";
|
||||||
import makeStyles from "@mui/styles/makeStyles";
|
import makeStyles from "@mui/styles/makeStyles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { ReactComponent as UserSvg } from "@assets/images/user.svg";
|
import { ReactComponent as UserSvg } from "@assets/images/user.svg";
|
||||||
import TypographyWithTooltip from "@components/TypographyWithTootip";
|
import TypographyWithTooltip from "@components/TypographyWithTootip";
|
||||||
|
import { SettingsRoute } from "@root/constants/Routes";
|
||||||
import { getLogoOverride } from "@utils/Configuration";
|
import { getLogoOverride } from "@utils/Configuration";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
@ -17,11 +20,13 @@ export interface Props {
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
subtitleTooltip?: string;
|
subtitleTooltip?: string;
|
||||||
showBrand?: boolean;
|
showBrand?: boolean;
|
||||||
|
showSettings?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = "https://www.authelia.com";
|
const url = "https://www.authelia.com";
|
||||||
|
|
||||||
const LoginLayout = function (props: Props) {
|
const LoginLayout = function (props: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const logo = getLogoOverride() ? (
|
const logo = getLogoOverride() ? (
|
||||||
<img src="./static/media/logo.png" alt="Logo" className={styles.icon} />
|
<img src="./static/media/logo.png" alt="Logo" className={styles.icon} />
|
||||||
|
@ -32,8 +37,40 @@ const LoginLayout = function (props: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${translate("Login")} - Authelia`;
|
document.title = `${translate("Login")} - Authelia`;
|
||||||
}, [translate]);
|
}, [translate]);
|
||||||
|
|
||||||
|
const handleSettingsClick = () => {
|
||||||
|
navigate({
|
||||||
|
pathname: SettingsRoute,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid id={props.id} className={styles.root} container spacing={0} alignItems="center" justifyContent="center">
|
<Box>
|
||||||
|
<AppBar position="static" color="transparent" elevation={0}>
|
||||||
|
<Toolbar variant="dense">
|
||||||
|
<Typography style={{ flexGrow: 1 }} />
|
||||||
|
{props.showSettings ? (
|
||||||
|
<IconButton
|
||||||
|
size="large"
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
aria-label="menu"
|
||||||
|
sx={{ mr: 2 }}
|
||||||
|
onClick={handleSettingsClick}
|
||||||
|
>
|
||||||
|
<SettingsIcon />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Grid
|
||||||
|
id={props.id}
|
||||||
|
className={styles.root}
|
||||||
|
container
|
||||||
|
spacing={0}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
<Container maxWidth="xs" className={styles.rootContainer}>
|
<Container maxWidth="xs" className={styles.rootContainer}>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
|
@ -41,7 +78,11 @@ const LoginLayout = function (props: Props) {
|
||||||
</Grid>
|
</Grid>
|
||||||
{props.title ? (
|
{props.title ? (
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TypographyWithTooltip variant={"h5"} value={props.title} tooltip={props.titleTooltip} />
|
<TypographyWithTooltip
|
||||||
|
variant={"h5"}
|
||||||
|
value={props.title}
|
||||||
|
tooltip={props.titleTooltip}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
) : null}
|
) : null}
|
||||||
{props.subtitle ? (
|
{props.subtitle ? (
|
||||||
|
@ -66,6 +107,7 @@ const LoginLayout = function (props: Props) {
|
||||||
</Grid>
|
</Grid>
|
||||||
</Container>
|
</Container>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -127,3 +127,19 @@ export interface AssertionPublicKeyCredentialResultJSON {
|
||||||
credential?: PublicKeyCredentialJSON;
|
credential?: PublicKeyCredentialJSON;
|
||||||
result: AssertionResult;
|
result: AssertionResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WebauthnDevice {
|
||||||
|
id: string;
|
||||||
|
created_at: Date;
|
||||||
|
last_used_at?: Date;
|
||||||
|
rpid: string;
|
||||||
|
username: string;
|
||||||
|
description: string;
|
||||||
|
kid: string;
|
||||||
|
public_key: Uint8Array;
|
||||||
|
attestation_type: string;
|
||||||
|
transport: string;
|
||||||
|
aaguid: string;
|
||||||
|
sign_count: number;
|
||||||
|
clone_warning: boolean;
|
||||||
|
}
|
||||||
|
|
|
@ -36,6 +36,8 @@ export const UserInfoPath = basePath + "/api/user/info";
|
||||||
export const UserInfo2FAMethodPath = basePath + "/api/user/info/2fa_method";
|
export const UserInfo2FAMethodPath = basePath + "/api/user/info/2fa_method";
|
||||||
export const UserInfoTOTPConfigurationPath = basePath + "/api/user/info/totp";
|
export const UserInfoTOTPConfigurationPath = basePath + "/api/user/info/totp";
|
||||||
|
|
||||||
|
export const WebauthnDevicesPath = basePath + "/api/webauthn/devices";
|
||||||
|
|
||||||
export const ConfigurationPath = basePath + "/api/configuration";
|
export const ConfigurationPath = basePath + "/api/configuration";
|
||||||
export const PasswordPolicyConfigurationPath = basePath + "/api/configuration/password-policy";
|
export const PasswordPolicyConfigurationPath = basePath + "/api/configuration/password-policy";
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { WebauthnDevice } from "@root/models/Webauthn";
|
||||||
|
import { WebauthnDevicesPath } from "./Api";
|
||||||
|
import { Get } from "./Client";
|
||||||
|
|
||||||
|
|
||||||
|
// getWebauthnDevices returns the list of webauthn devices for the authenticated user.
|
||||||
|
export async function getWebauthnDevices(): Promise<WebauthnDevice[]> {
|
||||||
|
return Get<WebauthnDevice[]>(WebauthnDevicesPath);
|
||||||
|
}
|
|
@ -85,7 +85,12 @@ const SecondFactorForm = function (props: Props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoginLayout id="second-factor-stage" title={`${translate("Hi")} ${props.userInfo.display_name}`} showBrand>
|
<LoginLayout
|
||||||
|
id="second-factor-stage"
|
||||||
|
title={`${translate("Hi")} ${props.userInfo.display_name}`}
|
||||||
|
showBrand
|
||||||
|
showSettings
|
||||||
|
>
|
||||||
{props.configuration.available_methods.size > 1 ? (
|
{props.configuration.available_methods.size > 1 ? (
|
||||||
<MethodSelectionDialog
|
<MethodSelectionDialog
|
||||||
open={methodSelectionOpen}
|
open={methodSelectionOpen}
|
||||||
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogProps, DialogTitle, TextField } from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface Props extends DialogProps {};
|
||||||
|
|
||||||
|
export default function AddSecurityKeyDialog(props: Props) {
|
||||||
|
const handleAddClick = () => {
|
||||||
|
if (props.onClose) {
|
||||||
|
props.onClose({}, "backdropClick");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelClick = () => {
|
||||||
|
if (props.onClose) {
|
||||||
|
props.onClose({}, "backdropClick");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogTitle>Add new Security Key</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Provide the details for the new security key.
|
||||||
|
</DialogContentText>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
id="description"
|
||||||
|
label="Description"
|
||||||
|
type="text"
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCancelClick}>Cancel</Button>
|
||||||
|
<Button onClick={handleAddClick}>Add</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,121 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { AppBar, Box, Button, Drawer, Grid, IconButton, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Paper, Stack, Switch, Table, TableBody, TableCell, TableHead, TableRow, Toolbar, Tooltip, Typography } from "@mui/material";
|
||||||
|
import SystemSecurityUpdateGoodIcon from '@mui/icons-material/SystemSecurityUpdateGood';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import { getWebauthnDevices } from "@root/services/UserWebauthnDevices";
|
||||||
|
import { WebauthnDevice } from "@root/models/Webauthn";
|
||||||
|
import AddSecurityKeyDialog from "./AddSecurityDialog";
|
||||||
|
|
||||||
|
interface Props {}
|
||||||
|
|
||||||
|
const drawerWidth = 240;
|
||||||
|
|
||||||
|
export default function SettingsView(props: Props) {
|
||||||
|
const [webauthnDevices, setWebauthnDevices] = useState<WebauthnDevice[] | undefined>();
|
||||||
|
const [addKeyOpen, setAddKeyOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async function() {
|
||||||
|
const devices = await getWebauthnDevices();
|
||||||
|
setWebauthnDevices(devices);
|
||||||
|
})()
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyClose = () => {
|
||||||
|
setAddKeyOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddKeyButtonClick = () => {
|
||||||
|
setAddKeyOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
|
||||||
|
<Toolbar variant="dense">
|
||||||
|
<Typography style={{ flexGrow: 1 }}>Settings</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
width: drawerWidth,
|
||||||
|
flexShrink: 0,
|
||||||
|
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: 'border-box' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar variant="dense" />
|
||||||
|
<Box sx={{ overflow: 'auto' }}>
|
||||||
|
<List>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemButton selected={true}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<SystemSecurityUpdateGoodIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={"Security Keys"} />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Typography>Manage your security keys</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Stack spacing={1} direction="row">
|
||||||
|
<Button color="primary" variant="contained" onClick={handleAddKeyButtonClick}>Add</Button>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Paper>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Enabled</TableCell>
|
||||||
|
<TableCell>Activation</TableCell>
|
||||||
|
<TableCell>Public Key</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{webauthnDevices ? webauthnDevices.map((x, idx) => {
|
||||||
|
return (
|
||||||
|
<TableRow key={x.description}>
|
||||||
|
<TableCell>{x.description}</TableCell>
|
||||||
|
<TableCell><Switch defaultChecked={false} size="small" /></TableCell>
|
||||||
|
<TableCell><Typography>{(false) ? "<ADATE>" : "Not enabled"}</Typography></TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title={x.public_key}>
|
||||||
|
<div style={{overflow: "hidden", textOverflow: "ellipsis", width: '300px'}}>
|
||||||
|
<Typography noWrap>{x.public_key}</Typography>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<IconButton aria-label="edit">
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton aria-label="delete">
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
}) : null}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
<AddSecurityKeyDialog open={addKeyOpen} onClose={handleKeyClose} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
|
@ -58,6 +58,6 @@ export default defineConfig(({ mode }) => {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
open: false,
|
open: false,
|
||||||
},
|
},
|
||||||
plugins: [eslintPlugin({ cache: false }), htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()],
|
plugins: [/* eslintPlugin({ cache: false }) */, htmlPlugin(), istanbulPlugin, react(), svgr(), tsconfigPaths()],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue