Implemented account system

This commit is contained in:
Michel Fedde 2024-07-05 16:50:53 +02:00
parent 51c20b55a0
commit ace0de4063
25 changed files with 1543 additions and 40 deletions

1025
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,12 +13,15 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.2",
"@types/bootstrap": "^5.2.10",
"bootstrap": "^5.3.3"
"bootstrap": "^5.3.3",
"datatables.net-bs5": "^2.0.8"
},
"devDependencies": {
"@types/jquery": "^3.5.30",
"assets-webpack-plugin": "^7.1.1",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0",
"mini-css-extract-plugin": "^2.9.0",
"resolve-url-loader": "^5.0.0",
"sass": "^1.77.5",

View file

@ -1 +1 @@
{"index":{"js":["js/runtime.js","js/index.js"],"css":"css/common.css"}}
{"index":{"js":["js/runtime.js","js/vendors.js","js/index.js"],"css":["css/vendors.css","css/common.css"]},"admin/accounts":{"js":["js/runtime.js","js/vendors.js","js/admin/accounts.js"],"css":["css/vendors.css","css/common.css"]}}

View file

@ -0,0 +1,102 @@
import '../../../css/common/index.scss';
import 'datatables.net-bs5/css/dataTables.bootstrap5.css';
import '../../common/index';
import DataTable from 'datatables.net-bs5';
import {Modal} from "bootstrap";
const DT_SERVERSIDE_PROCESSING_URL = '/api/dt/accounts';
const TABLE = document.querySelector('#user-table');
function displayEdit(data: any) {
const modalElem = document.querySelector('#edit-modal');
// @ts-ignore
modalElem.querySelector('.name-input').textContent = data.name;
// @ts-ignore
modalElem.querySelector('.login-method').textContent = data.loginMethod;
// @ts-ignore
modalElem.querySelector('.permission-editor').value = data.permissionIndex;
// @ts-ignore
modalElem?.querySelector('.js--save').dataset.userid = data.userid;
Modal.getOrCreateInstance('#edit-modal').show();
}
document.addEventListener('DOMContentLoaded', () => {
const dt = new DataTable('#user-table', {
ajax: {
url: DT_SERVERSIDE_PROCESSING_URL,
},
serverSide: true,
columns: [
{
data: 'profilePictureUrl',
render(data, type) {
if (type !== 'display') {
return data;
}
return `<img src="${ data }" alt="Profile Picture" class="ratio-1 rounded-circle w-100" />`
},
orderable: false,
searchable: false
},
{
name: 'Name',
data: 'name'
},
{
name: 'Permission',
data: 'permission'
},
{
name: 'Login-Method',
data: 'loginMethod'
}
],
order: [ [1, 'asc'] ],
drawCallback: function (settings) {
const api = new DataTable.Api(settings);
api.rows().every(function (row) {
const node = this.node();
const data = this.data();
node.addEventListener('click', (e) => {
displayEdit(data);
});
});
}
});
const button = <HTMLButtonElement>document.querySelector('#edit-modal .js--save');
if (!button) {
return;
}
button.addEventListener('click', async (e) => {
const permissionEditor = <HTMLSelectElement>document.querySelector('#edit-modal .permission-editor');
if (!permissionEditor) {
return;
}
const formData = new FormData();
formData.set('permission', permissionEditor.value);
const response = await fetch(
`/api/web/users/${button.dataset.userid}`,
{
method: 'POST',
body: formData
}
);
if (!response.ok) {
throw new Error(response.statusText);
}
Modal.getOrCreateInstance('#edit-modal').hide();
dt.draw();
})
})

View file

@ -13,4 +13,11 @@ enum LoginMethod: int
self::DISCORD => 'fa-discord',
};
}
public function getHumanReadableName(): string
{
return match ($this) {
self::DISCORD => 'Discord',
};
}
}

View file

@ -5,15 +5,21 @@ namespace GamesShop\Login;
enum UserPermission : int
{
case NONE = 0;
case VIEWER = 1;
case PROVIDER = 10;
case ADMIN = 100;
public function hasLevel(UserPermission $userPermission): bool {
return $this->value >= $userPermission->value;
}
public function getHumanReadableName() {
return match ($this) {
self::VIEWER => "Claimer",
self::PROVIDER => "Provider",
self::ADMIN => "Admin",
default => "None",
};
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace GamesShop\Routing;
use GamesShop\ContainerHandler;
use GamesShop\Login\LoginHandler;
use GamesShop\Login\UserPermission;
use GamesShop\Routing\Responses\TemplateResponse;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\Response\EmptyResponse;
use Laminas\Diactoros\Response\RedirectResponse;
use League\Route\Http\Exception\ForbiddenException;
use League\Route\Http\Exception\UnauthorizedException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class AdminAccountConfigRoute
{
public function __construct(
private readonly LoginHandler $loginHandler
)
{
}
/**
* @throws ForbiddenException
* @throws UnauthorizedException
*/
public function __invoke(ServerRequestInterface $request): ResponseInterface
{
if (!$this->loginHandler->isLoggedIn()) {
throw new UnauthorizedException();
}
$user = $this->loginHandler->getCurrentUser();
if ($user->getPermission()->value < UserPermission::ADMIN->value) {
throw new ForbiddenException();
}
return new TemplateResponse('admin/accounts');
}
public static function applyRoutes(\League\Route\Router $router) {
$router->get('/accounts', self::class);
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace GamesShop\Routing\Api;
use GamesShop\Routing\Api\DataTables\DataTablesAPIRoutes;
use GamesShop\Routing\Api\Web\WebAPIRoutes;
use League\Route\Router;
final class APIRoutes
{
public static function applyRoutes(Router $router) {
$router->group('/api/dt', DataTablesAPIRoutes::setupRoutes(...));
$router->group('/api/web', WebAPIRoutes::applyRoutes(...));
}
}

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace GamesShop\Routing\Api\DataTables;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Expr\Comparison;
use Doctrine\ORM\EntityManager;
use GamesShop\Entities\Account\User;
use GamesShop\Login\LoginHandler;
use GamesShop\Login\UserPermission;
use Laminas\Diactoros\Response\JsonResponse;
use League\Route\Http\Exception\ForbiddenException;
use League\Route\Http\Exception\UnauthorizedException;
use League\Route\RouteGroup;
use League\Route\Router;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class AccountsEndpoint
{
public function __construct(
private readonly LoginHandler $loginHandler,
private readonly EntityManager $entityManager,
)
{
}
public function __invoke(ServerRequestInterface $request): ResponseInterface
{
if (!$this->loginHandler->isLoggedIn()) {
throw new UnauthorizedException();
}
$user = $this->loginHandler->getCurrentUser();
if (!$user->getPermission()->hasLevel(UserPermission::ADMIN)) {
throw new ForbiddenException();
}
$params = $request->getQueryParams();
$draw = $params['draw'];
$start = $params['start'];
$length = $params['length'];
$searchValue = $params['search']['value'];
$repo = $this->entityManager->getRepository(User::class);
$total = $repo->count();
$criteria = Criteria::create();
$criteria->where(Criteria::expr()->contains('name', $searchValue));
$criteria->setFirstResult((int)$start);
$criteria->setMaxResults((int)$length);
$values = $repo->matching($criteria);
$filteredCount = $values->count();
return new JsonResponse([
'draw' => $draw,
'recordsTotal' => $total,
'recordsFiltered' => $filteredCount,
'data' =>
$values->map(function (User $user) {
return [
'userid' => $user->getId(),
'name' => $user->getName(),
'profilePictureUrl' => $user->getProfilePictureUrl(),
'permission' => $user->getPermission()->getHumanReadableName(),
'permissionIndex' => $user->getPermission()->value,
'loginMethod' => $user->getLoginMethod()->getHumanReadableName(),
];
})->toArray()
]);
}
public static function applyRoutes(RouteGroup $router) {
$router->get('/accounts', AccountsEndpoint::class);
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace GamesShop\Routing\Api\DataTables;
use League\Route\RouteGroup;
use League\Route\Router;
final class DataTablesAPIRoutes
{
public static function setupRoutes(RouteGroup $group): void {
AccountsEndpoint::applyRoutes($group);
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace GamesShop\Routing\Api\Web;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use GamesShop\Entities\Account\User;
use GamesShop\Login\LoginHandler;
use GamesShop\Login\UserPermission;
use Laminas\Diactoros\Response\EmptyResponse;
use League\Route\Http\Exception\ForbiddenException;
use League\Route\Http\Exception\UnauthorizedException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final readonly class UserModifyRoute
{
public function __construct(
private LoginHandler $loginHandler,
private EntityManager $entityManager,
)
{
}
public function __invoke(ServerRequestInterface $request, array $args): ResponseInterface
{
if (!$this->loginHandler->isLoggedIn()) {
throw new UnauthorizedException();
}
$user = $this->loginHandler->getCurrentUser();
if (!$user->getPermission()->hasLevel(UserPermission::ADMIN)) {
throw new ForbiddenException();
}
$permissions = $request->getParsedBody()['permission'];
$toChangeUser = $this->entityManager->getRepository(User::class)->find((int)$args['id']);
$toChangeUser->setPermission(UserPermission::from((int)$permissions));
$this->entityManager->flush();
return new EmptyResponse(200);
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace GamesShop\Routing\Api\Web;
use League\Route\RouteGroup;
final class WebAPIRoutes
{
public static function applyRoutes(RouteGroup $group): void {
$group->post('/users/{id:number}', UserModifyRoute::class);
}
}

View file

@ -11,7 +11,7 @@ use Psr\Http\Message\ResponseInterface;
final class ErrorRoute
{
public function renderErrorPage(int $errorCode): ResponseInterface {
$pageContent = ContainerHandler::get(TemplateEngine::class)->renderPage('error', [ 'errorCode' => $errorCode ]);
$pageContent = ContainerHandler::get(TemplateEngine::class)->renderErrorPage($errorCode);
$response = new Response;
$response->getBody()->write($pageContent);

View file

@ -9,6 +9,7 @@ use Doctrine\ORM\OptimisticLockException;
use GamesShop\ContainerHandler;
use GamesShop\Environment\EnvironmentHandler;
use GamesShop\Login\LoginHandler;
use GamesShop\Routing\Responses\TemplateResponse;
use GamesShop\Templates\TemplateEngine;
use Laminas\Diactoros\Response;
use Psr\Http\Message\ResponseInterface;
@ -24,16 +25,10 @@ final class LoginRoutes
public function login(ServerRequestInterface $request) {
$discordEnv = ContainerHandler::get(EnvironmentHandler::class)->getDiscordEnvironment();
$pageContent = ContainerHandler::get(TemplateEngine::class)->renderPage(
'login',
[
'discordUrl' => $discordEnv->loginUrl
]
);
$response = new Response;
$response->getBody()->write($pageContent);
return $response;
return new TemplateResponse('login', [
'discordUrl' => $discordEnv->loginUrl
]);
}
/**

View file

@ -6,9 +6,9 @@ namespace GamesShop\Routing;
use GamesShop\ContainerHandler;
use GamesShop\Paths;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\Uri;
use Mimey\MimeTypes;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class ResourceRoute
{
@ -19,9 +19,20 @@ final class ResourceRoute
'gif', 'svg', 'png', 'jpg'
];
public function __invoke(ServerRequestInterface $request, array $args): ResponseInterface {
public function isValid(Uri $uri): bool {
$path = $uri->getPath();
foreach (self::RESOURCE_EXTENSIONS as $extension) {
if (!str_ends_with($path, $extension)) {
continue;
}
$filePath = Paths::PUBLIC_PATH . $request->getUri()->getPath();
return true;
}
return false;
}
public function getResponse(Uri $uri): ResponseInterface {
$filePath = Paths::PUBLIC_PATH . $uri->getPath();
if (!file_exists($filePath)) {
$response = new Response(status: 404);
@ -41,11 +52,4 @@ final class ResourceRoute
return $response;
}
public static function addRouteEntry(\League\Route\Router $router): void {
$joinedResourceExtensions = implode('|', self::RESOURCE_EXTENSIONS);
$router->addPatternMatcher('resource', ".+[{$joinedResourceExtensions}]");
$router->get('/{resource:resource}', self::class);
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace GamesShop\Routing\Responses;
use GamesShop\ContainerHandler;
use GamesShop\Templates\TemplateEngine;
use Laminas\Diactoros\Response;
final class TemplateResponse extends Response
{
public function __construct(string $templateName, array $data = [], array $headers = [])
{
parent::__construct('php://memory', 200, $headers);
$templateEngine = ContainerHandler::get(TemplateEngine::class);
$body = $templateEngine->renderPage($templateName, $data);
$this->getBody()->write($body);
}
}

View file

@ -5,22 +5,36 @@ namespace GamesShop\Routing;
use GamesShop\ContainerHandler;
use GamesShop\Login\LoginHandler;
use GamesShop\Routing\Api\APIRoutes;
use GamesShop\Templates\TemplateEngine;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory;
use League\Container\Container;
use League\Route\Http\Exception\BadRequestException;
use League\Route\Http\Exception\ForbiddenException;
use League\Route\Http\Exception\NotFoundException;
use League\Route\Http\Exception\UnauthorizedException;
use League\Route\Strategy\ApplicationStrategy;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class Router
{
public function __construct(
private ResourceRoute $resourceRoute
)
{
}
public function route(): ResponseInterface
{
$request = ServerRequestFactory::fromGlobals(
$_SERVER, $_GET, $_POST, $_COOKIE, $_FILES
);
if ($this->resourceRoute->isValid($request->getUri())) {
return $this->resourceRoute->getResponse($request->getUri());
}
$router = new \League\Route\Router;
$strategy = (new ApplicationStrategy)->setContainer(ContainerHandler::getInstance());
$router->setStrategy($strategy);
@ -28,12 +42,18 @@ final class Router
IndexRoute::applyRoutes($router);
LoginRoutes::addRoutes($router);
SetupRoute::applyRoutes($router);
ResourceRoute::addRouteEntry($router);
AdminAccountConfigRoute::applyRoutes($router);
APIRoutes::applyRoutes($router);
try {
return $router->dispatch($request);
} catch (NotFoundException $e) {
return (new ErrorRoute())->renderErrorPage(404);
} catch (UnauthorizedException) {
return (new ErrorRoute())->renderErrorPage(401);
} catch (ForbiddenException) {
return (new ErrorRoute())->renderErrorPage(403);
}
}
}

View file

@ -29,7 +29,6 @@ final class SetupRoute
$attribute = $repo->find('ADMIN_SETUP_COMPLETED');
if ($attribute) {
return new RedirectResponse('/');
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace GamesShop\Templates;
use GamesShop\Login\UserPermission;
final class NavigationHeader
{
public function __construct(
public readonly string $title,
public readonly string $link,
public readonly UserPermission $minimumPermission,
)
{
}
}

View file

@ -3,6 +3,7 @@ declare(strict_types=1);
namespace GamesShop\Templates;
use GamesShop\Login\LoginHandler;
use GamesShop\Paths;
use League\Plates\Engine;
@ -12,11 +13,13 @@ final class TemplateEngine extends Engine
public function __construct(
private ResourceIndex $resourceIndex,
LoginHandler $loginHandler,
)
{
parent::__construct(self::TEMPLATES_PATH, 'php');
$this->addData([
'resources' => $this->resourceIndex,
'activeUser' => $loginHandler->isLoggedIn() ? $loginHandler->getCurrentUser() : null,
]);
}
@ -24,4 +27,8 @@ final class TemplateEngine extends Engine
{
return parent::render("pages/$page", $data);
}
public function renderErrorPage(int $error) {
return self::renderPage('error', [ 'errorCode' => $error ]);
}
}

View file

@ -1,31 +1,29 @@
<?php
declare(strict_types=1);
use GamesShop\ContainerHandler;
use GamesShop\Login\LoginHandler;
use GamesShop\Entities\Account\User;
/** @var User|null $activeUser */
$loginHandler = ContainerHandler::get(LoginHandler::class);
?>
<?php if ($loginHandler->isLoggedIn()):
$user = $loginHandler->getCurrentUser();
?>
<?php if ($activeUser !== null): ?>
<div class="d-flex avatar justify-content-center">
<div class="avatar-icon h-100 position-relative me-2 ratio-1">
<img src="<?= $user->getProfilePictureUrl(); ?>" class="rounded-circle h-100" alt="User Profile Picture" />
<img src="<?= $activeUser->getProfilePictureUrl(); ?>" class="rounded-circle h-100" alt="User Profile Picture" />
<div class="position-absolute bottom-0 end-0 ratio-1 d-flex align-items-center z-1 avatar-login-method">
<i class="fa-brands <?= $user->getLoginMethod()->getIconClass() ?>"></i>
<i class="fa-brands <?= $activeUser->getLoginMethod()->getIconClass() ?>"></i>
<span class="position-absolute w-100 h-100 bottom-0 end-0 ratio-1 bg-body rounded-circle z-n1 avatar-login-method-icon"></span>
</div>
</div>
<div class="d-flex flex-column">
<span class="me-2 h-3">
<?= $user->getName() ?>
<?= $activeUser->getName() ?>
</span>
<small class="text-muted">
<?= $user->getPermission()->getHumanReadableName() ?>
<?= $activeUser->getPermission()->getHumanReadableName() ?>
</small>
</div>
<div class="h-100 d-flex align-items-center ms-2">

View file

@ -39,5 +39,7 @@ $resource = $resources->getResource($resourceEntry);
<?= $this->section('content'); ?>
</main>
<?= $this->section('modal') ?>
</body>
</html>

View file

@ -1,10 +1,17 @@
<?php
declare(strict_types=1);
use GamesShop\ContainerHandler;
use GamesShop\Login\LoginHandler;
use GamesShop\Entities\Account\User;
use GamesShop\Login\UserPermission;
use GamesShop\Templates\NavigationHeader;
ContainerHandler::get(LoginHandler::class);
$headers = [
new NavigationHeader('My Keys', '/keys', UserPermission::PROVIDER),
new NavigationHeader('Accounts', '/accounts', UserPermission::ADMIN)
];
/** @var User|null $activeUser */
$currentPermission = $activeUser === null ? UserPermission::NONE : $activeUser->getPermission();
?>
@ -15,7 +22,19 @@ ContainerHandler::get(LoginHandler::class);
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbar-content">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"></ul>
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<?php foreach ($headers as $header):
if (!$currentPermission->hasLevel($header->minimumPermission)) {
continue;
}
?>
<li class="nav-link">
<a href="<?= $header->link ?>" class="nav-link"><?= $header->title ?></a>
</li>
<?php endforeach; ?>
</ul>
<?= $this->insert('layout/accountDisplay'); ?>
</div>

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use GamesShop\Login\UserPermission;
$this->layout('layout/main', [ 'resourceEntry' => 'admin/accounts' ]);
?>
<h1>Users</h1>
<table id="user-table" class="table table-striped w-100">
<thead>
<tr>
<th width="2.4rem"></th>
<th>Name</th>
<th>Permission</th>
<th>Login-Method</th>
</tr>
</thead>
<tbody></tbody>
</table>
<?php $this->start('modal') ?>
<div class="modal" id="edit-modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title h3">
Edit User
</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
Name: <span class="name-input"></span>
</div>
<div class="mb-2">
Login Method: <span class="login-method"></span>
</div>
<div>
<label for="permissions">Permissions:</label>
<select name="" id="permissions" class="form-select permission-editor">
<?php foreach (UserPermission::cases() as $userPermission):?>
<option value="<?= $userPermission->value ?>"><?= $userPermission->getHumanReadableName() ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary js--save">Save changes</button>
</div>
</div>
</div>
</div>
<?php $this->end() ?>

View file

@ -2,6 +2,7 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin'),
Path = require('path'),
AssetsPlugin = require('assets-webpack-plugin'),
CopyPlugin = require('copy-webpack-plugin');
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const PUBLIC_FOLDER = Path.resolve(__dirname, 'public'),
SOURCE_FOLDER = Path.resolve(__dirname, 'src'),
@ -38,6 +39,10 @@ module.exports = {
devtool: 'source-map',
optimization: {
runtimeChunk: "single",
minimize: true,
minimizer: [
new CssMinimizerPlugin()
],
splitChunks: {
cacheGroups: {
vendor: {
@ -86,7 +91,8 @@ module.exports = {
extensions: ['.js', '.ts'],
},
entry: {
index: JS_FOLDER + "/pages/index"
index: JS_FOLDER + "/pages/index",
'admin/accounts': JS_FOLDER + "/pages/admin/accounts",
},
output: {
path: PUBLIC_FOLDER,