Premier commit

This commit is contained in:
2024-09-09 10:22:45 +02:00
commit bcc2604080
74 changed files with 25819 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
/vendor
/web/assets
/web/manifest.json
deploy.sh
env.php
+3
View File
@@ -0,0 +1,3 @@
# AI-UI Interface pour LLMS
+34
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
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
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
}
}
}
Generated
+2231
View File
File diff suppressed because it is too large Load Diff
+58
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
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
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
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
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
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;
}
});
});
}
}
@@ -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);
}
}
@@ -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
]);
}
}
+78
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>
+23
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>
+145
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;
}
}
+28
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>
+34
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>
+74
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
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
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();
}
}
@@ -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'));
}
}
@@ -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('/');
}
}
+111
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'
];
+96
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();
}
}
+180
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();
}
}
+551
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']}";
}
}
+21
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;
+52
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
);
+58
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>
+17
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>
+111
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>
+152
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>
+71
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>
+93
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>
+77
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>
+82
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>
+31
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>
+75
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>
@@ -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'),
]);
}
}
+44
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;
}
}
+68
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>
+112
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>
+154
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>
+74
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>
+89
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
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+4
View File
@@ -0,0 +1,4 @@
node_modules
.DS_Store
dist
*.local
+1485
View File
File diff suppressed because it is too large Load Diff
+23
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"
}
}
+76
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
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>
+51
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
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
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
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;
}
+63
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";
}
+113
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
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
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
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
View File
@@ -0,0 +1 @@
output_buffering = Off
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.
+22
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

Binary file not shown.
Binary file not shown.
+22
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();
+451
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
File diff suppressed because one or more lines are too long