2FA totp, bugfix
This commit is contained in:
parent
e97ac34365
commit
0974ac9260
@ -55,7 +55,6 @@ namespace Core\API\Mail {
|
|||||||
use Core\External\PHPMailer\PHPMailer;
|
use Core\External\PHPMailer\PHPMailer;
|
||||||
use Core\Objects\Context;
|
use Core\Objects\Context;
|
||||||
use Core\Objects\DatabaseEntity\GpgKey;
|
use Core\Objects\DatabaseEntity\GpgKey;
|
||||||
use PhpParser\Node\Param;
|
|
||||||
|
|
||||||
class Test extends MailAPI {
|
class Test extends MailAPI {
|
||||||
|
|
||||||
|
@ -62,6 +62,7 @@ namespace Core\API\TFA {
|
|||||||
use Core\API\Parameter\StringType;
|
use Core\API\Parameter\StringType;
|
||||||
use Core\API\TfaAPI;
|
use Core\API\TfaAPI;
|
||||||
use Core\Driver\SQL\Condition\Compare;
|
use Core\Driver\SQL\Condition\Compare;
|
||||||
|
use Core\Driver\SQL\Query\Insert;
|
||||||
use Core\Objects\Context;
|
use Core\Objects\Context;
|
||||||
use Core\Objects\TwoFactor\AttestationObject;
|
use Core\Objects\TwoFactor\AttestationObject;
|
||||||
use Core\Objects\TwoFactor\AuthenticationData;
|
use Core\Objects\TwoFactor\AuthenticationData;
|
||||||
@ -131,6 +132,10 @@ namespace Core\API\TFA {
|
|||||||
|
|
||||||
return $this->success;
|
return $this->success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getDefaultACL(Insert $insert): void {
|
||||||
|
$insert->addRow(self::getEndpoint(), [], "Allows users to remove their 2FA-Tokens", true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TOTP
|
// TOTP
|
||||||
@ -167,11 +172,16 @@ namespace Core\API\TFA {
|
|||||||
$this->disableCache();
|
$this->disableCache();
|
||||||
die($twoFactorToken->generateQRCode($this->context));
|
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 {
|
class ConfirmTotp extends VerifyTotp {
|
||||||
public function __construct(Context $context, bool $externalCall = false) {
|
public function __construct(Context $context, bool $externalCall = false) {
|
||||||
parent::__construct($context, $externalCall);
|
parent::__construct($context, $externalCall);
|
||||||
|
$this->loginRequired = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function _execute(): bool {
|
public function _execute(): bool {
|
||||||
@ -196,6 +206,10 @@ namespace Core\API\TFA {
|
|||||||
|
|
||||||
return $this->success;
|
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 {
|
class VerifyTotp extends TfaAPI {
|
||||||
@ -211,10 +225,6 @@ namespace Core\API\TFA {
|
|||||||
public function _execute(): bool {
|
public function _execute(): bool {
|
||||||
|
|
||||||
$currentUser = $this->context->getUser();
|
$currentUser = $this->context->getUser();
|
||||||
if (!$currentUser) {
|
|
||||||
return $this->createError("You are not logged in.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$twoFactorToken = $currentUser->getTwoFactorToken();
|
$twoFactorToken = $currentUser->getTwoFactorToken();
|
||||||
if (!$twoFactorToken) {
|
if (!$twoFactorToken) {
|
||||||
return $this->createError("You did not add a two factor token yet.");
|
return $this->createError("You did not add a two factor token yet.");
|
||||||
@ -230,6 +240,10 @@ namespace Core\API\TFA {
|
|||||||
$twoFactorToken->authenticate();
|
$twoFactorToken->authenticate();
|
||||||
return $this->success;
|
return $this->success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getDefaultACL(Insert $insert): void {
|
||||||
|
$insert->addRow(self::getEndpoint(), [], "Allows users to verify time-based 2FA-Tokens", true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Key
|
// Key
|
||||||
@ -326,6 +340,10 @@ namespace Core\API\TFA {
|
|||||||
|
|
||||||
return $this->success;
|
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 {
|
class VerifyKey extends TfaAPI {
|
||||||
@ -384,5 +402,9 @@ namespace Core\API\TFA {
|
|||||||
|
|
||||||
return $this->success;
|
return $this->success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getDefaultACL(Insert $insert): void {
|
||||||
|
$insert->addRow(self::getEndpoint(), [], "Allows users to verify a 2FA hardware-key", true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -90,6 +90,8 @@ return [
|
|||||||
"gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...",
|
"gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...",
|
||||||
|
|
||||||
# 2fa
|
# 2fa
|
||||||
|
"2fa_type_totp" => "Zeitbasiertes 2FA (TOTP)",
|
||||||
|
"2fa_type_fido" => "Schlüsselbasiertes 2FA",
|
||||||
"register_2fa_device" => "Ein 2FA-Gerät registrieren",
|
"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. " .
|
"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.",
|
"Unter Android kannst du den Google Authenticator benutzen.",
|
||||||
|
@ -35,6 +35,7 @@ return [
|
|||||||
"no" => "Nein",
|
"no" => "Nein",
|
||||||
"create_new" => "Erstellen",
|
"create_new" => "Erstellen",
|
||||||
"unchanged" => "Unverändert",
|
"unchanged" => "Unverändert",
|
||||||
|
"click_to_copy" => "Klicken zum Kopieren",
|
||||||
|
|
||||||
# dialog / actions
|
# dialog / actions
|
||||||
"action" => "Aktion",
|
"action" => "Aktion",
|
||||||
|
@ -90,6 +90,8 @@ return [
|
|||||||
"gpg_key_placeholder_text" => "Paste or drag'n'drop your GPG-Key in ASCII format...",
|
"gpg_key_placeholder_text" => "Paste or drag'n'drop your GPG-Key in ASCII format...",
|
||||||
|
|
||||||
# 2fa
|
# 2fa
|
||||||
|
"2fa_type_totp" => "Time-Based 2FA (TOTP)",
|
||||||
|
"2fa_type_fido" => "Key-Based 2FA",
|
||||||
"register_2fa_device" => "Register a 2FA-Device",
|
"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). " .
|
"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.",
|
"On Android, you can use the Google Authenticator.",
|
||||||
|
@ -17,6 +17,7 @@ return [
|
|||||||
"no" => "No",
|
"no" => "No",
|
||||||
"create_new" => "Create",
|
"create_new" => "Create",
|
||||||
"unchanged" => "Unchanged",
|
"unchanged" => "Unchanged",
|
||||||
|
"click_to_copy" => "Click to copy",
|
||||||
|
|
||||||
# dialog / actions
|
# dialog / actions
|
||||||
"action" => "Action",
|
"action" => "Action",
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.2 KiB |
@ -1,7 +1,7 @@
|
|||||||
import React, {lazy, Suspense, useCallback, useState} from "react";
|
import React, {lazy, Suspense, useCallback, useState} from "react";
|
||||||
import {BrowserRouter, Route, Routes} from "react-router-dom";
|
import {BrowserRouter, Route, Routes} from "react-router-dom";
|
||||||
|
import Dialog from "shared/elements/dialog";
|
||||||
import Sidebar from "./elements/sidebar";
|
import Sidebar from "./elements/sidebar";
|
||||||
import Dialog from "./elements/dialog";
|
|
||||||
import Footer from "./elements/footer";
|
import Footer from "./elements/footer";
|
||||||
import {useContext, useEffect} from "react";
|
import {useContext, useEffect} from "react";
|
||||||
import {LocaleContext} from "shared/locale";
|
import {LocaleContext} from "shared/locale";
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -169,8 +169,7 @@ export default function AccessControlList(props) {
|
|||||||
{ type: "label", value: L("permissions.description") + ":" },
|
{ type: "label", value: L("permissions.description") + ":" },
|
||||||
{ type: "text", name: "description", value: permission.description, maxLength: 128 }
|
{ 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 />
|
<Edit />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton style={{padding: 0}} size={"small"} color={"secondary"}
|
<IconButton style={{padding: 0}} size={"small"} color={"secondary"}
|
||||||
@ -179,7 +178,7 @@ export default function AccessControlList(props) {
|
|||||||
open: true,
|
open: true,
|
||||||
title: L("permissions.delete_permission_confirm"),
|
title: L("permissions.delete_permission_confirm"),
|
||||||
message: L("permissions.method") + ": " + permission.method,
|
message: L("permissions.method") + ": " + permission.method,
|
||||||
onOption: (option) => option === 0 && onDeletePermission(permission.method)
|
onOption: (option) => option === 0 ? onDeletePermission(permission.method) : true
|
||||||
})} >
|
})} >
|
||||||
<Delete />
|
<Delete />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@ -253,7 +252,7 @@ export default function AccessControlList(props) {
|
|||||||
{ type: "label", value: L("permissions.description") + ":" },
|
{ type: "label", value: L("permissions.description") + ":" },
|
||||||
{ type: "text", name: "description", maxLength: 128, placeholder: 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")}
|
{L("general.add")}
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -3,11 +3,12 @@ import {Link, useNavigate, useParams} from "react-router-dom";
|
|||||||
import {LocaleContext} from "shared/locale";
|
import {LocaleContext} from "shared/locale";
|
||||||
import SearchField from "shared/elements/search-field";
|
import SearchField from "shared/elements/search-field";
|
||||||
import React from "react";
|
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 EditIcon from "@mui/icons-material/Edit";
|
||||||
import usePagination from "shared/hooks/pagination";
|
import usePagination from "shared/hooks/pagination";
|
||||||
import Dialog from "shared/elements/dialog";
|
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 {Add, Delete, KeyboardArrowLeft, Save} from "@mui/icons-material";
|
||||||
import {MuiColorInput} from "mui-color-input";
|
import {MuiColorInput} from "mui-color-input";
|
||||||
import ButtonBar from "../../elements/button-bar";
|
import ButtonBar from "../../elements/button-bar";
|
||||||
@ -27,6 +28,7 @@ export default function EditGroupView(props) {
|
|||||||
const isNewGroup = groupId === "new";
|
const isNewGroup = groupId === "new";
|
||||||
const pagination = usePagination();
|
const pagination = usePagination();
|
||||||
const api = props.api;
|
const api = props.api;
|
||||||
|
const showDialog = props.showDialog;
|
||||||
|
|
||||||
// data
|
// data
|
||||||
const [fetchGroup, setFetchGroup] = useState(!isNewGroup);
|
const [fetchGroup, setFetchGroup] = useState(!isNewGroup);
|
||||||
@ -41,7 +43,7 @@ export default function EditGroupView(props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
requestModules(props.api, ["general", "account"], currentLocale).then(data => {
|
requestModules(props.api, ["general", "account"], currentLocale).then(data => {
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
props.showDialog(data.msg, "Error fetching localization");
|
showDialog(data.msg, "Error fetching localization");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [currentLocale]);
|
}, [currentLocale]);
|
||||||
@ -51,7 +53,7 @@ export default function EditGroupView(props) {
|
|||||||
setFetchGroup(false);
|
setFetchGroup(false);
|
||||||
api.getGroup(groupId).then(res => {
|
api.getGroup(groupId).then(res => {
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
props.showDialog(res.msg, "Error fetching group");
|
showDialog(res.msg, "Error fetching group");
|
||||||
navigate("/admin/groups");
|
navigate("/admin/groups");
|
||||||
} else {
|
} else {
|
||||||
setGroup(res.group);
|
setGroup(res.group);
|
||||||
@ -66,11 +68,11 @@ export default function EditGroupView(props) {
|
|||||||
setMembers(res.users);
|
setMembers(res.users);
|
||||||
pagination.update(res.pagination);
|
pagination.update(res.pagination);
|
||||||
} else {
|
} else {
|
||||||
props.showDialog(res.msg, L("account.fetch_group_members_error"));
|
showDialog(res.msg, L("account.fetch_group_members_error"));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [groupId, api, pagination]);
|
}, [api, showDialog, pagination, groupId]);
|
||||||
|
|
||||||
const onRemoveMember = useCallback(userId => {
|
const onRemoveMember = useCallback(userId => {
|
||||||
api.removeGroupMember(groupId, userId).then(data => {
|
api.removeGroupMember(groupId, userId).then(data => {
|
||||||
@ -78,16 +80,16 @@ export default function EditGroupView(props) {
|
|||||||
let newMembers = members.filter(u => u.id !== userId);
|
let newMembers = members.filter(u => u.id !== userId);
|
||||||
setMembers(newMembers);
|
setMembers(newMembers);
|
||||||
} else {
|
} 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(() => {
|
const onAddMember = useCallback(() => {
|
||||||
if (selectedUser) {
|
if (selectedUser) {
|
||||||
api.addGroupMember(groupId, selectedUser.id).then(data => {
|
api.addGroupMember(groupId, selectedUser.id).then(data => {
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
props.showDialog(data.msg, L("account.add_group_member_error"));
|
showDialog(data.msg, L("account.add_group_member_error"));
|
||||||
} else {
|
} else {
|
||||||
let newMembers = [...members];
|
let newMembers = [...members];
|
||||||
newMembers.push(selectedUser);
|
newMembers.push(selectedUser);
|
||||||
@ -96,7 +98,7 @@ export default function EditGroupView(props) {
|
|||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [api, groupId, selectedUser])
|
}, [api, showDialog, groupId, selectedUser, members])
|
||||||
|
|
||||||
const onSave = useCallback(() => {
|
const onSave = useCallback(() => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@ -104,7 +106,7 @@ export default function EditGroupView(props) {
|
|||||||
api.createGroup(group.name, group.color).then(data => {
|
api.createGroup(group.name, group.color).then(data => {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
props.showDialog(data.msg, L("account.create_group_error"));
|
showDialog(data.msg, L("account.create_group_error"));
|
||||||
} else {
|
} else {
|
||||||
navigate(`/admin/group/${data.id}`)
|
navigate(`/admin/group/${data.id}`)
|
||||||
}
|
}
|
||||||
@ -113,31 +115,31 @@ export default function EditGroupView(props) {
|
|||||||
api.updateGroup(groupId, group.name, group.color).then(data => {
|
api.updateGroup(groupId, group.name, group.color).then(data => {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
if (!data.success) {
|
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) => {
|
const onSearchUser = useCallback((async (query) => {
|
||||||
let data = await api.searchUser(query);
|
let data = await api.searchUser(query);
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
props.showDialog(data.msg, L("account.search_users_error"));
|
showDialog(data.msg, L("account.search_users_error"));
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.users;
|
return data.users;
|
||||||
}), [api]);
|
}), [api, showDialog]);
|
||||||
|
|
||||||
const onDeleteGroup = useCallback(() => {
|
const onDeleteGroup = useCallback(() => {
|
||||||
api.deleteGroup(groupId).then(data => {
|
api.deleteGroup(groupId).then(data => {
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
props.showDialog(data.msg, L("account.delete_group_error"));
|
showDialog(data.msg, L("account.delete_group_error"));
|
||||||
} else {
|
} else {
|
||||||
navigate("/admin/groups");
|
navigate("/admin/groups");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [api, groupId]);
|
}, [api, showDialog, groupId]);
|
||||||
|
|
||||||
const onOpenMemberDialog = useCallback(() => {
|
const onOpenMemberDialog = useCallback(() => {
|
||||||
setDialogData({
|
setDialogData({
|
||||||
@ -146,30 +148,24 @@ export default function EditGroupView(props) {
|
|||||||
message: L("account.add_group_member_text"),
|
message: L("account.add_group_member_text"),
|
||||||
inputs: [
|
inputs: [
|
||||||
{
|
{
|
||||||
type: "custom", name: "search", element: SearchField,
|
type: "custom", name: "search",
|
||||||
size: "small", key: "search",
|
size: "small", key: "search",
|
||||||
|
element: SearchField,
|
||||||
onSearch: v => onSearchUser(v),
|
onSearch: v => onSearchUser(v),
|
||||||
onSelect: u => setSelectedUser(u),
|
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(() => {
|
useEffect(() => {
|
||||||
onFetchGroup();
|
onFetchGroup();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const complementaryColor = (color) => {
|
|
||||||
if (color.startsWith("#")) {
|
|
||||||
color = color.substring(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let numericValue = parseInt(color, 16);
|
|
||||||
return "#" + (0xFFFFFF - numericValue).toString(16);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (group === null) {
|
if (group === null) {
|
||||||
return <CircularProgress />
|
return <CircularProgress />
|
||||||
}
|
}
|
||||||
@ -243,7 +239,7 @@ export default function EditGroupView(props) {
|
|||||||
open: true,
|
open: true,
|
||||||
title: L("account.delete_group_title"),
|
title: L("account.delete_group_title"),
|
||||||
message: L("account.delete_group_text"),
|
message: L("account.delete_group_text"),
|
||||||
onOption: option => option === 0 && onDeleteGroup()
|
onOption: option => option === 0 ? onDeleteGroup() : true
|
||||||
})}>
|
})}>
|
||||||
{L("general.delete")}
|
{L("general.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
@ -252,7 +248,7 @@ export default function EditGroupView(props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isNewGroup && api.hasPermission("groups/getMembers") ?
|
{!isNewGroup && api.hasPermission("groups/getMembers") ?
|
||||||
<div className={"m-3 col-6"}>
|
<Box m={3} className={"col-6"}>
|
||||||
<h4>{L("account.members")}</h4>
|
<h4>{L("account.members")}</h4>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={members}
|
data={members}
|
||||||
@ -281,7 +277,7 @@ export default function EditGroupView(props) {
|
|||||||
open: true,
|
open: true,
|
||||||
title: L("account.remove_group_member_title"),
|
title: L("account.remove_group_member_title"),
|
||||||
message: sprintf(L("account.remove_group_member_text"), entry.fullName || entry.name),
|
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
|
onClick: onOpenMemberDialog
|
||||||
}]}
|
}]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
: <></>
|
: <></>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
26
react/admin-panel/src/views/profile/mfa-fido.js
Normal file
26
react/admin-panel/src/views/profile/mfa-fido.js
Normal file
@ -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
react/admin-panel/src/views/profile/mfa-totp.js
Normal file
55
react/admin-panel/src/views/profile/mfa-totp.js
Normal file
@ -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>
|
||||||
|
}
|
@ -7,7 +7,7 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
FormLabel, styled,
|
FormLabel, Paper, styled,
|
||||||
TextField
|
TextField
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
@ -23,6 +23,9 @@ import {
|
|||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import CollapseBox from "./collapse-box";
|
import CollapseBox from "./collapse-box";
|
||||||
import ButtonBar from "../../elements/button-bar";
|
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) => ({
|
const GpgKeyField = styled(TextField)((props) => ({
|
||||||
"& > div": {
|
"& > div": {
|
||||||
@ -46,6 +49,29 @@ const ProfileFormGroup = styled(FormGroup)((props) => ({
|
|||||||
marginBottom: props.theme.spacing(2)
|
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')({
|
const VisuallyHiddenInput = styled('input')({
|
||||||
clip: 'rect(0 0 0 0)',
|
clip: 'rect(0 0 0 0)',
|
||||||
clipPath: 'inset(50%)',
|
clipPath: 'inset(50%)',
|
||||||
@ -78,12 +104,15 @@ export default function ProfileView(props) {
|
|||||||
const [changePassword, setChangePassword] = useState({ old: "", new: "", confirm: "" });
|
const [changePassword, setChangePassword] = useState({ old: "", new: "", confirm: "" });
|
||||||
const [gpgKey, setGpgKey] = useState("");
|
const [gpgKey, setGpgKey] = useState("");
|
||||||
const [gpgKeyPassword, setGpgKeyPassword] = useState("");
|
const [gpgKeyPassword, setGpgKeyPassword] = useState("");
|
||||||
|
const [mfaPassword, set2FAPassword] = useState("");
|
||||||
|
|
||||||
// ui
|
// ui
|
||||||
const [openedTab, setOpenedTab] = useState(null);
|
const [openedTab, setOpenedTab] = useState(null);
|
||||||
const [isSaving, setSaving] = useState(false);
|
const [isSaving, setSaving] = useState(false);
|
||||||
const [isGpgKeyUploading, setGpgKeyUploading] = useState(false);
|
const [isGpgKeyUploading, setGpgKeyUploading] = useState(false);
|
||||||
const [isGpgKeyRemoving, setGpgKeyRemoving] = useState(false);
|
const [isGpgKeyRemoving, setGpgKeyRemoving] = useState(false);
|
||||||
|
const [is2FARemoving, set2FARemoving] = useState(false);
|
||||||
|
const [dialogData, setDialogData] = useState({show: false});
|
||||||
|
|
||||||
const onUpdateProfile = useCallback(() => {
|
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) => {
|
const getFileContents = useCallback((file, callback) => {
|
||||||
let reader = new FileReader();
|
let reader = new FileReader();
|
||||||
@ -167,6 +211,8 @@ export default function ProfileView(props) {
|
|||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
}, [showDialog]);
|
}, [showDialog]);
|
||||||
|
|
||||||
|
console.log("SELECTED USER:", profile.twoFactorToken);
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<div className={"content-header"}>
|
<div className={"content-header"}>
|
||||||
<div className={"container-fluid"}>
|
<div className={"container-fluid"}>
|
||||||
@ -315,7 +361,37 @@ export default function ProfileView(props) {
|
|||||||
<CollapseBox title={L("account.2fa_token")} open={openedTab === "2fa"}
|
<CollapseBox title={L("account.2fa_token")} open={openedTab === "2fa"}
|
||||||
onToggle={() => setOpenedTab(openedTab === "2fa" ? "" : "2fa")}
|
onToggle={() => setOpenedTab(openedTab === "2fa" ? "" : "2fa")}
|
||||||
icon={<Fingerprint />}>
|
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>
|
</CollapseBox>
|
||||||
|
|
||||||
<Box mt={2}>
|
<Box mt={2}>
|
||||||
@ -327,5 +403,13 @@ export default function ProfileView(props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</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} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
@ -201,7 +201,7 @@ export default function RouteListView(props) {
|
|||||||
{ type: "text", name: "pattern", value: route.pattern, disabled: true}
|
{ type: "text", name: "pattern", value: route.pattern, disabled: true}
|
||||||
],
|
],
|
||||||
options: [L("general.ok"), L("general.cancel")],
|
options: [L("general.ok"), L("general.cancel")],
|
||||||
onOption: btn => btn === 0 && onDeleteRoute(route.id)
|
onOption: btn => btn === 0 ? onDeleteRoute(route.id) : true
|
||||||
})}>
|
})}>
|
||||||
<Delete />
|
<Delete />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@ -21,6 +21,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.20.5",
|
"@babel/core": "^7.20.5",
|
||||||
"@babel/plugin-transform-react-jsx": "^7.19.0",
|
"@babel/plugin-transform-react-jsx": "^7.19.0",
|
||||||
|
"@eslint/js": "^9.0.0",
|
||||||
|
"eslint-plugin-react": "^7.34.1",
|
||||||
"customize-cra": "^1.0.0",
|
"customize-cra": "^1.0.0",
|
||||||
"parcel": "^2.8.0",
|
"parcel": "^2.8.0",
|
||||||
"react-app-rewired": "^2.2.1",
|
"react-app-rewired": "^2.2.1",
|
||||||
|
@ -12,7 +12,7 @@ import {
|
|||||||
|
|
||||||
export default function Dialog(props) {
|
export default function Dialog(props) {
|
||||||
|
|
||||||
const show = props.show;
|
const show = !!props.show;
|
||||||
const onClose = props.onClose || function() { };
|
const onClose = props.onClose || function() { };
|
||||||
const onOption = props.onOption || function() { };
|
const onOption = props.onOption || function() { };
|
||||||
const options = props.options || ["Close"];
|
const options = props.options || ["Close"];
|
||||||
@ -36,7 +36,13 @@ export default function Dialog(props) {
|
|||||||
for (const [index, name] of options.entries()) {
|
for (const [index, name] of options.entries()) {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<Button variant={"outlined"} size={"small"} key={"button-" + name}
|
<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}
|
{name}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@ -54,16 +60,21 @@ export default function Dialog(props) {
|
|||||||
inputElements.push(<span {...inputProps}>{input.value}</span>);
|
inputElements.push(<span {...inputProps}>{input.value}</span>);
|
||||||
break;
|
break;
|
||||||
case 'text':
|
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
|
inputElements.push(<TextField
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
type={input.type}
|
type={input.type === "number" ? "text" : input.type}
|
||||||
size={"small"} fullWidth={true}
|
size={"small"} fullWidth={true}
|
||||||
key={"input-" + input.name}
|
key={"input-" + input.name}
|
||||||
value={inputData[input.name] || ""}
|
value={inputData[input.name] || ""}
|
||||||
onChange={e => setInputData({ ...inputData, [input.name]: e.target.value })}
|
onChange={onChange}
|
||||||
/>)
|
/>)
|
||||||
break;
|
} break;
|
||||||
case 'list':
|
case 'list':
|
||||||
delete inputProps.items;
|
delete inputProps.items;
|
||||||
let listItems = input.items.map((item, index) => <ListItem key={"item-" + index}>{item}</ListItem>);
|
let listItems = input.items.map((item, index) => <ListItem key={"item-" + index}>{item}</ListItem>);
|
||||||
|
@ -4,12 +4,11 @@ import useAsyncSearch from "../hooks/async-search";
|
|||||||
|
|
||||||
export default function SearchField(props) {
|
export default function SearchField(props) {
|
||||||
|
|
||||||
const { onSearch, displayText, onSelect, ...other } = props;
|
const { onSearch, onSelect, ...other } = props;
|
||||||
|
|
||||||
const [searchString, setSearchString, results] = useAsyncSearch(props.onSearch, 3);
|
const [searchString, setSearchString, results] = useAsyncSearch(props.onSearch, 3);
|
||||||
|
|
||||||
return <Autocomplete {...other}
|
return <Autocomplete {...other}
|
||||||
getOptionLabel={r => displayText(r)}
|
|
||||||
options={Object.values(results ?? {})}
|
options={Object.values(results ?? {})}
|
||||||
onChange={(e, n) => onSelect(n)}
|
onChange={(e, n) => onSelect(n)}
|
||||||
renderInput={(params) => (
|
renderInput={(params) => (
|
||||||
|
@ -171,7 +171,7 @@ export default function LoginForm(props) {
|
|||||||
autoComplete={"code"}
|
autoComplete={"code"}
|
||||||
required fullWidth autoFocus
|
required fullWidth autoFocus
|
||||||
value={tfaCode} onChange={(e) => set2FACode(e.target.value)}
|
value={tfaCode} onChange={(e) => set2FACode(e.target.value)}
|
||||||
/>
|
onKeyDown={e => e.key === "Enter" && onSubmit2FA()} />
|
||||||
{
|
{
|
||||||
tfaToken.error ? <ResponseAlert severity="error">{tfaToken.error}</ResponseAlert> : <></>
|
tfaToken.error ? <ResponseAlert severity="error">{tfaToken.error}</ResponseAlert> : <></>
|
||||||
}
|
}
|
||||||
|
@ -1417,6 +1417,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f"
|
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f"
|
||||||
integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==
|
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":
|
"@floating-ui/core@^1.0.0":
|
||||||
version "1.6.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1"
|
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"
|
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3"
|
||||||
integrity sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==
|
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"
|
version "7.34.1"
|
||||||
resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.34.1.tgz#6806b70c97796f5bbfb235a5d3379ece5f4da997"
|
resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.34.1.tgz#6806b70c97796f5bbfb235a5d3379ece5f4da997"
|
||||||
integrity sha512-N97CxlouPT1AHt8Jn0mhhN2RrADlUAsk1/atcT2KyA/l9Q/E6ll7OIGwNumFmWfZ9skV3XXccYS19h80rHtgkw==
|
integrity sha512-N97CxlouPT1AHt8Jn0mhhN2RrADlUAsk1/atcT2KyA/l9Q/E6ll7OIGwNumFmWfZ9skV3XXccYS19h80rHtgkw==
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Base32\Base32;
|
use Base32\Base32;
|
||||||
use Core\Objects\Context;
|
|
||||||
use Core\Objects\TwoFactor\TimeBasedTwoFactorToken;
|
use Core\Objects\TwoFactor\TimeBasedTwoFactorToken;
|
||||||
|
|
||||||
class TimeBasedTwoFactorTokenTest extends PHPUnit\Framework\TestCase {
|
class TimeBasedTwoFactorTokenTest extends PHPUnit\Framework\TestCase {
|
||||||
|
Loading…
Reference in New Issue
Block a user