Browse Source

2FA totp, bugfix

Roman 3 weeks ago
parent
commit
0974ac9260

+ 0 - 1
Core/API/MailAPI.class.php

@@ -55,7 +55,6 @@ namespace Core\API\Mail {
   use Core\External\PHPMailer\PHPMailer;
   use Core\Objects\Context;
   use Core\Objects\DatabaseEntity\GpgKey;
-  use PhpParser\Node\Param;
 
   class Test extends MailAPI {
 

+ 26 - 4
Core/API/TfaAPI.class.php

@@ -62,6 +62,7 @@ namespace Core\API\TFA {
   use Core\API\Parameter\StringType;
   use Core\API\TfaAPI;
   use Core\Driver\SQL\Condition\Compare;
+  use Core\Driver\SQL\Query\Insert;
   use Core\Objects\Context;
   use Core\Objects\TwoFactor\AttestationObject;
   use Core\Objects\TwoFactor\AuthenticationData;
@@ -131,6 +132,10 @@ namespace Core\API\TFA {
 
       return $this->success;
     }
+
+    public static function getDefaultACL(Insert $insert): void {
+      $insert->addRow(self::getEndpoint(), [], "Allows users to remove their 2FA-Tokens", true);
+    }
   }
 
   // TOTP
@@ -167,11 +172,16 @@ namespace Core\API\TFA {
       $this->disableCache();
       die($twoFactorToken->generateQRCode($this->context));
     }
+
+    public static function getDefaultACL(Insert $insert): void {
+      $insert->addRow(self::getEndpoint(), [], "Allows users generate a QR-code to add a time-based 2FA-Token", true);
+    }
   }
 
   class ConfirmTotp extends VerifyTotp {
     public function __construct(Context $context, bool $externalCall = false) {
       parent::__construct($context, $externalCall);
+      $this->loginRequired = true;
     }
 
     public function _execute(): bool {
@@ -196,6 +206,10 @@ namespace Core\API\TFA {
 
       return $this->success;
     }
+
+    public static function getDefaultACL(Insert $insert): void {
+      $insert->addRow(self::getEndpoint(), [], "Allows users to confirm their time-based 2FA-Token", true);
+    }
   }
 
   class VerifyTotp extends TfaAPI {
@@ -211,10 +225,6 @@ namespace Core\API\TFA {
     public function _execute(): bool {
 
       $currentUser = $this->context->getUser();
-      if (!$currentUser) {
-        return $this->createError("You are not logged in.");
-      }
-
       $twoFactorToken = $currentUser->getTwoFactorToken();
       if (!$twoFactorToken) {
         return $this->createError("You did not add a two factor token yet.");
@@ -230,6 +240,10 @@ namespace Core\API\TFA {
       $twoFactorToken->authenticate();
       return $this->success;
     }
+
+    public static function getDefaultACL(Insert $insert): void {
+      $insert->addRow(self::getEndpoint(), [], "Allows users to verify time-based 2FA-Tokens", true);
+    }
   }
 
   // Key
@@ -326,6 +340,10 @@ namespace Core\API\TFA {
 
       return $this->success;
     }
+
+    public static function getDefaultACL(Insert $insert): void {
+      $insert->addRow(self::getEndpoint(), [], "Allows users to register a 2FA hardware-key", true);
+    }
   }
 
   class VerifyKey extends TfaAPI {
@@ -384,5 +402,9 @@ namespace Core\API\TFA {
 
       return $this->success;
     }
+
+    public static function getDefaultACL(Insert $insert): void {
+      $insert->addRow(self::getEndpoint(), [], "Allows users to verify a 2FA hardware-key", true);
+    }
   }
 }

+ 2 - 0
Core/Localization/de_DE/account.php

@@ -90,6 +90,8 @@ return [
   "gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...",
 
   # 2fa
+  "2fa_type_totp" => "Zeitbasiertes 2FA (TOTP)",
+  "2fa_type_fido" => "Schlüsselbasiertes 2FA",
   "register_2fa_device" => "Ein 2FA-Gerät registrieren",
   "register_2fa_totp_text" => "Scan den QR-Code mit einem Gerät, das du als Zwei-Faktor-Authentifizierung (2FA) benutzen willst. " .
     "Unter Android kannst du den Google Authenticator benutzen.",

+ 1 - 0
Core/Localization/de_DE/general.php

@@ -35,6 +35,7 @@ return [
   "no" => "Nein",
   "create_new" => "Erstellen",
   "unchanged" => "Unverändert",
+  "click_to_copy" => "Klicken zum Kopieren",
 
   # dialog / actions
   "action" => "Aktion",

+ 2 - 0
Core/Localization/en_US/account.php

@@ -90,6 +90,8 @@ return [
   "gpg_key_placeholder_text" => "Paste or drag'n'drop your GPG-Key in ASCII format...",
 
   # 2fa
+  "2fa_type_totp" => "Time-Based 2FA (TOTP)",
+  "2fa_type_fido" => "Key-Based 2FA",
   "register_2fa_device" => "Register a 2FA-Device",
   "register_2fa_totp_text" => "Scan the QR-Code with a device you want to use for Two-Factor-Authentication (2FA). " .
     "On Android, you can use the Google Authenticator.",

+ 1 - 0
Core/Localization/en_US/general.php

@@ -17,6 +17,7 @@ return [
   "no" => "No",
   "create_new" => "Create",
   "unchanged" => "Unchanged",
+  "click_to_copy" => "Click to copy",
 
   # dialog / actions
   "action" => "Action",

BIN
img/icons/nitrokey.png


+ 1 - 1
react/admin-panel/src/AdminDashboard.jsx

@@ -1,7 +1,7 @@
 import React, {lazy, Suspense, useCallback, useState} from "react";
 import {BrowserRouter, Route, Routes} from "react-router-dom";
+import Dialog from "shared/elements/dialog";
 import Sidebar from "./elements/sidebar";
-import Dialog from "./elements/dialog";
 import Footer from "./elements/footer";
 import {useContext, useEffect} from "react";
 import {LocaleContext} from "shared/locale";

+ 0 - 47
react/admin-panel/src/elements/dialog.jsx

@@ -1,47 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-
-export default function Dialog(props) {
-
-    const show = props.show;
-    const classes = ["modal", "fade"];
-    const style = { paddingRight: "12px", display: (show ? "block" : "none") };
-    const onClose = props.onClose || function() { };
-    const onOption = props.onOption || function() { };
-    const options = props.options || ["Close"];
-
-    let buttons = [];
-    for (let name of options) {
-        let type = "default";
-        if (name === "Yes") type = "warning";
-        else if(name === "No") type = "danger";
-
-        buttons.push(
-            <button type="button" key={"button-" + name} className={"btn btn-" + type}
-                    data-dismiss={"modal"} onClick={() => { onClose(); onOption(name); }}>
-                {name}
-            </button>
-        )
-    }
-
-    return (
-        <div className={clsx(classes, show && "show")} style={style} aria-modal={"true"} onClick={() => onClose()}>
-            <div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
-                <div className="modal-content">
-                    <div className="modal-header">
-                        <h4 className="modal-title">{props.title}</h4>
-                        <button type="button" className="close" data-dismiss="modal" aria-label="Close" onClick={() => onClose()}>
-                            <span aria-hidden="true">×</span>
-                        </button>
-                    </div>
-                    <div className="modal-body">
-                        <p>{props.message}</p>
-                    </div>
-                    <div className="modal-footer">
-                        { buttons }
-                    </div>
-                </div>
-            </div>
-        </div>
-    );
-}

+ 3 - 4
react/admin-panel/src/views/access-control-list.js

@@ -169,8 +169,7 @@ export default function AccessControlList(props) {
                                                     { type: "label", value: L("permissions.description") + ":" },
                                                     { type: "text", name: "description", value: permission.description, maxLength: 128 }
                                                 ],
-                                                onOption: (option, inputData) => option === 0 && onUpdatePermission(inputData, permission.groups)
-                                            })} >
+                                                onOption: (option, inputData) => option === 0 ? onUpdatePermission(inputData, permission.groups) : true                                            })} >
                                     <Edit />
                                 </IconButton>
                                 <IconButton style={{padding: 0}} size={"small"} color={"secondary"}
@@ -179,7 +178,7 @@ export default function AccessControlList(props) {
                                                 open: true,
                                                 title: L("permissions.delete_permission_confirm"),
                                                 message: L("permissions.method") + ": " + permission.method,
-                                                onOption: (option) => option === 0 && onDeletePermission(permission.method)
+                                                onOption: (option) => option === 0 ? onDeletePermission(permission.method) : true
                                             })} >
                                     <Delete />
                                 </IconButton>
@@ -253,7 +252,7 @@ export default function AccessControlList(props) {
                                     { type: "label", value: L("permissions.description") + ":" },
                                     { type: "text", name: "description", maxLength: 128, placeholder: L("permissions.description") }
                                 ],
-                                onOption: (option, inputData) => option === 0 && onUpdatePermission(inputData, [])
+                                onOption: (option, inputData) => option === 0 ? onUpdatePermission(inputData, []) : true
                             })} >
                         {L("general.add")}
                     </Button>

+ 30 - 34
react/admin-panel/src/views/group/group-edit.js

@@ -3,11 +3,12 @@ import {Link, useNavigate, useParams} from "react-router-dom";
 import {LocaleContext} from "shared/locale";
 import SearchField from "shared/elements/search-field";
 import React from "react";
-import {ControlsColumn, DataTable, NumericColumn, StringColumn} from "shared/elements/data-table";
+import {sprintf} from "sprintf-js";
+import {DataTable, ControlsColumn, NumericColumn, StringColumn} from "shared/elements/data-table";
 import EditIcon from "@mui/icons-material/Edit";
 import usePagination from "shared/hooks/pagination";
 import Dialog from "shared/elements/dialog";
-import {FormControl, FormGroup, FormLabel, styled, TextField, Button, CircularProgress} from "@mui/material";
+import {FormControl, FormGroup, FormLabel, TextField, Button, CircularProgress, Box} from "@mui/material";
 import {Add, Delete, KeyboardArrowLeft, Save} from "@mui/icons-material";
 import {MuiColorInput} from "mui-color-input";
 import ButtonBar from "../../elements/button-bar";
@@ -27,6 +28,7 @@ export default function EditGroupView(props) {
     const isNewGroup = groupId === "new";
     const pagination = usePagination();
     const api = props.api;
+    const showDialog = props.showDialog;
 
     // data
     const [fetchGroup, setFetchGroup] = useState(!isNewGroup);
@@ -41,7 +43,7 @@ export default function EditGroupView(props) {
     useEffect(() => {
         requestModules(props.api, ["general", "account"], currentLocale).then(data => {
             if (!data.success) {
-                props.showDialog(data.msg, "Error fetching localization");
+                showDialog(data.msg, "Error fetching localization");
             }
         });
     }, [currentLocale]);
@@ -51,7 +53,7 @@ export default function EditGroupView(props) {
             setFetchGroup(false);
             api.getGroup(groupId).then(res => {
                if (!res.success) {
-                   props.showDialog(res.msg, "Error fetching group");
+                   showDialog(res.msg, "Error fetching group");
                    navigate("/admin/groups");
                } else {
                    setGroup(res.group);
@@ -66,11 +68,11 @@ export default function EditGroupView(props) {
                 setMembers(res.users);
                 pagination.update(res.pagination);
             } else {
-                props.showDialog(res.msg, L("account.fetch_group_members_error"));
+                showDialog(res.msg, L("account.fetch_group_members_error"));
                 return null;
             }
         });
-    }, [groupId, api, pagination]);
+    }, [api, showDialog, pagination, groupId]);
 
     const onRemoveMember = useCallback(userId => {
         api.removeGroupMember(groupId, userId).then(data => {
@@ -78,16 +80,16 @@ export default function EditGroupView(props) {
                 let newMembers = members.filter(u => u.id !== userId);
                 setMembers(newMembers);
             } else {
-                props.showDialog(data.msg, L("account.remove_group_member_error"));
+                showDialog(data.msg, L("account.remove_group_member_error"));
             }
         });
-    }, [api, groupId, members]);
+    }, [api, showDialog, groupId, members]);
 
     const onAddMember = useCallback(() => {
         if (selectedUser) {
             api.addGroupMember(groupId, selectedUser.id).then(data => {
                 if (!data.success) {
-                    props.showDialog(data.msg, L("account.add_group_member_error"));
+                    showDialog(data.msg, L("account.add_group_member_error"));
                 } else {
                     let newMembers = [...members];
                     newMembers.push(selectedUser);
@@ -96,7 +98,7 @@ export default function EditGroupView(props) {
                 setSelectedUser(null);
             });
         }
-    }, [api, groupId, selectedUser])
+    }, [api, showDialog, groupId, selectedUser, members])
 
     const onSave = useCallback(() => {
         setSaving(true);
@@ -104,7 +106,7 @@ export default function EditGroupView(props) {
             api.createGroup(group.name, group.color).then(data => {
                 setSaving(false);
                 if (!data.success) {
-                   props.showDialog(data.msg, L("account.create_group_error"));
+                   showDialog(data.msg, L("account.create_group_error"));
                } else {
                    navigate(`/admin/group/${data.id}`)
                }
@@ -113,31 +115,31 @@ export default function EditGroupView(props) {
             api.updateGroup(groupId, group.name, group.color).then(data => {
                 setSaving(false);
                 if (!data.success) {
-                    props.showDialog(data.msg, L("account.update_group_error"));
+                    showDialog(data.msg, L("account.update_group_error"));
                 }
             });
         }
-    }, [api, groupId, isNewGroup, group]);
+    }, [api, showDialog, groupId, isNewGroup, group]);
 
     const onSearchUser = useCallback((async (query) => {
         let data = await api.searchUser(query);
         if (!data.success) {
-            props.showDialog(data.msg, L("account.search_users_error"));
+            showDialog(data.msg, L("account.search_users_error"));
             return [];
         }
 
         return data.users;
-    }), [api]);
+    }), [api, showDialog]);
 
     const onDeleteGroup = useCallback(() => {
         api.deleteGroup(groupId).then(data => {
            if (!data.success) {
-               props.showDialog(data.msg, L("account.delete_group_error"));
+               showDialog(data.msg, L("account.delete_group_error"));
            } else {
                navigate("/admin/groups");
            }
         });
-    }, [api, groupId]);
+    }, [api, showDialog, groupId]);
 
     const onOpenMemberDialog = useCallback(() => {
         setDialogData({
@@ -146,30 +148,24 @@ export default function EditGroupView(props) {
             message: L("account.add_group_member_text"),
             inputs: [
                 {
-                    type: "custom", name: "search", element: SearchField,
+                    type: "custom", name: "search",
                     size: "small", key: "search",
+                    element: SearchField,
                     onSearch: v => onSearchUser(v),
                     onSelect: u => setSelectedUser(u),
-                    displayText: u => u.fullName || u.name
+                    getOptionLabel: u => u.fullName || u.name
                 }
             ],
-            onOption: (option) => option === 0 ? onAddMember() : setSelectedUser(null)
+            onOption: (option) => option === 0 ?
+                onAddMember() :
+                setSelectedUser(null)
         });
-    }, []);
+    }, [onAddMember, onSearchUser, setSelectedUser, setDialogData]);
 
     useEffect(() => {
         onFetchGroup();
     }, []);
 
-    const complementaryColor = (color) => {
-        if (color.startsWith("#")) {
-            color = color.substring(1);
-        }
-
-        let numericValue = parseInt(color, 16);
-        return "#" + (0xFFFFFF - numericValue).toString(16);
-    }
-
     if (group === null) {
         return <CircularProgress />
     }
@@ -243,7 +239,7 @@ export default function EditGroupView(props) {
                                         open: true,
                                         title: L("account.delete_group_title"),
                                         message: L("account.delete_group_text"),
-                                        onOption: option => option === 0 && onDeleteGroup()
+                                        onOption: option => option === 0 ? onDeleteGroup() : true
                                     })}>
                                 {L("general.delete")}
                             </Button>
@@ -252,7 +248,7 @@ export default function EditGroupView(props) {
                 </div>
             </div>
             {!isNewGroup && api.hasPermission("groups/getMembers") ?
-                <div className={"m-3 col-6"}>
+                <Box m={3} className={"col-6"}>
                     <h4>{L("account.members")}</h4>
                     <DataTable
                         data={members}
@@ -281,7 +277,7 @@ export default function EditGroupView(props) {
                                         open: true,
                                         title: L("account.remove_group_member_title"),
                                         message: sprintf(L("account.remove_group_member_text"), entry.fullName || entry.name),
-                                        onOption: (option) => option === 0 && onRemoveMember(entry.id)
+                                        onOption: (option) => option === 0 ? onRemoveMember(entry.id) : true
                                     })
                                 }
                             ]),
@@ -295,7 +291,7 @@ export default function EditGroupView(props) {
                             onClick: onOpenMemberDialog
                         }]}
                     />
-                </div>
+                </Box>
                 : <></>
             }
         </div>

+ 26 - 0
react/admin-panel/src/views/profile/mfa-fido.js

@@ -0,0 +1,26 @@
+import {Box, Paper} from "@mui/material";
+import {LocaleContext} from "shared/locale";
+import {useCallback, useContext} from "react";
+
+export default function MfaFido(props) {
+
+    const {api, showDialog, setDialogData, ...other} = props;
+    const {translate: L} = useContext(LocaleContext);
+
+    const openDialog = useCallback(() => {
+        if (api.hasPermission("tfa/registerKey")) {
+
+        }
+    }, [api, showDialog]);
+
+    const disabledStyle = {
+        background: "gray",
+        cursor: "not-allowed"
+    }
+
+    return <Box component={Paper} onClick={openDialog}
+                style={!api.hasPermission("tfa/registerKey") ? disabledStyle : {}}>
+        <div><img src={"/img/icons/nitrokey.png"} alt={"[Nitro Key]"} /></div>
+        <div>{L("account.2fa_type_fido")}</div>
+    </Box>;
+}

+ 55 - 0
react/admin-panel/src/views/profile/mfa-totp.js

@@ -0,0 +1,55 @@
+import {Box, Paper} from "@mui/material";
+import {useCallback, useContext} from "react";
+import {LocaleContext} from "shared/locale";
+
+export default function MfaTotp(props) {
+
+    const {setDialogData, api, showDialog, ...other} = props;
+    const {translate: L} = useContext(LocaleContext);
+
+    const onConfirmTOTP = useCallback((code) => {
+        api.confirmTOTP(code).then(data => {
+            if (!data.success) {
+                showDialog(data.msg, L("account.confirm_totp_error"));
+            } else {
+                setDialogData({show: false});
+                showDialog(L("account.confirm_totp_success"), L("general.success"));
+            }
+        });
+        return false;
+    }, [api, showDialog]);
+
+    const openDialog = useCallback(() => {
+        if (api.hasPermission("tfa/generateQR")) {
+            setDialogData({
+                show: true,
+                title: L("Register a 2FA-Device"),
+                message: L("Scan the QR-Code with a device you want to use for Two-Factor-Authentication (2FA). " +
+                    "On Android, you can use the Google Authenticator."),
+                inputs: [
+                    {
+                        type: "custom", element: Box, textAlign: "center", children:
+                            <img src={"/api/tfa/generateQR?nocache=" + Math.random()} alt={"[QR-Code]"}/>
+                    },
+                    {
+                        type: "number", placeholder: L("account.6_digit_code"),
+                        inputProps: { maxLength: 6 }, name: "code",
+                        sx: { "& input": { textAlign: "center", fontFamily: "monospace" } },
+                    }
+                ],
+                onOption: (option, data) => option === 0 ? onConfirmTOTP(data.code) : true
+            })
+        }
+    }, [api, onConfirmTOTP]);
+
+    const disabledStyle = {
+        background: "gray",
+        cursor: "not-allowed"
+    }
+
+    return <Box component={Paper} onClick={openDialog}
+                style={!api.hasPermission("tfa/generateQR") ? disabledStyle : {}}>
+        <div><img src={"/img/icons/google_authenticator.svg"} alt={"[Google Authenticator]"} /></div>
+        <div>{L("account.2fa_type_totp")}</div>
+    </Box>
+}

+ 87 - 3
react/admin-panel/src/views/profile/profile.js

@@ -7,7 +7,7 @@ import {
     CircularProgress,
     FormControl,
     FormGroup,
-    FormLabel, styled,
+    FormLabel, Paper, styled,
     TextField
 } from "@mui/material";
 import {
@@ -23,6 +23,9 @@ import {
 } from "@mui/icons-material";
 import CollapseBox from "./collapse-box";
 import ButtonBar from "../../elements/button-bar";
+import MfaTotp from "./mfa-totp";
+import MfaFido from "./mfa-fido";
+import Dialog from "shared/elements/dialog";
 
 const GpgKeyField = styled(TextField)((props) => ({
     "& > div": {
@@ -46,6 +49,29 @@ const ProfileFormGroup = styled(FormGroup)((props) =>  ({
     marginBottom: props.theme.spacing(2)
 }));
 
+const MFAOptions = styled(Box)((props) => ({
+    "& > div": {
+        borderColor: props.theme.palette.divider,
+        borderStyle: "solid",
+        borderWidth: 1,
+        borderRadius: 5,
+        maxWidth: 150,
+        cursor: "pointer",
+        textAlign: "center",
+        display: "inline-grid",
+        gridTemplateRows: "130px 50px",
+        alignItems: "center",
+        padding: props.theme.spacing(1),
+        marginRight: props.theme.spacing(1),
+        "&:hover": {
+            backgroundColor: "lightgray",
+        },
+        "& img": {
+            width: "100%",
+        },
+    }
+}));
+
 const VisuallyHiddenInput = styled('input')({
     clip: 'rect(0 0 0 0)',
     clipPath: 'inset(50%)',
@@ -78,12 +104,15 @@ export default function ProfileView(props) {
     const [changePassword, setChangePassword] = useState({ old: "", new: "", confirm: "" });
     const [gpgKey, setGpgKey] = useState("");
     const [gpgKeyPassword, setGpgKeyPassword] = useState("");
+    const [mfaPassword, set2FAPassword] = useState("");
 
     // ui
     const [openedTab, setOpenedTab] = useState(null);
     const [isSaving, setSaving] = useState(false);
     const [isGpgKeyUploading, setGpgKeyUploading] = useState(false);
     const [isGpgKeyRemoving, setGpgKeyRemoving] = useState(false);
+    const [is2FARemoving, set2FARemoving] = useState(false);
+    const [dialogData, setDialogData] = useState({show: false});
 
     const onUpdateProfile = useCallback(() => {
 
@@ -147,7 +176,22 @@ export default function ProfileView(props) {
                 }
             });
         }
-    }, [api, showDialog, isGpgKeyRemoving, gpgKeyPassword]);
+    }, [api, showDialog, isGpgKeyRemoving, gpgKeyPassword, profile]);
+
+    const onRemove2FA = useCallback(() => {
+        if (!is2FARemoving) {
+            set2FARemoving(true);
+            api.remove2FA(mfaPassword).then(data => {
+                set2FARemoving(false);
+                set2FAPassword("");
+                if (!data.success) {
+                    showDialog(data.msg, L("account.remove_2fa_error"));
+                } else {
+                    setProfile({...profile, twoFactorToken: null});
+                }
+            });
+        }
+    }, [api, showDialog, is2FARemoving, mfaPassword, profile]);
 
     const getFileContents = useCallback((file, callback) => {
         let reader = new FileReader();
@@ -167,6 +211,8 @@ export default function ProfileView(props) {
         reader.readAsText(file);
     }, [showDialog]);
 
+    console.log("SELECTED USER:", profile.twoFactorToken);
+
     return <>
         <div className={"content-header"}>
             <div className={"container-fluid"}>
@@ -315,7 +361,37 @@ export default function ProfileView(props) {
             <CollapseBox title={L("account.2fa_token")} open={openedTab === "2fa"}
                          onToggle={() => setOpenedTab(openedTab === "2fa" ? "" : "2fa")}
                          icon={<Fingerprint />}>
-                <b>test</b>
+                {profile.twoFactorToken && profile.twoFactorToken.confirmed ?
+                    <Box>
+                        <GpgFingerprintBox mb={2}>
+                            { profile.twoFactorToken.confirmed ?
+                                <CheckCircle color={"info"} title={L("account.gpg_key_confirmed")} /> :
+                                <ErrorOutline color={"secondary"} title={L("account.gpg_key_pending")}  />
+                            }
+                            {L("account.2fa_type_" + profile.twoFactorToken.type)}
+                        </GpgFingerprintBox>
+                        <ProfileFormGroup>
+                            <FormLabel>{L("account.password")}</FormLabel>
+                            <FormControl>
+                                <TextField variant={"outlined"} size={"small"}
+                                           value={mfaPassword} type={"password"}
+                                           onChange={e => set2FAPassword(e.target.value)}
+                                           placeholder={L("account.password")}
+                                />
+                            </FormControl>
+                        </ProfileFormGroup>
+                        <Button startIcon={is2FARemoving ? <CircularProgress size={12} /> : <Remove />}
+                                color={"secondary"} onClick={onRemove2FA}
+                                variant={"outlined"} size={"small"}
+                                disabled={is2FARemoving || !api.hasPermission("tfa/remove")}>
+                            {is2FARemoving ? L("general.removing") + "…" : L("general.remove")}
+                        </Button>
+                    </Box> :
+                    <MFAOptions>
+                        <MfaTotp api={api} showDialog={showDialog} setDialogData={setDialogData}/>
+                        <MfaFido api={api} showDialog={showDialog} setDialogData={setDialogData}/>
+                    </MFAOptions>
+                }
             </CollapseBox>
 
             <Box mt={2}>
@@ -327,5 +403,13 @@ export default function ProfileView(props) {
                 </Button>
             </Box>
         </div>
+
+        <Dialog show={dialogData.show}
+                title={dialogData.title}
+                message={dialogData.message}
+                inputs={dialogData.inputs}
+                onClose={() => setDialogData({show: false})}
+                options={[L("general.ok"), L("general.cancel")]}
+                onOption={dialogData.onOption} />
     </>
 }

+ 1 - 1
react/admin-panel/src/views/route/route-list.js

@@ -201,7 +201,7 @@ export default function RouteListView(props) {
                                                     { type: "text", name: "pattern", value: route.pattern, disabled: true}
                                                 ],
                                                 options: [L("general.ok"), L("general.cancel")],
-                                                onOption: btn => btn === 0 && onDeleteRoute(route.id)
+                                                onOption: btn => btn === 0 ? onDeleteRoute(route.id) : true
                                             })}>
                                     <Delete />
                                 </IconButton>

+ 2 - 0
react/package.json

@@ -21,6 +21,8 @@
     "devDependencies": {
         "@babel/core": "^7.20.5",
         "@babel/plugin-transform-react-jsx": "^7.19.0",
+        "@eslint/js": "^9.0.0",
+        "eslint-plugin-react": "^7.34.1",
         "customize-cra": "^1.0.0",
         "parcel": "^2.8.0",
         "react-app-rewired": "^2.2.1",

+ 17 - 6
react/shared/elements/dialog.jsx

@@ -12,7 +12,7 @@ import {
 
 export default function Dialog(props) {
 
-    const show = props.show;
+    const show = !!props.show;
     const onClose = props.onClose || function() { };
     const onOption = props.onOption || function() { };
     const options = props.options || ["Close"];
@@ -36,7 +36,13 @@ export default function Dialog(props) {
     for (const [index, name] of options.entries()) {
         buttons.push(
             <Button variant={"outlined"} size={"small"} key={"button-" + name}
-                    onClick={() => { onClose(); onOption(index, inputData); setInputData({}); }}>
+                    onClick={() => {
+                        let res = onOption(index, inputData);
+                        if (res || res === undefined) {
+                            onClose();
+                            setInputData({});
+                        }
+                    }}>
                 {name}
             </Button>
         )
@@ -54,16 +60,21 @@ export default function Dialog(props) {
                 inputElements.push(<span {...inputProps}>{input.value}</span>);
                 break;
             case 'text':
-            case 'password':
+            case 'number':
+            case 'password': {
+                let onChange = (input.type === "number") ?
+                    e => setInputData({ ...inputData, [input.name]: e.target.value.replace(/[^0-9,.]/, '') }) :
+                    e => setInputData({ ...inputData, [input.name]: e.target.value });
+
                 inputElements.push(<TextField
                     {...inputProps}
-                    type={input.type}
+                    type={input.type === "number" ? "text" : input.type}
                     size={"small"} fullWidth={true}
                     key={"input-" + input.name}
                     value={inputData[input.name] || ""}
-                    onChange={e => setInputData({ ...inputData, [input.name]: e.target.value })}
+                    onChange={onChange}
                 />)
-                break;
+            } break;
             case 'list':
                 delete inputProps.items;
                 let listItems = input.items.map((item, index) => <ListItem key={"item-" + index}>{item}</ListItem>);

+ 1 - 2
react/shared/elements/search-field.js

@@ -4,12 +4,11 @@ import useAsyncSearch from "../hooks/async-search";
 
 export default function SearchField(props) {
 
-    const { onSearch, displayText, onSelect, ...other } = props;
+    const { onSearch, onSelect, ...other } = props;
 
     const [searchString, setSearchString, results] = useAsyncSearch(props.onSearch, 3);
 
     return <Autocomplete {...other}
-         getOptionLabel={r => displayText(r)}
          options={Object.values(results ?? {})}
          onChange={(e, n) => onSelect(n)}
          renderInput={(params) => (

+ 1 - 1
react/shared/views/login.jsx

@@ -171,7 +171,7 @@ export default function LoginForm(props) {
                         autoComplete={"code"}
                         required fullWidth autoFocus
                         value={tfaCode} onChange={(e) => set2FACode(e.target.value)}
-                    />
+                        onKeyDown={e => e.key === "Enter" && onSubmit2FA()} />
                     {
                         tfaToken.error ? <ResponseAlert severity="error">{tfaToken.error}</ResponseAlert> : <></>
                     }

+ 6 - 1
react/yarn.lock

@@ -1417,6 +1417,11 @@
   resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f"
   integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==
 
+"@eslint/js@^9.0.0":
+  version "9.0.0"
+  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.0.0.tgz#1a9e4b4c96d8c7886e0110ed310a0135144a1691"
+  integrity sha512-RThY/MnKrhubF6+s1JflwUjPEsnCEmYCWwqa/aRISKWNXGZ9epUwft4bUMM35SdKF9xvBrLydAM1RDHd1Z//ZQ==
+
 "@floating-ui/core@^1.0.0":
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1"
@@ -5386,7 +5391,7 @@ eslint-plugin-react-hooks@^4.3.0:
   resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3"
   integrity sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==
 
-eslint-plugin-react@^7.27.1:
+eslint-plugin-react@^7.27.1, eslint-plugin-react@^7.34.1:
   version "7.34.1"
   resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.34.1.tgz#6806b70c97796f5bbfb235a5d3379ece5f4da997"
   integrity sha512-N97CxlouPT1AHt8Jn0mhhN2RrADlUAsk1/atcT2KyA/l9Q/E6ll7OIGwNumFmWfZ9skV3XXccYS19h80rHtgkw==

+ 0 - 1
test/TimeBasedTwoFactorToken.test.php

@@ -1,7 +1,6 @@
 <?php
 
 use Base32\Base32;
-use Core\Objects\Context;
 use Core\Objects\TwoFactor\TimeBasedTwoFactorToken;
 
 class TimeBasedTwoFactorTokenTest extends PHPUnit\Framework\TestCase {