Archived
1
0
Fork 0

Compare commits

...

34 commits
v1.1 ... dev

Author SHA1 Message Date
50464bce0d Merge branch '37-admin-impersonate-user' into 'dev'
Impersonate users

Closes #37

See merge request pnx/httpcb!34
2023-05-01 15:49:58 +00:00
a757e95277 app/views/_common/_components/navigation.volt: show both users if the user are impersonating another user. 2023-05-01 15:49:42 +00:00
bf940f9523 app/library/Auth.php: Adding getImpersonator() 2023-05-01 15:49:42 +00:00
6dc1d0fb87 app/library/Auth.php: Fix typo. 2023-05-01 15:49:42 +00:00
0634db6d0c app/views/backend/user/index.volt: Add icon link to impersonate users 2023-05-01 15:49:42 +00:00
24edb5cb59 app/config/routes.yml: add impersonate route. 2023-05-01 15:49:42 +00:00
4ac770f733 app/controllers/backend/UserController.php: adding impersonateAction() 2023-05-01 15:49:42 +00:00
69fd7a6e19 app/library/Auth.php: adding impersonate() and impersonateClear() methods 2023-05-01 15:49:42 +00:00
6439a83edb app/listeners/ActivityLog.php: Adding onImpersonate listener 2023-05-01 15:49:42 +00:00
be4950ff88 Style fixes. 2023-04-30 16:57:42 +02:00
a7a59b690a app/library/Services.php: in _initSharedRouter() setup not found route. 2023-04-30 16:46:53 +02:00
dd140ccd8a app/library/Services.php: in _initSharedRouter() Remove default routes 2023-04-30 16:46:04 +02:00
d2e7d6b670 app/config/routes.yml: define all routes and use shorthand path fields. 2023-04-30 16:44:36 +02:00
8c9455a2d5 Merge branch '38-admin-send-activation-password-resets-to-email' into dev 2022-08-28 17:49:22 +02:00
151daa8529 app/controllers/backend/UserController.php: typo fix. 2022-08-28 17:49:03 +02:00
d3e52269cd app/views/backend/user/form.volt: only show "Send activation email" button for suspended users. 2022-08-28 17:48:25 +02:00
8048b3fda8 app/controllers/backend/UserController.php: in activationEmailAction() only send activation email to suspended users. 2022-08-28 17:47:56 +02:00
4ff351d39d app/models/Data/User.php: Adding isSuspended() 2022-08-28 17:46:16 +02:00
1b57749a91 app/views/backend/user/index.volt: Show badge. for status. 2022-08-28 17:38:00 +02:00
c07423f600 app/controllers/ControllerBase.php: set namespace in _forward404() 2022-08-28 17:37:30 +02:00
7315885877 app/config/local.sample.yml: minor fix. 2022-08-28 17:37:26 +02:00
ff15ee697c app/config/routes.yml: Add "status" to the url for backend-user-status route. 2022-08-28 17:36:50 +02:00
2e98191d56 app/controllers/ApiController.php: need to include App\Model\Data\User 2022-08-28 17:36:50 +02:00
0f5f42ca92 app/views/backend/user/form.volt: Add button for sending activation email.
# Conflicts:
#	app/views/backend/user/form.volt
2022-08-28 17:36:31 +02:00
14eb4a9137 app/controllers/backend/UserController.php: Adding activationEmailAction() 2022-08-28 17:36:31 +02:00
e47aa5188e app/library/Services.php: Attach AuthEmailListener to listen to auth events. 2022-08-28 17:35:58 +02:00
5b9d64c09e app/config/routes.yml: Add backend-user-activation-email route 2022-08-28 17:35:58 +02:00
ad2952f9fc app/listeners/ActivityLog.php: Adding onSentActivation 2022-08-28 17:35:58 +02:00
8d74cb2f06 Adding app/listeners/AuthEmailListener.php 2022-08-28 17:35:58 +02:00
6aeaf74a2f package.json: set to version 1.1.0 as npm does not like 1.1 2022-08-28 17:20:53 +02:00
e56c8f37ea app/library/Services.php: in _initSharedModelsMetadata() must pass a factory to adapter in phalcon 4 2022-08-28 17:14:04 +02:00
d7af32a1d7 app/library/Services.php: in _initSession() must pass a factory to adapter in phalcon 4 2022-08-28 17:13:19 +02:00
ee5c7719cb app/listeners/ActivityLog.php: pass true to getClientAddress() so it looks for X-Forwarded-For header. 2022-08-28 16:41:41 +02:00
fa28394a83 composer.json: require ext-mbstring 2022-08-28 16:41:41 +02:00
53 changed files with 398 additions and 193 deletions

View file

@ -30,7 +30,7 @@ session:
statsKey: _httpcb_sess_idx
prefix: _httpcb_sess_
#sendgrid
#sendgrid:
#key: value
# OAuth

View file

@ -4,58 +4,61 @@ router:
routes:
home-route:
pattern: '/'
path:
controller: index
action: index
path: Index::index
about-route:
pattern: '/about'
path:
controller: index
action: about
path: Index::about
# Callbacks
cb-list:
pattern: '/callback/list'
path: Callback::list
cb-new:
pattern: '/callback/new'
path: Callback::new
cb-created:
pattern: '/callback/created/{id}'
path:
controller: callback
action: created
path: Callback::created
cb-show:
pattern: '/callback/show/{id}'
path: Callback::show
cb-endpoint:
pattern: '/cb/{id}/:params'
path:
controller: api
action: endpoint
path: Api::endpoint
# login
login:
pattern: '/login'
path:
controller: auth
action: index
path: Auth::index
logout:
pattern: '/logout'
path:
controller: auth
action: logout
path: Auth::logout
oauth:
pattern: '/login/{strategy:([a-z]+)}/:params'
path:
controller: auth
action: oauth
path: Auth::oauth
oauth-disconnect:
pattern: '/oauth/{provider:([a-z]+)}/disconnect'
path: 'User::oauthdisconnect'
oauth-disconnect-confirm:
pattern: '/oauth/{provider:([a-z]+)}/disconnect/{confirm}'
path: 'User::oauthdisconnect'
# User
user-register:
pattern: '/register'
path: Auth::register
user-settings:
pattern: '/settings'
path:
controller: user
action: settings
path: User::settings
user-activity-log:
pattern: '/user/activity'
path: User::activity
activation-link:
pattern: '/activate/{link}'
path:
controller: api
action: activationlink
path: Api::activationlink
# Backend
backend-home:
@ -70,12 +73,15 @@ router:
backend-user-edit:
pattern: '/admin/user/{id:([0-9]+)}'
path: backend::user::edit
backend-user-impersonate:
pattern: '/admin/impersonate/{id:([0-9]+)}'
path: backend::user::impersonate
backend-user-activation-email:
pattern: '/admin/user/{id:([0-9]+)}/activation'
path: backend::user::activation-email
backend-user-status:
pattern: '/admin/user/{id:([0-9]+)}/{type}'
path:
module: backend
controller: user
action: status
pattern: '/admin/user/{id:([0-9]+)}/status/{type}'
path: backend::user::status
backend-log:
pattern: '/admin/log{page:/?([0-9]+)?}'
path: backend::log::index

View file

@ -6,6 +6,7 @@ use App\Controller\ControllerBase,
App\Model\Data\Callback as CallbackModel,
App\Model\Data\Request as RequestModel,
App\Model\Data\RequestMeta as RequestMetaModel,
App\Model\Data\User,
App\Model\Data\UserActivation;
class ApiController extends ControllerBase

View file

@ -118,8 +118,11 @@ class AuthController extends ControllerBase
}
$user = new User();
$user->assign($data->toArray(), null,
[ 'email', 'username', 'firstname', 'lastname' ]);
$user->assign(
$data->toArray(),
null,
['email', 'username', 'firstname', 'lastname']
);
$form = new RegistrationForm($user);

View file

@ -55,7 +55,8 @@ class CallbackController extends ControllerBase
return $this->response->redirect(array(
'for' => 'cb-created',
'id' => $callback->getPublicId()));
'id' => $callback->getPublicId()
));
} else {
foreach ($callback->getMessages() as $msg) {
$this->flash->error($msg);
@ -71,8 +72,6 @@ class CallbackController extends ControllerBase
$msg .= '</ul>';
$this->flash->message('error', $msg);
}
}
$this->view->form = $form;
@ -85,7 +84,6 @@ class CallbackController extends ControllerBase
{
$row = CallbackModel::get($id);
if (!$row) {
}
$this->view->id = $id;
}

View file

@ -18,6 +18,7 @@ class ControllerBase extends Controller
protected function _forward404()
{
$this->dispatcher->forward(array(
'namespace' => 'App\\Controller',
'controller' => 'error',
'action' => 'show404'
));

View file

@ -15,4 +15,3 @@ class IndexController extends ControllerBase
{
}
}

View file

@ -44,8 +44,11 @@ class UserController extends ControllerBase
]);
// Send the email.
$this->di->getMail()->send('Httpcb password activation',
$user->getEmail(), $content);
$this->di->getMail()->send(
'Httpcb password activation',
$user->getEmail(),
$content
);
$msg = "For security reasons. Before a password can be created "
. "a email has been sent to <strong>{$user->getEmail()}</strong> with "

View file

@ -97,4 +97,33 @@ class UserController extends \Phalcon\Mvc\Controller
$this->flash->success('The account was: ' . $status);
$this->response->redirect('/admin');
}
public function activationEmailAction($id)
{
$user = User::findFirstById($id);
if ($user) {
if ($user->isSuspended()) {
$this->eventsManager->fire('auth:onSentActivation', $user);
$this->flash->success('Activation email sent to: ' . $user->email);
} else {
$this->flash->error('Only suspended users can be sent activation emails.');
}
} else {
$this->flash->error('Invalid user: ' . $id);
}
$this->response->redirect('/admin');
}
public function impersonateAction($id)
{
$user = User::findFirstById($id);
try {
$this->auth->impersonate($user);
$this->response->redirect('/');
} catch (\Exception $ex) {
$this->flash->error($ex->getMessage());
$this->response->redirect('/admin');
}
}
}

View file

@ -7,12 +7,14 @@ use Phalcon\Forms\Form;
/**
* Element types
*/
use Phalcon\Forms\Element\Text;
use Phalcon\Forms\Element\Submit;
/**
* Validators
*/
use Phalcon\Validation\Validator\StringLength;
class CallbackCreate extends Form

View file

@ -7,6 +7,7 @@ use Phalcon\Forms\Form;
/**
* Element types
*/
use Phalcon\Forms\Element\Text;
use Phalcon\Forms\Element\Password;
use Phalcon\Forms\Element\Submit;
@ -14,6 +15,7 @@ use Phalcon\Forms\Element\Submit;
/**
* Validators
*/
use Phalcon\Validation\Validator\PresenceOf;
use Phalcon\Validation\Validator\Email as EmailValidator;
use Phalcon\Validation\Validator\StringLength;

View file

@ -5,16 +5,19 @@ namespace App\Form;
/**
* Models
*/
use App\Model\Data\User;
/**
* Phalcon Form
*/
use Httpcb\Form as FormBase;
/**
* Element types
*/
use Phalcon\Forms\Element\Text,
Phalcon\Forms\Element\Password,
Phalcon\Forms\Element\Submit;
@ -22,6 +25,7 @@ use Phalcon\Forms\Element\Text,
/**
* Validators
*/
use Phalcon\Validation,
Phalcon\Validation\Validator\Callback as CallbackValidator,
Phalcon\Validation\Validator\Alnum as AlnumValidator,
@ -54,7 +58,9 @@ class Registration extends FormBase
'messageMinimum' => 'Username must be at least :min characters long.',
]),
new CallbackValidator([
'callback' => function($data) { return User::findFirstByUsername($data['username']) === false; },
'callback' => function ($data) {
return User::findFirstByUsername($data['username']) === false;
},
'message' => 'The username already exists.',
'attribute' => 'username',
])
@ -87,7 +93,9 @@ class Registration extends FormBase
$email->addValidators([
new CallbackValidator([
'callback' => function($data) { return User::findFirstByEmail($data['email']) === false; },
'callback' => function ($data) {
return User::findFirstByEmail($data['email']) === false;
},
'message' => 'This email already exist.',
])
]);

View file

@ -5,16 +5,19 @@ namespace App\Form;
/**
* Models
*/
use App\Model\Data\User as UserModel;
/**
* Form
*/
use Httpcb\Form as FormBase;
/**
* Element types
*/
use Phalcon\Forms\Element\Text,
Phalcon\Forms\Element\Password,
Phalcon\Forms\Element\Submit;
@ -22,6 +25,7 @@ use Phalcon\Forms\Element\Text,
/**
* Validators
*/
use Phalcon\Validation\Validator\Callback as CallbackValidator,
Phalcon\Validation\Validator\Uniqueness as UniquenessValidator,
Phalcon\Validation\Validator\Alnum as AlnumValidator,

View file

@ -70,7 +70,6 @@ class Acl
if ($def instanceof Config) {
$inherits = $def->get('inherits');
$description = $def->get('description');
}
$role = new Role($name, $description);

View file

@ -10,6 +10,7 @@ use App\Model\Data\User,
class Auth extends Injectable
{
const SESSION_KEY = 'auth';
const IMPERSONATOR_ID = 'auth.impersonator';
/**
* Login using email/user + password combination.
@ -63,8 +64,11 @@ class Auth extends Injectable
$this->setIdentity($user->getId());
$this->eventsManager->fire('auth:onLogin', $this,
"OAuth {$data->getProvider()}");
$this->eventsManager->fire(
'auth:onLogin',
$this,
"OAuth {$data->getProvider()}"
);
return new Result(Result::SUCCESS);
@ -83,6 +87,40 @@ class Auth extends Injectable
$this->eventsManager->fire('auth:onLogin', $this, 'System');
}
public function getImpersonator()
{
$id = $this->session->get(self::IMPERSONATOR_ID);
return $id !== null ? User::findFirst($id) : null;
}
/**
* Impersonate a user
*
* @param User $user
*/
public function impersonate(User $user)
{
$current = $this->getIdentity();
if ($current === null) {
throw new \InvalidArgumentException("Need to be authenticated to be able to impersonate someone");
}
if ($current->getId() === $user->getId()) {
// Same user
throw new \DomainException("Can't impersonate yourself");
}
$this->session->set(self::IMPERSONATOR_ID, $current->getId());
$this->setIdentity($user->getId());
$this->eventsManager->fire('auth:onImpersonate', $this, $current);
}
public function impersonateClear($imp_id)
{
$this->session->remove(self::IMPERSONATOR_ID);
$this->session->set(self::SESSION_KEY, $imp_id);
}
/**
* @param $identity
* @return Auth
@ -132,7 +170,12 @@ class Auth extends Injectable
*/
public function clearIdentity()
{
$imp_id = $this->session->get(self::IMPERSONATOR_ID);
if ($imp_id !== null) {
$this->impersonateClear($imp_id);
} else {
$this->session->remove(self::SESSION_KEY);
}
return $this;
}
}

View file

@ -2,7 +2,8 @@
namespace Httpcb;
class Debug {
class Debug
{
public static function dump($var, $label = null, $echo = true)
{

View file

@ -53,7 +53,10 @@ class Form extends FormBase
$xhtml .= sprintf(
'<label class="%s" for="%s">%s</label>',
$opt['label-class'], $ele->getName(), $ele->getLabel());
$opt['label-class'],
$ele->getName(),
$ele->getLabel()
);
}
$xhtml .= '<div class="' . implode(' ', $opt['class']) . '">'

View file

@ -126,15 +126,22 @@ class Menu extends Tag
return $xhtml;
}
$xhtml = self::tagHtml('li', $node->isActive()
$xhtml = self::tagHtml(
'li',
$node->isActive()
? array('class' => $this->_activeClass) : null,
false, false, true);
false,
false,
true
);
// Generate the link.
$xhtml .= self::linkTo($node->getHref(), $node->getCaption());
if ($node->isActive() && $node->hasChildren()
&& ($max_depth === null || $depth < $max_depth)) {
if (
$node->isActive() && $node->hasChildren()
&& ($max_depth === null || $depth < $max_depth)
) {
$xhtml .= $this->_renderMenu($node->getChildren(), $depth + 1, $max_depth);
}

View file

@ -81,4 +81,3 @@ class Container
return $this;
}
}

View file

@ -16,6 +16,8 @@ use Phalcon\Di\FactoryDefault as DiDefault,
Phalcon\Cache\Frontend\Data as FrontendDataCache,
Phalcon\Cache\Backend\Apc as BackendApcCache,
Phalcon\Translate\Adapter\NativeArray as TranslateAdapter,
Phalcon\Storage\AdapterFactory as StorageAdapterFactory,
Phalcon\Cache\AdapterFactory as CacheAdapterFactory,
Phalcon\Logger,
Phalcon\Mvc\Router;
@ -30,7 +32,8 @@ use Httpcb\Auth,
use App\Listener\AccessListener,
App\Listener\DispatchListener,
App\Listener\ActivityLog;
App\Listener\ActivityLog,
App\Listener\AuthEmailListener;
class Services extends DiDefault
{
@ -46,8 +49,7 @@ class Services extends DiDefault
if (substr($method->getName(), 0, 11) == '_initShared') {
$service = lcfirst(substr($method->getName(), 11));
$this->setShared($service, $method->getClosure($this));
}
else if (substr($method->getName(),0, 5) == '_init') {
} else if (substr($method->getName(), 0, 5) == '_init') {
$service = lcfirst(substr($method->getName(), 5));
$this->set($service, $method->getClosure($this));
}
@ -177,8 +179,11 @@ class Services extends DiDefault
$options = $mdConfig['options'];
$adapter = $mdConfig['adapter'];
$serializerFactory = new \Phalcon\Storage\SerializerFactory();
$factory = new CacheAdapterFactory($serializerFactory);
$class = 'Phalcon\Mvc\Model\MetaData\\' . $adapter;
return new $class($options);
return new $class($factory, $options);
}
// Otherwise, default to Memory.
@ -195,8 +200,11 @@ class Services extends DiDefault
$adapter_name = isset($data['adapter']) ? $data['adapter'] : 'Stream';
$options = $data['options'];
$serializerFactory = new \Phalcon\Storage\SerializerFactory();
$factory = new StorageAdapterFactory($serializerFactory);
$class = 'Phalcon\Session\Adapter\\' . $adapter_name;
$adapter = new $class($options);
$adapter = new $class($factory, $options);
}
// Default to Stream
else {
@ -310,7 +318,7 @@ class Services extends DiDefault
$config = $this->get('config')->router;
// Create the router
$router = new Router();
$router = new Router(false);
$router->removeExtraSlashes($config->get('removeExtraSlashes', false));
foreach ($config->routes as $name => $def) {
@ -327,6 +335,9 @@ class Services extends DiDefault
$router->add($def->get('pattern'), $path)
->setName($name);
}
$router->notFound(['controller' => 'error', 'action' => 'show404']);
return $router;
}
@ -337,6 +348,7 @@ class Services extends DiDefault
$eventsManager = new \Phalcon\Events\Manager();
$eventsManager->attach('user', $activityLog);
$eventsManager->attach('auth', $activityLog);
$eventsManager->attach('auth', new AuthEmailListener);
return $eventsManager;
}

View file

@ -33,7 +33,8 @@ class ServerUrl extends AbstractHelper
// remove port if it's the default port.
if (($scheme == 'http' && $port == 80)
|| ($scheme == 'https' && $port == 443)) {
|| ($scheme == 'https' && $port == 443)
) {
$port = null;
}

View file

@ -23,6 +23,19 @@ class ActivityLog extends Injectable
$this->_log($auth->getUser(), sprintf("Logged in (%s)", $type));
}
/**
* On Impersonate event.
*
* @param Event $event
* @param Auth $auth
* @param User $user The user Impersonating the user in $auth
*/
public function onImpersonate(Event $event, Auth $auth, User $user)
{
$imp = $auth->getUser();
$this->_log($user, sprintf("Impersonated user (%s:%s)", $imp->getId(), $imp->getUsername()));
}
/**
* @param Event $event
* @param User $auth
@ -65,9 +78,21 @@ class ActivityLog extends Injectable
$this->_log($user, sprintf("OAuth disconnected (%s)", $providerName));
}
/**
* Fired when a activation email is sent.
*
* @param Event $event
* @param User $user
* @param string $providerName
*/
public function onSentActivation(Event $event, User $user)
{
$this->_log($user, sprintf("Activation email sent"));
}
protected function _log(User $user, $message)
{
$ip = (new \Phalcon\Http\Request())->getClientAddress();
$ip = (new \Phalcon\Http\Request())->getClientAddress(true);
return (new ActivityLogger())->assign([
'user_id' => $user->getId(),

View file

@ -0,0 +1,25 @@
<?php
namespace App\Listener;
use App\Model\Data\User,
App\Model\Data\UserActivation;
use Phalcon\Di\Injectable,
Phalcon\Events\Event;
class AuthEmailListener extends Injectable
{
public function onSentActivation(Event $event, User $user)
{
$activation = new UserActivation();
$activation->setUserId($user->getId())
->save();
$content = $this->di->getShared('template')->render('mail/account_activation', [
'link' => $activation->getActivationKey()
]);
$this->di->getMail()->send('Httpcb account activation', $user->getEmail(), $content);
}
}

View file

@ -10,8 +10,12 @@ class CreateRequestMetaTable extends AbstractMigration
$table = $this->table('request_meta');
$table->addColumn('callbackid', 'integer');
$table->addForeignKey('callbackid', 'callback', [ 'id' ],
[ 'constraint' => 'FK_callback' ]);
$table->addForeignKey(
'callbackid',
'callback',
['id'],
['constraint' => 'FK_callback']
);
$table->addColumn('source_ip', 'string', [
'limit' => 50,

View file

@ -9,8 +9,12 @@ class CreateRequestObjectTable extends AbstractMigration
{
$table = $this->table('request_object');
$table->addForeignKey('id', 'request_meta', ['id'],
[ 'constraint' => 'FK_request_meta' ]);
$table->addForeignKey(
'id',
'request_meta',
['id'],
['constraint' => 'FK_request_meta']
);
$table->addColumn('headers', 'blob', [
'null' => true,

View file

@ -265,6 +265,16 @@ class User extends Base
return $this->status == self::STATUS_ACTIVE;
}
/**
* Returns true if this user is suspended.
*
* @return bool
*/
public function isSuspended()
{
return $this->status == self::STATUS_SUSPENDED;
}
/**
* @return mixed
*/

View file

@ -44,4 +44,3 @@ abstract class Base implements ModuleDefinitionInterface
));
}
}

View file

@ -12,6 +12,8 @@
data-bs-toggle="dropdown" role="button" aria-expanded="false">
{{ icon('solid/user') }} <strong>{{ auth.getUser().username }}</strong>
{% set imp = auth.getImpersonator() %}
{% if imp %}( {{ icon('solid/user-secret') }} {{ imp.username }} ){% endif %}
</a>
<ul class="dropdown-menu navigation-user-menu-dropdown-list">

View file

@ -56,6 +56,13 @@
{% if (user.getId()) %}
{% set actions = [ 'Activate': 'Active', 'Suspend': 'Suspended', 'Delete': 'Deleted' ] %}
<div class="float-end">
{% if user.isSuspended() %}
<a class="button button-info" href="{{ url(['for': 'backend-user-activation-email', 'id': user.getId() ]) }}">
Send activation email
</a>
{% endif %}
{% for label, status in actions %}
{% if (user.status != status) %}

View file

@ -20,6 +20,7 @@
<th>Email</th>
<th>Type</th>
<th>Status</th>
<th>&nbsp;</th>
</tr>
</thead>
@ -36,7 +37,12 @@
<td>{{ item.name }}</td>
<td>{{ item.email }}</td>
<td>{{ item.type | capitalize }}</td>
<td>{{ item.status }}</td>
<td><span class="badge {{ item.isActive() ? 'badge-success' : 'badge-danger' }}">{{ item.status }}</span></td>
<td>
<a title="Impersonate" href="{{ url(['for': 'backend-user-impersonate', 'id': item.id ]) }}">
{{ icon('solid/user-secret') }}
</a>
</td>
</tr>
{% endfor %}
</tbody>

View file

@ -7,6 +7,7 @@
"ext-psr": ">=1.2",
"ext-redis": "*",
"ext-yaml": "*",
"ext-mbstring": "*",
"robmorgan/phinx": "^0.10.6",
"league/oauth2-client": "^2.3",
"league/oauth2-github": "^2.0",

5
package-lock.json generated
View file

@ -1,11 +1,12 @@
{
"name": "httpcb",
"version": "1.1",
"version": "1.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"version": "1.1",
"name": "httpcb",
"version": "1.1.0",
"license": "ISC",
"devDependencies": {
"@popperjs/core": "^2.11.5",

View file

@ -1,6 +1,6 @@
{
"name": "httpcb",
"version": "1.1",
"version": "1.1.0",
"description": "HTTP Callback Tool",
"devDependencies": {
"@popperjs/core": "^2.11.5",