Auth Framework, Part 1
Started buildig the authorization infrastructure to 1. create the initial admin class to then 2. the Auth manager class can be created to manage access based on roles. added number of template files as well just as a UI base to get things started. Auth Framework Part 2 will complete the Auth mangager and clean up the admin area.
This commit is contained in:
parent
74ae426275
commit
54b5227a0d
23 changed files with 3567 additions and 4 deletions
|
@ -7,13 +7,20 @@
|
||||||
"php": ">=8.1",
|
"php": ">=8.1",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
|
"doctrine/doctrine-bundle": "^2.7",
|
||||||
|
"doctrine/doctrine-migrations-bundle": "^3.2",
|
||||||
|
"doctrine/orm": "^2.13",
|
||||||
"sensio/framework-extra-bundle": "^6.2",
|
"sensio/framework-extra-bundle": "^6.2",
|
||||||
"symfony/console": "6.1.*",
|
"symfony/console": "6.1.*",
|
||||||
"symfony/dotenv": "6.1.*",
|
"symfony/dotenv": "6.1.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
|
"symfony/form": "6.1.*",
|
||||||
"symfony/framework-bundle": "6.1.*",
|
"symfony/framework-bundle": "6.1.*",
|
||||||
|
"symfony/proxy-manager-bridge": "6.1.*",
|
||||||
"symfony/runtime": "6.1.*",
|
"symfony/runtime": "6.1.*",
|
||||||
|
"symfony/security-csrf": "6.1.*",
|
||||||
"symfony/twig-bundle": "6.1.*",
|
"symfony/twig-bundle": "6.1.*",
|
||||||
|
"symfony/uid": "6.1.*",
|
||||||
"symfony/yaml": "6.1.*",
|
"symfony/yaml": "6.1.*",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
"twig/extra-bundle": "^2.12|^3.0",
|
||||||
"twig/twig": "^2.12|^3.0"
|
"twig/twig": "^2.12|^3.0"
|
||||||
|
@ -69,5 +76,8 @@
|
||||||
"allow-contrib": false,
|
"allow-contrib": false,
|
||||||
"require": "6.1.*"
|
"require": "6.1.*"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/maker-bundle": "^1.48"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
2580
composer.lock
generated
2580
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -5,4 +5,7 @@ return [
|
||||||
Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true],
|
Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||||
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
|
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
|
||||||
|
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||||
|
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||||
|
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||||
];
|
];
|
||||||
|
|
42
config/packages/doctrine.yaml
Normal file
42
config/packages/doctrine.yaml
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
|
|
||||||
|
# IMPORTANT: You MUST configure your server version,
|
||||||
|
# either here or in the DATABASE_URL env var (see .env file)
|
||||||
|
#server_version: '14'
|
||||||
|
orm:
|
||||||
|
auto_generate_proxy_classes: true
|
||||||
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
|
auto_mapping: true
|
||||||
|
mappings:
|
||||||
|
App:
|
||||||
|
is_bundle: false
|
||||||
|
dir: '%kernel.project_dir%/src/Entity'
|
||||||
|
prefix: 'App\Entity'
|
||||||
|
alias: App
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
# "TEST_TOKEN" is typically set by ParaTest
|
||||||
|
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
doctrine:
|
||||||
|
orm:
|
||||||
|
auto_generate_proxy_classes: false
|
||||||
|
query_cache_driver:
|
||||||
|
type: pool
|
||||||
|
pool: doctrine.system_cache_pool
|
||||||
|
result_cache_driver:
|
||||||
|
type: pool
|
||||||
|
pool: doctrine.result_cache_pool
|
||||||
|
|
||||||
|
framework:
|
||||||
|
cache:
|
||||||
|
pools:
|
||||||
|
doctrine.result_cache_pool:
|
||||||
|
adapter: cache.app
|
||||||
|
doctrine.system_cache_pool:
|
||||||
|
adapter: cache.system
|
6
config/packages/doctrine_migrations.yaml
Normal file
6
config/packages/doctrine_migrations.yaml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
doctrine_migrations:
|
||||||
|
migrations_paths:
|
||||||
|
# namespace is arbitrary but should be different from App\Migrations
|
||||||
|
# as migrations classes should NOT be autoloaded
|
||||||
|
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||||
|
enable_profiler: false
|
0
migrations/.gitignore
vendored
Normal file
0
migrations/.gitignore
vendored
Normal file
34
migrations/Version20221204210004.php
Normal file
34
migrations/Version20221204210004.php
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20221204210004 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE SEQUENCE member_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
|
||||||
|
$this->addSql('CREATE TABLE member (id INT NOT NULL, uuid VARCHAR(255) NOT NULL, handle VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, active BOOLEAN NOT NULL, role INT DEFAULT NULL, avatar VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE SCHEMA public');
|
||||||
|
$this->addSql('DROP SEQUENCE member_id_seq CASCADE');
|
||||||
|
$this->addSql('DROP TABLE member');
|
||||||
|
}
|
||||||
|
}
|
34
migrations/Version20221212212120.php
Normal file
34
migrations/Version20221212212120.php
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20221212212120 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE member ADD pronoun VARCHAR(255) NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE member ADD gender VARCHAR(255) NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE SCHEMA public');
|
||||||
|
$this->addSql('ALTER TABLE member DROP pronoun');
|
||||||
|
$this->addSql('ALTER TABLE member DROP gender');
|
||||||
|
}
|
||||||
|
}
|
35
migrations/Version20221212213909.php
Normal file
35
migrations/Version20221212213909.php
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20221212213909 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE member ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE member ADD last_login TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN member.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE SCHEMA public');
|
||||||
|
$this->addSql('ALTER TABLE member DROP created_at');
|
||||||
|
$this->addSql('ALTER TABLE member DROP last_login');
|
||||||
|
}
|
||||||
|
}
|
44
src/Controller/Routes/Back/Index.php
Normal file
44
src/Controller/Routes/Back/Index.php
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
// src/Controller/DataImport.php
|
||||||
|
// Grab data from transfer app
|
||||||
|
|
||||||
|
namespace App\Controller\Routes\Back;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
|
||||||
|
//use App\Utils\PageRender;
|
||||||
|
//use App\Data\Auth;
|
||||||
|
|
||||||
|
class Index extends AbstractController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @Route("/screendoor", name="back-index")
|
||||||
|
*/
|
||||||
|
public function showBackIndex(Request $request): Response
|
||||||
|
{
|
||||||
|
return $this->render("back/index.twig", [
|
||||||
|
"title" => "Close the door behind you",
|
||||||
|
]);
|
||||||
|
/*
|
||||||
|
$result = $auth->status();
|
||||||
|
if ($result["status"]) {
|
||||||
|
return $render->renderPage(
|
||||||
|
[
|
||||||
|
"bgImage" => "/images/base/tweed-flowers.png",
|
||||||
|
"role" => $result["role"],
|
||||||
|
],
|
||||||
|
"The Nile List | Welcome Back",
|
||||||
|
"front/index.html.twig"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
//back to index to login
|
||||||
|
header("Location:/login");
|
||||||
|
return new Response("<html><body>LOGGED IN</body></html>");
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
}
|
144
src/Controller/Routes/Back/Members.php
Normal file
144
src/Controller/Routes/Back/Members.php
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
// src/Controller/DataImport.php
|
||||||
|
// Grab data from transfer app
|
||||||
|
|
||||||
|
namespace App\Controller\Routes\Back;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
//use App\Utils\PageRender;
|
||||||
|
//use App\Utils\StringTools;
|
||||||
|
use App\Service\Auth;
|
||||||
|
use App\Service\HandleMembers;
|
||||||
|
|
||||||
|
class Members extends AbstractController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @Route("/dashboard/members", name="dash-members")
|
||||||
|
*/
|
||||||
|
public function showMembers(
|
||||||
|
Request $request,
|
||||||
|
Auth $auth
|
||||||
|
): Response {
|
||||||
|
$result = $auth->status();
|
||||||
|
if ($result["status"]) {
|
||||||
|
/*
|
||||||
|
return $render->renderPage(
|
||||||
|
["bgImage" => "", "mode" => "index"],
|
||||||
|
"The Nile List | Members",
|
||||||
|
"dash/members.html.twig"
|
||||||
|
);
|
||||||
|
*/
|
||||||
|
} else {
|
||||||
|
//back to index to login
|
||||||
|
header("Location:/knockknock");
|
||||||
|
return new Response("<html><body>LOGGED IN</body></html>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/screendoor/members/add", name="members-add")
|
||||||
|
*/
|
||||||
|
public function addMembers(
|
||||||
|
Request $request,
|
||||||
|
Auth $auth,
|
||||||
|
HandleMembers $members,
|
||||||
|
ManagerRegistry $doctrine
|
||||||
|
): Response {
|
||||||
|
$result = $auth->status();
|
||||||
|
if ($result["status"]) {
|
||||||
|
if ($request->getMethod() == "GET") {
|
||||||
|
return $this->render("back/members.twig", [
|
||||||
|
"title" => "Get a class from the cupboard",
|
||||||
|
"mode" => "add"
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
//add new member
|
||||||
|
$token = $request->get("token");
|
||||||
|
$notice = "";
|
||||||
|
$entityManager = $doctrine->getManager();
|
||||||
|
|
||||||
|
//token check
|
||||||
|
if (!$this->isCsrfTokenValid("upload", $token)) {
|
||||||
|
$logger->info("CSRF failure");
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
"Operation not allowed",
|
||||||
|
Response::HTTP_BAD_REQUEST,
|
||||||
|
[
|
||||||
|
"content-type" => "text/plain",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$request->request->get("handle") == "" ||
|
||||||
|
$request->request->get("role") == "" ||
|
||||||
|
$request->request->get("gender") == "" ||
|
||||||
|
$request->request->get("email") == "" ||
|
||||||
|
$request->request->get("pronoun") == ""
|
||||||
|
) {
|
||||||
|
return new Response("<html><body>All fields required</body></html>");
|
||||||
|
|
||||||
|
/*
|
||||||
|
$notice = "All fields are required, champ.";
|
||||||
|
return $render->renderPage(
|
||||||
|
["bgImage" => "", "mode" => "add", "notice" => $notice],
|
||||||
|
"The Nile List | Add Member Error",
|
||||||
|
"dash/members.html.twig"
|
||||||
|
);
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!filter_var($request->request->get("email"), FILTER_VALIDATE_EMAIL)
|
||||||
|
) {
|
||||||
|
return new Response("<html><body>BOGUS EMAIL</body></html>");
|
||||||
|
|
||||||
|
/*
|
||||||
|
$notice = "Need a valid email, slick.";
|
||||||
|
return $render->renderPage(
|
||||||
|
["bgImage" => "", "mode" => "add", "notice" => $notice],
|
||||||
|
"The Nile List | Add Member Error",
|
||||||
|
"dash/members.html.twig"
|
||||||
|
);
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
||||||
|
//check clear, call add method
|
||||||
|
$response = $members->addMember($request);
|
||||||
|
if ($response["status"]) {
|
||||||
|
/*
|
||||||
|
return $render->renderPage(
|
||||||
|
[
|
||||||
|
"bgImage" => "",
|
||||||
|
"mode" => "add",
|
||||||
|
"notice" => $response["message"],
|
||||||
|
],
|
||||||
|
"The Nile List | Add Members",
|
||||||
|
"dash/members.html.twig"
|
||||||
|
);
|
||||||
|
*/
|
||||||
|
return new Response("<html><body>MEMBER ADDED</body></html>");
|
||||||
|
} else {
|
||||||
|
return new Response("<html><body>" . $response["message"] . "</body></html>");
|
||||||
|
/*
|
||||||
|
return $render->renderPage(
|
||||||
|
["bgImage" => "", "message" => $response["message"]],
|
||||||
|
"The Nile List | Uh Oh Time",
|
||||||
|
"front/error.html.twig"
|
||||||
|
);
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
//back to index to login
|
||||||
|
header("Location:/knockknock");
|
||||||
|
return new Response("<html><body>LOGGED IN</body></html>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,7 +9,6 @@ use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpClient\HttpClient;
|
|
||||||
|
|
||||||
//use App\Utils\PageRender;
|
//use App\Utils\PageRender;
|
||||||
//use App\Data\Auth;
|
//use App\Data\Auth;
|
||||||
|
@ -42,4 +41,14 @@ class Index extends AbstractController
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Route("/knockknock", name="access")
|
||||||
|
*/
|
||||||
|
public function access(Request $request): Response
|
||||||
|
{
|
||||||
|
return $this->render("front/knock.twig", [
|
||||||
|
"title" => "Wipe Your feet",
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
0
src/Entity/.gitignore
vendored
Normal file
0
src/Entity/.gitignore
vendored
Normal file
186
src/Entity/Member.php
Normal file
186
src/Entity/Member.php
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\MemberRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: MemberRepository::class)]
|
||||||
|
class Member
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $uuid = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $handle = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $email = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $password = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?bool $active = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $role = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $avatar = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $pronoun = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $gender = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private ?\DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
|
||||||
|
private ?\DateTimeInterface $lastLogin = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUuid(): ?string
|
||||||
|
{
|
||||||
|
return $this->uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUuid(string $uuid): self
|
||||||
|
{
|
||||||
|
$this->uuid = $uuid;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHandle(): ?string
|
||||||
|
{
|
||||||
|
return $this->handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHandle(string $handle): self
|
||||||
|
{
|
||||||
|
$this->handle = $handle;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmail(): ?string
|
||||||
|
{
|
||||||
|
return $this->email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEmail(string $email): self
|
||||||
|
{
|
||||||
|
$this->email = $email;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPassword(): ?string
|
||||||
|
{
|
||||||
|
return $this->password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPassword(string $password): self
|
||||||
|
{
|
||||||
|
$this->password = $password;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isActive(): ?bool
|
||||||
|
{
|
||||||
|
return $this->active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setActive(bool $active): self
|
||||||
|
{
|
||||||
|
$this->active = $active;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRole(): ?int
|
||||||
|
{
|
||||||
|
return $this->role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRole(?int $role): self
|
||||||
|
{
|
||||||
|
$this->role = $role;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAvatar(): ?string
|
||||||
|
{
|
||||||
|
return $this->avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAvatar(?string $avatar): self
|
||||||
|
{
|
||||||
|
$this->avatar = $avatar;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPronoun(): ?string
|
||||||
|
{
|
||||||
|
return $this->pronoun;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPronoun(string $pronoun): self
|
||||||
|
{
|
||||||
|
$this->pronoun = $pronoun;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGender(): ?string
|
||||||
|
{
|
||||||
|
return $this->gender;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setGender(string $gender): self
|
||||||
|
{
|
||||||
|
$this->gender = $gender;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedAt(\DateTimeImmutable $createdAt): self
|
||||||
|
{
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastLogin(): ?\DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->lastLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLastLogin(\DateTimeInterface $lastLogin): self
|
||||||
|
{
|
||||||
|
$this->lastLogin = $lastLogin;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
0
src/Repository/.gitignore
vendored
Normal file
0
src/Repository/.gitignore
vendored
Normal file
66
src/Repository/MemberRepository.php
Normal file
66
src/Repository/MemberRepository.php
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Member;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Member>
|
||||||
|
*
|
||||||
|
* @method Member|null find($id, $lockMode = null, $lockVersion = null)
|
||||||
|
* @method Member|null findOneBy(array $criteria, array $orderBy = null)
|
||||||
|
* @method Member[] findAll()
|
||||||
|
* @method Member[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||||
|
*/
|
||||||
|
class MemberRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Member::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Member $entity, bool $flush = false): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->persist($entity);
|
||||||
|
|
||||||
|
if ($flush) {
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(Member $entity, bool $flush = false): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->remove($entity);
|
||||||
|
|
||||||
|
if ($flush) {
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * @return Member[] Returns an array of Member objects
|
||||||
|
// */
|
||||||
|
// public function findByExampleField($value): array
|
||||||
|
// {
|
||||||
|
// return $this->createQueryBuilder('m')
|
||||||
|
// ->andWhere('m.exampleField = :val')
|
||||||
|
// ->setParameter('val', $value)
|
||||||
|
// ->orderBy('m.id', 'ASC')
|
||||||
|
// ->setMaxResults(10)
|
||||||
|
// ->getQuery()
|
||||||
|
// ->getResult()
|
||||||
|
// ;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// public function findOneBySomeField($value): ?Member
|
||||||
|
// {
|
||||||
|
// return $this->createQueryBuilder('m')
|
||||||
|
// ->andWhere('m.exampleField = :val')
|
||||||
|
// ->setParameter('val', $value)
|
||||||
|
// ->getQuery()
|
||||||
|
// ->getOneOrNullResult()
|
||||||
|
// ;
|
||||||
|
// }
|
||||||
|
}
|
97
src/Service/Auth.php
Normal file
97
src/Service/Auth.php
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
// src/Controller/ProductController.php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use App\Entity\Members;
|
||||||
|
use ReallySimpleJWT\Token;
|
||||||
|
|
||||||
|
class Auth
|
||||||
|
{
|
||||||
|
private $session;
|
||||||
|
private $entityManager;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
EntityManagerInterface $entityManager,
|
||||||
|
RequestStack $requestStack
|
||||||
|
) {
|
||||||
|
$this->entityManager = $entityManager;
|
||||||
|
$this->session = $requestStack->getSession();
|
||||||
|
$this->secret = '!$ec7eT$l0w*';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authCheck($email, $password)
|
||||||
|
{
|
||||||
|
$response = [];
|
||||||
|
$member = new Members();
|
||||||
|
$members = $this->entityManager->getRepository(Members::class);
|
||||||
|
$member = $members->findOneBy(["email" => $email]);
|
||||||
|
if (!$member) {
|
||||||
|
$response = ["status" => false, "message" => "Member Not Found"];
|
||||||
|
} else {
|
||||||
|
if (!password_verify($password, $member->getPassword())) {
|
||||||
|
$response = ["status" => false, "message" => "Check that password"];
|
||||||
|
} else {
|
||||||
|
$this->session->set("member", $member);
|
||||||
|
|
||||||
|
$secret = $this->secret;
|
||||||
|
$expiration = time() + 3600;
|
||||||
|
$token = Token::create(
|
||||||
|
$member->getMemberId(),
|
||||||
|
$secret,
|
||||||
|
$expiration,
|
||||||
|
"nile_admin"
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->session->set("token", $token);
|
||||||
|
$response = ["status" => true, "message" => "Welcome Back"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logOut()
|
||||||
|
{
|
||||||
|
$this->session->set("member", null);
|
||||||
|
$this->session->set("token", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function APIStatus()
|
||||||
|
{
|
||||||
|
$response = [];
|
||||||
|
$verify = Token::validate($this->session->get("token"), $this->secret);
|
||||||
|
|
||||||
|
if ($verify) {
|
||||||
|
$response = [
|
||||||
|
"status" => true,
|
||||||
|
"message" => "Token is good",
|
||||||
|
"token" => $this->session->get("token"),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$response = ["status" => false, "message" => "Bad Token, champ."];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function status()
|
||||||
|
{
|
||||||
|
$response = [];
|
||||||
|
if ($this->session->get("member")) {
|
||||||
|
//$member = $this->session->get("member");
|
||||||
|
$response = [
|
||||||
|
"status" => true,
|
||||||
|
"role" => $this->session->get("member")->getRole(),
|
||||||
|
"token" => $this->session->get("token"),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$response = ["status" => false, "role" => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
173
src/Service/HandleMembers.php
Normal file
173
src/Service/HandleMembers.php
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
// src/Controller/ProductController.php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\DBALException;
|
||||||
|
use Doctrine\ORM\ORMException;
|
||||||
|
use PDOException;
|
||||||
|
use Exception;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
use App\Entity\Member;
|
||||||
|
|
||||||
|
//use App\Utils\StringTools;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Members
|
||||||
|
*
|
||||||
|
* Data class for interacting with Member data from the DB
|
||||||
|
*/
|
||||||
|
class HandleMembers
|
||||||
|
{
|
||||||
|
private $session;
|
||||||
|
private $entityManager;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
EntityManagerInterface $entityManager,
|
||||||
|
RequestStack $requestStack
|
||||||
|
) {
|
||||||
|
$this->entityManager = $entityManager;
|
||||||
|
$this->session = $requestStack->getSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grabs member data object from the current section
|
||||||
|
*
|
||||||
|
* @return MEMBERS data object
|
||||||
|
*/
|
||||||
|
public function getMember()
|
||||||
|
{
|
||||||
|
$member = $this->session->get("member");
|
||||||
|
return $member;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add new member to db
|
||||||
|
*
|
||||||
|
* @param Request $request object containing posted data
|
||||||
|
* @return JSON
|
||||||
|
*/
|
||||||
|
public function addMember($request)
|
||||||
|
{
|
||||||
|
$errorMessage = null;
|
||||||
|
$member = new Member();
|
||||||
|
|
||||||
|
//submitted values
|
||||||
|
$handle = $request->request->get("handle");
|
||||||
|
$member->setHandle($handle);
|
||||||
|
$gender = $request->request->get("gender");
|
||||||
|
$member->setGender($gender);
|
||||||
|
$role = $request->request->get("role");
|
||||||
|
$member->setRole($role);
|
||||||
|
$email = $request->request->get("email");
|
||||||
|
$member->setEmail($email);
|
||||||
|
$pronoun = $request->request->get("pronoun");
|
||||||
|
$member->setPronoun($pronoun);
|
||||||
|
|
||||||
|
//set defaults
|
||||||
|
//$utils = new StringTools();
|
||||||
|
$uuid = $hash = password_hash("passw0rd!", PASSWORD_DEFAULT);
|
||||||
|
$member->setPassword($hash);
|
||||||
|
$member->setAvatar("default-member-avatar");
|
||||||
|
$member->setUuid(Uuid::v4());
|
||||||
|
$member->setActive(false);
|
||||||
|
$member->setCreatedAt(new \DateTimeImmutable());
|
||||||
|
$member->setLastLogin(new \DateTimeImmutable());
|
||||||
|
|
||||||
|
$this->entityManager->persist($member);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
} catch (PDOException $error) {
|
||||||
|
$errorMessage = $error->getMessage();
|
||||||
|
} catch (DBALException $error) {
|
||||||
|
$errorMessage = $error->getMessage();
|
||||||
|
} catch (ORMException $error) {
|
||||||
|
$errorMessage = $error->getMessage();
|
||||||
|
} catch (Exception $error) {
|
||||||
|
$errorMessage = $error->getMessage();
|
||||||
|
} catch (SyntaxErrorException $e) {
|
||||||
|
$errorMessage = $error->getMessage();
|
||||||
|
}
|
||||||
|
// return result status
|
||||||
|
if ($errorMessage == null) {
|
||||||
|
return $response = [
|
||||||
|
"status" => true,
|
||||||
|
"message" => "New member added. Woohoo!",
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
return $response = ["status" => false, "message" => $errorMessage];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates member data in db
|
||||||
|
*
|
||||||
|
* @param Request $request object containing posted data
|
||||||
|
* @return JSON
|
||||||
|
*/
|
||||||
|
public function updateMember($request)
|
||||||
|
{
|
||||||
|
$errorMessage = null;
|
||||||
|
$currentMember = $this->getMember();
|
||||||
|
$id = $currentMember->getMemberId();
|
||||||
|
$member = $this->entityManager->getRepository(Members::class)->find($id);
|
||||||
|
$image = $request->files->get("avi");
|
||||||
|
if (!empty($image)) {
|
||||||
|
$name = $image->getClientOriginalName();
|
||||||
|
$member->setAvatar($name);
|
||||||
|
}
|
||||||
|
$first = $request->request->get("first_name");
|
||||||
|
$member->setFirstName($first);
|
||||||
|
$last = $request->request->get("last_name");
|
||||||
|
$member->setLastName($last);
|
||||||
|
$handle = $request->request->get("handle");
|
||||||
|
$member->setHandle($handle);
|
||||||
|
$gender = $request->request->get("gender");
|
||||||
|
$member->setGender($gender);
|
||||||
|
$public = $request->request->get("public");
|
||||||
|
if ($public == "true") {
|
||||||
|
$member->setPublicProfile(true);
|
||||||
|
} else {
|
||||||
|
$member->setPublicProfile(false);
|
||||||
|
}
|
||||||
|
$email = $request->request->get("email");
|
||||||
|
$member->setEmail($email);
|
||||||
|
$pronoun = $request->request->get("pronoun");
|
||||||
|
$member->setPronouns($pronoun);
|
||||||
|
$pass_new = $request->request->get("password_new");
|
||||||
|
if ($pass_new != "" || $pass_new != null) {
|
||||||
|
$hash = password_hash($pass_new, PASSWORD_DEFAULT);
|
||||||
|
$member->setPassword($hash);
|
||||||
|
}
|
||||||
|
$this->entityManager->persist($member);
|
||||||
|
|
||||||
|
//error checking
|
||||||
|
try {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
} catch (PDOException $error) {
|
||||||
|
$errorMessage = $error->getMessage();
|
||||||
|
} catch (DBALException $error) {
|
||||||
|
$errorMessage = $error->getMessage();
|
||||||
|
} catch (ORMException $error) {
|
||||||
|
$errorMessage = $error->getMessage();
|
||||||
|
} catch (Exception $error) {
|
||||||
|
$errorMessage = $error->getMessage();
|
||||||
|
} catch (SyntaxErrorException $e) {
|
||||||
|
$errorMessage = $error->getMessage();
|
||||||
|
}
|
||||||
|
// return result status
|
||||||
|
if ($errorMessage == null) {
|
||||||
|
$this->session->set("member", $member);
|
||||||
|
return $response = [
|
||||||
|
"status" => true,
|
||||||
|
"message" => "Profile Updated! Nice!",
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
return $response = ["status" => false, "message" => $errorMessage];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
36
symfony.lock
36
symfony.lock
|
@ -8,6 +8,33 @@
|
||||||
"ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05"
|
"ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"doctrine/doctrine-bundle": {
|
||||||
|
"version": "2.7",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "2.4",
|
||||||
|
"ref": "d562b46dd8075ab2de3d58bf7895cf6b8365cb72"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/doctrine.yaml",
|
||||||
|
"src/Entity/.gitignore",
|
||||||
|
"src/Repository/.gitignore"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"doctrine/doctrine-migrations-bundle": {
|
||||||
|
"version": "3.2",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "3.1",
|
||||||
|
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/doctrine_migrations.yaml",
|
||||||
|
"migrations/.gitignore"
|
||||||
|
]
|
||||||
|
},
|
||||||
"sensio/framework-extra-bundle": {
|
"sensio/framework-extra-bundle": {
|
||||||
"version": "6.2",
|
"version": "6.2",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
@ -63,6 +90,15 @@
|
||||||
"src/Kernel.php"
|
"src/Kernel.php"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"symfony/maker-bundle": {
|
||||||
|
"version": "1.48",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "1.0",
|
||||||
|
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
|
||||||
|
}
|
||||||
|
},
|
||||||
"symfony/routing": {
|
"symfony/routing": {
|
||||||
"version": "6.1",
|
"version": "6.1",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|
10
templates/back/index.twig
Normal file
10
templates/back/index.twig
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{% extends "base/frame.twig" %}
|
||||||
|
{% block stylesheets %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="/assets/css/front/start.css?=sdfsdf">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<section role="intro">
|
||||||
|
This is the screendoor index
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
12
templates/back/members.twig
Normal file
12
templates/back/members.twig
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
{% extends "base/frame.twig" %}
|
||||||
|
{% block stylesheets %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="/assets/css/front/start.css?=sdfsdf">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<section role="intro">
|
||||||
|
This is the screendoor member page
|
||||||
|
|
||||||
|
{{ include("forms/add-member-form.twig") }}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
36
templates/forms/add-member-form.twig
Normal file
36
templates/forms/add-member-form.twig
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
<form action="{{ path('members-add') }}" method="post" enctype="multipart/form-data">
|
||||||
|
<div>
|
||||||
|
<label>Handle</label><br/>
|
||||||
|
<input type="text" name="handle" value=""/>
|
||||||
|
<br/>
|
||||||
|
<label>Email</label><br/>
|
||||||
|
<input type="text" name="email" value=""/>
|
||||||
|
<br/>
|
||||||
|
<label>Gender</label><br/>
|
||||||
|
<select name="gender">
|
||||||
|
<option value="" disabled selected>Choose Gender
|
||||||
|
</option>
|
||||||
|
<option value="man">Man</option>
|
||||||
|
<option value="woman">Woman</option>
|
||||||
|
<option value="non_binary">Non-Binary</option>
|
||||||
|
</select>
|
||||||
|
<br/>
|
||||||
|
<label>Pronoun</label><br/>
|
||||||
|
<select name="pronoun">
|
||||||
|
<option value="" disabled selected>Choose Pronoun</option>
|
||||||
|
<option value="they/them">They/Them</option>
|
||||||
|
<option value="she/her">She/Her</option>
|
||||||
|
<option value="he/him">He/Him</option>
|
||||||
|
</select>
|
||||||
|
<br/>
|
||||||
|
<label>Role</label><br/>
|
||||||
|
<select name="role">
|
||||||
|
<option value="" disabled selected>Choose Role
|
||||||
|
</option>
|
||||||
|
<option value="1">Admin</option>
|
||||||
|
<option value="2">Editor</option>
|
||||||
|
<option value="3">Contributer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="token" value="{{ csrf_token('upload') }}"/>
|
||||||
|
<input type="submit" value="Add Member" name="submit_button"></form>
|
10
templates/front/knock.twig
Normal file
10
templates/front/knock.twig
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{% extends "base/frame.twig" %}
|
||||||
|
{% block stylesheets %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="/assets/css/front/start.css?=sdfsdf">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<section role="intro">
|
||||||
|
This is where you login
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
Loading…
Reference in a new issue