Premier commit
This commit is contained in:
commit
bcc2604080
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/vendor
|
||||
/web/assets
|
||||
/web/manifest.json
|
||||
deploy.sh
|
||||
env.php
|
34
cli/init_assistant.php
Normal file
34
cli/init_assistant.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
if (PHP_SAPI != 'cli') {
|
||||
exit('PHP SAPI must be cli');
|
||||
}
|
||||
|
||||
require(__DIR__ . '/../vendor/autoload.php');
|
||||
|
||||
foreach (require __DIR__ . '/../env.php' as $key => $val) {
|
||||
putenv("{$key}={$val}");
|
||||
}
|
||||
|
||||
$db = new PDO('sqlite:' . getenv('SQLITE_DB'));
|
||||
|
||||
|
||||
$query =<<<SQL
|
||||
CREATE TABLE IF NOT EXISTS "chat_assistant" (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
model VARCHAR(64) NOT NULL,
|
||||
system_prompt TEXT NOT NULL,
|
||||
temperature REAL NOT NULL DEFAULT 0,
|
||||
top_p REAL NOT NULL 0,
|
||||
"default" INTEGER NOT NULL DEFAULT 0,
|
||||
foreign key (user_id) references "user" (id) on delete cascade on update cascade
|
||||
);
|
||||
SQL;
|
||||
|
||||
if ($db->exec($query) === false) {
|
||||
$error = $db->errorInfo();
|
||||
throw new RuntimeException("Query failed with error : {$error[2]}");
|
||||
}
|
||||
|
||||
echo "Assistant table created.\n";
|
52
cli/init_user.php
Normal file
52
cli/init_user.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
if (PHP_SAPI != 'cli') {
|
||||
exit('PHP SAPI must be cli');
|
||||
}
|
||||
|
||||
use app\modules\user\models\User;
|
||||
use app\modules\user\Rbac;
|
||||
|
||||
require(__DIR__ . '/../vendor/autoload.php');
|
||||
|
||||
|
||||
foreach (require __DIR__ . '/../env.php' as $key => $val) {
|
||||
putenv("{$key}={$val}");
|
||||
}
|
||||
|
||||
$db = new PDO('sqlite:' . getenv('SQLITE_DB'));
|
||||
|
||||
Rbac::$db = $db;
|
||||
|
||||
$query = file_get_contents(__DIR__ . '/../modules/user/sql/install-sqlite.sql');
|
||||
|
||||
if ($db->exec($query) === false) {
|
||||
$error = $db->errorInfo();
|
||||
throw new RuntimeException("Query failed with error : {$error[2]}");
|
||||
}
|
||||
|
||||
echo "Users table created.\n";
|
||||
|
||||
echo "Create admin user\n";
|
||||
|
||||
$name = readline("Nom : ");
|
||||
$email = readline("Email : ");
|
||||
$username = readline("Nom d'utilisateur : ");
|
||||
$password = readline("Mot de passe : ");
|
||||
|
||||
$user = new User($db);
|
||||
$user->bind([
|
||||
'name' => $name,
|
||||
'username' => $username,
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
]);
|
||||
|
||||
if ($user->save()) {
|
||||
echo "Utilisateur $username créé.\n";
|
||||
}
|
||||
|
||||
if (!Rbac::roleExists('admin')) {
|
||||
Rbac::createRole('admin');
|
||||
}
|
||||
|
||||
Rbac::assignRole($user->id, 'admin');
|
28
composer.json
Normal file
28
composer.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "piko/openai",
|
||||
"description": "Application to interact with Openai GPT-3.",
|
||||
"type": "project",
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"piko/framework": "^3.0",
|
||||
"piko/user": "^2.0",
|
||||
"tectalic/openai": "^1.2",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"tracy/tracy": "^2.9",
|
||||
"monolog/monolog": "^2.8",
|
||||
"rahul900day/gpt-3-encoder": "^1.1",
|
||||
"piko/db-record": "^2.0",
|
||||
"nette/mail": "^4.0",
|
||||
"piko/i18n": "^2.1"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"app\\": ""
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"php-http/discovery": true
|
||||
}
|
||||
}
|
||||
}
|
2231
composer.lock
generated
Normal file
2231
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
58
config/app.php
Normal file
58
config/app.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
|
||||
return [
|
||||
'basePath' => realpath(__DIR__ . '/../'),
|
||||
'defaultLayoutPath' => '@app/modules/site/layouts',
|
||||
'defaultLayout' => 'main',
|
||||
'errorRoute' => 'site/default/error',
|
||||
'language' => getenv('APP_LANGUAGE'),
|
||||
'components' => [
|
||||
'Piko\View' => [
|
||||
'themeMap' => [
|
||||
'@app/modules/user/views' => '@app/overrides/user/views',
|
||||
],
|
||||
],
|
||||
'Piko\Router' => [
|
||||
'construct' => [
|
||||
[
|
||||
'routes' => require __DIR__ . '/routes.php',
|
||||
]
|
||||
]
|
||||
],
|
||||
'Piko\User' => [
|
||||
'identityClass' => 'app\overrides\user\models\User',
|
||||
'checkAccess' => 'app\modules\user\AccessChecker::checkAccess'
|
||||
],
|
||||
'Monolog\Logger' => function() {
|
||||
// create a log channel
|
||||
$logger = new Logger('app');
|
||||
$level = getenv('APP_DEBUG') ? Logger::DEBUG : Logger::ERROR;
|
||||
$logger->pushHandler(new StreamHandler( __DIR__ . '/../var/log/app.log', $level));
|
||||
|
||||
return $logger;
|
||||
},
|
||||
'PDO' => [
|
||||
'construct' => [
|
||||
'sqlite:' . getenv('SQLITE_DB')
|
||||
]
|
||||
],
|
||||
'Piko\I18n' => [
|
||||
'language' => getenv('APP_LANGUAGE'),
|
||||
'translations' => [
|
||||
'user' => '@app/modules/user/messages'
|
||||
]
|
||||
],
|
||||
],
|
||||
'modules' => [
|
||||
'site' => 'app\modules\site\Module',
|
||||
'user' => [
|
||||
'class' => 'app\modules\user\Module',
|
||||
'controllerMap' => [
|
||||
'admin' => 'app\overrides\user\controllers\AdminController'
|
||||
]
|
||||
],
|
||||
],
|
||||
'bootstrap' => ['site', 'user']
|
||||
];
|
17
config/routes.php
Normal file
17
config/routes.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
/**
|
||||
* Routes definitions is a key-value paired array where
|
||||
* keys are request uris and values are internal routes following this format:
|
||||
*
|
||||
* '{moduleId}/{controllerId}/{actionId}'
|
||||
* '{moduleId}/{subModuleId}/.../{controllerId}/{actionId}'
|
||||
*/
|
||||
|
||||
return [
|
||||
'/' => 'site/assistant/index',
|
||||
'/about' => 'site/default/about',
|
||||
'/login' => 'user/default/login',
|
||||
'/logout' => 'user/default/logout',
|
||||
'/account' => 'user/default/edit',
|
||||
'/contact' => 'site/default/contact',
|
||||
];
|
51
lib/AuthMiddleware.php
Normal file
51
lib/AuthMiddleware.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
namespace app\lib;
|
||||
|
||||
use PDO;
|
||||
use Piko\ModularApplication;
|
||||
use HttpSoft\Message\Response;
|
||||
use app\modules\user\models\User;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
final class AuthMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private ModularApplication $application;
|
||||
|
||||
public function __construct(ModularApplication $app)
|
||||
{
|
||||
$this->application = $app;
|
||||
|
||||
$pdo = $this->application->getComponent('PDO');
|
||||
assert($pdo instanceof PDO);
|
||||
|
||||
User::$pdo = $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* @see \Psr\Http\Server\MiddlewareInterface::process()
|
||||
*/
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$user = $this->application->getComponent('Piko\User');
|
||||
assert($user instanceof \Piko\User);
|
||||
|
||||
$router = $this->application->getComponent('Piko\Router');
|
||||
assert($router instanceof \Piko\Router);
|
||||
$loginUrl = $router->getUrl('user/default/login');
|
||||
|
||||
$params = $request->getServerParams();
|
||||
|
||||
if ($user->isGuest() && $params['REQUEST_URI'] != $loginUrl) {
|
||||
|
||||
$response= new Response();
|
||||
|
||||
return $response->withHeader('Location', $loginUrl);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
22
lib/CorsMiddleware.php
Normal file
22
lib/CorsMiddleware.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace app\lib;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
final class CorsMiddleware implements MiddlewareInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* @see \Psr\Http\Server\MiddlewareInterface::process()
|
||||
*/
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$response = $handler->handle($request);
|
||||
|
||||
return $response->withHeader('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
}
|
107
lib/Vite.php
Normal file
107
lib/Vite.php
Normal file
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
namespace app\lib;
|
||||
|
||||
use Piko;
|
||||
|
||||
class Vite
|
||||
{
|
||||
// For a real-world example check here:
|
||||
// https://github.com/wp-bond/bond/blob/master/src/Tooling/Vite.php
|
||||
// https://github.com/wp-bond/boilerplate/tree/master/app/themes/boilerplate
|
||||
|
||||
// you might check @vitejs/plugin-legacy if you need to support older browsers
|
||||
// https://github.com/vitejs/vite/tree/main/packages/plugin-legacy
|
||||
|
||||
/**
|
||||
* Prints all the html entries needed for Vite
|
||||
*/
|
||||
|
||||
public static function vite(string $entry): string
|
||||
{
|
||||
return implode("\n", [
|
||||
static::jsTag($entry),
|
||||
static::jsPreloadImports($entry),
|
||||
static::cssTag($entry)
|
||||
]);
|
||||
}
|
||||
|
||||
// Helpers to print tags
|
||||
private static function jsTag(string $entry): string
|
||||
{
|
||||
if (strpos($entry, '.js') === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$url = getenv('VITE_ENV') === 'dev'
|
||||
? getenv('VITE_HOST') . Piko::getAlias('@vite_web/' . $entry)
|
||||
: static::assetUrl($entry);
|
||||
|
||||
if (!$url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<script type="module" crossorigin src="' . $url . '"></script>';
|
||||
}
|
||||
|
||||
private static function jsPreloadImports(string $entry): string
|
||||
{
|
||||
if (getenv('VITE_ENV') === 'dev') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$res = '';
|
||||
|
||||
foreach (static::importsUrls($entry) as $url) {
|
||||
$res .= '<link rel="modulepreload" href="'. $url. '">';
|
||||
}
|
||||
|
||||
return $res;
|
||||
}
|
||||
|
||||
private static function cssTag(string $entry): string
|
||||
{
|
||||
// Not needed on dev, it's inject by Vite
|
||||
if (getenv('VITE_ENV') === 'dev' || strpos($entry, '.css') === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$url = static::assetUrl($entry);
|
||||
|
||||
if (!$url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<link rel="stylesheet" type="text/css" href="' . $url . '">';
|
||||
}
|
||||
|
||||
// Helpers to locate files
|
||||
private static function getManifest(): array
|
||||
{
|
||||
$content = file_get_contents(Piko::getAlias('@webroot/manifest.json'));
|
||||
|
||||
return json_decode($content, true);
|
||||
}
|
||||
|
||||
private static function assetUrl(string $entry): string
|
||||
{
|
||||
$manifest = static::getManifest();
|
||||
|
||||
return isset($manifest[$entry])
|
||||
? Piko::getAlias('@web/' . $manifest[$entry]['file'])
|
||||
: '';
|
||||
}
|
||||
|
||||
private static function importsUrls(string $entry): array
|
||||
{
|
||||
$urls = [];
|
||||
$manifest = static::getManifest();
|
||||
|
||||
if (!empty($manifest[$entry]['imports'])) {
|
||||
foreach ($manifest[$entry]['imports'] as $imports) {
|
||||
$urls[] = Piko::getAlias('@web/' . $manifest[$imports]['file']);
|
||||
}
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
}
|
43
modules/site/Module.php
Normal file
43
modules/site/Module.php
Normal 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;
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
338
modules/site/controllers/AssistantController.php
Normal file
338
modules/site/controllers/AssistantController.php
Normal file
@ -0,0 +1,338 @@
|
||||
<?php
|
||||
namespace app\modules\site\controllers;
|
||||
|
||||
use Piko\User;
|
||||
use Piko\HttpException;
|
||||
use Monolog\Logger;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
|
||||
use app\modules\site\models\Assistant;
|
||||
use PDO;
|
||||
|
||||
class AssistantController extends \Piko\Controller
|
||||
{
|
||||
protected PDO $db;
|
||||
|
||||
protected User $user;
|
||||
|
||||
/**
|
||||
* @var Logger
|
||||
*/
|
||||
private Logger $log;
|
||||
|
||||
protected function init(): void
|
||||
{
|
||||
$app = $this->module->getApplication();
|
||||
|
||||
$this->log = $app->getComponent(Logger::class);
|
||||
assert($this->log instanceof Logger);
|
||||
|
||||
$this->user = $app->getComponent('Piko\User');
|
||||
assert($this->user instanceof User);
|
||||
|
||||
$this->db = $app->getComponent('PDO');
|
||||
assert($this->db instanceof PDO);
|
||||
}
|
||||
|
||||
protected function getUserApiKey()
|
||||
{
|
||||
$identity = $this->user->getIdentity();
|
||||
$apiKey = $identity->profil['api_key'] ?? null;
|
||||
|
||||
if ($apiKey === null) {
|
||||
$this->log->error('API key not defined for user ID :' . $this->user->getId());
|
||||
throw new HttpException(500, 'Internal error');
|
||||
}
|
||||
|
||||
return $apiKey;
|
||||
}
|
||||
|
||||
private function proxyRequest(string $method, string $endPoint, array $params = []): array
|
||||
{
|
||||
$client = new Client([
|
||||
'base_uri' => getenv('PROXY_BASE_URL'),
|
||||
]);
|
||||
|
||||
/*
|
||||
$identity = $this->user->getIdentity();
|
||||
$apiKey = $identity->profil['api_key'] ?? null;
|
||||
|
||||
if ($apiKey === null) {
|
||||
$this->log->error('API key not defined for user ID :' . $this->user->getId());
|
||||
throw new HttpException(500, 'Internal error');
|
||||
}
|
||||
*/
|
||||
|
||||
$apiKey = getenv('PROXY_MASTER_KEY');
|
||||
|
||||
$headers = [
|
||||
'Authorization' => 'Bearer ' . $apiKey,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
|
||||
try {
|
||||
$response = $client->request($method, $endPoint, [
|
||||
'headers' => $headers,
|
||||
'json' => $params
|
||||
]);
|
||||
} catch (RequestException $e) {
|
||||
$response = $e->getResponse();
|
||||
$responseBody = (string) $response->getBody();
|
||||
$contentType = $response->getHeader('Content-Type');
|
||||
$errorCode = 500;
|
||||
|
||||
if ($contentType[0] == 'application/json') {
|
||||
$data = json_decode($responseBody);
|
||||
|
||||
if (isset($data->error)) {
|
||||
$responseBody = $data->error->message;
|
||||
$errorCode = $data->error->code;
|
||||
}
|
||||
}
|
||||
|
||||
$this->log->error('Chat request error', ['request_body' => (string) $e->getRequest()->getBody()]);
|
||||
$this->log->error('Chat response error', ['response_body' => $responseBody]);
|
||||
throw new HttpException($errorCode, $responseBody);
|
||||
}
|
||||
|
||||
$body = $response->getBody();
|
||||
|
||||
return [
|
||||
'headers' => $response->getHeaders(),
|
||||
'body' => (string) $body
|
||||
];
|
||||
}
|
||||
|
||||
private function getModels()
|
||||
{
|
||||
if (!isset($_SESSION['models'])) {
|
||||
|
||||
$models = [];
|
||||
$response = $this->proxyRequest('GET', '/models');
|
||||
|
||||
if (isset($response['body'])) {
|
||||
$body = json_decode($response['body'], true);
|
||||
$data = $body['data'] ?? [];
|
||||
|
||||
foreach ($data as $obj) {
|
||||
if (isset($obj['id'])) {
|
||||
$models[] = $obj['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['models'] = $models;
|
||||
}
|
||||
|
||||
return $_SESSION['models'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream de la réponse
|
||||
*
|
||||
* Pour désactiver l'output buffering:
|
||||
* php -d output_buffering=0 -S localhost:8080 -t web
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function responseAction()
|
||||
{
|
||||
$identity = $this->user->getIdentity();
|
||||
|
||||
$client = new Client([
|
||||
'base_uri' => getenv('PROXY_BASE_URL'),
|
||||
'stream' => true,
|
||||
]);
|
||||
|
||||
$headers = [
|
||||
'Authorization' => 'Bearer ' . getenv('PROXY_KEY'),
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
|
||||
$data = json_decode((string) $this->request->getBody());
|
||||
|
||||
// bdump($data);
|
||||
|
||||
try {
|
||||
$response = $client->request('POST', '/chat/completions', [
|
||||
'headers' => $headers,
|
||||
'stream' => true,
|
||||
'json' => [
|
||||
'model' => $data->model,
|
||||
'messages' => $data->messages,
|
||||
'stream' => true,
|
||||
'temperature' => $data->temperature ?? 0,
|
||||
'top_p' => $config->top_p ?? 0,
|
||||
'user' => $identity->email
|
||||
]
|
||||
]);
|
||||
} catch (RequestException $e) {
|
||||
|
||||
header("Content-Type: text/event-stream;charset=UTF-8");
|
||||
$responseBody = (string) $e->getResponse()->getBody();
|
||||
echo 'data: ' . str_replace("\n", '', $responseBody) . "\n\n";
|
||||
$this->log->error('Chat request error', ['request_body' => (string) $e->getRequest()->getBody()]);
|
||||
$this->log->error('Chat response error', ['response_body' => $responseBody]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$body = $response->getBody();
|
||||
$contentType = $response->getHeader('Content-Type');
|
||||
|
||||
if (count($contentType)) {
|
||||
header("Content-Type:{$contentType[0]}");
|
||||
}
|
||||
|
||||
$content = '';
|
||||
|
||||
while (!$body->eof()) {
|
||||
$line = Utils::readLine($body);
|
||||
|
||||
if ($line != '[DONE]') {
|
||||
$data = preg_replace('/^data:/', '', $line);
|
||||
$data = json_decode($data, true);
|
||||
|
||||
if (isset($data['choices'][0]['delta']['content'])) {
|
||||
$content .= $data['choices'][0]['delta']['content'];
|
||||
}
|
||||
}
|
||||
|
||||
echo $line;
|
||||
flush();
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
public function indexAction()
|
||||
{
|
||||
// $assistants = Assistant::find($this->db, $this->user->getId(), '`title` ASC');
|
||||
// $assistant = array_pop($assistants);
|
||||
// $apiKey = $this->getUserApiKey();
|
||||
// $assistant = Assistant::getDefaultUserAssistant($this->db, $this->user->getId());
|
||||
|
||||
return $this->render('index', [
|
||||
// 'assistant' => $assistant,
|
||||
'models' => $this->getModels(),
|
||||
// 'apiKey' => $apiKey,
|
||||
]);
|
||||
}
|
||||
|
||||
public function listAction()
|
||||
{
|
||||
$assistants = Assistant::find($this->db, $this->user->getId(), '`title` ASC');
|
||||
|
||||
return $this->jsonResponse($assistants);
|
||||
}
|
||||
|
||||
public function assistantsAction()
|
||||
{
|
||||
return $this->render('assistants', [
|
||||
'assistants' => Assistant::find($this->db, $this->user->getId(), '`title` ASC')
|
||||
]);
|
||||
}
|
||||
|
||||
public function editAction($id = 0)
|
||||
{
|
||||
$model = new Assistant($this->db);
|
||||
|
||||
if ($id) {
|
||||
$model->load($id);
|
||||
}
|
||||
|
||||
$response = [];
|
||||
|
||||
$post = $this->request->getParsedBody();
|
||||
|
||||
if (!empty($post)) {
|
||||
|
||||
$model->bind($post);
|
||||
$model->user_id = $this->user->getId();
|
||||
|
||||
try {
|
||||
|
||||
|
||||
|
||||
if ($model->isValid() && $model->save()) {
|
||||
$response['status'] = 'success';
|
||||
} else {
|
||||
$response['status'] = 'error';
|
||||
$response['error'] = array_pop($model->getErrors());
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
exit ($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$response['model'] = $model->toArray();
|
||||
|
||||
return $this->jsonResponse($response);
|
||||
}
|
||||
|
||||
public function saveAction()
|
||||
{
|
||||
$model = new Assistant($this->db);
|
||||
|
||||
$data = json_decode((string) $this->request->getBody(), true);
|
||||
|
||||
if (isset($data['id'])) {
|
||||
$model->load($data['id']);
|
||||
unset($data['id']);
|
||||
}
|
||||
|
||||
$response = [];
|
||||
|
||||
$model->bind($data);
|
||||
$model->user_id = $this->user->getId();
|
||||
|
||||
try {
|
||||
if ($model->isValid() && $model->save()) {
|
||||
$response['status'] = 'success';
|
||||
} else {
|
||||
$response['status'] = 'error';
|
||||
$response['errors'] = $model->getErrors();
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
exit ($e->getMessage());
|
||||
}
|
||||
|
||||
$response['assistant'] = $model->toArray();
|
||||
|
||||
return $this->jsonResponse($response);
|
||||
}
|
||||
|
||||
public function setAsDefaultAction($id = 0)
|
||||
{
|
||||
$model = new Assistant($this->db);
|
||||
|
||||
if ($id) {
|
||||
$model->load($id);
|
||||
$model->default = 1;
|
||||
$model->save();
|
||||
|
||||
return $this->jsonResponse(true);
|
||||
}
|
||||
|
||||
return $this->jsonResponse(false);
|
||||
}
|
||||
|
||||
public function deleteAction($id = 0)
|
||||
{
|
||||
$model = new Assistant($this->db);
|
||||
|
||||
if ($id) {
|
||||
$model->load($id);
|
||||
|
||||
if ($model->delete()) {
|
||||
return $this->jsonResponse(true);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->jsonResponse(false);
|
||||
}
|
||||
|
||||
}
|
14
modules/site/controllers/DefaultController.php
Normal file
14
modules/site/controllers/DefaultController.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace app\modules\site\controllers;
|
||||
|
||||
use Throwable;
|
||||
|
||||
class DefaultController extends \Piko\Controller
|
||||
{
|
||||
public function errorAction(Throwable $exception)
|
||||
{
|
||||
return $this->render('error', [
|
||||
'exception' => $exception
|
||||
]);
|
||||
}
|
||||
}
|
78
modules/site/layouts/main.php
Normal file
78
modules/site/layouts/main.php
Normal 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
modules/site/layouts/minimal.php
Normal file
23
modules/site/layouts/minimal.php
Normal 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
modules/site/models/Assistant.php
Normal file
145
modules/site/models/Assistant.php
Normal 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
modules/site/views/assistant/index.php
Normal file
28
modules/site/views/assistant/index.php
Normal 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
modules/site/views/default/error.php
Normal file
34
modules/site/views/default/error.php
Normal 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
modules/user/AccessChecker.php
Normal file
74
modules/user/AccessChecker.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of the Piko user module
|
||||
*
|
||||
* @copyright 2020 Sylvain PHILIP.
|
||||
* @license LGPL-3.0; see LICENSE.txt
|
||||
* @link https://github.com/piko-framework/piko-user
|
||||
*/
|
||||
namespace app\modules\user;
|
||||
|
||||
use app\modules\user\models\User;
|
||||
|
||||
/**
|
||||
* Access checker class
|
||||
*
|
||||
* @author Sylvain PHILIP <contact@sphilip.com>
|
||||
*/
|
||||
class AccessChecker
|
||||
{
|
||||
public static $adminRole;
|
||||
|
||||
/**
|
||||
* User roles
|
||||
*
|
||||
* @var null|array
|
||||
*/
|
||||
private static $roles = null;
|
||||
|
||||
/**
|
||||
* User permissions
|
||||
*
|
||||
* @var null|array
|
||||
*/
|
||||
private static $permissions = null;
|
||||
|
||||
/**
|
||||
* Check Permission or role access
|
||||
*
|
||||
* @param int $userId The user Id
|
||||
* @param string $permission The permission or role name
|
||||
* @return bool
|
||||
*
|
||||
* @see \piko\User
|
||||
*/
|
||||
public static function checkAccess($userId, string $permission) : bool
|
||||
{
|
||||
$identity = User::findIdentity($userId);
|
||||
|
||||
if ($identity !== null) {
|
||||
|
||||
if (static::$roles === null) {
|
||||
static::$roles = Rbac::getUserRoles($identity->id);
|
||||
}
|
||||
|
||||
if (in_array(static::$adminRole, static::$roles)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array($permission, static::$roles)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (static::$permissions === null) {
|
||||
static::$permissions = Rbac::getUserPermissions($identity->id);
|
||||
}
|
||||
|
||||
if (in_array($permission, static::$permissions)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
73
modules/user/Module.php
Normal file
73
modules/user/Module.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of the Piko user module
|
||||
*
|
||||
* @copyright 2020 Sylvain PHILIP.
|
||||
* @license LGPL-3.0; see LICENSE.txt
|
||||
* @link https://github.com/piko-framework/piko-user
|
||||
*
|
||||
* Routes :
|
||||
* /user/default/login : Process login
|
||||
* /user/default/logout : Process logout
|
||||
* /user/default/register : Process user registration
|
||||
* /user/default/edit : User account form
|
||||
* /user/admin/users : Manage users, roles, permissions
|
||||
*/
|
||||
namespace app\modules\user;
|
||||
|
||||
use PDO;
|
||||
use app\modules\user\models\User;
|
||||
use app\modules\user\Rbac;
|
||||
|
||||
|
||||
/**
|
||||
* User module class
|
||||
*
|
||||
* @author Sylvain PHILIP <contact@sphilip.com>
|
||||
*/
|
||||
class Module extends \Piko\Module
|
||||
{
|
||||
/**
|
||||
* The admin role
|
||||
* @var string
|
||||
*/
|
||||
public $adminRole = 'admin';
|
||||
|
||||
/**
|
||||
* Allow user registration
|
||||
*
|
||||
* @var boolean
|
||||
*/
|
||||
public $allowUserRegistration = false;
|
||||
|
||||
/**
|
||||
* Min length of the user password
|
||||
*
|
||||
* @var integer
|
||||
*/
|
||||
public $passwordMinLength = 8;
|
||||
|
||||
public function bootstrap()
|
||||
{
|
||||
$pdo = $this->application->getComponent('PDO');
|
||||
assert($pdo instanceof PDO);
|
||||
|
||||
User::$pdo = $pdo;
|
||||
Rbac::$db = $pdo;
|
||||
AccessChecker::$adminRole = $this->adminRole;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* @see \piko\Module::init()
|
||||
*/
|
||||
protected function init()
|
||||
{
|
||||
/* @var $i18n \piko\i18n */
|
||||
// $i18n = Piko::get('i18n');
|
||||
// $i18n->addTranslation('user', __DIR__ . '/messages');
|
||||
|
||||
// parent::init();
|
||||
}
|
||||
|
||||
}
|
263
modules/user/Rbac.php
Normal file
263
modules/user/Rbac.php
Normal 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();
|
||||
}
|
||||
}
|
248
modules/user/controllers/AdminController.php
Normal file
248
modules/user/controllers/AdminController.php
Normal file
@ -0,0 +1,248 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of the Piko user module
|
||||
*
|
||||
* @copyright 2020 Sylvain PHILIP.
|
||||
* @license LGPL-3.0; see LICENSE.txt
|
||||
* @link https://github.com/piko-framework/piko-user
|
||||
*/
|
||||
namespace app\modules\user\controllers;
|
||||
|
||||
use Piko\HttpException;
|
||||
use function Piko\I18n\__;
|
||||
use Piko\User as PikoUser;
|
||||
use app\modules\user\models\Role;
|
||||
use app\modules\user\models\User;
|
||||
use app\modules\user\models\Permission;
|
||||
|
||||
/**
|
||||
* User admin controller
|
||||
*
|
||||
* @author Sylvain PHILIP <contact@sphilip.com>
|
||||
*/
|
||||
class AdminController extends \Piko\Controller
|
||||
{
|
||||
protected PikoUser $user;
|
||||
protected \PDO $db;
|
||||
|
||||
public function init(): void
|
||||
{
|
||||
$app = $this->module->getApplication();
|
||||
|
||||
$user = $app->getComponent('Piko\User');
|
||||
assert($user instanceof PikoUser);
|
||||
$this->user = $user;
|
||||
|
||||
$db = $app->getComponent('PDO');
|
||||
assert($db instanceof \PDO);
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* @see \piko\Controller::runAction()
|
||||
*/
|
||||
public function runAction($id)
|
||||
{
|
||||
assert($this->module instanceof \app\modules\user\Module);
|
||||
|
||||
if (!$this->user->can($this->module->adminRole)) {
|
||||
throw new HttpException('Not authorized.', 403);
|
||||
}
|
||||
|
||||
return parent::runAction($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render users view
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function usersAction()
|
||||
{
|
||||
return $this->render('users', [
|
||||
'users' => User::find()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render User form and create or update user
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function editAction(int $id = 0)
|
||||
{
|
||||
$user = new User($this->db);
|
||||
|
||||
if ($id) {
|
||||
$user->load($id);
|
||||
}
|
||||
|
||||
$user->scenario = User::SCENARIO_ADMIN;
|
||||
$message = false;
|
||||
|
||||
$post = $this->request->getParsedBody();
|
||||
|
||||
if (!empty($post)) {
|
||||
|
||||
$user->bind($post);
|
||||
|
||||
if ($user->isValid() && $user->save()) {
|
||||
$message['type'] = 'success';
|
||||
$message['content'] = __('user', 'User successfully saved');
|
||||
} else {
|
||||
$message['type'] = 'danger';
|
||||
$message['content'] = __('user', 'Save error!') . implode(' ', $user->errors);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('edit', [
|
||||
'user' => $user,
|
||||
'message' => $message,
|
||||
'roles' => Role::find('`name` ASC'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete users
|
||||
*/
|
||||
public function deleteAction()
|
||||
{
|
||||
$post = $this->request->getParsedBody();
|
||||
$ids = isset($post['items'])? $post['items'] : [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$user = new User($id);
|
||||
$user->delete();
|
||||
}
|
||||
|
||||
$this->redirect($this->getUrl('user/admin/users'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render roles view
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function rolesAction()
|
||||
{
|
||||
return $this->render('roles', [
|
||||
'roles' => Role::find(),
|
||||
'permissions' => Permission::find('`name` ASC'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/update role (AJAX)
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function editRoleAction(int $id = 0)
|
||||
{
|
||||
$role = new Role($this->db);
|
||||
|
||||
if ($id) {
|
||||
$role->load($id);
|
||||
}
|
||||
|
||||
$role->scenario = Role::SCENARIO_ADMIN;
|
||||
|
||||
$post = $this->request->getParsedBody();
|
||||
|
||||
$response = [
|
||||
'role' => $role
|
||||
];
|
||||
|
||||
if (!empty($post)) {
|
||||
|
||||
$role->bind($post);
|
||||
|
||||
if ($role->isValid() && $role->save()) {
|
||||
$response['status'] = 'success';
|
||||
} else {
|
||||
$response['status'] = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
return $this->jsonResponse($response);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Delete roles
|
||||
*/
|
||||
public function deleteRolesAction()
|
||||
{
|
||||
$post = $this->request->getParsedBody();
|
||||
$ids = isset($post['items'])? $post['items'] : [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$item = new Role($id);
|
||||
$item->delete();
|
||||
}
|
||||
|
||||
$this->redirect($this->getUrl('user/admin/roles'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render permissions view
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function permissionsAction()
|
||||
{
|
||||
return $this->render('permissions', [
|
||||
'permissions' => Permission::find()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/update permission (AJAX)
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function editPermissionAction(int $id = 0)
|
||||
{
|
||||
$permission = new Permission($this->db);
|
||||
|
||||
if ($id) {
|
||||
$permission->load($id);
|
||||
}
|
||||
|
||||
$response = [
|
||||
'permission' => $permission
|
||||
];
|
||||
|
||||
$post = $this->request->getParsedBody();
|
||||
|
||||
if (!empty($post)) {
|
||||
|
||||
$permission->bind($post);
|
||||
|
||||
if ($permission->isValid() && $permission->save()) {
|
||||
$response['status'] = 'success';
|
||||
} else {
|
||||
$response['status'] = 'error';
|
||||
$response['error'] = array_pop($permission->getErrors());
|
||||
}
|
||||
}
|
||||
|
||||
return $this->jsonResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete permissions
|
||||
*/
|
||||
public function deletePermissionsAction()
|
||||
{
|
||||
$post = $this->request->getParsedBody();
|
||||
$ids = isset($post['items'])? $post['items'] : [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$item = new Permission($id);
|
||||
$item->delete();
|
||||
}
|
||||
|
||||
$this->redirect($this->getUrl('user/admin/permissions'));
|
||||
}
|
||||
}
|
295
modules/user/controllers/DefaultController.php
Normal file
295
modules/user/controllers/DefaultController.php
Normal file
@ -0,0 +1,295 @@
|
||||
<?php
|
||||
/**
|
||||
* This file is part of the Piko user module
|
||||
*
|
||||
* @copyright 2020 Sylvain PHILIP.
|
||||
* @license LGPL-3.0; see LICENSE.txt
|
||||
* @link https://github.com/piko-framework/piko-user
|
||||
*/
|
||||
namespace app\modules\user\controllers;
|
||||
|
||||
use function Piko\I18n\__;
|
||||
use piko\HttpException;
|
||||
use app\modules\user\models\User;
|
||||
use Piko\User as PikoUser;
|
||||
|
||||
/**
|
||||
* User default controller
|
||||
*
|
||||
* @author Sylvain PHILIP <contact@sphilip.com>
|
||||
*/
|
||||
class DefaultController extends \Piko\Controller
|
||||
{
|
||||
protected PikoUser $user;
|
||||
protected \PDO $db;
|
||||
|
||||
public function init(): void
|
||||
{
|
||||
$app = $this->module->getApplication();
|
||||
|
||||
$user = $app->getComponent('Piko\User');
|
||||
assert($user instanceof PikoUser);
|
||||
$this->user = $user;
|
||||
|
||||
$db = $app->getComponent('PDO');
|
||||
assert($db instanceof \PDO);
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render and process user registration
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function registerAction()
|
||||
{
|
||||
if (!$this->user->isGuest()) {
|
||||
return $this->redirect('/');
|
||||
}
|
||||
|
||||
$message = false;
|
||||
$post = $this->request->getParsedBody();
|
||||
|
||||
if (!empty($post)) {
|
||||
|
||||
$user = new User($this->db);
|
||||
|
||||
$user->scenario = User::SCENARIO_REGISTER;
|
||||
|
||||
$user->bind($post);
|
||||
|
||||
if ($user->isValid() && $user->save()) {
|
||||
// $user->sendRegistrationConfirmation();
|
||||
$message['type'] = 'success';
|
||||
$message['content'] = __(
|
||||
'user',
|
||||
'Your account was created. Please activate it through the confirmation email that was sent to you.'
|
||||
);
|
||||
} else {
|
||||
$message['type'] = 'danger';
|
||||
$message['content'] = implode(', ', $user->errors);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('register', [
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate registration (AJAX)
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function checkRegistrationAction()
|
||||
{
|
||||
$errors = [];
|
||||
$this->layout = false;
|
||||
$post = $this->request->getParsedBody();
|
||||
|
||||
if (!empty($post)) {
|
||||
|
||||
$user = new User($this->db);
|
||||
$user->scenario = 'register';
|
||||
$user->bind($post);
|
||||
$user->isValid();
|
||||
$errors = $user->getErrors();
|
||||
}
|
||||
|
||||
return $this->jsonResponse($errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render user activation confirmation
|
||||
*
|
||||
* @throws HttpException
|
||||
* @return string
|
||||
*/
|
||||
public function confirmationAction($token)
|
||||
{
|
||||
$user = User::findByAuthKey($token);
|
||||
|
||||
if (!$user) {
|
||||
throw new HttpException('Not found.', 404);
|
||||
}
|
||||
|
||||
$message = false;
|
||||
|
||||
if (!$user->isActivated()) {
|
||||
|
||||
if ($user->activate()) {
|
||||
$message['type'] = 'success';
|
||||
$message['content'] = __('user', 'Your account has been activated. You can now log in.');
|
||||
} else {
|
||||
$message['type'] = 'danger';
|
||||
$message['content'] = __(
|
||||
'user',
|
||||
'Unable to activate your account. Please contact the site manager.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$message['type'] = 'warning';
|
||||
$message['content'] = __('user', 'Your account has already been activated.');
|
||||
}
|
||||
|
||||
return $this->render('login', ['message' => $message]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render reminder password form and send email to change password
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function reminderAction()
|
||||
{
|
||||
$message = false;
|
||||
$post = $this->request->getParsedBody();
|
||||
|
||||
$reminder = $post['reminder']?? '';
|
||||
|
||||
if (!empty($reminder)) {
|
||||
|
||||
$user = User::findByUsername($reminder);
|
||||
|
||||
if (!$user) {
|
||||
$user = User::findByEmail($reminder);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
// $user->sendResetPassword();
|
||||
$message['type'] = 'success';
|
||||
$message['content'] = __(
|
||||
'user',
|
||||
'A link has been sent to you by email ({email}). It will allow you to recreate your password.',
|
||||
['email' => $user->email]
|
||||
);
|
||||
} else {
|
||||
$message['type'] = 'danger';
|
||||
$message['content'] = __('user', 'Account not found.');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('reminder', [
|
||||
'message' => $message,
|
||||
'reminder' => $reminder,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render and process reset password
|
||||
*
|
||||
* @throws HttpException
|
||||
* @return string
|
||||
*/
|
||||
public function resetPasswordAction($token)
|
||||
{
|
||||
$user = User::findByAuthKey($token);
|
||||
|
||||
if (!$user) {
|
||||
throw new HttpException('Not found', 404);
|
||||
}
|
||||
|
||||
$message = false;
|
||||
$post = $this->request->getParsedBody();
|
||||
|
||||
if (!empty($post)) {
|
||||
$user->scenario = 'reset';
|
||||
|
||||
$user->bind($post);
|
||||
|
||||
if ($user->isValid() && $user->save()) {
|
||||
$message['type'] = 'success';
|
||||
$message['content'] = __('user', 'Your password has been successfully updated.');
|
||||
} else {
|
||||
$message['type'] = 'danger';
|
||||
$message['content'] = implode(', ', $user->errors);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('reset', [
|
||||
'message' => $message,
|
||||
'user' => $user,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render user form and update changes
|
||||
*
|
||||
* @throws HttpException
|
||||
* @return string
|
||||
*/
|
||||
public function editAction()
|
||||
{
|
||||
if ($this->user->isGuest()) {
|
||||
throw new HttpException(__('user', 'You must be logged to access this page.'), 401);
|
||||
}
|
||||
|
||||
$identity = $this->user->getIdentity();
|
||||
|
||||
assert($identity instanceof User);
|
||||
|
||||
$message = false;
|
||||
$post = $this->request->getParsedBody();
|
||||
|
||||
if (!empty($post)) {
|
||||
$identity->bind($post);
|
||||
|
||||
if ($identity->isValid() && $identity->save()) {
|
||||
$message['type'] = 'success';
|
||||
$message['content'] = __('user', 'Changes saved!');
|
||||
} else {
|
||||
$message['type'] = 'danger';
|
||||
$message['content'] = implode(', ', $identity->getErrors());
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('edit', [
|
||||
'user' => $identity,
|
||||
'message' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render login form and process login
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function loginAction()
|
||||
{
|
||||
$message = false;
|
||||
$post = $this->request->getParsedBody();
|
||||
|
||||
if (!empty($post)) {
|
||||
$identity = User::findByUsername($post['username']);
|
||||
|
||||
if ($identity instanceof User && $identity->validatePassword($post['password'])) {
|
||||
|
||||
$this->user->login($identity);
|
||||
$identity->last_login_at = time();
|
||||
$identity->save();
|
||||
|
||||
return $this->redirect('/');
|
||||
|
||||
} else {
|
||||
$message['type'] = 'danger';
|
||||
$message['content'] = __('user', 'Authentication failure');
|
||||
}
|
||||
}
|
||||
|
||||
assert($this->module instanceof \app\modules\user\Module);
|
||||
|
||||
return $this->render('login', [
|
||||
'message' => $message,
|
||||
'canRegister' => $this->module->allowUserRegistration
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* User logout
|
||||
*/
|
||||
public function logoutAction()
|
||||
{
|
||||
$this->user->logout();
|
||||
$this->redirect('/');
|
||||
}
|
||||
}
|
111
modules/user/messages/fr.php
Normal file
111
modules/user/messages/fr.php
Normal 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
modules/user/models/Permission.php
Normal file
96
modules/user/models/Permission.php
Normal 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
modules/user/models/Role.php
Normal file
180
modules/user/models/Role.php
Normal 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
modules/user/models/User.php
Normal file
551
modules/user/models/User.php
Normal 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
modules/user/sql/install-mysql.sql
Normal file
21
modules/user/sql/install-mysql.sql
Normal 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
modules/user/sql/install-sqlite.sql
Normal file
52
modules/user/sql/install-sqlite.sql
Normal 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
modules/user/views/admin/edit.php
Normal file
58
modules/user/views/admin/edit.php
Normal 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
modules/user/views/admin/nav.php
Normal file
17
modules/user/views/admin/nav.php
Normal 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
modules/user/views/admin/permissions.php
Normal file
111
modules/user/views/admin/permissions.php
Normal 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'] ?>">
|
||||
<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
modules/user/views/admin/roles.php
Normal file
152
modules/user/views/admin/roles.php
Normal 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'] ?>">
|
||||
<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
modules/user/views/admin/users.php
Normal file
71
modules/user/views/admin/users.php
Normal 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'] ?>">
|
||||
<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
modules/user/views/default/edit.php
Normal file
93
modules/user/views/default/edit.php
Normal 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
modules/user/views/default/login.php
Normal file
77
modules/user/views/default/login.php
Normal 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">© 2017–2023</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
modules/user/views/default/register.php
Normal file
82
modules/user/views/default/register.php
Normal 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
modules/user/views/default/reminder.php
Normal file
31
modules/user/views/default/reminder.php
Normal 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
modules/user/views/default/reset.php
Normal file
75
modules/user/views/default/reset.php
Normal 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>
|
||||
|
||||
|
||||
|
49
overrides/user/controllers/AdminController.php
Normal file
49
overrides/user/controllers/AdminController.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\overrides\user\controllers;
|
||||
|
||||
use app\overrides\user\models\User;
|
||||
use function Piko\I18n\__;
|
||||
use app\modules\user\models\Role;
|
||||
|
||||
class AdminController extends \app\modules\user\controllers\AdminController
|
||||
{
|
||||
/**
|
||||
* Render User form and create or update user
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function editAction(int $id = 0)
|
||||
{
|
||||
$user = new User($this->db);
|
||||
|
||||
if ($id) {
|
||||
$user->load($id);
|
||||
}
|
||||
|
||||
$user->scenario = User::SCENARIO_ADMIN;
|
||||
$message = [];
|
||||
|
||||
$post = $this->request->getParsedBody();
|
||||
|
||||
if (!empty($post)) {
|
||||
|
||||
$user->bind($post);
|
||||
|
||||
if ($user->isValid() && $user->save()) {
|
||||
$message['type'] = 'success';
|
||||
$message['content'] = __('user', 'User successfully saved');
|
||||
} else {
|
||||
$message['type'] = 'danger';
|
||||
$message['content'] = __('user', 'Save error!') . implode(' ', $user->errors);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return $this->render('edit', [
|
||||
'user' => $user,
|
||||
'message' => $message,
|
||||
'roles' => Role::find('`name` ASC'),
|
||||
]);
|
||||
}
|
||||
}
|
44
overrides/user/models/User.php
Normal file
44
overrides/user/models/User.php
Normal 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
overrides/user/views/admin/edit.php
Normal file
68
overrides/user/views/admin/edit.php
Normal 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
overrides/user/views/admin/permissions.php
Normal file
112
overrides/user/views/admin/permissions.php
Normal 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'] ?>">
|
||||
<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
overrides/user/views/admin/roles.php
Normal file
154
overrides/user/views/admin/roles.php
Normal 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'] ?>">
|
||||
<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
overrides/user/views/admin/users.php
Normal file
74
overrides/user/views/admin/users.php
Normal 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'] ?>">
|
||||
<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
overrides/user/views/default/edit.php
Normal file
89
overrides/user/views/default/edit.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
use function Piko\I18n\__;
|
||||
|
||||
assert($this instanceof Piko\View);
|
||||
|
||||
/* @var $message array */
|
||||
/* @var $user piko\user\models\User */
|
||||
|
||||
$this->title = __('user', 'Edit your account');
|
||||
|
||||
if (is_array($message)) {
|
||||
$this->params['message'] = $message;
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<div class="container mt-5">
|
||||
<h1><?= $this->title ?></h1>
|
||||
|
||||
<form method="post" novalidate>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username"><?= __('user', 'Username') ?> : <strong><?= $user->username ?></strong></label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email"><?= __('user', 'Email') ?></label>
|
||||
<input type="text" class="form-control" id="email" name="email" value="<?= $user->email ?>">
|
||||
<?php if (!empty($user->errors['email'])): ?>
|
||||
<div class="alert alert-danger" role="alert"><?= $user->errors['email'] ?></div>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password"><?= __('user', 'Password (leave blank to keep the same)') ?></label>
|
||||
<input type="password" class="form-control" id="password" name="password" value="" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="lastname"><?= __('user', 'Last name') ?></label>
|
||||
<input type="text" class="form-control" id="lastname" name="profil[lastname]" value="<?= isset($user->profil->lastname) ? $user->profil->lastname : '' ?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="firstname"><?= __('user', 'First name') ?></label>
|
||||
<input type="text" class="form-control" id="firstname" name="profil[firstname]" value="<?= isset($user->profil->firstname) ? $user->profil->firstname : '' ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="company"><?= __('user', 'Company') ?></label>
|
||||
<input type="text" class="form-control" id="company" name="profil[company]" value="<?= isset($user->profil->company) ? $user->profil->company : ''?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="telephone"><?= __('user', 'Phone number') ?></label>
|
||||
<input type="text" class="form-control" id="telephone" name="profil[telephone]" value="<?= isset($user->profil->telephone) ? $user->profil->telephone : ''?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="address"><?= __('user', 'Address') ?></label>
|
||||
<input type="text" class="form-control" id="address" name="profil[address]" value="<?= isset($user->profil->address) ? $user->profil->address : ''?>">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="zipcode"><?= __('user', 'Zip code') ?></label>
|
||||
<input type="text" class="form-control" id="zipcode" name="profil[zipcode]" value="<?= isset($user->profil->zipcode) ? $user->profil->zipcode : ''?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="city"><?= __('user', 'City') ?></label>
|
||||
<input type="text" class="form-control" id="city" name="profil[city]" value="<?= isset($user->profil->city) ? $user->profil->city : ''?>">
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="country"><?= __('user', 'Country') ?></label>
|
||||
<input type="text" class="form-control" id="country" name="profil[country]" value="<?= isset($user->profil->country) ? $user->profil->country : ''?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary"><?= __('user', 'Save') ?></button>
|
||||
<a href="<?= Piko::getAlias('@web/')?>" class="btn btn-default"><?= __('user', 'Cancel') ?></a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
2
var/.gitignore
vendored
Normal file
2
var/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
4
vite/.gitignore
vendored
Normal file
4
vite/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
*.local
|
1485
vite/package-lock.json
generated
Normal file
1485
vite/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
vite/package.json
Normal file
23
vite/package.json
Normal 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
vite/src/AssistantForm.svelte
Normal file
76
vite/src/AssistantForm.svelte
Normal file
@ -0,0 +1,76 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let assistant = {
|
||||
title: '',
|
||||
model: 'gpt-3.5',
|
||||
system_prompt: '',
|
||||
temperature: 1.0,
|
||||
top_p: 1.0,
|
||||
};
|
||||
|
||||
export let models = [
|
||||
'gpt-4o', 'gpt-3.5', 'gpt-4'
|
||||
];
|
||||
|
||||
let errors = {}
|
||||
|
||||
export function setErrors(data) {
|
||||
errors = data;
|
||||
}
|
||||
|
||||
export function setAssistant(data) {
|
||||
assistant = data;
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
// Émet l'événement 'save' avec les données de l'assistant
|
||||
dispatch('save', assistant);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">
|
||||
<input type="text" class="form-control {errors.title ? 'is-invalid' : ''}" placeholder="Titre" bind:value={assistant.title}>
|
||||
{#if errors.system_prompt}
|
||||
<div class="invalid-feedback">{errors.system_prompt}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label for="assistant-model">Model</label>
|
||||
<select class="form-select" bind:value={assistant.model}>
|
||||
{#each models as model}
|
||||
<option value={model}>{model}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label for="assistant-system_prompt">Prompt système</label>
|
||||
<textarea class="form-control {errors.system_prompt ? 'is-invalid' : ''}" rows="3" bind:value={assistant.system_prompt}></textarea>
|
||||
{#if errors.system_prompt}
|
||||
<div class="invalid-feedback">{errors.system_prompt}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label for="assistant-temperature" class="form-label">Température</label>
|
||||
<input type="range" min="0" max="2" step="0.01" bind:value={assistant.temperature} class="form-range">
|
||||
<output>{assistant.temperature}</output>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label for="assistant-top_p" class="form-label">Top p</label>
|
||||
<input type="range" min="0" max="1" step="0.01" bind:value={assistant.top_p} class="form-range">
|
||||
<output>{assistant.top_p}</output>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||
<button class="btn btn-primary" on:click={handleSave}>Enregistrer</button>
|
||||
</div>
|
||||
|
529
vite/src/ChatApp.svelte
Normal file
529
vite/src/ChatApp.svelte
Normal 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
vite/src/ChatMessage.svelte
Normal file
51
vite/src/ChatMessage.svelte
Normal file
@ -0,0 +1,51 @@
|
||||
<script>
|
||||
import { beforeUpdate, afterUpdate } from 'svelte';
|
||||
|
||||
export let message;
|
||||
export let container;
|
||||
export let markdown;
|
||||
|
||||
let autoscroll;
|
||||
let renderedContent = '';
|
||||
|
||||
$: renderedContent = markdown.render(message.content);
|
||||
|
||||
beforeUpdate(() => {
|
||||
autoscroll = container && container.offsetHeight + container.scrollTop >
|
||||
container.scrollHeight - 20;
|
||||
});
|
||||
|
||||
afterUpdate(() => {
|
||||
if (autoscroll) container.scrollTo(0, container.scrollHeight);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.hljs-code-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 3px 6px;
|
||||
background-color: #9b9b9b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 2px 16px;
|
||||
}
|
||||
|
||||
code:not(.hljs) {
|
||||
color: #4d4d4d;
|
||||
padding: 0 5px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.assistant {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if message.role != 'system'}
|
||||
<div class={message.role + ' message'}>
|
||||
{@html renderedContent}
|
||||
</div>
|
||||
{/if}
|
37
vite/src/Modal.svelte
Normal file
37
vite/src/Modal.svelte
Normal file
@ -0,0 +1,37 @@
|
||||
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let modalElement;
|
||||
let modal;
|
||||
export let ariaLabelledby = '';
|
||||
|
||||
onMount(() => {
|
||||
modal = new bootstrap.Modal(modalElement);
|
||||
});
|
||||
|
||||
export function show()
|
||||
{
|
||||
if (modal) modal.show();
|
||||
}
|
||||
|
||||
export function hide()
|
||||
{
|
||||
if (modal) modal.hide();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="modal fade" bind:this={modalElement} tabindex="-1" role="dialog" aria-hidden="true" aria-labelledby={ariaLabelledby}>
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
33
vite/src/main.js
Normal file
33
vite/src/main.js
Normal file
@ -0,0 +1,33 @@
|
||||
// Styles
|
||||
import './styles/site.scss'
|
||||
|
||||
import ChatApp from './ChatApp.svelte'
|
||||
|
||||
// import * as bootstrap from 'bootstrap/dist/js/bootstrap';
|
||||
// window.bootstrap = bootstrap;
|
||||
|
||||
import {Modal, Alert} from 'bootstrap';
|
||||
|
||||
window.ChatApp = ChatApp;
|
||||
|
||||
window.bootstrap = {
|
||||
Modal: Modal,
|
||||
Alert: Alert,
|
||||
};
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
/*
|
||||
const hamburgerBtn = document.querySelector('.hamburger')
|
||||
hamburgerBtn.addEventListener('click', function () {
|
||||
this.classList.toggle('is-open')
|
||||
this.classList.toggle('is-closed')
|
||||
})
|
||||
*/
|
||||
|
||||
const activeLink = document.querySelector('#mainmenu a[href="' + location.pathname + '"]');
|
||||
|
||||
if (activeLink) {
|
||||
activeLink.parentNode.classList.add('active');
|
||||
}
|
||||
});
|
155
vite/src/styles/_chat.scss
Normal file
155
vite/src/styles/_chat.scss
Normal 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
vite/src/styles/_fonts.scss
Normal file
63
vite/src/styles/_fonts.scss
Normal 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
vite/src/styles/_hamburger.scss
Normal file
113
vite/src/styles/_hamburger.scss
Normal file
@ -0,0 +1,113 @@
|
||||
// -------------------------------
|
||||
// Hamburger-Cross
|
||||
// -------------------------------
|
||||
|
||||
// https://codepen.io/djdabe/pen/qXgJNV
|
||||
|
||||
.hamburger {
|
||||
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
.hamb-top, .hamb-middle, .hamb-bottom {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
&.is-closed {
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100px;
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
-webkit-transform: translate3d(0,0,0);
|
||||
-webkit-transition: all .35s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover{
|
||||
&:before {
|
||||
opacity: 1;
|
||||
display: block;
|
||||
transform: translate3d(-100px, 0, 0);
|
||||
transition: all .35s ease-in-out;
|
||||
}
|
||||
|
||||
.hamb-top {
|
||||
top: 0;
|
||||
transition: all .35s ease-in-out;
|
||||
}
|
||||
.hamb-bottom {
|
||||
bottom: 0;
|
||||
transition: all .35s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.hamb-top {
|
||||
top: 5px;
|
||||
-webkit-transition: all .35s ease-in-out;
|
||||
}
|
||||
.hamb-middle {
|
||||
top: 50%;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.hamb-bottom {
|
||||
bottom: 5px;
|
||||
-webkit-transition: all .35s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-open {
|
||||
.hamb-top,
|
||||
.hamb-bottom {
|
||||
top: 50%;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.hamb-top {
|
||||
-webkit-transform: rotate(45deg);
|
||||
-webkit-transition: -webkit-transform .2s cubic-bezier(.73,1,.28,.08);
|
||||
}
|
||||
.hamb-middle {
|
||||
display: none;
|
||||
}
|
||||
.hamb-bottom {
|
||||
-webkit-transform: rotate(-45deg);
|
||||
-webkit-transition: -webkit-transform .2s cubic-bezier(.73,1,.28,.08);
|
||||
}
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100px;
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
-webkit-transform: translate3d(0,0,0);
|
||||
-webkit-transition: all .35s ease-in-out;
|
||||
}
|
||||
&:hover:before {
|
||||
opacity: 1;
|
||||
display: block;
|
||||
-webkit-transform: translate3d(-100px,0,0);
|
||||
-webkit-transition: all .35s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
66
vite/src/styles/site.scss
Normal file
66
vite/src/styles/site.scss
Normal file
@ -0,0 +1,66 @@
|
||||
@import "~bootstrap/scss/bootstrap";
|
||||
@import "~highlightjs/scss/github";
|
||||
|
||||
@import "fonts";
|
||||
@import "hamburger";
|
||||
|
||||
// @import "chat";
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #cccccc;
|
||||
}
|
||||
|
||||
#navBtn {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
#chat-app {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#chat {
|
||||
white-space: pre-wrap;
|
||||
|
||||
.user {
|
||||
color: #ffeaa4;
|
||||
}
|
||||
|
||||
.assistant {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.form-signin {
|
||||
max-width: 330px;
|
||||
padding: 1rem;
|
||||
|
||||
.form-floating:focus-within {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
margin-bottom: -1px;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
input[type="password"] {
|
||||
margin-bottom: 10px;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
7
vite/svelte.config.js
Normal file
7
vite/svelte.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
}
|
65
vite/vite.config.js
Normal file
65
vite/vite.config.js
Normal file
@ -0,0 +1,65 @@
|
||||
// View your website at your own local server
|
||||
// for example http://vite-php-setup.test
|
||||
|
||||
// http://localhost:5133 is serving Vite on development
|
||||
// but accessing it directly will be empty
|
||||
// TIP: consider changing the port for each project, see below
|
||||
|
||||
// IMPORTANT image urls in CSS works fine
|
||||
// BUT you need to create a symlink on dev server to map this folder during dev:
|
||||
// ln -s {path_to_project_source}/src/assets {path_to_public_html}/assets
|
||||
// on production everything will work just fine
|
||||
// (this happens because our Vite code is outside the server public access,
|
||||
// if it where, we could use https://vitejs.dev/config/server-options.html#server-origin)
|
||||
|
||||
import { defineConfig, splitVendorChunkPlugin } from 'vite'
|
||||
import liveReload from 'vite-plugin-live-reload'
|
||||
import path from 'path'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import svelteConfig from './svelte.config.js' // Configuration Svelte
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
liveReload([
|
||||
// edit live reload paths according to your source code
|
||||
__dirname + '/../modules/**/*.php',
|
||||
__dirname + '/../config/*.php',
|
||||
__dirname + '/../web/*.php',
|
||||
]),
|
||||
splitVendorChunkPlugin(),
|
||||
svelte(svelteConfig),
|
||||
],
|
||||
root: 'src',
|
||||
base: process.env.APP_ENV === 'development'
|
||||
? '/dev/'
|
||||
: '/',
|
||||
|
||||
build: {
|
||||
// Output dir for production build
|
||||
outDir: '../../web',
|
||||
emptyOutDir: false,
|
||||
|
||||
// Emit manifest so PHP can find the hashed files
|
||||
manifest: true,
|
||||
|
||||
// Our entry
|
||||
rollupOptions: {
|
||||
input: path.resolve(__dirname, 'src/main.js'),
|
||||
}
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'~bootstrap': path.resolve(__dirname, 'node_modules/bootstrap'),
|
||||
'~highlightjs': path.resolve(__dirname, 'node_modules/highlight.js'),
|
||||
}
|
||||
},
|
||||
|
||||
server: {
|
||||
// we need a strict port to match on PHP side
|
||||
// change freely, but update on PHP to match the same port
|
||||
strictPort: true,
|
||||
port: 5133
|
||||
},
|
||||
})
|
1
web/.user.ini
Normal file
1
web/.user.ini
Normal file
@ -0,0 +1 @@
|
||||
output_buffering = Off
|
BIN
web/favicon.ico
Normal file
BIN
web/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
BIN
web/fonts/icomoon.eot
Normal file
BIN
web/fonts/icomoon.eot
Normal file
Binary file not shown.
22
web/fonts/icomoon.svg
Normal file
22
web/fonts/icomoon.svg
Normal 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=" " horiz-adv-x="512" d="" />
|
||||
<glyph unicode="" 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="" glyph-name="warning" d="M554 340.667v172h-84v-172h84zM554 170.667v86h-84v-86h84zM42 42.667l470 810 470-810h-940z" />
|
||||
<glyph unicode="" 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="" 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="" glyph-name="add" d="M810 384.667h-256v-256h-84v256h-256v84h256v256h84v-256h256v-84z" />
|
||||
<glyph unicode="" 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="" 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="" 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="" glyph-name="file_download" d="M214 170.667h596v-86h-596v86zM810 554.667l-298-298-298 298h170v256h256v-256h170z" />
|
||||
<glyph unicode="" glyph-name="file_upload" d="M214 170.667h596v-86h-596v86zM384 256.667v256h-170l298 298 298-298h-170v-256h-256z" />
|
||||
<glyph unicode="" 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="" glyph-name="settings" d="M512 276.667q62 0 106 44t44 106-44 106-106 44-106-44-44-106 44-106 106-44zM830 384.667l90-70q14-10 4-28l-86-148q-8-14-26-8l-106 42q-42-30-72-42l-16-112q-4-18-20-18h-172q-16 0-20 18l-16 112q-38 16-72 42l-106-42q-18-6-26 8l-86 148q-10 18 4 28l90 70q-2 14-2 42t2 42l-90 70q-14 10-4 28l86 148q8 14 26 8l106-42q42 30 72 42l16 112q4 18 20 18h172q16 0 20-18l16-112q38-16 72-42l106 42q18 6 26-8l86-148q10-18-4-28l-90-70q2-14 2-42t-2-42z" />
|
||||
</font></defs></svg>
|
After Width: | Height: | Size: 3.1 KiB |
BIN
web/fonts/icomoon.ttf
Normal file
BIN
web/fonts/icomoon.ttf
Normal file
Binary file not shown.
BIN
web/fonts/icomoon.woff
Normal file
BIN
web/fonts/icomoon.woff
Normal file
Binary file not shown.
22
web/index.php
Normal file
22
web/index.php
Normal 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
web/js/DataTables/datatables.css
Normal file
451
web/js/DataTables/datatables.css
Normal 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;
|
||||
}
|
||||
|
||||
|
15939
web/js/DataTables/datatables.js
Normal file
15939
web/js/DataTables/datatables.js
Normal file
File diff suppressed because it is too large
Load Diff
19
web/js/DataTables/datatables.min.css
vendored
Normal file
19
web/js/DataTables/datatables.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
22
web/js/DataTables/datatables.min.js
vendored
Normal file
22
web/js/DataTables/datatables.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
web/js/jquery-3.7.1.min.js
vendored
Normal file
2
web/js/jquery-3.7.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user