Premier commit

This commit is contained in:
Sylvain Philip 2024-09-09 10:22:45 +02:00
commit bcc2604080
74 changed files with 25819 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/vendor
/web/assets
/web/manifest.json
deploy.sh
env.php

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# AI-UI Interface pour LLMS

34
cli/init_assistant.php Normal file
View File

@ -0,0 +1,34 @@
<?php
if (PHP_SAPI != 'cli') {
exit('PHP SAPI must be cli');
}
require(__DIR__ . '/../vendor/autoload.php');
foreach (require __DIR__ . '/../env.php' as $key => $val) {
putenv("{$key}={$val}");
}
$db = new PDO('sqlite:' . getenv('SQLITE_DB'));
$query =<<<SQL
CREATE TABLE IF NOT EXISTS "chat_assistant" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title VARCHAR(255) NOT NULL,
model VARCHAR(64) NOT NULL,
system_prompt TEXT NOT NULL,
temperature REAL NOT NULL DEFAULT 0,
top_p REAL NOT NULL 0,
"default" INTEGER NOT NULL DEFAULT 0,
foreign key (user_id) references "user" (id) on delete cascade on update cascade
);
SQL;
if ($db->exec($query) === false) {
$error = $db->errorInfo();
throw new RuntimeException("Query failed with error : {$error[2]}");
}
echo "Assistant table created.\n";

52
cli/init_user.php Normal file
View File

@ -0,0 +1,52 @@
<?php
if (PHP_SAPI != 'cli') {
exit('PHP SAPI must be cli');
}
use app\modules\user\models\User;
use app\modules\user\Rbac;
require(__DIR__ . '/../vendor/autoload.php');
foreach (require __DIR__ . '/../env.php' as $key => $val) {
putenv("{$key}={$val}");
}
$db = new PDO('sqlite:' . getenv('SQLITE_DB'));
Rbac::$db = $db;
$query = file_get_contents(__DIR__ . '/../modules/user/sql/install-sqlite.sql');
if ($db->exec($query) === false) {
$error = $db->errorInfo();
throw new RuntimeException("Query failed with error : {$error[2]}");
}
echo "Users table created.\n";
echo "Create admin user\n";
$name = readline("Nom : ");
$email = readline("Email : ");
$username = readline("Nom d'utilisateur : ");
$password = readline("Mot de passe : ");
$user = new User($db);
$user->bind([
'name' => $name,
'username' => $username,
'email' => $email,
'password' => $password,
]);
if ($user->save()) {
echo "Utilisateur $username créé.\n";
}
if (!Rbac::roleExists('admin')) {
Rbac::createRole('admin');
}
Rbac::assignRole($user->id, 'admin');

28
composer.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "piko/openai",
"description": "Application to interact with Openai GPT-3.",
"type": "project",
"license": "MIT",
"require": {
"piko/framework": "^3.0",
"piko/user": "^2.0",
"tectalic/openai": "^1.2",
"guzzlehttp/guzzle": "^7.5",
"tracy/tracy": "^2.9",
"monolog/monolog": "^2.8",
"rahul900day/gpt-3-encoder": "^1.1",
"piko/db-record": "^2.0",
"nette/mail": "^4.0",
"piko/i18n": "^2.1"
},
"autoload": {
"psr-4": {
"app\\": ""
}
},
"config": {
"allow-plugins": {
"php-http/discovery": true
}
}
}

2231
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

58
config/app.php Normal file
View File

@ -0,0 +1,58 @@
<?php
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
return [
'basePath' => realpath(__DIR__ . '/../'),
'defaultLayoutPath' => '@app/modules/site/layouts',
'defaultLayout' => 'main',
'errorRoute' => 'site/default/error',
'language' => getenv('APP_LANGUAGE'),
'components' => [
'Piko\View' => [
'themeMap' => [
'@app/modules/user/views' => '@app/overrides/user/views',
],
],
'Piko\Router' => [
'construct' => [
[
'routes' => require __DIR__ . '/routes.php',
]
]
],
'Piko\User' => [
'identityClass' => 'app\overrides\user\models\User',
'checkAccess' => 'app\modules\user\AccessChecker::checkAccess'
],
'Monolog\Logger' => function() {
// create a log channel
$logger = new Logger('app');
$level = getenv('APP_DEBUG') ? Logger::DEBUG : Logger::ERROR;
$logger->pushHandler(new StreamHandler( __DIR__ . '/../var/log/app.log', $level));
return $logger;
},
'PDO' => [
'construct' => [
'sqlite:' . getenv('SQLITE_DB')
]
],
'Piko\I18n' => [
'language' => getenv('APP_LANGUAGE'),
'translations' => [
'user' => '@app/modules/user/messages'
]
],
],
'modules' => [
'site' => 'app\modules\site\Module',
'user' => [
'class' => 'app\modules\user\Module',
'controllerMap' => [
'admin' => 'app\overrides\user\controllers\AdminController'
]
],
],
'bootstrap' => ['site', 'user']
];

17
config/routes.php Normal file
View File

@ -0,0 +1,17 @@
<?php
/**
* Routes definitions is a key-value paired array where
* keys are request uris and values are internal routes following this format:
*
* '{moduleId}/{controllerId}/{actionId}'
* '{moduleId}/{subModuleId}/.../{controllerId}/{actionId}'
*/
return [
'/' => 'site/assistant/index',
'/about' => 'site/default/about',
'/login' => 'user/default/login',
'/logout' => 'user/default/logout',
'/account' => 'user/default/edit',
'/contact' => 'site/default/contact',
];

51
lib/AuthMiddleware.php Normal file
View File

@ -0,0 +1,51 @@
<?php
namespace app\lib;
use PDO;
use Piko\ModularApplication;
use HttpSoft\Message\Response;
use app\modules\user\models\User;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class AuthMiddleware implements MiddlewareInterface
{
private ModularApplication $application;
public function __construct(ModularApplication $app)
{
$this->application = $app;
$pdo = $this->application->getComponent('PDO');
assert($pdo instanceof PDO);
User::$pdo = $pdo;
}
/**
* {@inheritDoc}
* @see \Psr\Http\Server\MiddlewareInterface::process()
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$user = $this->application->getComponent('Piko\User');
assert($user instanceof \Piko\User);
$router = $this->application->getComponent('Piko\Router');
assert($router instanceof \Piko\Router);
$loginUrl = $router->getUrl('user/default/login');
$params = $request->getServerParams();
if ($user->isGuest() && $params['REQUEST_URI'] != $loginUrl) {
$response= new Response();
return $response->withHeader('Location', $loginUrl);
}
return $handler->handle($request);
}
}

22
lib/CorsMiddleware.php Normal file
View File

@ -0,0 +1,22 @@
<?php
namespace app\lib;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class CorsMiddleware implements MiddlewareInterface
{
/**
* {@inheritDoc}
* @see \Psr\Http\Server\MiddlewareInterface::process()
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
return $response->withHeader('Access-Control-Allow-Origin', '*');
}
}

107
lib/Vite.php Normal file
View File

@ -0,0 +1,107 @@
<?php
namespace app\lib;
use Piko;
class Vite
{
// For a real-world example check here:
// https://github.com/wp-bond/bond/blob/master/src/Tooling/Vite.php
// https://github.com/wp-bond/boilerplate/tree/master/app/themes/boilerplate
// you might check @vitejs/plugin-legacy if you need to support older browsers
// https://github.com/vitejs/vite/tree/main/packages/plugin-legacy
/**
* Prints all the html entries needed for Vite
*/
public static function vite(string $entry): string
{
return implode("\n", [
static::jsTag($entry),
static::jsPreloadImports($entry),
static::cssTag($entry)
]);
}
// Helpers to print tags
private static function jsTag(string $entry): string
{
if (strpos($entry, '.js') === false) {
return '';
}
$url = getenv('VITE_ENV') === 'dev'
? getenv('VITE_HOST') . Piko::getAlias('@vite_web/' . $entry)
: static::assetUrl($entry);
if (!$url) {
return '';
}
return '<script type="module" crossorigin src="' . $url . '"></script>';
}
private static function jsPreloadImports(string $entry): string
{
if (getenv('VITE_ENV') === 'dev') {
return '';
}
$res = '';
foreach (static::importsUrls($entry) as $url) {
$res .= '<link rel="modulepreload" href="'. $url. '">';
}
return $res;
}
private static function cssTag(string $entry): string
{
// Not needed on dev, it's inject by Vite
if (getenv('VITE_ENV') === 'dev' || strpos($entry, '.css') === false) {
return '';
}
$url = static::assetUrl($entry);
if (!$url) {
return '';
}
return '<link rel="stylesheet" type="text/css" href="' . $url . '">';
}
// Helpers to locate files
private static function getManifest(): array
{
$content = file_get_contents(Piko::getAlias('@webroot/manifest.json'));
return json_decode($content, true);
}
private static function assetUrl(string $entry): string
{
$manifest = static::getManifest();
return isset($manifest[$entry])
? Piko::getAlias('@web/' . $manifest[$entry]['file'])
: '';
}
private static function importsUrls(string $entry): array
{
$urls = [];
$manifest = static::getManifest();
if (!empty($manifest[$entry]['imports'])) {
foreach ($manifest[$entry]['imports'] as $imports) {
$urls[] = Piko::getAlias('@web/' . $manifest[$imports]['file']);
}
}
return $urls;
}
}

43
modules/site/Module.php Normal file
View File

@ -0,0 +1,43 @@
<?php
namespace app\modules\site;
use Piko;
use Piko\Module\Event\CreateControllerEvent;
use Piko\Controller\Event\BeforeActionEvent;
class Module extends \Piko\Module
{
public function bootstrap()
{
Piko::setAlias('@vite_web', '/dev');
// Instanciate once i18n to setup the language config
$this->application->getComponent('Piko\I18n');
$user = $this->application->getComponent('Piko\User');
assert($user instanceof \Piko\User);
// Pass some parameters to the View component
$view = $this->application->getComponent('Piko\View');
$view->params['user'] = $user;
$view->params['language'] = $this->application->language;
$view->attachBehavior('vite', 'app\lib\Vite::vite');
$userModule = $this->application->getModule('user');
assert ($userModule instanceof \app\modules\user\Module);
$userModule->on(CreateControllerEvent::class, function(CreateControllerEvent $event) {
$event->controller->on(BeforeActionEvent::class, function (BeforeActionEvent $event) {
$action = $event->actionId;
switch($action) {
case 'login':
$event->controller->layout = 'minimal';
break;
}
});
});
}
}

View File

@ -0,0 +1,338 @@
<?php
namespace app\modules\site\controllers;
use Piko\User;
use Piko\HttpException;
use Monolog\Logger;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Utils;
use GuzzleHttp\Exception\RequestException;
use app\modules\site\models\Assistant;
use PDO;
class AssistantController extends \Piko\Controller
{
protected PDO $db;
protected User $user;
/**
* @var Logger
*/
private Logger $log;
protected function init(): void
{
$app = $this->module->getApplication();
$this->log = $app->getComponent(Logger::class);
assert($this->log instanceof Logger);
$this->user = $app->getComponent('Piko\User');
assert($this->user instanceof User);
$this->db = $app->getComponent('PDO');
assert($this->db instanceof PDO);
}
protected function getUserApiKey()
{
$identity = $this->user->getIdentity();
$apiKey = $identity->profil['api_key'] ?? null;
if ($apiKey === null) {
$this->log->error('API key not defined for user ID :' . $this->user->getId());
throw new HttpException(500, 'Internal error');
}
return $apiKey;
}
private function proxyRequest(string $method, string $endPoint, array $params = []): array
{
$client = new Client([
'base_uri' => getenv('PROXY_BASE_URL'),
]);
/*
$identity = $this->user->getIdentity();
$apiKey = $identity->profil['api_key'] ?? null;
if ($apiKey === null) {
$this->log->error('API key not defined for user ID :' . $this->user->getId());
throw new HttpException(500, 'Internal error');
}
*/
$apiKey = getenv('PROXY_MASTER_KEY');
$headers = [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json',
];
try {
$response = $client->request($method, $endPoint, [
'headers' => $headers,
'json' => $params
]);
} catch (RequestException $e) {
$response = $e->getResponse();
$responseBody = (string) $response->getBody();
$contentType = $response->getHeader('Content-Type');
$errorCode = 500;
if ($contentType[0] == 'application/json') {
$data = json_decode($responseBody);
if (isset($data->error)) {
$responseBody = $data->error->message;
$errorCode = $data->error->code;
}
}
$this->log->error('Chat request error', ['request_body' => (string) $e->getRequest()->getBody()]);
$this->log->error('Chat response error', ['response_body' => $responseBody]);
throw new HttpException($errorCode, $responseBody);
}
$body = $response->getBody();
return [
'headers' => $response->getHeaders(),
'body' => (string) $body
];
}
private function getModels()
{
if (!isset($_SESSION['models'])) {
$models = [];
$response = $this->proxyRequest('GET', '/models');
if (isset($response['body'])) {
$body = json_decode($response['body'], true);
$data = $body['data'] ?? [];
foreach ($data as $obj) {
if (isset($obj['id'])) {
$models[] = $obj['id'];
}
}
}
$_SESSION['models'] = $models;
}
return $_SESSION['models'];
}
/**
* Stream de la réponse
*
* Pour désactiver l'output buffering:
* php -d output_buffering=0 -S localhost:8080 -t web
*
* @return void
*/
public function responseAction()
{
$identity = $this->user->getIdentity();
$client = new Client([
'base_uri' => getenv('PROXY_BASE_URL'),
'stream' => true,
]);
$headers = [
'Authorization' => 'Bearer ' . getenv('PROXY_KEY'),
'Content-Type' => 'application/json',
];
$data = json_decode((string) $this->request->getBody());
// bdump($data);
try {
$response = $client->request('POST', '/chat/completions', [
'headers' => $headers,
'stream' => true,
'json' => [
'model' => $data->model,
'messages' => $data->messages,
'stream' => true,
'temperature' => $data->temperature ?? 0,
'top_p' => $config->top_p ?? 0,
'user' => $identity->email
]
]);
} catch (RequestException $e) {
header("Content-Type: text/event-stream;charset=UTF-8");
$responseBody = (string) $e->getResponse()->getBody();
echo 'data: ' . str_replace("\n", '', $responseBody) . "\n\n";
$this->log->error('Chat request error', ['request_body' => (string) $e->getRequest()->getBody()]);
$this->log->error('Chat response error', ['response_body' => $responseBody]);
exit;
}
$body = $response->getBody();
$contentType = $response->getHeader('Content-Type');
if (count($contentType)) {
header("Content-Type:{$contentType[0]}");
}
$content = '';
while (!$body->eof()) {
$line = Utils::readLine($body);
if ($line != '[DONE]') {
$data = preg_replace('/^data:/', '', $line);
$data = json_decode($data, true);
if (isset($data['choices'][0]['delta']['content'])) {
$content .= $data['choices'][0]['delta']['content'];
}
}
echo $line;
flush();
}
exit;
}
public function indexAction()
{
// $assistants = Assistant::find($this->db, $this->user->getId(), '`title` ASC');
// $assistant = array_pop($assistants);
// $apiKey = $this->getUserApiKey();
// $assistant = Assistant::getDefaultUserAssistant($this->db, $this->user->getId());
return $this->render('index', [
// 'assistant' => $assistant,
'models' => $this->getModels(),
// 'apiKey' => $apiKey,
]);
}
public function listAction()
{
$assistants = Assistant::find($this->db, $this->user->getId(), '`title` ASC');
return $this->jsonResponse($assistants);
}
public function assistantsAction()
{
return $this->render('assistants', [
'assistants' => Assistant::find($this->db, $this->user->getId(), '`title` ASC')
]);
}
public function editAction($id = 0)
{
$model = new Assistant($this->db);
if ($id) {
$model->load($id);
}
$response = [];
$post = $this->request->getParsedBody();
if (!empty($post)) {
$model->bind($post);
$model->user_id = $this->user->getId();
try {
if ($model->isValid() && $model->save()) {
$response['status'] = 'success';
} else {
$response['status'] = 'error';
$response['error'] = array_pop($model->getErrors());
}
} catch (\Exception $e) {
exit ($e->getMessage());
}
}
$response['model'] = $model->toArray();
return $this->jsonResponse($response);
}
public function saveAction()
{
$model = new Assistant($this->db);
$data = json_decode((string) $this->request->getBody(), true);
if (isset($data['id'])) {
$model->load($data['id']);
unset($data['id']);
}
$response = [];
$model->bind($data);
$model->user_id = $this->user->getId();
try {
if ($model->isValid() && $model->save()) {
$response['status'] = 'success';
} else {
$response['status'] = 'error';
$response['errors'] = $model->getErrors();
}
} catch (\Exception $e) {
exit ($e->getMessage());
}
$response['assistant'] = $model->toArray();
return $this->jsonResponse($response);
}
public function setAsDefaultAction($id = 0)
{
$model = new Assistant($this->db);
if ($id) {
$model->load($id);
$model->default = 1;
$model->save();
return $this->jsonResponse(true);
}
return $this->jsonResponse(false);
}
public function deleteAction($id = 0)
{
$model = new Assistant($this->db);
if ($id) {
$model->load($id);
if ($model->delete()) {
return $this->jsonResponse(true);
}
}
return $this->jsonResponse(false);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace app\modules\site\controllers;
use Throwable;
class DefaultController extends \Piko\Controller
{
public function errorAction(Throwable $exception)
{
return $this->render('error', [
'exception' => $exception
]);
}
}

View File

@ -0,0 +1,78 @@
<?php
/* @var $this \Piko\View */
/* @var $content string */
$user = $this->params['user'];
assert($user instanceof Piko\User);
if (!$this->title) $this->title = 'Openai';
?>
<!DOCTYPE html>
<html lang="<?= $this->params['language'] ?>">
<head>
<meta charset="<?= $this->charset ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $this->escape($this->title) ?></title>
<?= $this->head() ?>
<?= $this->vite('main.css') ?>
</head>
<body>
<nav class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasNavbar" aria-labelledby="offcanvasNavbarLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasNavbarLabel">Openai</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<ul class="navbar-nav justify-content-end flex-grow-1 pe-3">
<li class="nav-item"><a class="nav-link active" aria-current="page" href="/">Assistant</a></li>
<li class="nav-item"><a class="nav-link" href="<?= $this->getUrl('site/ia/assistants') ?>">Gérer les assistants</a></li>
<?php /*if ($user->can('access.completion')): ?>
<li class="nav-item">
<a class="nav-link" href="<?= $this->getUrl('openai/v1/completions') ?>">Completions</a>
</li>
<?php endif */ ?>
<?php /* if ($user->can('access.edit')): ?>
<li class="nav-item">
<a class="nav-link" href="<?= $this->getUrl('openai/v1/edits') ?>">Edits</a>
</li>
<?php endif */ ?>
<li class="nav-item">
<a class="nav-link" href="<?= $this->getUrl('user/default/edit') ?>">Compte</a>
</li>
<li class="nav-item">
<a class="nav-link" href="<?= $this->getUrl('user/default/logout') ?>">Déconnexion</a>
</li>
<?php if ($user->can('admin')): ?>
<li class="nav-item">
<a class="nav-link" href="<?= $this->getUrl('user/admin/users') ?>">Gestion utilisateurs</a>
</li>
<?php endif ?>
</ul>
</div>
</nav>
<button type="button" id="navBtn" class="hamburger is-closed" data-bs-toggle="offcanvas" data-bs-target="#offcanvasNavbar" aria-controls="offcanvasNavbar" >
<span class="hamb-top"></span>
<span class="hamb-middle"></span>
<span class="hamb-bottom"></span>
</button>
<?php if (isset($this->params['message']) && is_array($this->params['message'])): ?>
<div class="container alert alert-<?= $this->params['message']['type'] ?> alert-dismissible fade show" role="alert">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<?= $this->params['message']['content'] ?>
</div>
<?php endif ?>
<?= $content ?>
<?= $this->endBody() ?>
<?= $this->vite('main.js') ?>
</body>
</html>

View File

@ -0,0 +1,23 @@
<?php
/* @var $this \Piko\View */
/* @var $content string */
if (!$this->title) $this->title = 'Openai';
?>
<!DOCTYPE html>
<html lang="<?= $this->params['language'] ?>">
<head>
<meta charset="<?= $this->charset ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $this->escape($this->title) ?></title>
<?= $this->head() ?>
<?= $this->vite('main.css') ?>
</head>
<body class="d-flex align-items-center py-4">
<?= $content ?>
<?= $this->endBody() ?>
<?= $this->vite('main.js') ?>
</body>
</html>

View File

@ -0,0 +1,145 @@
<?php
namespace app\modules\site\models;
/**
* This is the model class for table "chat_assistant".
*
* @property integer $id
* @property integer $user_id
* @property string $title;
* @property string $model;
* @property string $system_prompt;
* @property float $temperature;
* @property float $top_p;
* @property integer $default
*
* @author Sylvain PHILIP <contact@sphilip.com>
*/
class Assistant extends \Piko\DbRecord
{
/**
* The table name
*
* @var string
*/
protected $tableName = 'chat_assistant';
/**
* The role permissions
*
* @var array
*/
public $permissions = [];
/**
* The table schema
*
* @var array
*/
protected $schema = [
'id' => self::TYPE_INT,
'user_id' => self::TYPE_INT,
'title' => self::TYPE_STRING,
'model' => self::TYPE_STRING,
'system_prompt' => self::TYPE_STRING,
'temperature' => self::TYPE_STRING,
'top_p' => self::TYPE_STRING,
'default' => self::TYPE_INT,
];
/**
* {@inheritDoc}
* @see \Piko\ModelTrait::validate()
*/
protected function validate(): void
{
if (empty($this->user_id)) {
$this->errors['user_id'] = 'L\'id de l\'utilisateur est obligatoire.';
}
if (empty($this->title)) {
$this->errors['title'] = 'Le titre doit être renseigné.';
}
if (empty($this->model)) {
$this->errors['model'] = 'Le modèle doit être renseigné.';
}
if (empty($this->system_prompt)) {
$this->errors['system_prompt'] = 'Le prompt système doit être renseigné.';
}
if (!empty($this->temperature) && $this->temperature < 0 || $this->temperature > 2) {
$this->errors['temperature'] = 'Le température doit être comprise entre 0 et 2.';
}
}
/**
* @inheritDoc
*/
protected function beforeSave(bool $insert): bool
{
if ($this->default == 1) {
$this->db->beginTransaction();
try {
// Reset default for all other assistants of the same user
$sth = $this->db->prepare('UPDATE chat_assistant SET `default` = 0 WHERE user_id = :user_id');
$sth->execute(['user_id' => $this->user_id]);
$this->db->commit();
} catch (\Exception $e) {
$this->db->rollBack();
throw $e;
}
}
return parent::beforeSave($insert);
}
/**
* Get assistants
*
* @param \PDO $db A pdo connexion
* @param string $order The order condition
* @param number $start The offset start
* @param number $limit The offset limit
*
* @return array An array of role rows
*/
public static function find(\PDO $db, $userId = 0, $order = '', $start = 0, $limit = 0)
{
$query = 'SELECT * FROM chat_assistant';
if ($userId) {
$query .= ' WHERE user_id = :user_id';
}
$query .= ' ORDER BY ' . (empty($order) ? '`id` DESC' : $order);
if (!empty($start)) {
$query .= ' OFFSET ' . (int) $start;
}
if (!empty($limit)) {
$query .= ' LIMIT ' . (int) $limit;
}
$sth = $db->prepare($query);
$sth->execute(['user_id' => $userId]);
return $sth->fetchAll(\PDO::FETCH_ASSOC);
}
public static function getDefaultUserAssistant(\PDO $db, int $userId): ?self
{
$query = 'SELECT * FROM chat_assistant WHERE user_id = :user_id AND `default` = 1 LIMIT 1';
$sth = $db->prepare($query);
$sth->execute(['user_id' => $userId]);
return $sth->fetchObject(self::class) ?: null;
}
}

View File

@ -0,0 +1,28 @@
<?php
assert($this instanceof \Piko\View);
$this->title = 'Assistant chat';
$modelsJs = json_encode($models);
$responseUrl = $this->getUrl('site/assistant/response');
$script = <<<JS
document.addEventListener('DOMContentLoaded', () => {
new ChatApp({
target: document.getElementById('chat-app'),
props : {
proxyBaseUrl: '$responseUrl',
model_list: $modelsJs
}
});
})
JS;
$this->registerJs($script);
?>
<div id="chat-app"></div>

View File

@ -0,0 +1,34 @@
<?php
/* @var $this \piko\View */
/* @var $exception \Exception */
$message = getenv('APP_ENV') === 'dev' ? $exception->getMessage() . ' (#' . $exception->getCode() . ')' : 'Not found';
$this->title = $message;
?>
<div class="site-error">
<h1><?= $this->escape($this->title) ?></h1>
<div class="alert alert-danger">
<?= nl2br($this->escape($message)) ?>
</div>
<p>
The above error occurred while the Web server was processing your request.
</p>
<p>
Please contact us if you think this is a server error. Thank you.
</p>
<?php if (getenv('APP_DEBUG')): ?>
<div class="card bg-light mb-3">
<div class="card-header">Trace:</div>
<div class="card-body">
<?= nl2br($exception->getTraceAsString()) ?>
</div>
</div>
<?php endif ?>
</div>

View File

@ -0,0 +1,74 @@
<?php
/**
* This file is part of the Piko user module
*
* @copyright 2020 Sylvain PHILIP.
* @license LGPL-3.0; see LICENSE.txt
* @link https://github.com/piko-framework/piko-user
*/
namespace app\modules\user;
use app\modules\user\models\User;
/**
* Access checker class
*
* @author Sylvain PHILIP <contact@sphilip.com>
*/
class AccessChecker
{
public static $adminRole;
/**
* User roles
*
* @var null|array
*/
private static $roles = null;
/**
* User permissions
*
* @var null|array
*/
private static $permissions = null;
/**
* Check Permission or role access
*
* @param int $userId The user Id
* @param string $permission The permission or role name
* @return bool
*
* @see \piko\User
*/
public static function checkAccess($userId, string $permission) : bool
{
$identity = User::findIdentity($userId);
if ($identity !== null) {
if (static::$roles === null) {
static::$roles = Rbac::getUserRoles($identity->id);
}
if (in_array(static::$adminRole, static::$roles)) {
return true;
}
if (in_array($permission, static::$roles)) {
return true;
}
if (static::$permissions === null) {
static::$permissions = Rbac::getUserPermissions($identity->id);
}
if (in_array($permission, static::$permissions)) {
return true;
}
}
return false;
}
}

73
modules/user/Module.php Normal file
View File

@ -0,0 +1,73 @@
<?php
/**
* This file is part of the Piko user module
*
* @copyright 2020 Sylvain PHILIP.
* @license LGPL-3.0; see LICENSE.txt
* @link https://github.com/piko-framework/piko-user
*
* Routes :
* /user/default/login : Process login
* /user/default/logout : Process logout
* /user/default/register : Process user registration
* /user/default/edit : User account form
* /user/admin/users : Manage users, roles, permissions
*/
namespace app\modules\user;
use PDO;
use app\modules\user\models\User;
use app\modules\user\Rbac;
/**
* User module class
*
* @author Sylvain PHILIP <contact@sphilip.com>
*/
class Module extends \Piko\Module
{
/**
* The admin role
* @var string
*/
public $adminRole = 'admin';
/**
* Allow user registration
*
* @var boolean
*/
public $allowUserRegistration = false;
/**
* Min length of the user password
*
* @var integer
*/
public $passwordMinLength = 8;
public function bootstrap()
{
$pdo = $this->application->getComponent('PDO');
assert($pdo instanceof PDO);
User::$pdo = $pdo;
Rbac::$db = $pdo;
AccessChecker::$adminRole = $this->adminRole;
}
/**
* {@inheritDoc}
* @see \piko\Module::init()
*/
protected function init()
{
/* @var $i18n \piko\i18n */
// $i18n = Piko::get('i18n');
// $i18n->addTranslation('user', __DIR__ . '/messages');
// parent::init();
}
}

263
modules/user/Rbac.php Normal file
View File

@ -0,0 +1,263 @@
<?php
/**
* This file is part of the Piko user module
*
* @copyright 2020 Sylvain PHILIP.
* @license LGPL-3.0; see LICENSE.txt
* @link https://github.com/piko-framework/piko-user
*/
namespace app\modules\user;
use PDO;
/**
* Rbac utility class
*
* @author Sylvain PHILIP <contact@sphilip.com>
*/
class Rbac
{
public static PDO $db;
/**
* Create a role
*
* @param string $name The role name
* @param string $description The role description
* @return int The role Id
*/
public static function createRole($name, $description = '')
{
$query = 'INSERT INTO `auth_role` (`name`, `description`) VALUES (?, ?)';
static::$db->beginTransaction();
$st = static::$db->prepare($query);
$st->execute([$name, $description]);
static::$db->commit();
return static::$db->lastInsertId();
}
/**
* Check if the role exists
*
* @param string $name The role name
* @return boolean
*/
public static function roleExists($name)
{
$st = static::$db->prepare('SELECT COUNT(`id`) FROM `auth_role` WHERE `name` = :name');
$st->execute(['name' => $name]);
return ((int) $st->fetchColumn() > 0) ? true : false;
}
/**
* Get the role Id
*
* @param string $name The role name
* @return int The role Id (0 if the role is not found)
*/
public static function getRoleId($name)
{
$st = static::$db->prepare('SELECT `id` FROM `auth_role` WHERE `name` = :name');
$st->execute(['name' => $name]);
return (int) $st->fetchColumn();
}
/**
* Assign a role to an user
*
* @param int $userId The user Id
* @param string $roleName The role name
* @throws \RuntimeException If the role doesn't exists
*/
public static function assignRole($userId, $roleName)
{
$roleId = static::getRoleId($roleName);
if (!$roleId) {
throw new \RuntimeException("Role $roleName doesn't exists");
}
$query = 'INSERT INTO `auth_assignment` (`role_id`, `user_id`) VALUES (?, ?)';
static::$db->beginTransaction();
$st = static::$db->prepare($query);
$st->execute([$roleId, $userId]);
static::$db->commit();
}
/**
* Get user roles
*
* @param int $userId The user Id
* @return array An array containing user roles
*/
public static function getUserRoles($userId)
{
$query = 'SELECT `auth_role`.`name` FROM `auth_role` '
. 'INNER JOIN `auth_assignment` ON `auth_assignment`.`role_id` = `auth_role`.`id` '
. 'WHERE `auth_assignment`.`user_id` = :user_id '
. 'GROUP BY role_id';
$st = static::$db->prepare($query);
$st->execute(['user_id' => $userId]);
return $st->fetchAll(\PDO::FETCH_COLUMN);
}
/**
* Get user roles ids
*
* @param int $userId The user Id
* @return array An array containing user role ids
*/
public static function getUserRoleIds($userId)
{
$query = 'SELECT role_id FROM `auth_assignment` WHERE user_id = :user_id';
$sth = static::$db->prepare($query);
$sth->execute(['user_id' => $userId]);
return $sth->fetchAll(\PDO::FETCH_COLUMN);
}
/**
* Get user permissions
*
* @param int $userId The user Id
* @return array An array containing user permissions
*/
public static function getUserPermissions($userId)
{
$query = 'SELECT p.`name` FROM `auth_permission` AS p '
. 'INNER JOIN `auth_role_has_permission` AS ap ON ap.`permission_id` = p.`id` '
. 'INNER JOIN `auth_assignment` AS aa ON aa.`role_id` = ap.`role_id` '
. 'WHERE aa.`user_id` = :user_id '
. 'GROUP BY permission_id';
$st = static::$db->prepare($query);
$st->execute(['user_id' => $userId]);
return $st->fetchAll(\PDO::FETCH_COLUMN);
}
/**
* Get role permissions
*
* @param string $roleName The role name
* @return array An array of permissions as string
*/
public static function getRolePermissions($roleName): array
{
$roleId = static::getRoleId($roleName);
if (!$roleId) {
throw new \RuntimeException("Role $roleName doesn't exists");
}
$query = 'SELECT p.`name` FROM `auth_permission` AS p '
. 'INNER JOIN `auth_role_has_permission` AS ap ON ap.`permission_id` = p.`id` '
. 'WHERE ap.`role_id` = :role_id '
. 'GROUP BY permission_id';
$st = static::$db->prepare($query);
$st->execute(['role_id' => $roleId]);
return $st->fetchAll(\PDO::FETCH_COLUMN);
}
/**
* Get role permission ids
*
* @param string $roleName The role name
* @return array An array of permission ids
*/
public static function getRolePermissionIds($roleName)
{
$roleId = static::getRoleId($roleName);
if (!$roleId) {
throw new \RuntimeException("Role $roleName doesn't exists");
}
$query = 'SELECT permission_id FROM `auth_role_has_permission` WHERE role_id = :role_id';
$sth = static::$db->prepare($query);
$sth->execute(['role_id' => $roleId]);
return $sth->fetchAll(\PDO::FETCH_COLUMN);
}
/**
* Create a permission
*
* @param string $name The permission name
* @return int The permission id
*/
public static function createPermission($name): int
{
$query = 'INSERT INTO `auth_permission` (`name`) VALUES (?)';
static::$db->beginTransaction();
$st = static::$db->prepare($query);
$st->execute([$name]);
static::$db->commit();
return (int) static::$db->lastInsertId();
}
/**
* Check if the permission exists
*
* @param string $name The permission name
* @return boolean
*/
public static function permissionExists($name): bool
{
$st = static::$db->prepare('SELECT COUNT(`id`) FROM `auth_permission` WHERE `name` = :name');
$st->execute(['name' => $name]);
return ((int) $st->fetchColumn() > 0) ? true : false;
}
/**
* Get the permission Id
*
* @param string $name The permission name
* @return int The permission id (0 if the permission is not found)
*/
public static function getPermissionId($name): int
{
$st = static::$db->prepare('SELECT `id` FROM `auth_permission` WHERE `name` = :name');
$st->execute(['name' => $name]);
return (int) $st->fetchColumn();
}
/**
* Assign a permission to a role
*
* @param string $roleName The role name
* @param string $permissionName The permission name
* @throws \RuntimeException If the role or the permission doesn't exists
*/
public static function assignPermission($roleName, $permissionName): void
{
$roleId = static::getRoleId($roleName);
$permissionId = static::getPermissionId($permissionName);
if (!$roleId) {
throw new \RuntimeException("Role $roleName doesn't exists");
}
if (!$permissionId) {
throw new \RuntimeException("Permission $permissionName doesn't exists");
}
$query = 'INSERT INTO `auth_role_has_permission` (`role_id`, `permission_id`) VALUES (?, ?)';
static::$db->beginTransaction();
$st = static::$db->prepare($query);
$st->execute([$roleId, $permissionId]);
static::$db->commit();
}
}

View File

@ -0,0 +1,248 @@
<?php
/**
* This file is part of the Piko user module
*
* @copyright 2020 Sylvain PHILIP.
* @license LGPL-3.0; see LICENSE.txt
* @link https://github.com/piko-framework/piko-user
*/
namespace app\modules\user\controllers;
use Piko\HttpException;
use function Piko\I18n\__;
use Piko\User as PikoUser;
use app\modules\user\models\Role;
use app\modules\user\models\User;
use app\modules\user\models\Permission;
/**
* User admin controller
*
* @author Sylvain PHILIP <contact@sphilip.com>
*/
class AdminController extends \Piko\Controller
{
protected PikoUser $user;
protected \PDO $db;
public function init(): void
{
$app = $this->module->getApplication();
$user = $app->getComponent('Piko\User');
assert($user instanceof PikoUser);
$this->user = $user;
$db = $app->getComponent('PDO');
assert($db instanceof \PDO);
$this->db = $db;
}
/**
* {@inheritDoc}
* @see \piko\Controller::runAction()
*/
public function runAction($id)
{
assert($this->module instanceof \app\modules\user\Module);
if (!$this->user->can($this->module->adminRole)) {
throw new HttpException('Not authorized.', 403);
}
return parent::runAction($id);
}
/**
* Render users view
*
* @return string
*/
public function usersAction()
{
return $this->render('users', [
'users' => User::find()
]);
}
/**
* Render User form and create or update user
*
* @return string
*/
public function editAction(int $id = 0)
{
$user = new User($this->db);
if ($id) {
$user->load($id);
}
$user->scenario = User::SCENARIO_ADMIN;
$message = false;
$post = $this->request->getParsedBody();
if (!empty($post)) {
$user->bind($post);
if ($user->isValid() && $user->save()) {
$message['type'] = 'success';
$message['content'] = __('user', 'User successfully saved');
} else {
$message['type'] = 'danger';
$message['content'] = __('user', 'Save error!') . implode(' ', $user->errors);
}
}
return $this->render('edit', [
'user' => $user,
'message' => $message,
'roles' => Role::find('`name` ASC'),
]);
}
/**
* Delete users
*/
public function deleteAction()
{
$post = $this->request->getParsedBody();
$ids = isset($post['items'])? $post['items'] : [];
foreach ($ids as $id) {
$user = new User($id);
$user->delete();
}
$this->redirect($this->getUrl('user/admin/users'));
}
/**
* Render roles view
*
* @return string
*/
public function rolesAction()
{
return $this->render('roles', [
'roles' => Role::find(),
'permissions' => Permission::find('`name` ASC'),
]);
}
/**
* Create/update role (AJAX)
*
* @return string
*/
public function editRoleAction(int $id = 0)
{
$role = new Role($this->db);
if ($id) {
$role->load($id);
}
$role->scenario = Role::SCENARIO_ADMIN;
$post = $this->request->getParsedBody();
$response = [
'role' => $role
];
if (!empty($post)) {
$role->bind($post);
if ($role->isValid() && $role->save()) {
$response['status'] = 'success';
} else {
$response['status'] = 'error';
}
}
return $this->jsonResponse($response);
}
/**
* Delete roles
*/
public function deleteRolesAction()
{
$post = $this->request->getParsedBody();
$ids = isset($post['items'])? $post['items'] : [];
foreach ($ids as $id) {
$item = new Role($id);
$item->delete();
}
$this->redirect($this->getUrl('user/admin/roles'));
}
/**
* Render permissions view
*
* @return string
*/
public function permissionsAction()
{
return $this->render('permissions', [
'permissions' => Permission::find()
]);
}
/**
* Create/update permission (AJAX)
*
* @return string
*/
public function editPermissionAction(int $id = 0)
{
$permission = new Permission($this->db);
if ($id) {
$permission->load($id);
}
$response = [
'permission' => $permission
];
$post = $this->request->getParsedBody();
if (!empty($post)) {
$permission->bind($post);
if ($permission->isValid() && $permission->save()) {
$response['status'] = 'success';
} else {
$response['status'] = 'error';
$response['error'] = array_pop($permission->getErrors());
}
}
return $this->jsonResponse($response);
}
/**
* Delete permissions
*/
public function deletePermissionsAction()
{
$post = $this->request->getParsedBody();
$ids = isset($post['items'])? $post['items'] : [];
foreach ($ids as $id) {
$item = new Permission($id);
$item->delete();
}
$this->redirect($this->getUrl('user/admin/permissions'));
}
}

View File

@ -0,0 +1,295 @@
<?php
/**
* This file is part of the Piko user module
*
* @copyright 2020 Sylvain PHILIP.
* @license LGPL-3.0; see LICENSE.txt
* @link https://github.com/piko-framework/piko-user
*/
namespace app\modules\user\controllers;
use function Piko\I18n\__;
use piko\HttpException;
use app\modules\user\models\User;
use Piko\User as PikoUser;
/**
* User default controller
*
* @author Sylvain PHILIP <contact@sphilip.com>
*/
class DefaultController extends \Piko\Controller
{
protected PikoUser $user;
protected \PDO $db;
public function init(): void
{
$app = $this->module->getApplication();
$user = $app->getComponent('Piko\User');
assert($user instanceof PikoUser);
$this->user = $user;
$db = $app->getComponent('PDO');
assert($db instanceof \PDO);
$this->db = $db;
}
/**
* Render and process user registration
*
* @return string
*/
public function registerAction()
{
if (!$this->user->isGuest()) {
return $this->redirect('/');
}
$message = false;
$post = $this->request->getParsedBody();
if (!empty($post)) {
$user = new User($this->db);
$user->scenario = User::SCENARIO_REGISTER;
$user->bind($post);
if ($user->isValid() && $user->save()) {
// $user->sendRegistrationConfirmation();
$message['type'] = 'success';
$message['content'] = __(
'user',
'Your account was created. Please activate it through the confirmation email that was sent to you.'
);
} else {
$message['type'] = 'danger';
$message['content'] = implode(', ', $user->errors);
}
}
return $this->render('register', [
'message' => $message,
]);
}
/**
* Validate registration (AJAX)
*
* @return string
*/
public function checkRegistrationAction()
{
$errors = [];
$this->layout = false;
$post = $this->request->getParsedBody();
if (!empty($post)) {
$user = new User($this->db);
$user->scenario = 'register';
$user->bind($post);
$user->isValid();
$errors = $user->getErrors();
}
return $this->jsonResponse($errors);
}
/**
* Render user activation confirmation
*
* @throws HttpException
* @return string
*/
public function confirmationAction($token)
{
$user = User::findByAuthKey($token);
if (!$user) {
throw new HttpException('Not found.', 404);
}
$message = false;
if (!$user->isActivated()) {
if ($user->activate()) {
$message['type'] = 'success';
$message['content'] = __('user', 'Your account has been activated. You can now log in.');
} else {
$message['type'] = 'danger';
$message['content'] = __(
'user',
'Unable to activate your account. Please contact the site manager.'
);
}
} else {
$message['type'] = 'warning';
$message['content'] = __('user', 'Your account has already been activated.');
}
return $this->render('login', ['message' => $message]);
}
/**
* Render reminder password form and send email to change password
*
* @return string
*/
public function reminderAction()
{
$message = false;
$post = $this->request->getParsedBody();
$reminder = $post['reminder']?? '';
if (!empty($reminder)) {
$user = User::findByUsername($reminder);
if (!$user) {
$user = User::findByEmail($reminder);
}
if ($user) {
// $user->sendResetPassword();
$message['type'] = 'success';
$message['content'] = __(
'user',
'A link has been sent to you by email ({email}). It will allow you to recreate your password.',
['email' => $user->email]
);
} else {
$message['type'] = 'danger';
$message['content'] = __('user', 'Account not found.');
}
}
return $this->render('reminder', [
'message' => $message,
'reminder' => $reminder,
]);
}
/**
* Render and process reset password
*
* @throws HttpException
* @return string
*/
public function resetPasswordAction($token)
{
$user = User::findByAuthKey($token);
if (!$user) {
throw new HttpException('Not found', 404);
}
$message = false;
$post = $this->request->getParsedBody();
if (!empty($post)) {
$user->scenario = 'reset';
$user->bind($post);
if ($user->isValid() && $user->save()) {
$message['type'] = 'success';
$message['content'] = __('user', 'Your password has been successfully updated.');
} else {
$message['type'] = 'danger';
$message['content'] = implode(', ', $user->errors);
}
}
return $this->render('reset', [
'message' => $message,
'user' => $user,
]);
}
/**
* Render user form and update changes
*
* @throws HttpException
* @return string
*/
public function editAction()
{
if ($this->user->isGuest()) {
throw new HttpException(__('user', 'You must be logged to access this page.'), 401);
}
$identity = $this->user->getIdentity();
assert($identity instanceof User);
$message = false;
$post = $this->request->getParsedBody();
if (!empty($post)) {
$identity->bind($post);
if ($identity->isValid() && $identity->save()) {
$message['type'] = 'success';
$message['content'] = __('user', 'Changes saved!');
} else {
$message['type'] = 'danger';
$message['content'] = implode(', ', $identity->getErrors());
}
}
return $this->render('edit', [
'user' => $identity,
'message' => $message,
]);
}
/**
* Render login form and process login
*
* @return string
*/
public function loginAction()
{
$message = false;
$post = $this->request->getParsedBody();
if (!empty($post)) {
$identity = User::findByUsername($post['username']);
if ($identity instanceof User && $identity->validatePassword($post['password'])) {
$this->user->login($identity);
$identity->last_login_at = time();
$identity->save();
return $this->redirect('/');
} else {
$message['type'] = 'danger';
$message['content'] = __('user', 'Authentication failure');
}
}
assert($this->module instanceof \app\modules\user\Module);
return $this->render('login', [
'message' => $message,
'canRegister' => $this->module->allowUserRegistration
]);
}
/**
* User logout
*/
public function logoutAction()
{
$this->user->logout();
$this->redirect('/');
}
}

View File

@ -0,0 +1,111 @@
<?php
$confirmationMailBody = <<<MSG
Bonjour,
Merci de vous être inscrit sur {site_name}. Votre compte a été créé et doit être activé avant que vous puissiez l'utiliser.
Pour l'activer, cliquez sur le lien ci-dessous ou copiez et collez le dans votre navigateur :
{link}
Après activation vous pourrez vous connecter sur {base_url} en utilisant l'identifiant suivant et le mot de passe utilisé à l'enregistrement :
Identifiant : {username}
MSG;
$resetPasswordMailBody = <<<MSG
Bonjour,
Une demande de changement de mot passe a été effectuée pour votre compte sur {site_name}.
Votre identifiant est : {username}.
Pour changer votre mot de passe , cliquez sur le lien ci-dessous.
{link}
Merci.
MSG;
return [
'Users' => 'Utilisateurs',
'Name' => 'Nom',
'Username' => 'Identifiant',
'Email' => 'Email',
'Password' => 'Mot de passe',
'Last login at' => 'Dernière connexion',
'Created at' => 'Créé le',
'Id' => 'Id',
'Roles' => 'Rôles',
'Role name' => 'Nom du rôle',
'Role name must be filled in.' => 'Le nom du role doit être renseigné.',
'Role already exists.' => 'Le rôle existe déjà.',
'Description' => 'Description',
'Role permissions' => 'Permissions du rôle',
'Users management' => 'Gestion des utilisateurs',
'Are you sure you want to perform this action?' => 'Êtes-vous certain de vouloir effectuer cette action ?',
'Create user' => 'Nouvel utilisateur',
'Edit user' => 'Modifier l\'utilisateur',
'New role' => 'Nouveau rôle',
'Delete' => 'Supprimer',
'Close' => 'Fermer',
'Cancel' => 'Annuler',
'Permissions' => 'Permissions',
'New permission' => 'Nouvelle permission',
'Permission name' => 'Nom de la permission',
'Permission name must be filled in.' => 'Le nom de la permission doit être renseigné.',
'Permission already exists.' => 'La permission existe déjà',
'User successfully saved' => 'Utilisateur correctement enregistré',
'Save' => 'Enregistrer',
'Save error!' => 'Erreur lors de l\'enregistrement',
'Email must be filled in.' => 'L\'email doit être renseigné.',
'{email} is not a valid email address.' => '{email} n\'est pas une adresse email valide.',
'Username must be filled in.' => 'Le nom d\'utilisateur doit être renseigné.',
'The username should only contain alphanumeric characters.' => 'Le nom d\'utilisateur ne doit contenir que des caractères alphanumériques.',
'This email is already used.' => 'Cet email est déjà utilisé.',
'This username is already used.' => 'Cet identifant est déjà utilisé.',
'Password must be filled in.' => 'Le mot de passe doit être renseigné.',
'Password is to short. Minimum {num}: characters.' => 'Mot de passe trop court. Minimum {num} caractères.',
'Passwords are not the same.' => 'Les mots de passe ne sont pas identiques.',
// Register Account
'Your account was created. Please activate it through the confirmation email that was sent to you.' => 'Votre compte a été créé. Merci de l\'activer via le mail de confirmation qui vous a été envoyé.',
'confirmation_mail_body' => $confirmationMailBody,
'Registration confirmation on {site_name}' => 'Confirmation de l\'inscription sur {site_name}',
// Account activation
'Your account has been activated. You can now log in.' => 'Votre compte a bien été activé. Vous pouvez désormais vous connecter.',
'Unable to activate your account. Please contact the site manager.' => 'Impossible d\'activer votre compte. Merci de contacter le responsable du site.',
'Your account has already been activated.' => 'Votre compte a déjà été activé.',
// Password reset / reminder
'A link has been sent to you by email ({email}). It will allow you to recreate your password.' => 'Un lien vous a été envoyé par email ({email}). Il vous permettra de recréer votre mot de passe.',
'reset_password_mail_body' => $resetPasswordMailBody,
'Password change request on {site_name}' => 'Demande de changement de mot de passe sur {site_name}',
'Account not found.' => 'Compte innexistant',
'Your password has been successfully updated.' => 'Votre mot de passe a bien été modifié.',
'Forget password' => 'Mot de passe oublié',
'Your email or your username' => 'Votre email ou votre identifiant',
'Send' => 'Envoyer',
'Change your account ({account}) password' => 'Réinitialisation du mot de passe pour le compte : {account}',
// Edit account
'You must be logged to access this page.' => 'Vous devez vous connecter pour accéder à cette page.',
'Changes saved!' => 'Modifications enregistrées !',
'Edit your account' => 'Modification de votre compte',
'Password (leave blank to keep the same)' => 'Mot de passe (laisser vide pour garder le même)',
'Last name' => 'Nom',
'First name' => 'Prénom',
'Company' => 'Entreprise',
'Phone number' => 'Téléphone',
'Address' => 'Adresse',
'Zip code' => 'Code postal',
'City' => 'Ville',
'Country' => 'Pays',
// Login
'Authentication failure' => 'Échec de l\'authentification.',
'Login' => 'Connexion',
'No account yet?' => 'Pas encore de compte ?',
'Register' => 'Créer un compte',
'Forget password?' => 'Mot de passe oublié ?',
// register
'Confirm your password' => 'Confirmez votre mot de passe'
];

View File

@ -0,0 +1,96 @@
<?php
/**
* This file is part of the Piko user module
*
* @copyright 2020 Sylvain PHILIP.
* @license LGPL-3.0; see LICENSE.txt
* @link https://github.com/piko-framework/piko-user
*/
namespace app\modules\user\models;
use function Piko\I18n\__;
/**
* This is the model class for table "auth_permission".
*
* @property integer $id
* @property string $name;
*
* @author Sylvain PHILIP <contact@sphilip.com>
*/
class Permission extends \piko\DbRecord
{
/**
* The table name
*
* @var string
*/
protected $tableName = 'auth_permission';
/**
* The model errors
*
* @var array
*/
public $errors = [];
/**
* The table schema
*
* @var array
*/
protected $schema = [
'id' => self::TYPE_INT,
'name' => self::TYPE_STRING,
];
/**
* {@inheritDoc}
* @see \Piko\ModelTrait::validate()
*/
protected function validate(): void
{
if (empty($this->name)) {
$this->errors['name'] = __('user', 'Permission name must be filled in.');
} else {
$st = $this->db->prepare('SELECT COUNT(`id`) FROM `auth_permission` WHERE name = :name');
$st->execute(['name' => $this->name]);
$count = (int) $st->fetchColumn();
if ($count) {
$this->errors['name'] = __('user', 'Permission already exists.');
}
}
}
/**
* Find permissions
*
* @param string $order The order condition
* @param number $start The offset start
* @param number $limit The offset limit
*
* @return array An array of permission rows
*/
public static function find($order = '', $start = 0, $limit = 0)
{
$db = User::$pdo;
$query = 'SELECT `id`, `name` FROM `auth_permission`';
$query .= ' ORDER BY ' . (empty($order) ? '`id` DESC' : $order);
if (!empty($start)) {
$query .= ' OFFSET ' . (int) $start;
}
if (!empty($limit)) {
$query .= ' LIMIT ' . (int) $limit;
}
$sth = $db->prepare($query);
$sth->execute();
return $sth->fetchAll();
}
}

View File

@ -0,0 +1,180 @@
<?php
/**
* This file is part of the Piko user module
*
* @copyright 2020 Sylvain PHILIP.
* @license LGPL-3.0; see LICENSE.txt
* @link https://github.com/piko-framework/piko-user
*/
namespace app\modules\user\models;
use function Piko\I18n\__;
use app\modules\user\Rbac;
/**
* This is the model class for table "auth_role.
*
* @property integer $id
* @property integer $parent_id
* @property string $name;
* @property string $description;
*
* @author Sylvain PHILIP <contact@sphilip.com>
*/
class Role extends \Piko\DbRecord
{
const SCENARIO_ADMIN = 'admin';
/**
* The table name
*
* @var string
*/
protected $tableName = 'auth_role';
/**
* The model scenario
*
* @var string
*/
public $scenario = '';
/**
* The model errors
*
* @var array
*/
public $errors = [];
/**
* The role permissions
*
* @var array
*/
public $permissions = [];
/**
* The table schema
*
* @var array
*/
protected $schema = [
'id' => self::TYPE_INT,
'name' => self::TYPE_STRING,
'description' => self::TYPE_STRING,
];
/**
* {@inheritDoc}
* @see \piko\Component::init()
*/
protected function init()
{
if (!empty($this->name)) {
$this->permissions = Rbac::getRolePermissionIds($this->name);
}
}
/**
* {@inheritDoc}
* @see \Piko\DbRecord::bind()
*/
public function bind($data): void
{
if (isset($data['permissions'])) {
$this->permissions = $data['permissions'];
unset($data['permissions']);
}
parent::bind($data);
}
/**
* {@inheritDoc}
* @see \Piko\DbRecord::afterSave()
*/
protected function afterSave(): void
{
if ($this->scenario === self::SCENARIO_ADMIN) {
$st = $this->db->prepare('DELETE FROM `auth_role_has_permission` WHERE role_id = :role_id');
if (!$st->execute(['role_id' => $this->id])) {
throw new \RuntimeException(
"Error while trying to delete role id {$this->id} in auth_role_has_permission table"
);
}
if (!empty($this->permissions)) {
$values = [];
foreach ($this->permissions as $id) {
$values[] = '(' . (int) $this->id . ',' . (int) $id . ')';
}
$query = 'INSERT INTO `auth_role_has_permission` (role_id, permission_id) VALUES '
. implode(', ', $values);
$this->db->beginTransaction();
$st = $this->db->prepare($query);
$st->execute();
$this->db->commit();
}
}
parent::afterSave();
}
/**
* {@inheritDoc}
* @see \Piko\ModelTrait::validate()
*/
protected function validate(): void
{
if (empty($this->name)) {
$this->errors['name'] = __('user', 'Role name must be filled in.');
} else {
$st = $this->db->prepare('SELECT COUNT(`id`) FROM `auth_role` WHERE name = :name');
$st->execute(['name' => $this->name]);
$count = (int) $st->fetchColumn();
if ($count) {
$this->errors['name'] = __('user', 'Role already exists.');
}
}
}
/**
* Get roles
*
* @param string $order The order condition
* @param number $start The offset start
* @param number $limit The offset limit
*
* @return array An array of role rows
*/
public static function find($order = '', $start = 0, $limit = 0)
{
$db = User::$pdo;
$query = 'SELECT * FROM `auth_role`';
$query .= ' ORDER BY ' . (empty($order) ? '`id` DESC' : $order);
if (!empty($start)) {
$query .= ' OFFSET ' . (int) $start;
}
if (!empty($limit)) {
$query .= ' LIMIT ' . (int) $limit;
}
$sth = $db->prepare($query);
$sth->execute();
return $sth->fetchAll();
}
}

View File

@ -0,0 +1,551 @@
<?php
/**
* This file is part of the Piko user module
*
* @copyright 2020 Sylvain PHILIP.
* @license LGPL-3.0; see LICENSE.txt
* @link https://github.com/piko-framework/piko-user
*/
namespace app\modules\user\models;
use function Piko\I18n\__;
use Piko\Router;
use app\modules\user\Rbac;
use app\modules\user\Module;
use Nette\Mail\Message;
use Nette\Mail\SmtpMailer;
use Nette\Utils\Random;
/**
* This is the model class for table "user".
*
* @property integer $id
* @property string $name;
* @property string $username;
* @property string $email;
* @property string $password;
* @property string $auth_key;
* @property integer $confirmed_at;
* @property integer $blocked_at;
* @property string $registration_ip;
* @property integer $created_at;
* @property integer $updated_at;
* @property integer $last_login_at;
* @property string $timezone;
* @property string $profil;
*
* @author Sylvain PHILIP <contact@sphilip.com>
*/
class User extends \Piko\DbRecord implements \Piko\User\IdentityInterface
{
const SCENARIO_ADMIN = 'admin';
const SCENARIO_REGISTER = 'register';
const SCENARIO_RESET = 'reset';
public static \PDO $pdo;
public static Module $module;
/**
* The table name
* @var string
*/
protected $tableName = 'user';
/**
* The model errors
*
* @var array
*/
public $errors = [];
/**
* The model scenario
*
* @var string
*/
public $scenario = '';
/**
* The user role ids
*
* @var array
*/
protected $roleIds = [];
/**
* The confirmation password
*
* @var string
*/
protected $password2 = '';
/**
* Reset password state
*
* @var boolean
*/
protected $resetPassword = false;
/**
* The table schema
*
* @var array
*/
protected $schema = [
'id' => self::TYPE_INT,
'name' => self::TYPE_STRING,
'username' => self::TYPE_STRING,
'email' => self::TYPE_STRING,
'password' => self::TYPE_STRING,
'auth_key' => self::TYPE_STRING,
'confirmed_at' => self::TYPE_INT,
'blocked_at' => self::TYPE_INT,
'registration_ip' => self::TYPE_STRING,
'created_at' => self::TYPE_INT,
'updated_at' => self::TYPE_INT,
'last_login_at' => self::TYPE_INT,
'is_admin' => self::TYPE_INT,
'timezone' => self::TYPE_STRING,
'profil' => self::TYPE_STRING,
];
/**
* {@inheritDoc}
* @see \Piko\DbRecord::beforeSave()
*/
protected function beforeSave($isNew): bool
{
if ($isNew) {
$this->name = $this->username;
$this->password = sha1($this->password);
$this->created_at = time();
$this->auth_key = sha1(Random::generate(10));
} else {
$this->updated_at = time();
if ($this->resetPassword) {
$this->password = sha1($this->password);
}
}
return parent::beforeSave($isNew);
}
/**
* {@inheritDoc}
* @see \Piko\DbRecord::afterSave()
*/
protected function afterSave(): void
{
if ($this->scenario === self::SCENARIO_ADMIN) {
// Don't allow admin user to remove its admin role
/*
if ($this->id == Piko::get('user')->getId()) {
$adminRole = Piko::get('userModule')->adminRole;
$adminRoleId = Rbac::getRoleId($adminRole);
if (!in_array($adminRoleId, $this->roleIds)) {
$this->roleIds[] = $adminRoleId;
}
}
*/
if (!empty($this->roleIds)) {
$roleIds = Rbac::getUserRoleIds($this->id);
$idsToRemove = array_diff($roleIds, $this->roleIds);
$idsToAdd = array_diff($this->roleIds, $roleIds);
if (!empty($idsToRemove)) {
$query = 'DELETE FROM `auth_assignment` WHERE user_id = :user_id AND role_id IN('
. implode(',', $idsToRemove) . ')';
$st = $this->db->prepare($query);
$st->execute(['user_id' => $this->id]);
}
if (!empty($idsToAdd)) {
$values = [];
foreach ($idsToAdd as $id) {
$values[] = '(' . (int) $this->id . ',' . (int) $id . ')';
}
$query = 'INSERT INTO `auth_assignment` (user_id, role_id) VALUES ' . implode(', ', $values);
$this->db->beginTransaction();
$st = $this->db->prepare($query);
$st->execute();
$this->db->commit();
}
} else {
$st = $this->db->prepare('DELETE FROM `auth_assignment` WHERE user_id = :user_id');
$st->execute(['user_id' => $this->id]);
}
}
parent::afterSave();
}
/**
* {@inheritDoc}
* @see \Piko\DbRecord::bind()
*/
public function bind($data): void
{
if (isset($data['password']) && empty($data['password'])) {
unset($data['password']);
}
if (isset($data['password2'])) {
$this->password2 = $data['password2'];
unset($data['password2']);
}
if (!empty($data['password']) && !$this->validatePassword($data['password'])) {
$this->resetPassword = true;
}
if (!empty($data['profil']) && is_array($data['profil'])) {
$data['profil'] = json_encode($data['profil']);
}
if (isset($data['roles']) && $this->scenario == self::SCENARIO_ADMIN) {
$this->roleIds = $data['roles'];
unset($data['roles']);
}
parent::bind($data);
}
/**
* {@inheritDoc}
* @see \Piko\ModeTrait::validate()
*/
protected function validate(): void
{
if (empty($this->email)) {
$this->errors['email'] = __('user', 'Email must be filled in.');
} elseif (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
$this->errors['email'] = __(
'user',
'{email} is not a valid email address.',
['email' => $this->data['email']]
);
}
if (($this->scenario == self::SCENARIO_REGISTER || $this->scenario == self::SCENARIO_ADMIN)
&& empty($this->username)) {
$this->errors['username'] = __('user', 'Username must be filled in.') ;
}
// New user
if (($this->scenario == self::SCENARIO_REGISTER || $this->scenario == self::SCENARIO_ADMIN)
&& empty($this->id)) {
$st = $this->db->prepare('SELECT id FROM user WHERE email = ?');
$st->execute([$this->email]);
$id = $st->fetchColumn();
if ($id) {
$this->errors['email'] = __('user', 'This email is already used.');
}
$st = $this->db->prepare('SELECT id FROM user WHERE username = ?');
$st->execute([$this->username]);
$id = $st->fetchColumn();
if ($id) {
$this->errors['username'] = __('user', 'This username is already used.');
}
}
if (($this->scenario == self::SCENARIO_REGISTER || $this->scenario == self::SCENARIO_RESET)
&& empty($this->password)) {
$this->errors['password'] = __('user', 'Password must be filled in.');
} elseif (($this->scenario == self::SCENARIO_REGISTER || $this->scenario == self::SCENARIO_RESET) &&
strlen($this->password) < static::$module->passwordMinLength) {
$this->errors['password'] = __(
'user',
'Password is to short. Minimum {num}: characters.',
['num' => static::$module->passwordMinLength]
);
}
if (($this->scenario == self::SCENARIO_REGISTER || $this->scenario == self::SCENARIO_RESET) &&
$this->password != $this->password2) {
$this->errors['password2'] = __('user', 'Passwords are not the same.');
}
}
/**
* Get user role ids
*
* @return array An array containg role ids
*/
public function getRoleIds()
{
return Rbac::getUserRoleIds($this->id);
}
/**
* Activate an user
*
* @return boolean
*/
public function activate()
{
$this->confirmed_at = time();
return $this->save();
}
/**
* Check if the user is activated
* @return boolean
*/
public function isActivated()
{
return empty($this->confirmed_at) ? false : true;
}
/**
* Send Registration confirmation email
*
* @return boolean Return false if fail to send email
*/
public function sendRegistrationConfirmation(Router $router, SmtpMailer $mailer)
{
$siteName = getenv('SITE_NAME');
$baseUrl = $this->getAbsoluteBaseUrl();
$message = __('user', 'confirmation_mail_body', [
'site_name' => $siteName,
'link' => $baseUrl . $router->getUrl('user/default/confirmation', ['token' => $this->auth_key]),
'base_url' => $baseUrl,
'username' => $this->username,
]);
$subject = __('user', 'Registration confirmation on {site_name}', ['site_name' => $siteName]);
$mail = new Message();
$mail->setFrom($siteName . ' <' . getenv('NO_REPLY_EMAIL') . '>')
->addTo($this->email)
->setSubject($subject)
->setBody($message);
try {
$mailer->send($mail);
return true;
} catch (\Exception $e) {
$this->errors['sendmail'] = $e->getMessage();
}
return false;
}
/**
* Send reset password email
*
* @return boolean Return false if fail to send email
*/
public function sendResetPassword(Router $router, SmtpMailer $mailer)
{
$siteName = getenv('SITE_NAME');
$baseUrl = $this->getAbsoluteBaseUrl();
$message = __('user', 'reset_password_mail_body', [
'site_name' => $siteName,
'link' => $baseUrl . $router->getUrl('user/default/reset-password', ['token' => $this->auth_key]),
'username' => $this->username,
]);
$subject = __('user', 'Password change request on {site_name}', ['site_name' => $siteName]);
$mail = new Message();
$mail->setFrom($siteName . ' <' . getenv('NO_REPLY_EMAIL') . '>')
->addTo($this->email)
->setSubject($subject)
->setBody($message);
try {
$mailer->send($mail);
return true;
} catch (\Exception $e) {
$this->errors['sendmail'] = $e->getMessage();
}
return false;
}
/**
* Get users
*
* @param array $filters Array of filter conditions (['name' => ''])
* @param string $order The order condition
* @param number $start The offset start
* @param number $limit The offset limit
*
* @return array An array of user rows
*/
public static function find($filters = [], $order = '', $start = 0, $limit = 0)
{
$query = 'SELECT * FROM `user`';
$where = [];
if (!empty($filters['name'])) {
$where[] = '`name` LIKE :search';
}
if (!empty($where)) {
$query .= ' WHERE ' . implode(' AND ', $where);
}
$query .= ' ORDER BY ' . (empty($order) ? '`id` DESC' : $order);
if (!empty($start)) {
$query .= ' OFFSET ' . (int) $start;
}
if (!empty($limit)) {
$query .= ' LIMIT ' . (int) $limit;
}
$sth = static::$pdo->prepare($query);
$sth->execute($filters);
return $sth->fetchAll();
}
/**
* Find user by username
*
* @param string $username
* @return User|NULL
*/
public static function findByUsername($username)
{
$st = static::$pdo->prepare('SELECT id FROM user WHERE username = ?');
$st->bindParam(1, $username, \PDO::PARAM_STR);
if ($st->execute()) {
$id = $st->fetchColumn();
if ($id) {
$user = new static(static::$pdo);
$user->load($id);
return $user;
}
}
return null;
}
/**
* Find user by email
*
* @param string $email
* @return User|NULL
*/
public static function findByEmail($email)
{
$st = static::$pdo->prepare('SELECT id FROM user WHERE email = ?');
$st->bindParam(1, $email, \PDO::PARAM_STR);
if ($st->execute()) {
$id = $st->fetchColumn();
if ($id) {
$user = new static(static::$pdo);
return $user->load($id);
}
}
return null;
}
/**
* Find user by auth key
*
* @param string $token
* @return User|NULL
*/
public static function findByAuthKey($token)
{
$st = static::$pdo->prepare('SELECT id FROM `user` WHERE `auth_key` = ?');
if ($st->execute([$token])) {
$id = $st->fetchColumn();
if ($id) {
$user = new static(static::$pdo);
$user->load($id);
return $user;
}
}
return null;
}
/**
* Validate password
*
* @param string $password
* @return boolean
*/
public function validatePassword($password)
{
return $this->password == sha1($password);
}
/**
* Find user by Id
*
* @param int $id
* @return User|NULL
*/
public static function findIdentity($id)
{
try {
$user = new static(static::$pdo);
return $user->load($id);
} catch (\RuntimeException $e) {
}
return null;
}
/**
* {@inheritDoc}
* @see \piko\IdentityInterface::getId()
*/
public function getId()
{
return $this->id;
}
/**
* Get absolute base Url
*
* @return string
*/
protected function getAbsoluteBaseUrl()
{
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
return "$protocol://{$_SERVER['HTTP_HOST']}";
}
}

View File

@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS `user` (
`id` int(11) NOT NULL auto_increment,
`name` varchar(50) NOT NULL,
`username` varchar(50) NOT NULL,
`email` varchar(100) NOT NULL,
`password` varchar(50) NOT NULL,
`auth_key` varchar(100) NOT NULL DEFAULT '',
`confirmed_at` datetime DEFAULT NULL,
`blocked_at` datetime DEFAULT NULL,
`registration_ip` varchar(40) DEFAULT '' COMMENT 'Stores ip v4 or ip v6',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`last_login_at` datetime DEFAULT NULL,
`is_admin` tinyint(1) NOT NULL DEFAULT 0,
`timezone` varchar(40) DEFAULT '',
`profil` text NOT NULL DEFAULT '{}' COMMENT 'Json encoded profil',
PRIMARY KEY (`id`),
INDEX `username` (`username`),
UNIQUE INDEX `email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci;

View File

@ -0,0 +1,52 @@
CREATE TABLE IF NOT EXISTS "user" (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
username TEXT NOT NULL,
email TEXT NOT NULL,
password TEXT NOT NULL,
auth_key TEXT,
confirmed_at INTEGER,
blocked_at INTEGER,
registration_ip TEXT,
created_at INTEGER,
updated_at INTEGER,
last_login_at INTEGER,
timezone TEXT,
profil TEXT,
UNIQUE(email)
);
CREATE TABLE IF NOT EXISTS auth_role
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(64) NOT NULL,
description TEXT,
UNIQUE(name)
);
CREATE TABLE IF NOT EXISTS auth_permission
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(64) NOT NULL,
UNIQUE(name)
);
CREATE TABLE IF NOT EXISTS auth_role_has_permission
(
role_id INTEGER NOT NULL,
permission_id INTEGER NOT NULL,
primary key (role_id, permission_id),
foreign key (role_id) references auth_role(id) on delete cascade on update cascade,
foreign key (permission_id) references auth_permission(id) on delete cascade on update cascade
);
CREATE TABLE IF NOT EXISTS auth_assignment
(
role_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
primary key (role_id, user_id),
foreign key (role_id) references auth_role(id) on delete cascade on update cascade,
foreign key (user_id) references "user" (id) on delete cascade on update cascade
);

View File

@ -0,0 +1,58 @@
<?php
use function Piko\I18n\__;
assert($this instanceof Piko\View);
assert($user instanceof app\modules\user\models\User);
/* @var $message array */
/* @var $roles array */
$this->title = empty($user->id) ? __('user', 'Create user') : __('user', 'Edit user');
$roleIds = $user->getRoleIds();
?>
<div class="container">
<?php if (is_array($message)): ?>
<div class="alert alert-<?= $message['type'] ?> alert-dismissible" role="alert">
<?= $message['content'] ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif ?>
<form method="post">
<div class="mb-3">
<label for="name"><?= __('user', 'Name') ?></label>
<input type="text" class="form-control" id="name" name="name" value="<?= $user->name ?>">
</div>
<div class="mb-3">
<label for="email"><?= __('user', 'Email') ?></label>
<input type="text" class="form-control" id="email" name="email" value="<?= $user->email ?>">
</div>
<div class="mb-3">
<label for="username"><?= __('user', 'Username') ?></label>
<input type="text" class="form-control" id="username" name="username" value="<?= $user->username ?>">
</div>
<div class="mb-3">
<label for="password"><?= __('user', 'Password') ?></label>
<input type="text" class="form-control" id="password" name="password" value="">
</div>
<div class="mb-3">
<label for="roles"><?= __('user', 'Roles') ?></label>
<select class="form-select" id="roles" name="roles[]" multiple>
<?php foreach ($roles as $role): ?>
<option value="<?= $role['id']?>"<?= in_array($role['id'], $roleIds)? ' selected' : '' ?>><?= $role['name']?></option>
<?php endforeach ?>
</select>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary"><?= __('user', 'Save') ?></button>
<a href="<?= $this->getUrl('user/admin/users')?>" class="btn btn-default"><?= __('user', 'Close') ?></a>
</div>
</form>
</div>

View File

@ -0,0 +1,17 @@
<?php
use function Piko\I18n\__;
/* @var $this \piko\View */
/* @var $page string */
?>
<ul class="nav nav-tabs my-3">
<li class="nav-item">
<a class="nav-link<?= $page == 'users' ? ' active' : '' ?>" href="<?= $this->getUrl('user/admin/users') ?>"><?= __('user', 'Users') ?></a>
</li>
<li class="nav-item">
<a class="nav-link<?= $page == 'roles' ? ' active' : '' ?>" href="<?= $this->getUrl('user/admin/roles') ?>"><?= __('user', 'Roles') ?></a>
</li>
<li class="nav-item">
<a class="nav-link<?= $page == 'permissions' ? ' active' : '' ?>" href="<?= $this->getUrl('user/admin/permissions') ?>"><?= __('user', 'Permissions') ?></a>
</li>
</ul>

View File

@ -0,0 +1,111 @@
<?php
use Piko;
use function Piko\I18n\__;
assert($this instanceof Piko\View);
/* @var $permissions array */
$this->title = __('user', 'Permissions');
$this->registerCSSFile(Piko::getAlias('@web/js/DataTables/datatables.min.css'));
$this->registerJsFile(Piko::getAlias('@web/js/jquery-3.7.1.min.js'));
$this->registerJsFile(Piko::getAlias('@web/js/DataTables/datatables.min.js'));
$script = <<<JS
$(function() {
$('#permissions-table').DataTable({
'order': [[1, 'desc']]
});
$('#delete').click(function(e) {
if (confirm('Êtes-vous sûr de vouloir effectuer cette action ?')) {
$('#admin-form').attr('action', '/user/admin/delete-permissions')
$('#admin-form').submit()
}
});
$('#btn-new-permission, .edit-permission').on('click', function(e) {
e.preventDefault();
var permissionName = '';
var permissionId = $(this).data('id');
var action = $(this).attr('href');
const modal = new bootstrap.Modal('#editPermissionModal');
if ($(this).hasClass('edit-permission')) {
permissionName = $(this).text();
}
$('#permission-name').val(permissionName);
modal.show();
$('#btn-save-permission').on('click', function() {
if ($('#permission-name').val()) {
$.ajax({
method: 'post',
url: action,
data: {name: $('#permission-name').val(), id: permissionId}
})
.done(function(data) {
if (data.status == 'success') {
location.reload();
}
if (data.status == 'error') {
alert(data.error)
}
});
}
});
});
});
JS;
$this->registerJs($script);
?>
<?= $this->render('nav', ['page' => 'permissions']) ?>
<form action="" method="post" id="admin-form">
<div class="btn-group mb-4" role="group">
<a href="<?= $this->getUrl('user/admin/edit-permission') ?>" class="btn btn-primary btn-sm" id="btn-new-permission"><?= __('user', 'New permission') ?></a>
<button type="button" class="btn btn-danger btn-sm" id="delete"><?= __('user', 'Delete') ?></button>
</div>
<table class="table table-striped" id="permissions-table">
<thead>
<tr>
<th><?= __('user', 'Name') ?></th>
<th><?= __('user', 'Id') ?></th>
</tr>
</thead>
<tbody>
<?php foreach($permissions as $permission): ?>
<tr>
<td>
<input type="checkbox" name="items[]" value="<?= $permission['id'] ?>">&nbsp;
<a href="<?= $this->getUrl('user/admin/edit-permission', ['id' => $permission['id']])?>"
class="edit-permission" data-id="<?= $permission['id'] ?>"><?= $permission['name'] ?></a>
</td>
<td><?= $permission['id'] ?></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</form>
<div class="modal fade" id="editPermissionModal" tabindex="-1" role="dialog" aria-labelledby="editPermissionModal" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body">
<input type="text" id="permission-name" class="form-control" placeholder="<?= __('user', 'Permission name') ?>">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?= __('user', 'Cancel') ?></button>
<button type="button" class="btn btn-primary" id="btn-save-permission"><?= __('user', 'Save') ?></button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,152 @@
<?php
use Piko;
use function Piko\I18n\__;
assert($this instanceof Piko\View);
/* @var $roles array */
/* @var $permissions array */
$this->title = __('user', 'Roles');
$this->registerCSSFile(Piko::getAlias('@web/js/DataTables/datatables.min.css'));
$this->registerJsFile(Piko::getAlias('@web/js/jquery-3.7.1.min.js'));
$this->registerJsFile(Piko::getAlias('@web/js/DataTables/datatables.min.js'));
$confirmDeleteMsg = __('user', 'Are you sure you want to perform this action?');
$script = <<<JS
$(function() {
$('#roles-table').DataTable({
'order': [[1, 'desc']]
});
$('#delete').click(function(e) {
if (confirm('{$confirmDeleteMsg}')) {
$('#admin-form').attr('action', '/user/admin/delete-roles')
$('#admin-form').submit()
}
});
$('#btn-new, .edit-role').on('click', function(e) {
e.preventDefault();
var data = {
id: $(this).data('id'),
parent_id: $(this).data('parent_id'),
name: '',
description: $(this).data('description')
};
var action = $(this).attr('href');
const modal = new bootstrap.Modal('#editRoleModal');
if ($(this).hasClass('edit-role')) {
data.name = $(this).text();
}
$('#role-name').val(data.name);
$('#role-description').val(data.description);
$.ajax({
method: 'get',
url: action,
})
.done(function(data) {
if (data.role.permissions) {
$('#permissions').val(data.role.permissions)
}
});
modal.show();
$('#btn-save').on('click', function() {
if ($('#role-name').val()) {
data.name = $('#role-name').val();
data.description = $('#role-description').val();
data.parent_id = $('#role-parent-id').val();
data.permissions = $('#permissions').val();
$.ajax({
method: 'post',
url: action,
data: data
})
.done(function(data) {
if (data.status == 'success') {
location.reload();
}
});
}
});
});
});
JS;
$this->registerJs($script);
?>
<?= $this->render('nav', ['page' => 'roles']) ?>
<form action="" method="post" id="admin-form">
<div class="btn-group mb-4" role="group">
<a href="<?= $this->getUrl('user/admin/edit-role') ?>" class="btn btn-primary btn-sm" id="btn-new"><?= __('user', 'New role') ?></a>
<button type="button" class="btn btn-danger btn-sm" id="delete"><?= __('user', 'Delete') ?></button>
</div>
<table class="table table-striped" id="roles-table">
<thead>
<tr>
<th><?= __('user', 'Name') ?></th>
<th><?= __('user', 'Description') ?></th>
<th><?= __('user', 'Id') ?></th>
</tr>
</thead>
<tbody>
<?php foreach($roles as $role): ?>
<tr>
<td>
<input type="checkbox" name="items[]" value="<?= $role['id'] ?>">&nbsp;
<a href="<?= $this->getUrl('user/admin/edit-role', ['id' => $role['id']])?>"
class="edit-role"
data-description="<?= $role['description'] ?>"
data-id="<?= $role['id'] ?>"><?= $role['name'] ?></a>
</td>
<td><?= $role['description'] ?></td>
<td><?= $role['id'] ?></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</form>
<div class="modal fade" id="editRoleModal" tabindex="-1" role="dialog" aria-labelledby="editRoleModal" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body">
<div class="form-group">
<label for="role-name"><?= __('user', 'Role name') ?></label>
<input type="text" id="role-name" class="form-control">
</div>
<div class="form-group">
<label for="role-description"><?= __('user', 'Description') ?></label>
<textarea rows="3" class="form-control" id="role-description"></textarea>
</div>
<div class="form-group">
<label for="permissions"><?= __('user', 'Role permissions') ?></label>
<select class="form-select" id="permissions" multiple>
<?php foreach ($permissions as $perm): ?>
<option value="<?= $perm['id']?>"><?= $perm['name']?></option>
<?php endforeach ?>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?= __('user', 'Cancel') ?></button>
<button type="button" class="btn btn-primary" id="btn-save"><?= __('user', 'Save') ?></button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,71 @@
<?php
use function Piko\I18n\__;
assert($this instanceof Piko\View);
/* @var $users array */
$this->title = __('user', 'Users management');
$this->registerCSSFile(Piko::getAlias('@web/js/DataTables/datatables.min.css'));
$this->registerJsFile(Piko::getAlias('@web/js/jquery-3.7.1.min.js'));
$this->registerJsFile(Piko::getAlias('@web/js/DataTables/datatables.min.js'));
$confirmDeleteMsg = __('user', 'Are you sure you want to perform this action?');
$script = <<<JS
$(document).ready(function() {
$('#users-table').DataTable({
'order': [[3, 'desc']]
});
$('#delete').click(function(e) {
if (confirm('{$confirmDeleteMsg}')) {
$('#admin-form').attr('action', '/user/admin/delete')
$('#admin-form').submit()
}
});
});
JS;
$this->registerJs($script);
?>
<?= $this->render('nav', ['page' => 'users']) ?>
<form action="" method="post" id="admin-form">
<div class="btn-group mb-4" role="group">
<a href="<?= $this->getUrl('user/admin/edit') ?>" class="btn btn-primary btn-sm"><?= __('user', 'Create user') ?></a>
<button type="button" class="btn btn-danger btn-sm" id="delete"><?= __('user', 'Delete') ?></button>
</div>
<table id="users-table" class="table table-striped">
<thead>
<tr>
<th><?= __('user', 'Name') ?></th>
<th><?= __('user', 'Username') ?></th>
<th><?= __('user', 'Email') ?></th>
<th><?= __('user', 'Last login at') ?></th>
<th><?= __('user', 'Created at') ?></th>
<th><?= __('user', 'Id') ?></th>
</tr>
</thead>
<tbody>
<?php foreach($users as $user): ?>
<tr>
<td>
<input type="checkbox" name="items[]" value="<?= $user['id'] ?>">&nbsp;
<a href="<?= $this->getUrl('user/admin/edit', ['id' => $user['id']])?>"><?= $user['name'] ?></a>
</td>
<td><?= $user['username']?></td>
<td><?= $user['email']?></td>
<td><?= empty($user['last_login_at']) ? '' : date('Y-m-d H:i', $user['last_login_at']) ?></td>
<td><?= date('Y-m-d H:i', $user['created_at']) ?></td>
<td><?= $user['id'] ?></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</form>

View File

@ -0,0 +1,93 @@
<?php
use function Piko\I18n\__;
assert($this instanceof Piko\View);
/* @var $message array */
/* @var $user piko\user\models\User */
$this->title = __('user', 'Edit your account');
if (is_array($message)) {
$this->params['message'] = $message;
}
if (!empty($user->profil)) {
$user->profil = json_decode($user->profil);
}
?>
<div class="container mt-5">
<h1><?= $this->title ?></h1>
<form method="post" novalidate>
<div class="form-group">
<label for="username"><?= __('user', 'Username') ?> : <strong><?= $user->username ?></strong></label>
</div>
<div class="form-group">
<label for="email"><?= __('user', 'Email') ?></label>
<input type="text" class="form-control" id="email" name="email" value="<?= $user->email ?>">
<?php if (!empty($user->errors['email'])): ?>
<div class="alert alert-danger" role="alert"><?= $user->errors['email'] ?></div>
<?php endif ?>
</div>
<div class="form-group">
<label for="password"><?= __('user', 'Password (leave blank to keep the same)') ?></label>
<input type="password" class="form-control" id="password" name="password" value="" autocomplete="off">
</div>
<div class="form-row">
<div class="col-md-6 mb-3">
<label for="lastname"><?= __('user', 'Last name') ?></label>
<input type="text" class="form-control" id="lastname" name="profil[lastname]" value="<?= isset($user->profil->lastname) ? $user->profil->lastname : '' ?>">
</div>
<div class="col-md-6 mb-3">
<label for="firstname"><?= __('user', 'First name') ?></label>
<input type="text" class="form-control" id="firstname" name="profil[firstname]" value="<?= isset($user->profil->firstname) ? $user->profil->firstname : '' ?>">
</div>
</div>
<div class="form-row">
<div class="col-md-6 mb-3">
<label for="company"><?= __('user', 'Company') ?></label>
<input type="text" class="form-control" id="company" name="profil[company]" value="<?= isset($user->profil->company) ? $user->profil->company : ''?>">
</div>
<div class="col-md-6 mb-3">
<label for="telephone"><?= __('user', 'Phone number') ?></label>
<input type="text" class="form-control" id="telephone" name="profil[telephone]" value="<?= isset($user->profil->telephone) ? $user->profil->telephone : ''?>">
</div>
</div>
<div class="form-group mb-3">
<label for="address"><?= __('user', 'Address') ?></label>
<input type="text" class="form-control" id="address" name="profil[address]" value="<?= isset($user->profil->address) ? $user->profil->address : ''?>">
</div>
<div class="form-row">
<div class="col-md-4 mb-3">
<label for="zipcode"><?= __('user', 'Zip code') ?></label>
<input type="text" class="form-control" id="zipcode" name="profil[zipcode]" value="<?= isset($user->profil->zipcode) ? $user->profil->zipcode : ''?>">
</div>
<div class="col-md-4 mb-3">
<label for="city"><?= __('user', 'City') ?></label>
<input type="text" class="form-control" id="city" name="profil[city]" value="<?= isset($user->profil->city) ? $user->profil->city : ''?>">
</div>
<div class="col-md-4 mb-3">
<label for="country"><?= __('user', 'Country') ?></label>
<input type="text" class="form-control" id="country" name="profil[country]" value="<?= isset($user->profil->country) ? $user->profil->country : ''?>">
</div>
</div>
<button type="submit" class="btn btn-primary"><?= __('user', 'Save') ?></button>
<a href="<?= Piko::getAlias('@web/')?>" class="btn btn-default"><?= __('user', 'Cancel') ?></a>
</form>
</div>

View File

@ -0,0 +1,77 @@
<?php
use function Piko\I18n\__;
assert($this instanceof Piko\View);
/**
* @var $message boolean | array
* @var $canRegister boolean
*/
$this->title = __('user', 'Login');
$this->params['breadcrumbs'][] = $this->title;
if (is_array($message)) {
$this->params['message'] = $message;
}
?>
<main class="form-signin w-100 m-auto">
<form action="<?= $this->getUrl('user/default/login') ?>" id="login-form" method="post">
<h1 class="h3 mb-3 fw-normal"><?= $this->title ?></h1>
<div class="form-floating">
<input type="text" class="form-control" id="username" name="username" autofocus="autofocus" aria-required="true"
aria-invalid="true">
<label for="username"><?= __('user', 'Username') ?></label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="loginform-password" name="password" value="" aria-required="true">
<label for="loginform-password"><?= __('user', 'Password') ?></label>
</div>
<button class="btn btn-primary w-100 py-2" type="submit"><?= __('user', 'Login') ?></button>
<p class="mt-5 mb-3 text-body-secondary">&copy; 20172023</p>
<!--
<div class="form-group row">
<label class="col-sm-3 col-form-label" for="loginform-username"><?= __('user', 'Username') ?></label>
<div class="col-sm-9">
<input type="text" id="username" class="form-control" name="username" autofocus="autofocus" aria-required="true"
aria-invalid="true">
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label" for="loginform-password"><?= __('user', 'Password') ?></label>
<div class="col-sm-9">
<input type="password" id="loginform-password" class="form-control" name="password" value="" aria-required="true">
</div>
</div>
<div class="form-group">
<div class="offset-sm-3 col-sm-9">
<button type="submit" class="btn btn-primary" name="login-button"><?= __('user', 'Login') ?></button>
</div>
</div>
-->
</form>
<?php if ($canRegister): ?>
<div class="p-3 border bg-light text-dark">
<p><?= __('user', 'No account yet?') ?></p>
<p><a href="<?= $this->getUrl('user/default/register')?>" class="btn btn-primary"><?= __('user', 'Register') ?></a></p>
<hr>
<p><a href="<?= $this->getUrl('user/default/reminder')?>"><?= __('user', 'Forget password?') ?></a></p>
</div>
<?php endif ?>
</main>

View File

@ -0,0 +1,82 @@
<?php
use piko\Piko;
/* @var $this \piko\View */
/* @var $router \piko\Router */
/* @var $message array */
$router = Piko::get('router');
$this->title = Piko::t('user', 'Register');
if (is_array($message)) {
$this->params['message'] = $message;
return;
}
$js = <<<SCRIPT
jQuery(document).ready(function($) {
function validateField(e) {
var that = this;
$.post('{$router->getUrl('user/default/check-registration')}', $('#register-form').serialize(), function(errors) {
if (errors[that.name]) {
$(that).addClass('is-invalid')
$(that).removeClass('is-valid')
$(that).next('.invalid-feedback').text(errors[that.name])
} else {
$(that).removeClass('is-invalid')
$(that).addClass('is-valid')
}
});
}
$('#username').focusout(validateField);
$('#email').focusout(validateField);
$('#password').focusout(validateField);
$('#password2').focusout(validateField);
});
SCRIPT;
$this->registerJs($js);
?>
<div class="container mt-5">
<h1><?= $this->title ?></h1>
<form method="post" id="register-form" novalidate>
<div class="form-group">
<label for="username"><?= Piko::t('user', 'Username') ?></label>
<input type="text" class="form-control" id="username" name="username" value="">
<div class="invalid-feedback"></div>
</div>
<div class="form-group">
<label for="email"><?= Piko::t('user', 'Email') ?></label>
<input type="text" class="form-control" id="email" name="email" value="">
<div class="invalid-feedback"></div>
</div>
<div class="form-group">
<label for="password"><?= Piko::t('user', 'Password') ?></label>
<input type="password" class="form-control" id="password" name="password" value="" autocomplete="off">
<div class="invalid-feedback"></div>
</div>
<div class="form-group">
<label for="password2"><?= Piko::t('user', 'Confirm your password') ?></label>
<input type="password" class="form-control" id="password2" name="password2" value="" autocomplete="off">
<div class="invalid-feedback"></div>
</div>
<button type="submit" class="btn btn-primary"><?= Piko::t('user', 'Register') ?></button>
</form>
</div>

View File

@ -0,0 +1,31 @@
<?php
use piko\Piko;
/* @var $this \piko\View */
/* @var $message array */
/* @var $reminder string */
$this->title = Piko::t('user', 'Forget password');
if (is_array($message)) {
$this->params['message'] = $message;
}
?>
<div class="container" style="margin-top: 100px">
<h1><?= $this->title ?></h1>
<form method="post" id="reminder-form" novalidate>
<div class="form-group">
<label for="reminder"><?= Piko::t('user', 'Your email or your username') ?></label>
<input type="text" class="form-control" id="reminder" name="reminder" value="<?= $reminder ?>" autocomplete="off">
</div>
<button type="submit" class="btn btn-primary"><?= Piko::t('user', 'Send') ?></button>
</form>
</div>

View File

@ -0,0 +1,75 @@
<?php
use piko\Piko;
/* @var $this \piko\View */
/* @var $user piko\user\models\User */
/* @var $message array */
/* @var $router \piko\Router */
$router = Piko::get('router');
$this->title = Piko::t('user', 'Change your account ({account}) password',['account' => $user->username]);
if (is_array($message)) {
$this->params['message'] = $message;
echo '<div class="container text-center"><a class="btn btn-primary" href="'. $router->getUrl('user/default/login').'">'
. Piko::t('user', 'Login') . '</a></div>';
return;
}
$js = <<<SCRIPT
jQuery(document).ready(function($) {
function validateField(e) {
var that = this;
$.post('{$router->getUrl('user/default/check-registration')}', $('#register-form').serialize(), function(errors) {
if (errors[that.name]) {
$(that).addClass('is-invalid')
$(that).removeClass('is-valid')
$(that).next('.invalid-feedback').text(errors[that.name])
} else {
$(that).removeClass('is-invalid')
$(that).addClass('is-valid')
}
});
}
$('#password').focusout(validateField);
$('#password2').focusout(validateField);
});
SCRIPT;
$this->registerJs($js);
?>
<div class="container" style="margin-top: 100px">
<h1 class="h4"><?= $this->title ?></h1>
<form method="post" id="register-form" novalidate>
<div class="form-group">
<label for="password"><?= Piko::t('user', 'Password') ?></label>
<input type="password" class="form-control" id="password" name="password" value="" autocomplete="off">
<div class="invalid-feedback"></div>
</div>
<div class="form-group">
<label for="password2"><?= Piko::t('user', 'Confirm your password') ?></label>
<input type="password" class="form-control" id="password2" name="password2" value="" autocomplete="off">
<div class="invalid-feedback"></div>
</div>
<button type="submit" class="btn btn-primary"><?= Piko::t('user', 'Send') ?></button>
</form>
</div>

View File

@ -0,0 +1,49 @@
<?php
namespace app\overrides\user\controllers;
use app\overrides\user\models\User;
use function Piko\I18n\__;
use app\modules\user\models\Role;
class AdminController extends \app\modules\user\controllers\AdminController
{
/**
* Render User form and create or update user
*
* @return string
*/
public function editAction(int $id = 0)
{
$user = new User($this->db);
if ($id) {
$user->load($id);
}
$user->scenario = User::SCENARIO_ADMIN;
$message = [];
$post = $this->request->getParsedBody();
if (!empty($post)) {
$user->bind($post);
if ($user->isValid() && $user->save()) {
$message['type'] = 'success';
$message['content'] = __('user', 'User successfully saved');
} else {
$message['type'] = 'danger';
$message['content'] = __('user', 'Save error!') . implode(' ', $user->errors);
}
}
return $this->render('edit', [
'user' => $user,
'message' => $message,
'roles' => Role::find('`name` ASC'),
]);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace app\overrides\user\models;
use Piko\DbRecord;
class User extends \app\modules\user\models\User
{
/**
* {@inheritDoc}
* @see \Piko\DbRecord::beforeSave()
*/
protected function beforeSave($isNew): bool
{
if (is_array($this->profil)) {
$this->profil = json_encode($this->profil);
}
return parent::beforeSave($isNew);
}
/**
* {@inheritDoc}
* @see \Piko\DbRecord::afterSave()
*/
protected function afterSave(): void
{
$this->profil = empty($this->profil) ? [] : json_decode($this->profil, true);
parent::afterSave();
}
/**
* {@inheritDoc}
* @see \Piko\DbRecord::load()
*/
public function load($id = 0): DbRecord
{
parent::load($id);
$this->profil = empty($this->profil) ? [] : json_decode($this->profil, true);
return $this;
}
}

View File

@ -0,0 +1,68 @@
<?php
use function Piko\I18n\__;
assert($this instanceof Piko\View);
assert($user instanceof \app\overrides\user\models\User);
/* @var $message array */
/* @var $roles array */
if (!empty($message)) {
$this->params['message'] = $message;
}
$this->title = empty($user->id) ? __('user', 'Create user') : __('user', 'Edit user');
$roleIds = $user->getRoleIds();
?>
<div class="container">
<form method="post">
<div class="mb-3">
<label for="name"><?= __('user', 'Name') ?></label>
<input type="text" class="form-control" id="name" name="name" value="<?= $user->name ?>">
</div>
<div class="mb-3">
<label for="email"><?= __('user', 'Email') ?></label>
<input type="text" class="form-control" id="email" name="email" value="<?= $user->email ?>">
</div>
<div class="mb-3">
<label for="username"><?= __('user', 'Username') ?></label>
<input type="text" class="form-control" id="username" name="username" value="<?= $user->username ?>">
</div>
<div class="mb-3">
<label for="password"><?= __('user', 'Password') ?></label>
<input type="text" class="form-control" id="password" name="password" value="">
</div>
<div class="mb-3">
<label for="roles"><?= __('user', 'Roles') ?></label>
<select class="form-select" id="roles" name="roles[]" multiple>
<?php foreach ($roles as $role): ?>
<option value="<?= $role['id']?>"<?= in_array($role['id'], $roleIds)? ' selected' : '' ?>><?= $role['name']?></option>
<?php endforeach ?>
</select>
</div>
<hr>
<div class="mb-3">
<label for="proxy_user_id">ID utilisateur proxy</label>
<input type="text" class="form-control" id="proxy_user_id"
name="profil[proxy_user_id]" value="<?= isset($user->profil['proxy_user_id'])? $user->profil['proxy_user_id'] : '' ?>">
</div>
<div class="mb-3">
<label for="api_key">Clé d'API</label>
<input type="text" class="form-control" id="api_key" name="profil[api_key]" value="<?= $user->profil['api_key']?? '' ?>">
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary"><?= __('user', 'Save') ?></button>
<a href="<?= $this->getUrl('user/admin/users')?>" class="btn btn-default"><?= __('user', 'Close') ?></a>
</div>
</form>
</div>

View File

@ -0,0 +1,112 @@
<?php
use Piko;
use function Piko\I18n\__;
assert($this instanceof Piko\View);
/* @var $permissions array */
$this->title = __('user', 'Permissions');
$this->registerCSSFile(Piko::getAlias('@web/js/DataTables/datatables.min.css'));
$this->registerJsFile(Piko::getAlias('@web/js/jquery-3.7.1.min.js'));
$this->registerJsFile(Piko::getAlias('@web/js/DataTables/datatables.min.js'));
$script = <<<JS
$(function() {
$('#permissions-table').DataTable({
'order': [[1, 'desc']]
});
$('#delete').click(function(e) {
if (confirm('Êtes-vous sûr de vouloir effectuer cette action ?')) {
$('#admin-form').attr('action', '/user/admin/delete-permissions')
$('#admin-form').submit()
}
});
$('#btn-new-permission, .edit-permission').on('click', function(e) {
e.preventDefault();
var permissionName = '';
var permissionId = $(this).data('id');
var action = $(this).attr('href');
const modal = new bootstrap.Modal('#editPermissionModal');
if ($(this).hasClass('edit-permission')) {
permissionName = $(this).text();
}
$('#permission-name').val(permissionName);
modal.show();
$('#btn-save-permission').on('click', function() {
if ($('#permission-name').val()) {
$.ajax({
method: 'post',
url: action,
data: {name: $('#permission-name').val(), id: permissionId}
})
.done(function(data) {
if (data.status == 'success') {
location.reload();
}
if (data.status == 'error') {
alert(data.error)
}
});
}
});
});
});
JS;
$this->registerJs($script);
?>
<div class="container-xxl">
<?= $this->render('nav', ['page' => 'permissions']) ?>
<form action="" method="post" id="admin-form">
<div class="btn-group mb-4" role="group">
<a href="<?= $this->getUrl('user/admin/edit-permission') ?>" class="btn btn-primary btn-sm" id="btn-new-permission"><?= __('user', 'New permission') ?></a>
<button type="button" class="btn btn-danger btn-sm" id="delete"><?= __('user', 'Delete') ?></button>
</div>
<table class="table table-striped" id="permissions-table">
<thead>
<tr>
<th><?= __('user', 'Name') ?></th>
<th><?= __('user', 'Id') ?></th>
</tr>
</thead>
<tbody>
<?php foreach($permissions as $permission): ?>
<tr>
<td>
<input type="checkbox" name="items[]" value="<?= $permission['id'] ?>">&nbsp;
<a href="<?= $this->getUrl('user/admin/edit-permission', ['id' => $permission['id']])?>"
class="edit-permission" data-id="<?= $permission['id'] ?>"><?= $permission['name'] ?></a>
</td>
<td><?= $permission['id'] ?></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</form>
</div>
<div class="modal fade" id="editPermissionModal" tabindex="-1" role="dialog" aria-labelledby="editPermissionModal" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body">
<input type="text" id="permission-name" class="form-control" placeholder="<?= __('user', 'Permission name') ?>">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?= __('user', 'Cancel') ?></button>
<button type="button" class="btn btn-primary" id="btn-save-permission"><?= __('user', 'Save') ?></button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,154 @@
<?php
use Piko;
use function Piko\I18n\__;
assert($this instanceof Piko\View);
/* @var $roles array */
/* @var $permissions array */
$this->title = __('user', 'Roles');
$this->registerCSSFile(Piko::getAlias('@web/js/DataTables/datatables.min.css'));
$this->registerJsFile(Piko::getAlias('@web/js/jquery-3.7.1.min.js'));
$this->registerJsFile(Piko::getAlias('@web/js/DataTables/datatables.min.js'));
$confirmDeleteMsg = __('user', 'Are you sure you want to perform this action?');
$script = <<<JS
$(function() {
$('#roles-table').DataTable({
'order': [[1, 'desc']]
});
$('#delete').click(function(e) {
if (confirm('{$confirmDeleteMsg}')) {
$('#admin-form').attr('action', '/user/admin/delete-roles')
$('#admin-form').submit()
}
});
$('#btn-new, .edit-role').on('click', function(e) {
e.preventDefault();
var data = {
id: $(this).data('id'),
parent_id: $(this).data('parent_id'),
name: '',
description: $(this).data('description')
};
var action = $(this).attr('href');
const modal = new bootstrap.Modal('#editRoleModal');
if ($(this).hasClass('edit-role')) {
data.name = $(this).text();
}
$('#role-name').val(data.name);
$('#role-description').val(data.description);
$.ajax({
method: 'get',
url: action,
})
.done(function(data) {
if (data.role.permissions) {
$('#permissions').val(data.role.permissions)
}
});
modal.show();
$('#btn-save').on('click', function() {
if ($('#role-name').val()) {
data.name = $('#role-name').val();
data.description = $('#role-description').val();
data.parent_id = $('#role-parent-id').val();
data.permissions = $('#permissions').val();
$.ajax({
method: 'post',
url: action,
data: data
})
.done(function(data) {
if (data.status == 'success') {
location.reload();
}
});
}
});
});
});
JS;
$this->registerJs($script);
?>
<div class="container-xxl">
<?= $this->render('nav', ['page' => 'roles']) ?>
<form action="" method="post" id="admin-form">
<div class="btn-group mb-4" role="group">
<a href="<?= $this->getUrl('user/admin/edit-role') ?>" class="btn btn-primary btn-sm" id="btn-new"><?= __('user', 'New role') ?></a>
<button type="button" class="btn btn-danger btn-sm" id="delete"><?= __('user', 'Delete') ?></button>
</div>
<table class="table table-striped" id="roles-table">
<thead>
<tr>
<th><?= __('user', 'Name') ?></th>
<th><?= __('user', 'Description') ?></th>
<th><?= __('user', 'Id') ?></th>
</tr>
</thead>
<tbody>
<?php foreach($roles as $role): ?>
<tr>
<td>
<input type="checkbox" name="items[]" value="<?= $role['id'] ?>">&nbsp;
<a href="<?= $this->getUrl('user/admin/edit-role', ['id' => $role['id']])?>"
class="edit-role"
data-description="<?= $role['description'] ?>"
data-id="<?= $role['id'] ?>"><?= $role['name'] ?></a>
</td>
<td><?= $role['description'] ?></td>
<td><?= $role['id'] ?></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</form>
</div>
<div class="modal fade" id="editRoleModal" tabindex="-1" role="dialog" aria-labelledby="editRoleModal" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body">
<div class="form-group">
<label for="role-name"><?= __('user', 'Role name') ?></label>
<input type="text" id="role-name" class="form-control">
</div>
<div class="form-group">
<label for="role-description"><?= __('user', 'Description') ?></label>
<textarea rows="3" class="form-control" id="role-description"></textarea>
</div>
<div class="form-group">
<label for="permissions"><?= __('user', 'Role permissions') ?></label>
<select class="form-select" id="permissions" multiple>
<?php foreach ($permissions as $perm): ?>
<option value="<?= $perm['id']?>"><?= $perm['name']?></option>
<?php endforeach ?>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"><?= __('user', 'Cancel') ?></button>
<button type="button" class="btn btn-primary" id="btn-save"><?= __('user', 'Save') ?></button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,74 @@
<?php
use function Piko\I18n\__;
assert($this instanceof Piko\View);
/* @var $users array */
$this->title = __('user', 'Users management');
$this->registerCSSFile(Piko::getAlias('@web/js/DataTables/datatables.min.css'));
$this->registerJsFile(Piko::getAlias('@web/js/jquery-3.7.1.min.js'));
$this->registerJsFile(Piko::getAlias('@web/js/DataTables/datatables.min.js'));
$confirmDeleteMsg = __('user', 'Are you sure you want to perform this action?');
$script = <<<JS
$(document).ready(function() {
$('#users-table').DataTable({
'order': [[3, 'desc']]
});
$('#delete').click(function(e) {
if (confirm('{$confirmDeleteMsg}')) {
$('#admin-form').attr('action', '/user/admin/delete')
$('#admin-form').submit()
}
});
});
JS;
$this->registerJs($script);
?>
<div class="container-xxl">
<?= $this->render('nav', ['page' => 'users']) ?>
<form action="" method="post" id="admin-form">
<div class="btn-group mb-4" role="group">
<a href="<?= $this->getUrl('user/admin/edit') ?>" class="btn btn-primary btn-sm"><?= __('user', 'Create user') ?></a>
<button type="button" class="btn btn-danger btn-sm" id="delete"><?= __('user', 'Delete') ?></button>
</div>
<table id="users-table" class="table table-striped">
<thead>
<tr>
<th><?= __('user', 'Name') ?></th>
<th><?= __('user', 'Username') ?></th>
<th><?= __('user', 'Email') ?></th>
<th><?= __('user', 'Last login at') ?></th>
<th><?= __('user', 'Created at') ?></th>
<th><?= __('user', 'Id') ?></th>
</tr>
</thead>
<tbody>
<?php foreach($users as $user): ?>
<tr>
<td>
<input type="checkbox" name="items[]" value="<?= $user['id'] ?>">&nbsp;
<a href="<?= $this->getUrl('user/admin/edit', ['id' => $user['id']])?>"><?= $user['name'] ?></a>
</td>
<td><?= $user['username']?></td>
<td><?= $user['email']?></td>
<td><?= empty($user['last_login_at']) ? '' : date('Y-m-d H:i', $user['last_login_at']) ?></td>
<td><?= date('Y-m-d H:i', $user['created_at']) ?></td>
<td><?= $user['id'] ?></td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</form>
</div>

View File

@ -0,0 +1,89 @@
<?php
use function Piko\I18n\__;
assert($this instanceof Piko\View);
/* @var $message array */
/* @var $user piko\user\models\User */
$this->title = __('user', 'Edit your account');
if (is_array($message)) {
$this->params['message'] = $message;
}
?>
<div class="container mt-5">
<h1><?= $this->title ?></h1>
<form method="post" novalidate>
<div class="form-group">
<label for="username"><?= __('user', 'Username') ?> : <strong><?= $user->username ?></strong></label>
</div>
<div class="form-group">
<label for="email"><?= __('user', 'Email') ?></label>
<input type="text" class="form-control" id="email" name="email" value="<?= $user->email ?>">
<?php if (!empty($user->errors['email'])): ?>
<div class="alert alert-danger" role="alert"><?= $user->errors['email'] ?></div>
<?php endif ?>
</div>
<div class="form-group">
<label for="password"><?= __('user', 'Password (leave blank to keep the same)') ?></label>
<input type="password" class="form-control" id="password" name="password" value="" autocomplete="off">
</div>
<div class="form-row">
<div class="col-md-6 mb-3">
<label for="lastname"><?= __('user', 'Last name') ?></label>
<input type="text" class="form-control" id="lastname" name="profil[lastname]" value="<?= isset($user->profil->lastname) ? $user->profil->lastname : '' ?>">
</div>
<div class="col-md-6 mb-3">
<label for="firstname"><?= __('user', 'First name') ?></label>
<input type="text" class="form-control" id="firstname" name="profil[firstname]" value="<?= isset($user->profil->firstname) ? $user->profil->firstname : '' ?>">
</div>
</div>
<div class="form-row">
<div class="col-md-6 mb-3">
<label for="company"><?= __('user', 'Company') ?></label>
<input type="text" class="form-control" id="company" name="profil[company]" value="<?= isset($user->profil->company) ? $user->profil->company : ''?>">
</div>
<div class="col-md-6 mb-3">
<label for="telephone"><?= __('user', 'Phone number') ?></label>
<input type="text" class="form-control" id="telephone" name="profil[telephone]" value="<?= isset($user->profil->telephone) ? $user->profil->telephone : ''?>">
</div>
</div>
<div class="form-group mb-3">
<label for="address"><?= __('user', 'Address') ?></label>
<input type="text" class="form-control" id="address" name="profil[address]" value="<?= isset($user->profil->address) ? $user->profil->address : ''?>">
</div>
<div class="form-row">
<div class="col-md-4 mb-3">
<label for="zipcode"><?= __('user', 'Zip code') ?></label>
<input type="text" class="form-control" id="zipcode" name="profil[zipcode]" value="<?= isset($user->profil->zipcode) ? $user->profil->zipcode : ''?>">
</div>
<div class="col-md-4 mb-3">
<label for="city"><?= __('user', 'City') ?></label>
<input type="text" class="form-control" id="city" name="profil[city]" value="<?= isset($user->profil->city) ? $user->profil->city : ''?>">
</div>
<div class="col-md-4 mb-3">
<label for="country"><?= __('user', 'Country') ?></label>
<input type="text" class="form-control" id="country" name="profil[country]" value="<?= isset($user->profil->country) ? $user->profil->country : ''?>">
</div>
</div>
<button type="submit" class="btn btn-primary"><?= __('user', 'Save') ?></button>
<a href="<?= Piko::getAlias('@web/')?>" class="btn btn-default"><?= __('user', 'Cancel') ?></a>
</form>
</div>

2
var/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

4
vite/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
.DS_Store
dist
*.local

1485
vite/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
vite/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "openai",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "APP_ENV=development vite",
"build": "vite build"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"markdown-it": "^14.1.0",
"sass": "^1.77.8",
"svelte": "^4.2.19",
"vite": "^5.4.1",
"vite-plugin-live-reload": "^3.0.3"
},
"dependencies": {
"alpinejs": "^3.14.1",
"bootstrap": "^5.3.3",
"clipboard": "^2.0.11",
"highlight.js": "^11.10.0"
}
}

View File

@ -0,0 +1,76 @@
<script>
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
let assistant = {
title: '',
model: 'gpt-3.5',
system_prompt: '',
temperature: 1.0,
top_p: 1.0,
};
export let models = [
'gpt-4o', 'gpt-3.5', 'gpt-4'
];
let errors = {}
export function setErrors(data) {
errors = data;
}
export function setAssistant(data) {
assistant = data;
}
function handleSave() {
// Émet l'événement 'save' avec les données de l'assistant
dispatch('save', assistant);
}
</script>
<div class="modal-body">
<div class="mb-2">
<input type="text" class="form-control {errors.title ? 'is-invalid' : ''}" placeholder="Titre" bind:value={assistant.title}>
{#if errors.system_prompt}
<div class="invalid-feedback">{errors.system_prompt}</div>
{/if}
</div>
<div class="mb-2">
<label for="assistant-model">Model</label>
<select class="form-select" bind:value={assistant.model}>
{#each models as model}
<option value={model}>{model}</option>
{/each}
</select>
</div>
<div class="mb-2">
<label for="assistant-system_prompt">Prompt système</label>
<textarea class="form-control {errors.system_prompt ? 'is-invalid' : ''}" rows="3" bind:value={assistant.system_prompt}></textarea>
{#if errors.system_prompt}
<div class="invalid-feedback">{errors.system_prompt}</div>
{/if}
</div>
<div class="mb-2">
<label for="assistant-temperature" class="form-label">Température</label>
<input type="range" min="0" max="2" step="0.01" bind:value={assistant.temperature} class="form-range">
<output>{assistant.temperature}</output>
</div>
<div class="mb-2">
<label for="assistant-top_p" class="form-label">Top p</label>
<input type="range" min="0" max="1" step="0.01" bind:value={assistant.top_p} class="form-range">
<output>{assistant.top_p}</output>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
<button class="btn btn-primary" on:click={handleSave}>Enregistrer</button>
</div>

529
vite/src/ChatApp.svelte Normal file
View File

@ -0,0 +1,529 @@
<script>
import markdownit from 'markdown-it'
import hljs from 'highlight.js';
// import ClipboardJS from 'clipboard';
import ChatMessage from './ChatMessage.svelte';
import Modal from './Modal.svelte';
import AssistantForm from './AssistantForm.svelte';
import { onMount } from 'svelte';
// export let apiKey = '';
export let proxyBaseUrl = '';
export let model_list = [];
let assistant = null;
let assistant_id = 0;
let assistant_title = '';
let assistants = [];
let messages = [];
let newMessage = '';
let chatContainer;
let assistantForm; // Reference to the AssistantForm component
let modal; // Reference to the Modal component
onMount(() => {
refreshAssistants();
});
const md = markdownit({
linkify: true,
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
const html = hljs.highlight(code, {language: language, ignoreIllegals: true }).value
return `<pre class="hljs-code-container my-3"><div class="hljs-code-header"><span>${language}</span><button class="hljs-copy-button">Copy</button></div><code class="hljs language-${language}">${html}</code></pre>`
},
});
/*
new ClipboardJS('.hljs-copy-button', {
target: function(trigger) {
console.log(trigger.parentNode.nextElementSibling)
return trigger.parentNode.nextElementSibling;
}
});
*/
async function postRequest(url, headers, body) {
const response = await fetch(url, {
method: "POST",
headers: headers,
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(await response.text());
}
return response;
}
async function readStream(stream, progressCallback) {
const reader = stream.getReader();
const textDecoder = new TextDecoder('utf-8');
let responseObj = {};
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = textDecoder.decode(value).split("\n");
processLines(lines, responseObj, progressCallback);
}
return responseObj;
}
function processLines(lines, responseObj, progressCallback) {
for (const line of lines) {
if (line.startsWith("data: ")) {
if (line.includes("[DONE]")) {
return responseObj;
}
try {
const data = JSON.parse(line.slice(6));
const delta = data.choices[0].delta;
Object.keys(delta).forEach(key => {
responseObj[key] = (responseObj[key] || "") + delta[key];
progressCallback(responseObj);
});
} catch (e) {
console.log("Error parsing line:", line);
}
}
}
}
async function complete(messages, apiUrl,/* token,*/ params, progressCallback) {
// const apiUrl = baseUrl + '/chat/completions';
const headers = {
"Content-Type": "application/json",
// "Authorization": `Bearer ${token}`
};
const body = {
model: params.model,
messages: messages,
stream: true,
};
if (params.temperature != undefined) {
body.temperature = params.temperature;
}
if (params.top_p != undefined) {
body.top_p = params.top_p;
}
const response = await postRequest(apiUrl, headers, body);
return readStream(response.body, progressCallback);
}
function sendMessage() {
if (newMessage.trim() === '') return;
if (!messages.length && assistant) {
const systemMessage = { role: 'system', content: assistant.system_prompt };
messages.push(systemMessage);
}
const userMessage = { role: 'user', content: newMessage };
messages.push(userMessage);
messages.push({ role: 'assistant', content: '' });
messages = messages;
const lastMsgIndex = messages.length - 1;
try {
if (assistant) {
complete(
messages,
proxyBaseUrl,
// apiKey,
assistant,
(message) => {
if (message.content)
messages[lastMsgIndex].content = message.content;
}
);
} else if (!assistants.length) {
messages[lastMsgIndex].content = "Aucun assistant n'a été trouvé. Veuillez en créer un nouveau en cliquant sur le bouton +.";
}
} catch (error) {
console.log(error.message);
return;
} finally {
newMessage = '';
}
}
function clearMessages() {
messages = [];
}
function loadAssistant(config = {}) {
assistant = null;
// ES6 Destructuring object properties into variables with default values
const {
model = '',
system_prompt = '',
temperature = 0,
top_p = 0
} = config;
// ES6 (Property Shorthand)
assistant = {model, system_prompt, temperature, top_p };
assistant_id = config.id;
assistant_title = config.title;
console.log("Changed assistant " + assistant_id);
console.log(assistant);
}
async function refreshAssistants() {
try {
const response = await fetch('/site/assistant/list', {method: 'GET'});
if (!response.ok) {
throw new Error('Failed to fetch assistants');
}
assistants = await response.json();
if (assistants.length) {
assistants.forEach(config => {
if (config.default == 1) {
loadAssistant(config);
return;
}
});
if (!assistant) {
loadAssistant(assistants[0]);
}
}
} catch (error) {
console.error('Error refreshing assistants:', error);
}
}
// Handle saving assistant data
async function handleSave(event) {
try {
const assistantData = event.detail;
const response = await fetch('/site/assistant/save', {
method: 'POST',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(assistantData),
});
if (!response.ok) {
throw new Error('Server response error');
}
const data = await response.json();
if (data.status == 'success') {
refreshAssistants();
modal.hide();
} else if (data.status == 'error' && data.errors) {
assistantForm.setErrors(data.errors);
}
} catch (error) {
console.error('Error saving assistant:', error);
}
}
// Handle changing assistant
async function handleChange() {
try {
assistants.forEach(config => {
if (config.id == assistant_id) {
loadAssistant(config);
return;
}
});
const response = await fetch('/site/assistant/set-as-default?id=' + assistant_id);
if (!response.ok) {
throw new Error('Server response error');
}
const status = await response.json();
if (!status) {
throw new Error('Failed to set default assistant');
}
} catch (error) {
console.error('Error changing assistant:', error);
}
}
function createAssistant()
{
assistantForm.setAssistant({
title: '',
model: 'gpt-4o',
system_prompt: '',
temperature: 0,
top_p: 1.0,
default: 1
});
modal.show();
}
function editAssistant()
{
assistants.forEach(config => {
if (config.id == assistant_id) {
assistantForm.setAssistant(config);
modal.show();
return;
}
});
}
async function deleteAssistant()
{
if (assistant && confirm(`Êtes-vous certain de vouloir effacer l'assistant ${assistant_title} ?`)) {
try {
const response = await fetch('/site/assistant/delete?id=' + assistant_id);
if (!response.ok) {
throw new Error('Server response error');
}
const status = await response.json();
if (!status) {
throw new Error('Failed to delete assistant');
}
assistant = null;
refreshAssistants();
} catch (error) {
console.error('Error deleting assistant:', error);
}
}
}
function exportMessages() {
const date = new Date();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const formattedDate = `${day}-${month}-${date.getFullYear()}`;
const formattedTime = `${hours}-${minutes}-${seconds}`;
const filename = `${assistant_title}_${formattedDate}_${formattedTime}.json`;
const json = JSON.stringify(messages, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// Fonction pour importer les messages depuis un fichier JSON
function importMessages(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedMessages = JSON.parse(e.target.result);
if (Array.isArray(importedMessages)) {
messages = importedMessages;
} else {
throw new Error("Invalid JSON format");
}
} catch (error) {
console.error("Error importing messages:", error);
}
};
reader.readAsText(file);
}
/*
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
let messages = writable([]);
let newMessage = writable("");
const sendMessage = () => {
if ($newMessage.trim() !== "") {
messages.update(msgs => [...msgs, { role: 'user', content: $newMessage }]);
newMessage.set("");
}
};
// Sample function to simulate receiving a message from the AI
const receiveMessage = (content) => {
messages.update(msgs => [...msgs, { role: 'assistant', content }]);
};
onMount(() => {
// Simulate receiving an initial message from the AI
receiveMessage("Hello! How can I assist you today?");
});
*/
</script>
<header>
<h1 class="text-center">{assistant_title}</h1>
<div class="toolbar">
<button class="btn btn-primary" on:click={createAssistant} title="Créer un assistant"><span class="icon-add"></span></button>
{#if assistants.length}
<select class="form-select" bind:value={assistant_id} on:change={handleChange}>
{#each assistants as config}
<option value={config.id}>{config.title}</option>
{/each}
</select>
{#if assistant}
<button class="btn btn-secondary" on:click={editAssistant} title="Modifier l'assistant {assistant_title}"><span class="icon-edit"></span></button>
<button class="btn btn-danger" on:click={deleteAssistant} title="Supprimer l'assistant {assistant_title}"><span class="icon-delete"></span></button>
{/if}
{/if}
{#if messages.length}
<div class="separator d-none d-sm-block"></div>
<button class="btn btn-warning clear" on:click="{clearMessages}" title="Effacer les messages"><span class="icon-clean"></button>
<button class="btn btn-info" on:click="{exportMessages}" title="Exporter la conversation"><span class="icon-file_download"></span></button>
<div class="separator d-none d-sm-block"></div>
{/if}
<input id="import-file" type="file" accept=".json" on:change="{importMessages}" style="display:none" />
<label for="import-file" class="btn btn-info"><span class="icon-file_upload" title="Importer une conversation"></span></label>
</div>
</header>
<main>
<div class="chat-container" bind:this="{chatContainer}" >
{#each messages as message, index (index)}
<ChatMessage message="{message}" container="{chatContainer.parentElement}" markdown={md} />
{/each}
</div>
</main>
<footer>
<textarea class="chat-input" rows="1"
bind:value="{newMessage}"
on:keyup={e => { if (e.key === 'Enter') sendMessage(); }}
placeholder="Saisissez votre message ici..."
aria-label="Chat with AI"></textarea>
</footer>
<Modal bind:this="{modal}" ariaLabelledby="editAssistantModal">
<AssistantForm models={model_list} bind:this={assistantForm} on:save={handleSave} />
</Modal>
<style lang="scss">
header {
background-color: #333;
color: white;
padding: 8px;
}
.toolbar {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
.separator {
background-color: #4a4a4a;
width: 1px;
margin: 0 15px;
}
& > select {
width: auto;
}
/*
& > button {
}
*/
}
main {
flex-grow: 1;
overflow-y: auto;
}
button.clear {
right: 0;
}
footer {
width: 100%;
padding: 8px;
display: flex;
justify-content: center;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.chat-input {
width: 100%;
max-width: 768px;
padding: 8px;
border-width: 1px;
border-color: rgba(0, 0, 0, 0.1);
border-radius: 6px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
min-height: 40px;
}
.chat-container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 768px;
margin: 0 auto;
overflow-wrap: break-word;
}
</style>

View File

@ -0,0 +1,51 @@
<script>
import { beforeUpdate, afterUpdate } from 'svelte';
export let message;
export let container;
export let markdown;
let autoscroll;
let renderedContent = '';
$: renderedContent = markdown.render(message.content);
beforeUpdate(() => {
autoscroll = container && container.offsetHeight + container.scrollTop >
container.scrollHeight - 20;
});
afterUpdate(() => {
if (autoscroll) container.scrollTo(0, container.scrollHeight);
});
</script>
<style>
.hljs-code-header {
display: flex;
justify-content: space-between;
padding: 3px 6px;
background-color: #9b9b9b;
color: #fff;
}
.message {
padding: 2px 16px;
}
code:not(.hljs) {
color: #4d4d4d;
padding: 0 5px;
display: inline-block;
}
.assistant {
background-color: #f0f0f0;
}
</style>
{#if message.role != 'system'}
<div class={message.role + ' message'}>
{@html renderedContent}
</div>
{/if}

37
vite/src/Modal.svelte Normal file
View File

@ -0,0 +1,37 @@
<script>
import { onMount } from 'svelte';
let modalElement;
let modal;
export let ariaLabelledby = '';
onMount(() => {
modal = new bootstrap.Modal(modalElement);
});
export function show()
{
if (modal) modal.show();
}
export function hide()
{
if (modal) modal.hide();
}
</script>
<div class="modal fade" bind:this={modalElement} tabindex="-1" role="dialog" aria-hidden="true" aria-labelledby={ariaLabelledby}>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<slot></slot>
</div>
</div>
</div>

33
vite/src/main.js Normal file
View File

@ -0,0 +1,33 @@
// Styles
import './styles/site.scss'
import ChatApp from './ChatApp.svelte'
// import * as bootstrap from 'bootstrap/dist/js/bootstrap';
// window.bootstrap = bootstrap;
import {Modal, Alert} from 'bootstrap';
window.ChatApp = ChatApp;
window.bootstrap = {
Modal: Modal,
Alert: Alert,
};
window.addEventListener('DOMContentLoaded', function() {
/*
const hamburgerBtn = document.querySelector('.hamburger')
hamburgerBtn.addEventListener('click', function () {
this.classList.toggle('is-open')
this.classList.toggle('is-closed')
})
*/
const activeLink = document.querySelector('#mainmenu a[href="' + location.pathname + '"]');
if (activeLink) {
activeLink.parentNode.classList.add('active');
}
});

155
vite/src/styles/_chat.scss Normal file
View File

@ -0,0 +1,155 @@
/*
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
font-family: -apple-system, "system-ui", "Segoe UI Adjusted", "Segoe UI",
"Liberation Sans", sans-serif;
}
pre {
overflow-x: auto;
border-radius: 6px;
}
button {
border: none;
background-color: transparent;
color: inherit;
padding: 0;
cursor: pointer;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
*/
#chat-app {
height: 100%;
display: flex;
flex-direction: column;
header {
position: relative;
width: 100%;
height: 48px;
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: #333;
color: white;
& > button {
position: absolute;
padding: 12px;
}
}
main {
flex-grow: 1;
overflow-y: auto;
}
button.menu {
left: 0;
}
button.clear {
right: 0;
}
.hidden {
display: none;
}
footer {
width: 100%;
padding: 8px;
display: flex;
justify-content: center;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
#input-box {
width: 100%;
max-width: 768px;
padding: 8px;
border-width: 1px;
border-color: rgba(0, 0, 0, 0.1);
border-radius: 6px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
min-height: 40px;
}
.hljs-code-header {
display: flex;
justify-content: space-between;
padding: 3px 6px;
background-color: #9b9b9b;
color: #fff;
}
}
/*
aside {
position: fixed;
top: 0;
width: 100%;
height: 100%;
z-index: 1;
display: flex;
}
.sidebar-container {
background-color: #333;
color: white;
width: 300px;
overflow-y: auto;
}
.sidebar-modal {
flex: 1 0;
min-width: 64px;
background-color: rgba(0, 0, 0, 0.3);
}
li > button {
width: 100%;
height: 48px;
text-align: left;
padding: 0 16px;
transition: background-color 0.2s;
}
li > button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
main {
flex-grow: 1;
overflow-y: auto;
}
*/
.chat-container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 768px;
margin: 0 auto;
overflow-wrap: break-word;
}
.chat-container .message {
padding: 2px 16px;
}
.chat-container code:not(.hljs) {
color: #4d4d4d;
padding: 0 5px;
display: inline-block;
}
.chat-container .assistant {
background-color: #f0f0f0;
}

View File

@ -0,0 +1,63 @@
@font-face {
font-family: 'icomoon';
src: url('/fonts/icomoon.eot?ws5e0y');
src: url('/fonts/icomoon.eot?ws5e0y#iefix') format('embedded-opentype'),
url('/fonts/icomoon.ttf?ws5e0y') format('truetype'),
url('/fonts/icomoon.woff?ws5e0y') format('woff'),
url('/fonts/icomoon.svg?ws5e0y#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
}
[class^="icon-"], [class*=" icon-"] {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'icomoon' !important;
speak: never;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-error:before {
content: "\e900";
}
.icon-warning:before {
content: "\e901";
}
.icon-loop:before {
content: "\e902";
}
.icon-mic:before {
content: "\e903";
}
.icon-library_add:before {
content: "\e90a";
}
.icon-add:before {
content: "\e904";
}
.icon-edit:before {
content: "\e905";
}
.icon-file_download:before {
content: "\e908";
}
.icon-file_upload:before {
content: "\e909";
}
.icon-delete:before {
content: "\e906";
}
.icon-settings:before {
content: "\e90b";
}
.icon-clean:before {
content: "\e907";
}

View File

@ -0,0 +1,113 @@
// -------------------------------
// Hamburger-Cross
// -------------------------------
// https://codepen.io/djdabe/pen/qXgJNV
.hamburger {
display: block;
width: 32px;
height: 32px;
background: transparent;
border: none;
outline: none;
.hamb-top, .hamb-middle, .hamb-bottom {
position: absolute;
left: 0;
height: 4px;
width: 100%;
background-color: #1a1a1a;
}
&.is-closed {
&:before {
content: '';
display: block;
width: 100px;
font-size: 14px;
color: #fff;
line-height: 32px;
text-align: center;
opacity: 0;
-webkit-transform: translate3d(0,0,0);
-webkit-transition: all .35s ease-in-out;
}
&:hover{
&:before {
opacity: 1;
display: block;
transform: translate3d(-100px, 0, 0);
transition: all .35s ease-in-out;
}
.hamb-top {
top: 0;
transition: all .35s ease-in-out;
}
.hamb-bottom {
bottom: 0;
transition: all .35s ease-in-out;
}
}
.hamb-top {
top: 5px;
-webkit-transition: all .35s ease-in-out;
}
.hamb-middle {
top: 50%;
margin-top: -2px;
}
.hamb-bottom {
bottom: 5px;
-webkit-transition: all .35s ease-in-out;
}
}
&.is-open {
.hamb-top,
.hamb-bottom {
top: 50%;
margin-top: -2px;
}
.hamb-top {
-webkit-transform: rotate(45deg);
-webkit-transition: -webkit-transform .2s cubic-bezier(.73,1,.28,.08);
}
.hamb-middle {
display: none;
}
.hamb-bottom {
-webkit-transform: rotate(-45deg);
-webkit-transition: -webkit-transform .2s cubic-bezier(.73,1,.28,.08);
}
&:before {
content: '';
display: block;
width: 100px;
font-size: 14px;
color: #fff;
line-height: 32px;
text-align: center;
opacity: 0;
-webkit-transform: translate3d(0,0,0);
-webkit-transition: all .35s ease-in-out;
}
&:hover:before {
opacity: 1;
display: block;
-webkit-transform: translate3d(-100px,0,0);
-webkit-transition: all .35s ease-in-out;
}
}
}

66
vite/src/styles/site.scss Normal file
View File

@ -0,0 +1,66 @@
@import "~bootstrap/scss/bootstrap";
@import "~highlightjs/scss/github";
@import "fonts";
@import "hamburger";
// @import "chat";
html,
body {
height: 100%;
}
body {
background-color: #cccccc;
}
#navBtn {
position: fixed;
top: 10px;
left: 10px;
z-index: 999;
}
#chat-app {
height: 100%;
display: flex;
flex-direction: column;
}
#chat {
white-space: pre-wrap;
.user {
color: #ffeaa4;
}
.assistant {
color: #ffffff;
}
}
.form-signin {
max-width: 330px;
padding: 1rem;
.form-floating:focus-within {
z-index: 2;
}
input[type="text"] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
input[type="password"] {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}

7
vite/svelte.config.js Normal file
View File

@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}

65
vite/vite.config.js Normal file
View File

@ -0,0 +1,65 @@
// View your website at your own local server
// for example http://vite-php-setup.test
// http://localhost:5133 is serving Vite on development
// but accessing it directly will be empty
// TIP: consider changing the port for each project, see below
// IMPORTANT image urls in CSS works fine
// BUT you need to create a symlink on dev server to map this folder during dev:
// ln -s {path_to_project_source}/src/assets {path_to_public_html}/assets
// on production everything will work just fine
// (this happens because our Vite code is outside the server public access,
// if it where, we could use https://vitejs.dev/config/server-options.html#server-origin)
import { defineConfig, splitVendorChunkPlugin } from 'vite'
import liveReload from 'vite-plugin-live-reload'
import path from 'path'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import svelteConfig from './svelte.config.js' // Configuration Svelte
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
liveReload([
// edit live reload paths according to your source code
__dirname + '/../modules/**/*.php',
__dirname + '/../config/*.php',
__dirname + '/../web/*.php',
]),
splitVendorChunkPlugin(),
svelte(svelteConfig),
],
root: 'src',
base: process.env.APP_ENV === 'development'
? '/dev/'
: '/',
build: {
// Output dir for production build
outDir: '../../web',
emptyOutDir: false,
// Emit manifest so PHP can find the hashed files
manifest: true,
// Our entry
rollupOptions: {
input: path.resolve(__dirname, 'src/main.js'),
}
},
resolve: {
alias: {
'~bootstrap': path.resolve(__dirname, 'node_modules/bootstrap'),
'~highlightjs': path.resolve(__dirname, 'node_modules/highlight.js'),
}
},
server: {
// we need a strict port to match on PHP side
// change freely, but update on PHP to match the same port
strictPort: true,
port: 5133
},
})

1
web/.user.ini Normal file
View File

@ -0,0 +1 @@
output_buffering = Off

BIN
web/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
web/fonts/icomoon.eot Normal file

Binary file not shown.

22
web/fonts/icomoon.svg Normal file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Generated by IcoMoon</metadata>
<defs>
<font id="icomoon" horiz-adv-x="1024">
<font-face units-per-em="1024" ascent="960" descent="-64" />
<missing-glyph horiz-adv-x="1024" />
<glyph unicode="&#x20;" horiz-adv-x="512" d="" />
<glyph unicode="&#xe900;" glyph-name="error" d="M554 384.667v256h-84v-256h84zM554 212.667v86h-84v-86h84zM512 852.667q176 0 301-125t125-301-125-301-301-125-301 125-125 301 125 301 301 125z" />
<glyph unicode="&#xe901;" glyph-name="warning" d="M554 340.667v172h-84v-172h84zM554 170.667v86h-84v-86h84zM42 42.667l470 810 470-810h-940z" />
<glyph unicode="&#xe902;" glyph-name="loop" d="M512 170.667v128l170-170-170-172v128q-140 0-241 101t-101 241q0 100 54 182l62-62q-30-54-30-120 0-106 75-181t181-75zM512 768.667q140 0 241-101t101-241q0-100-54-182l-62 62q30 54 30 120 0 106-75 181t-181 75v-128l-170 170 170 172v-128z" />
<glyph unicode="&#xe903;" glyph-name="mic" d="M738 468.667h72q0-108-75-189t-181-97v-140h-84v140q-106 16-181 97t-75 189h72q0-94 67-155t159-61 159 61 67 155zM512 340.667q-52 0-90 38t-38 90v256q0 52 38 90t90 38 90-38 38-90v-256q0-52-38-90t-90-38z" />
<glyph unicode="&#xe904;" glyph-name="add" d="M810 384.667h-256v-256h-84v256h-256v84h256v256h84v-256h256v-84z" />
<glyph unicode="&#xe905;" glyph-name="create" d="M884 638.667l-78-78-160 160 78 78q12 12 30 12t30-12l100-100q12-12 12-30t-12-30zM128 202.667l472 472 160-160-472-472h-160v160z" />
<glyph unicode="&#xe906;" glyph-name="delete" d="M810 768.667v-86h-596v86h148l44 42h212l44-42h148zM256 128.667v512h512v-512q0-34-26-60t-60-26h-340q-34 0-60 26t-26 60z" />
<glyph unicode="&#xe907;" glyph-name="clean" d="M682 468.667h-42v342q0 36-25 61t-61 25h-84q-36 0-61-25t-25-61v-342h-42q-60 0-108-28t-77-77-29-107v-300h768v300q0 58-29 107t-77 77-108 28zM810 42.667h-84v128q0 18-13 30t-31 12q-16 0-29-12t-13-30v-128h-86v128q0 18-12 30t-30 12-30-12-12-30v-128h-86v128q0 18-13 30t-29 12q-18 0-31-12t-13-30v-128h-84v214q0 34 17 63t46 47 65 18h340q36 0 65-18t46-47 17-63v-214z" />
<glyph unicode="&#xe908;" glyph-name="file_download" d="M214 170.667h596v-86h-596v86zM810 554.667l-298-298-298 298h170v256h256v-256h170z" />
<glyph unicode="&#xe909;" glyph-name="file_upload" d="M214 170.667h596v-86h-596v86zM384 256.667v256h-170l298 298 298-298h-170v-256h-256z" />
<glyph unicode="&#xe90a;" glyph-name="library_add" d="M810 468.667v86h-170v170h-86v-170h-170v-86h170v-170h86v170h170zM854 852.667q34 0 59-25t25-59v-512q0-34-25-60t-59-26h-512q-34 0-60 26t-26 60v512q0 34 26 59t60 25h512zM170 682.667v-598h598v-84h-598q-34 0-59 25t-25 59v598h84z" />
<glyph unicode="&#xe90b;" glyph-name="settings" d="M512 276.667q62 0 106 44t44 106-44 106-106 44-106-44-44-106 44-106 106-44zM830 384.667l90-70q14-10 4-28l-86-148q-8-14-26-8l-106 42q-42-30-72-42l-16-112q-4-18-20-18h-172q-16 0-20 18l-16 112q-38 16-72 42l-106-42q-18-6-26 8l-86 148q-10 18 4 28l90 70q-2 14-2 42t2 42l-90 70q-14 10-4 28l86 148q8 14 26 8l106-42q42 30 72 42l16 112q4 18 20 18h172q16 0 20-18l16-112q38-16 72-42l106 42q18 6 26-8l86-148q10-18-4-28l-90-70q2-14 2-42t-2-42z" />
</font></defs></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
web/fonts/icomoon.ttf Normal file

Binary file not shown.

BIN
web/fonts/icomoon.woff Normal file

Binary file not shown.

22
web/index.php Normal file
View File

@ -0,0 +1,22 @@
<?php
use Tracy\Debugger;
use Piko\ModularApplication;
require(__DIR__ . '/../vendor/autoload.php');
foreach (require __DIR__ . '/../env.php' as $key => $val) {
putenv("{$key}={$val}");
}
if (getenv('APP_DEBUG')) {
Debugger::enable(Debugger::DEVELOPMENT);
}
$config = require __DIR__ . '/../config/app.php';
$app = new ModularApplication($config);
$app->pipe(new \app\lib\AuthMiddleware($app));
$app->pipe(new \app\lib\CorsMiddleware());
$app->run();

View File

@ -0,0 +1,451 @@
/*
* This combined file was created by the DataTables downloader builder:
* https://datatables.net/download
*
* To rebuild or modify this file with the latest versions of the included
* software please visit:
* https://datatables.net/download/#bs5/dt-1.13.6
*
* Included libraries:
* DataTables 1.13.6
*/
@charset "UTF-8";
:root {
--dt-row-selected: 13, 110, 253;
--dt-row-selected-text: 255, 255, 255;
--dt-row-selected-link: 9, 10, 11;
--dt-row-stripe: 0, 0, 0;
--dt-row-hover: 0, 0, 0;
--dt-column-ordering: 0, 0, 0;
--dt-html-background: white;
}
:root.dark {
--dt-html-background: rgb(33, 37, 41);
}
table.dataTable td.dt-control {
text-align: center;
cursor: pointer;
}
table.dataTable td.dt-control:before {
display: inline-block;
color: rgba(0, 0, 0, 0.5);
content: "►";
}
table.dataTable tr.dt-hasChild td.dt-control:before {
content: "▼";
}
html.dark table.dataTable td.dt-control:before {
color: rgba(255, 255, 255, 0.5);
}
html.dark table.dataTable tr.dt-hasChild td.dt-control:before {
color: rgba(255, 255, 255, 0.5);
}
table.dataTable thead > tr > th.sorting, table.dataTable thead > tr > th.sorting_asc, table.dataTable thead > tr > th.sorting_desc, table.dataTable thead > tr > th.sorting_asc_disabled, table.dataTable thead > tr > th.sorting_desc_disabled,
table.dataTable thead > tr > td.sorting,
table.dataTable thead > tr > td.sorting_asc,
table.dataTable thead > tr > td.sorting_desc,
table.dataTable thead > tr > td.sorting_asc_disabled,
table.dataTable thead > tr > td.sorting_desc_disabled {
cursor: pointer;
position: relative;
padding-right: 26px;
}
table.dataTable thead > tr > th.sorting:before, table.dataTable thead > tr > th.sorting:after, table.dataTable thead > tr > th.sorting_asc:before, table.dataTable thead > tr > th.sorting_asc:after, table.dataTable thead > tr > th.sorting_desc:before, table.dataTable thead > tr > th.sorting_desc:after, table.dataTable thead > tr > th.sorting_asc_disabled:before, table.dataTable thead > tr > th.sorting_asc_disabled:after, table.dataTable thead > tr > th.sorting_desc_disabled:before, table.dataTable thead > tr > th.sorting_desc_disabled:after,
table.dataTable thead > tr > td.sorting:before,
table.dataTable thead > tr > td.sorting:after,
table.dataTable thead > tr > td.sorting_asc:before,
table.dataTable thead > tr > td.sorting_asc:after,
table.dataTable thead > tr > td.sorting_desc:before,
table.dataTable thead > tr > td.sorting_desc:after,
table.dataTable thead > tr > td.sorting_asc_disabled:before,
table.dataTable thead > tr > td.sorting_asc_disabled:after,
table.dataTable thead > tr > td.sorting_desc_disabled:before,
table.dataTable thead > tr > td.sorting_desc_disabled:after {
position: absolute;
display: block;
opacity: 0.125;
right: 10px;
line-height: 9px;
font-size: 0.8em;
}
table.dataTable thead > tr > th.sorting:before, table.dataTable thead > tr > th.sorting_asc:before, table.dataTable thead > tr > th.sorting_desc:before, table.dataTable thead > tr > th.sorting_asc_disabled:before, table.dataTable thead > tr > th.sorting_desc_disabled:before,
table.dataTable thead > tr > td.sorting:before,
table.dataTable thead > tr > td.sorting_asc:before,
table.dataTable thead > tr > td.sorting_desc:before,
table.dataTable thead > tr > td.sorting_asc_disabled:before,
table.dataTable thead > tr > td.sorting_desc_disabled:before {
bottom: 50%;
content: "▲";
content: "▲"/"";
}
table.dataTable thead > tr > th.sorting:after, table.dataTable thead > tr > th.sorting_asc:after, table.dataTable thead > tr > th.sorting_desc:after, table.dataTable thead > tr > th.sorting_asc_disabled:after, table.dataTable thead > tr > th.sorting_desc_disabled:after,
table.dataTable thead > tr > td.sorting:after,
table.dataTable thead > tr > td.sorting_asc:after,
table.dataTable thead > tr > td.sorting_desc:after,
table.dataTable thead > tr > td.sorting_asc_disabled:after,
table.dataTable thead > tr > td.sorting_desc_disabled:after {
top: 50%;
content: "▼";
content: "▼"/"";
}
table.dataTable thead > tr > th.sorting_asc:before, table.dataTable thead > tr > th.sorting_desc:after,
table.dataTable thead > tr > td.sorting_asc:before,
table.dataTable thead > tr > td.sorting_desc:after {
opacity: 0.6;
}
table.dataTable thead > tr > th.sorting_desc_disabled:after, table.dataTable thead > tr > th.sorting_asc_disabled:before,
table.dataTable thead > tr > td.sorting_desc_disabled:after,
table.dataTable thead > tr > td.sorting_asc_disabled:before {
display: none;
}
table.dataTable thead > tr > th:active,
table.dataTable thead > tr > td:active {
outline: none;
}
div.dataTables_scrollBody > table.dataTable > thead > tr > th:before, div.dataTables_scrollBody > table.dataTable > thead > tr > th:after,
div.dataTables_scrollBody > table.dataTable > thead > tr > td:before,
div.dataTables_scrollBody > table.dataTable > thead > tr > td:after {
display: none;
}
div.dataTables_processing {
position: absolute;
top: 50%;
left: 50%;
width: 200px;
margin-left: -100px;
margin-top: -26px;
text-align: center;
padding: 2px;
}
div.dataTables_processing > div:last-child {
position: relative;
width: 80px;
height: 15px;
margin: 1em auto;
}
div.dataTables_processing > div:last-child > div {
position: absolute;
top: 0;
width: 13px;
height: 13px;
border-radius: 50%;
background: rgb(13, 110, 253);
background: rgb(var(--dt-row-selected));
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
div.dataTables_processing > div:last-child > div:nth-child(1) {
left: 8px;
animation: datatables-loader-1 0.6s infinite;
}
div.dataTables_processing > div:last-child > div:nth-child(2) {
left: 8px;
animation: datatables-loader-2 0.6s infinite;
}
div.dataTables_processing > div:last-child > div:nth-child(3) {
left: 32px;
animation: datatables-loader-2 0.6s infinite;
}
div.dataTables_processing > div:last-child > div:nth-child(4) {
left: 56px;
animation: datatables-loader-3 0.6s infinite;
}
@keyframes datatables-loader-1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes datatables-loader-3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes datatables-loader-2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}
table.dataTable.nowrap th, table.dataTable.nowrap td {
white-space: nowrap;
}
table.dataTable th.dt-left,
table.dataTable td.dt-left {
text-align: left;
}
table.dataTable th.dt-center,
table.dataTable td.dt-center,
table.dataTable td.dataTables_empty {
text-align: center;
}
table.dataTable th.dt-right,
table.dataTable td.dt-right {
text-align: right;
}
table.dataTable th.dt-justify,
table.dataTable td.dt-justify {
text-align: justify;
}
table.dataTable th.dt-nowrap,
table.dataTable td.dt-nowrap {
white-space: nowrap;
}
table.dataTable thead th,
table.dataTable thead td,
table.dataTable tfoot th,
table.dataTable tfoot td {
text-align: left;
}
table.dataTable thead th.dt-head-left,
table.dataTable thead td.dt-head-left,
table.dataTable tfoot th.dt-head-left,
table.dataTable tfoot td.dt-head-left {
text-align: left;
}
table.dataTable thead th.dt-head-center,
table.dataTable thead td.dt-head-center,
table.dataTable tfoot th.dt-head-center,
table.dataTable tfoot td.dt-head-center {
text-align: center;
}
table.dataTable thead th.dt-head-right,
table.dataTable thead td.dt-head-right,
table.dataTable tfoot th.dt-head-right,
table.dataTable tfoot td.dt-head-right {
text-align: right;
}
table.dataTable thead th.dt-head-justify,
table.dataTable thead td.dt-head-justify,
table.dataTable tfoot th.dt-head-justify,
table.dataTable tfoot td.dt-head-justify {
text-align: justify;
}
table.dataTable thead th.dt-head-nowrap,
table.dataTable thead td.dt-head-nowrap,
table.dataTable tfoot th.dt-head-nowrap,
table.dataTable tfoot td.dt-head-nowrap {
white-space: nowrap;
}
table.dataTable tbody th.dt-body-left,
table.dataTable tbody td.dt-body-left {
text-align: left;
}
table.dataTable tbody th.dt-body-center,
table.dataTable tbody td.dt-body-center {
text-align: center;
}
table.dataTable tbody th.dt-body-right,
table.dataTable tbody td.dt-body-right {
text-align: right;
}
table.dataTable tbody th.dt-body-justify,
table.dataTable tbody td.dt-body-justify {
text-align: justify;
}
table.dataTable tbody th.dt-body-nowrap,
table.dataTable tbody td.dt-body-nowrap {
white-space: nowrap;
}
/*! Bootstrap 5 integration for DataTables
*
* ©2020 SpryMedia Ltd, all rights reserved.
* License: MIT datatables.net/license/mit
*/
table.dataTable {
clear: both;
margin-top: 6px !important;
margin-bottom: 6px !important;
max-width: none !important;
border-collapse: separate !important;
border-spacing: 0;
}
table.dataTable td,
table.dataTable th {
-webkit-box-sizing: content-box;
box-sizing: content-box;
}
table.dataTable td.dataTables_empty,
table.dataTable th.dataTables_empty {
text-align: center;
}
table.dataTable.nowrap th,
table.dataTable.nowrap td {
white-space: nowrap;
}
table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1) > * {
box-shadow: none;
}
table.dataTable > tbody > tr {
background-color: transparent;
}
table.dataTable > tbody > tr.selected > * {
box-shadow: inset 0 0 0 9999px rgb(13, 110, 253);
box-shadow: inset 0 0 0 9999px rgb(var(--dt-row-selected));
color: rgb(255, 255, 255);
color: rgb(var(--dt-row-selected-text));
}
table.dataTable > tbody > tr.selected a {
color: rgb(9, 10, 11);
color: rgb(var(--dt-row-selected-link));
}
table.dataTable.table-striped > tbody > tr.odd > * {
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-stripe), 0.05);
}
table.dataTable.table-striped > tbody > tr.odd.selected > * {
box-shadow: inset 0 0 0 9999px rgba(13, 110, 253, 0.95);
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.95);
}
table.dataTable.table-hover > tbody > tr:hover > * {
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-hover), 0.075);
}
table.dataTable.table-hover > tbody > tr.selected:hover > * {
box-shadow: inset 0 0 0 9999px rgba(13, 110, 253, 0.975);
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.975);
}
div.dataTables_wrapper div.dataTables_length label {
font-weight: normal;
text-align: left;
white-space: nowrap;
}
div.dataTables_wrapper div.dataTables_length select {
width: auto;
display: inline-block;
}
div.dataTables_wrapper div.dataTables_filter {
text-align: right;
}
div.dataTables_wrapper div.dataTables_filter label {
font-weight: normal;
white-space: nowrap;
text-align: left;
}
div.dataTables_wrapper div.dataTables_filter input {
margin-left: 0.5em;
display: inline-block;
width: auto;
}
div.dataTables_wrapper div.dataTables_info {
padding-top: 0.85em;
}
div.dataTables_wrapper div.dataTables_paginate {
margin: 0;
white-space: nowrap;
text-align: right;
}
div.dataTables_wrapper div.dataTables_paginate ul.pagination {
margin: 2px 0;
white-space: nowrap;
justify-content: flex-end;
}
div.dataTables_wrapper div.dt-row {
position: relative;
}
div.dataTables_scrollHead table.dataTable {
margin-bottom: 0 !important;
}
div.dataTables_scrollBody > table {
border-top: none;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
div.dataTables_scrollBody > table > thead .sorting:before,
div.dataTables_scrollBody > table > thead .sorting_asc:before,
div.dataTables_scrollBody > table > thead .sorting_desc:before,
div.dataTables_scrollBody > table > thead .sorting:after,
div.dataTables_scrollBody > table > thead .sorting_asc:after,
div.dataTables_scrollBody > table > thead .sorting_desc:after {
display: none;
}
div.dataTables_scrollBody > table > tbody tr:first-child th,
div.dataTables_scrollBody > table > tbody tr:first-child td {
border-top: none;
}
div.dataTables_scrollFoot > .dataTables_scrollFootInner {
box-sizing: content-box;
}
div.dataTables_scrollFoot > .dataTables_scrollFootInner > table {
margin-top: 0 !important;
border-top: none;
}
@media screen and (max-width: 767px) {
div.dataTables_wrapper div.dataTables_length,
div.dataTables_wrapper div.dataTables_filter,
div.dataTables_wrapper div.dataTables_info,
div.dataTables_wrapper div.dataTables_paginate {
text-align: center;
}
div.dataTables_wrapper div.dataTables_paginate ul.pagination {
justify-content: center !important;
}
}
table.dataTable.table-sm > thead > tr > th:not(.sorting_disabled) {
padding-right: 20px;
}
table.table-bordered.dataTable {
border-right-width: 0;
}
table.table-bordered.dataTable thead tr:first-child th,
table.table-bordered.dataTable thead tr:first-child td {
border-top-width: 1px;
}
table.table-bordered.dataTable th,
table.table-bordered.dataTable td {
border-left-width: 0;
}
table.table-bordered.dataTable th:first-child, table.table-bordered.dataTable th:first-child,
table.table-bordered.dataTable td:first-child,
table.table-bordered.dataTable td:first-child {
border-left-width: 1px;
}
table.table-bordered.dataTable th:last-child, table.table-bordered.dataTable th:last-child,
table.table-bordered.dataTable td:last-child,
table.table-bordered.dataTable td:last-child {
border-right-width: 1px;
}
table.table-bordered.dataTable th,
table.table-bordered.dataTable td {
border-bottom-width: 1px;
}
div.dataTables_scrollHead table.table-bordered {
border-bottom-width: 0;
}
div.table-responsive > div.dataTables_wrapper > div.row {
margin: 0;
}
div.table-responsive > div.dataTables_wrapper > div.row > div[class^=col-]:first-child {
padding-left: 0;
}
div.table-responsive > div.dataTables_wrapper > div.row > div[class^=col-]:last-child {
padding-right: 0;
}
:root[data-bs-theme=dark] {
--dt-row-hover: 255, 255, 255;
--dt-row-stripe: 255, 255, 255;
--dt-column-ordering: 255, 255, 255;
}

File diff suppressed because it is too large Load Diff

19
web/js/DataTables/datatables.min.css vendored Normal file

File diff suppressed because one or more lines are too long

22
web/js/DataTables/datatables.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
web/js/jquery-3.7.1.min.js vendored Normal file

File diff suppressed because one or more lines are too long