diff --git a/Core/API/MailAPI.class.php b/Core/API/MailAPI.class.php index da3a354..3388a77 100644 --- a/Core/API/MailAPI.class.php +++ b/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 { diff --git a/Core/API/TfaAPI.class.php b/Core/API/TfaAPI.class.php index 76d83b6..8e8366e 100644 --- a/Core/API/TfaAPI.class.php +++ b/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); + } } } \ No newline at end of file diff --git a/Core/Localization/de_DE/account.php b/Core/Localization/de_DE/account.php index b377c25..6af0dcd 100644 --- a/Core/Localization/de_DE/account.php +++ b/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.", diff --git a/Core/Localization/de_DE/general.php b/Core/Localization/de_DE/general.php index 150e549..c7e307a 100644 --- a/Core/Localization/de_DE/general.php +++ b/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", diff --git a/Core/Localization/en_US/account.php b/Core/Localization/en_US/account.php index cc49424..19a10dd 100644 --- a/Core/Localization/en_US/account.php +++ b/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.", diff --git a/Core/Localization/en_US/general.php b/Core/Localization/en_US/general.php index 3559ddb..aaa8366 100644 --- a/Core/Localization/en_US/general.php +++ b/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", diff --git a/img/icons/nitrokey.png b/img/icons/nitrokey.png index e463ffd..5c69624 100644 Binary files a/img/icons/nitrokey.png and b/img/icons/nitrokey.png differ diff --git a/react/admin-panel/src/AdminDashboard.jsx b/react/admin-panel/src/AdminDashboard.jsx index 49f5533..fb91d08 100644 --- a/react/admin-panel/src/AdminDashboard.jsx +++ b/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"; diff --git a/react/admin-panel/src/elements/dialog.jsx b/react/admin-panel/src/elements/dialog.jsx deleted file mode 100644 index 0bc909e..0000000 --- a/react/admin-panel/src/elements/dialog.jsx +++ /dev/null @@ -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( - - ) - } - - return ( -
onClose()}> -
e.stopPropagation()}> -
-
-

{props.title}

- -
-
-

{props.message}

-
-
- { buttons } -
-
-
-
- ); -} \ No newline at end of file diff --git a/react/admin-panel/src/views/access-control-list.js b/react/admin-panel/src/views/access-control-list.js index 06eef97..9826c1c 100644 --- a/react/admin-panel/src/views/access-control-list.js +++ b/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 })} > option === 0 && onDeletePermission(permission.method) + onOption: (option) => option === 0 ? onDeletePermission(permission.method) : true })} > @@ -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")} diff --git a/react/admin-panel/src/views/group/group-edit.js b/react/admin-panel/src/views/group/group-edit.js index 0327a7e..0d685ee 100644 --- a/react/admin-panel/src/views/group/group-edit.js +++ b/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 } @@ -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")} @@ -252,7 +248,7 @@ export default function EditGroupView(props) { {!isNewGroup && api.hasPermission("groups/getMembers") ? -
+

{L("account.members")}

option === 0 && onRemoveMember(entry.id) + onOption: (option) => option === 0 ? onRemoveMember(entry.id) : true }) } ]), @@ -295,7 +291,7 @@ export default function EditGroupView(props) { onClick: onOpenMemberDialog }]} /> -
+ : <> } diff --git a/react/admin-panel/src/views/profile/mfa-fido.js b/react/admin-panel/src/views/profile/mfa-fido.js new file mode 100644 index 0000000..8267dc6 --- /dev/null +++ b/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 +
{"[Nitro
+
{L("account.2fa_type_fido")}
+
; +} \ No newline at end of file diff --git a/react/admin-panel/src/views/profile/mfa-totp.js b/react/admin-panel/src/views/profile/mfa-totp.js new file mode 100644 index 0000000..e8f6848 --- /dev/null +++ b/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: + {"[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 +
{"[Google
+
{L("account.2fa_type_totp")}
+
+} \ No newline at end of file diff --git a/react/admin-panel/src/views/profile/profile.js b/react/admin-panel/src/views/profile/profile.js index 1d44daa..15b588d 100644 --- a/react/admin-panel/src/views/profile/profile.js +++ b/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 <>
@@ -315,7 +361,37 @@ export default function ProfileView(props) { setOpenedTab(openedTab === "2fa" ? "" : "2fa")} icon={}> - test + {profile.twoFactorToken && profile.twoFactorToken.confirmed ? + + + { profile.twoFactorToken.confirmed ? + : + + } + {L("account.2fa_type_" + profile.twoFactorToken.type)} + + + {L("account.password")} + + set2FAPassword(e.target.value)} + placeholder={L("account.password")} + /> + + + + : + + + + + } @@ -327,5 +403,13 @@ export default function ProfileView(props) {
+ + setDialogData({show: false})} + options={[L("general.ok"), L("general.cancel")]} + onOption={dialogData.onOption} /> } \ No newline at end of file diff --git a/react/admin-panel/src/views/route/route-list.js b/react/admin-panel/src/views/route/route-list.js index adf6236..2322557 100644 --- a/react/admin-panel/src/views/route/route-list.js +++ b/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 })}> diff --git a/react/package.json b/react/package.json index ab8b0a4..64d7a68 100644 --- a/react/package.json +++ b/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", diff --git a/react/shared/elements/dialog.jsx b/react/shared/elements/dialog.jsx index a7204d2..001a69a 100644 --- a/react/shared/elements/dialog.jsx +++ b/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( ) @@ -54,16 +60,21 @@ export default function Dialog(props) { inputElements.push({input.value}); 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( setInputData({ ...inputData, [input.name]: e.target.value })} + onChange={onChange} />) - break; + } break; case 'list': delete inputProps.items; let listItems = input.items.map((item, index) => {item}); diff --git a/react/shared/elements/search-field.js b/react/shared/elements/search-field.js index 3c81533..c2cd317 100644 --- a/react/shared/elements/search-field.js +++ b/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 displayText(r)} options={Object.values(results ?? {})} onChange={(e, n) => onSelect(n)} renderInput={(params) => ( diff --git a/react/shared/views/login.jsx b/react/shared/views/login.jsx index fa58581..4a3b985 100644 --- a/react/shared/views/login.jsx +++ b/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 ? {tfaToken.error} : <> } diff --git a/react/yarn.lock b/react/yarn.lock index 9faed0f..5bcf891 100644 --- a/react/yarn.lock +++ b/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== diff --git a/test/TimeBasedTwoFactorToken.test.php b/test/TimeBasedTwoFactorToken.test.php index a137530..deac727 100644 --- a/test/TimeBasedTwoFactorToken.test.php +++ b/test/TimeBasedTwoFactorToken.test.php @@ -1,7 +1,6 @@