Replace the single user (defined in /data/config.php) with a multi user management thing

This commit is contained in:
Bernd Bestel 2018-07-24 19:31:43 +02:00
parent b52ab91606
commit 7f8540ff4e
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
19 changed files with 695 additions and 23 deletions

View File

@ -14,9 +14,9 @@ A household needs to be managed. I did this so far (almost 10 years) with my fir
> >
> See https://github.com/berrnd/grocy-desktop or directly download the [latest release](https://releases.grocy.info/latest-desktop) - the installation is nothing more than just clicking 2 times "next"... > See https://github.com/berrnd/grocy-desktop or directly download the [latest release](https://releases.grocy.info/latest-desktop) - the installation is nothing more than just clicking 2 times "next"...
Just unpack the [latest release](https://releases.grocy.info/latest) on your PHP (SQLite extension required, currently only tested with PHP 7.2) enabled webserver (webservers root should point to the `/public` directory), copy `config-dist.php` to `data/config.php`, edit it to your needs, ensure that the `data` directory is writable and you're ready to go. Just unpack the [latest release](https://releases.grocy.info/latest) on your PHP (SQLite extension required, currently only tested with PHP 7.2) enabled webserver (webservers root should point to the `/public` directory), copy `config-dist.php` to `data/config.php`, edit it to your needs, ensure that the `data` directory is writable and you're ready to go. Default login is user `admin` with password `admin`, please change the password immediately (see user menu).
Default login is user `admin` with password `admin` - see the `data/config.php` file. Alternatively clone this repository and install Composer and Yarn dependencies manually. Alternatively clone this repository and install Composer and Yarn dependencies manually.
If you use nginx as your webserver, please include `try_files $uri /index.php;` in your location block. If you use nginx as your webserver, please include `try_files $uri /index.php;` in your location block.

View File

@ -1,9 +1,5 @@
<?php <?php
# Login credentials
Setting('HTTP_USER', 'admin');
Setting('HTTP_PASSWORD', 'admin');
# Either "production" or "dev" # Either "production" or "dev"
Setting('MODE', 'production'); Setting('MODE', 'production');

View File

@ -24,10 +24,22 @@ class LoginController extends BaseController
$postParams = $request->getParsedBody(); $postParams = $request->getParsedBody();
if (isset($postParams['username']) && isset($postParams['password'])) if (isset($postParams['username']) && isset($postParams['password']))
{ {
if ($postParams['username'] === HTTP_USER && $postParams['password'] === HTTP_PASSWORD) $user = $this->Database->users()->where('username', $postParams['username'])->fetch();
$inputPassword = $postParams['password'];
if ($user !== null && password_verify($inputPassword, $user->password))
{ {
$sessionKey = $this->SessionService->CreateSession(); $sessionKey = $this->SessionService->CreateSession($user->id);
setcookie($this->SessionCookieName, $sessionKey, time() + 31536000); // Cookie expires in 1 year, but session validity is up to SessionService setcookie($this->SessionCookieName, $sessionKey, time() + 31536000); // Cookie expires in 1 year, but session validity is up to SessionService
define('GROCY_USER_USERNAME', $user->username);
define('GROCY_USER_ID', $user->id);
if (password_needs_rehash($user->password, PASSWORD_DEFAULT))
{
$user->update(array(
'password' => password_hash($inputPassword, PASSWORD_DEFAULT)
));
}
return $response->withRedirect($this->AppContainer->UrlManager->ConstructUrl('/')); return $response->withRedirect($this->AppContainer->UrlManager->ConstructUrl('/'));
} }
@ -69,6 +81,30 @@ class LoginController extends BaseController
return $response->withRedirect($this->AppContainer->UrlManager->ConstructUrl('/stockoverview')); return $response->withRedirect($this->AppContainer->UrlManager->ConstructUrl('/stockoverview'));
} }
public function UsersList(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
return $this->AppContainer->view->render($response, 'users', [
'users' => $this->Database->users()->orderBy('username')
]);
}
public function UserEditForm(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
if ($args['userId'] == 'new')
{
return $this->AppContainer->view->render($response, 'userform', [
'mode' => 'create'
]);
}
else
{
return $this->AppContainer->view->render($response, 'userform', [
'user' => $this->Database->users($args['userId']),
'mode' => 'edit'
]);
}
}
public function GetSessionCookieName() public function GetSessionCookieName()
{ {
return $this->SessionCookieName; return $this->SessionCookieName;

View File

@ -0,0 +1,59 @@
<?php
namespace Grocy\Controllers;
use \Grocy\Services\UsersService;
class UsersApiController extends BaseApiController
{
public function __construct(\Slim\Container $container)
{
parent::__construct($container);
$this->UsersService = new UsersService();
}
protected $UsersService;
public function CreateUser(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
$requestBody = $request->getParsedBody();
try
{
$this->UsersService->CreateUser($requestBody['username'], $requestBody['first_name'], $requestBody['last_name'], $requestBody['password']);
return $this->ApiResponse(array('success' => $success));
}
catch (\Exception $ex)
{
return $this->VoidApiActionResponse($response, false, 400, $ex->getMessage());
}
}
public function DeleteUser(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
try
{
$this->UsersService->DeleteUser($args['userId']);
return $this->ApiResponse(array('success' => $success));
}
catch (\Exception $ex)
{
return $this->VoidApiActionResponse($response, false, 400, $ex->getMessage());
}
}
public function EditUser(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args)
{
$requestBody = $request->getParsedBody();
try
{
$this->UsersService->EditUser($args['userId'], $requestBody['username'], $requestBody['first_name'], $requestBody['last_name'], $requestBody['password']);
return $this->ApiResponse(array('success' => $success));
}
catch (\Exception $ex)
{
return $this->VoidApiActionResponse($response, false, 400, $ex->getMessage());
}
}
}

View File

@ -370,6 +370,140 @@
} }
} }
}, },
"/users/create": {
"post": {
"description": "Creates a new user",
"tags": [
"User management"
],
"requestBody": {
"description": "A valid user object",
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
},
"responses": {
"200": {
"description": "A VoidApiActionResponse object",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/VoidApiActionResponse"
}
}
}
},
"400": {
"description": "A VoidApiActionResponse object",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorExampleVoidApiActionResponse"
}
}
}
}
}
}
},
"/users/edit/{userId}": {
"post": {
"description": "Edits the given user",
"tags": [
"User management"
],
"parameters": [
{
"in": "path",
"name": "userId",
"required": true,
"description": "A valid user id",
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"description": "A valid user object",
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
},
"responses": {
"200": {
"description": "A VoidApiActionResponse object",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/VoidApiActionResponse"
}
}
}
},
"400": {
"description": "A VoidApiActionResponse object",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorExampleVoidApiActionResponse"
}
}
}
}
}
}
},
"/users/delete/{userId}": {
"get": {
"description": "Deletes the given user",
"tags": [
"User management"
],
"parameters": [
{
"in": "path",
"name": "userId",
"required": true,
"description": "A valid user id",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "A VoidApiActionResponse object",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/VoidApiActionResponse"
}
}
}
},
"400": {
"description": "A VoidApiActionResponse object",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorExampleVoidApiActionResponse"
}
}
}
}
}
}
},
"/stock/add-product/{productId}/{amount}": { "/stock/add-product/{productId}/{amount}": {
"get": { "get": {
"description": "Adds the the given amount of the given product to stock", "description": "Adds the the given amount of the given product to stock",
@ -1182,6 +1316,30 @@
} }
} }
}, },
"User": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"username": {
"type": "string"
},
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"password": {
"type": "string"
},
"row_created_timestamp": {
"type": "string",
"format": "date-time"
}
}
},
"ApiKey": { "ApiKey": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -169,6 +169,17 @@ return array(
'Put missing amount on shopping list' => 'Fehlende Menge auf den Einkaufszettel setzen', 'Put missing amount on shopping list' => 'Fehlende Menge auf den Einkaufszettel setzen',
'Are you sure to put all missing ingredients for recipe "#1" on the shopping list?' => 'Sicher alle fehlenden Zutaten für Rezept "#1" auf die Einkaufsliste zu setzen?', 'Are you sure to put all missing ingredients for recipe "#1" on the shopping list?' => 'Sicher alle fehlenden Zutaten für Rezept "#1" auf die Einkaufsliste zu setzen?',
'Added for recipe #1' => 'Hinzugefügt für Rezept #1', 'Added for recipe #1' => 'Hinzugefügt für Rezept #1',
'Manage users' => 'Benutzer verwalten',
'Users' => 'Benutzer',
'Are you sure to delete user "#1"?' => 'Benutzer "#1" wirklich löschen?',
'Create user' => 'Benutzer erstellen',
'Edit user' => 'Benutzer bearbeiten',
'First name' => 'Vorname',
'Last name' => 'Nachname',
'A username is required' => 'Ein Benutzername ist erforderlich',
'Confirm password' => 'Passwort bestätigen',
'Passwords do not match' => 'Passwörter stimmen nicht überein',
'Change password' => 'Passwort ändern',
//Constants //Constants
'manually' => 'Manuell', 'manually' => 'Manuell',
@ -226,5 +237,6 @@ return array(
'German' => 'Deutsch', 'German' => 'Deutsch',
'Italian' => 'Italienisch', 'Italian' => 'Italienisch',
'Demo in different language' => 'Demo in anderer Sprache', 'Demo in different language' => 'Demo in anderer Sprache',
'This is the note content of the recipe ingredient' => 'Dies ist der Inhalt der Notiz der Zutat' 'This is the note content of the recipe ingredient' => 'Dies ist der Inhalt der Notiz der Zutat',
'Demo User' => 'Demo Benutzer'
); );

View File

@ -3,6 +3,7 @@
namespace Grocy\Middleware; namespace Grocy\Middleware;
use \Grocy\Services\SessionService; use \Grocy\Services\SessionService;
use \Grocy\Services\LocalizationService;
class SessionAuthMiddleware extends BaseMiddleware class SessionAuthMiddleware extends BaseMiddleware
{ {
@ -21,7 +22,15 @@ class SessionAuthMiddleware extends BaseMiddleware
if ($routeName === 'root' || $this->ApplicationService->IsDemoInstallation() || $this->ApplicationService->IsEmbeddedInstallation()) if ($routeName === 'root' || $this->ApplicationService->IsDemoInstallation() || $this->ApplicationService->IsEmbeddedInstallation())
{ {
define('AUTHENTICATED', $this->ApplicationService->IsDemoInstallation() || $this->ApplicationService->IsEmbeddedInstallation()); if ($this->ApplicationService->IsDemoInstallation() || $this->ApplicationService->IsEmbeddedInstallation())
{
define('AUTHENTICATED', true);
$localizationService = new LocalizationService(CULTURE);
define('GROCY_USER_USERNAME', $localizationService->Localize('Demo User'));
define('GROCY_USER_ID', -1);
}
$response = $next($request, $response); $response = $next($request, $response);
} }
else else
@ -34,7 +43,18 @@ class SessionAuthMiddleware extends BaseMiddleware
} }
else else
{ {
define('AUTHENTICATED', $routeName !== 'login'); if ($routeName !== 'login')
{
$user = $sessionService->GetUserBySessionKey($_COOKIE[$this->SessionCookieName]);
define('AUTHENTICATED', true);
define('GROCY_USER_USERNAME', $user->username);
define('GROCY_USER_ID', $user->id);
}
else
{
define('AUTHENTICATED', false);
}
$response = $next($request, $response); $response = $next($request, $response);
} }
} }

20
migrations/0026.sql Normal file
View File

@ -0,0 +1,20 @@
CREATE TABLE users (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
username TEXT NOT NULL UNIQUE,
first_name TEXT,
last_name TEXT,
password TEXT NOT NULL,
row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime'))
);
DROP TABLE sessions;
CREATE TABLE sessions (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
session_key TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
expires DATETIME,
last_used DATETIME,
row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime'))
)

24
migrations/0027.php Normal file
View File

@ -0,0 +1,24 @@
<?php
// This is executed inside DatabaseMigrationService class/context
$db = $this->DatabaseService->GetDbConnection();
if (defined('HTTP_USER'))
{
// Migrate old user defined in config file to database
$newUserRow = $db->users()->createRow(array(
'username' => HTTP_USER,
'password' => password_hash(HTTP_PASSWORD, PASSWORD_DEFAULT)
));
$newUserRow->save();
}
else
{
// Create default user "admin" with password "admin"
$newUserRow = $db->users()->createRow(array(
'username' => 'admin',
'password' => password_hash('admin', PASSWORD_DEFAULT)
));
$newUserRow->save();
}

73
public/viewjs/userform.js Normal file
View File

@ -0,0 +1,73 @@
$('#save-user-button').on('click', function(e)
{
e.preventDefault();
if (Grocy.EditMode === 'create')
{
Grocy.Api.Post('users/create', $('#user-form').serializeJSON(),
function(result)
{
window.location.href = U('/users');
},
function(xhr)
{
console.error(xhr);
}
);
}
else
{
Grocy.Api.Post('users/edit/' + Grocy.EditObjectId, $('#user-form').serializeJSON(),
function(result)
{
window.location.href = U('/users');
},
function(xhr)
{
console.error(xhr);
}
);
}
});
$('#user-form input').keyup(function (event)
{
var element = document.getElementById("password_confirm");
if ($("#password").val() !== $("#password_confirm").val())
{
element.setCustomValidity("error");
}
else
{
element.setCustomValidity("");
}
Grocy.FrontendHelpers.ValidateForm('user-form');
});
$('#user-form input').keydown(function (event)
{
if (event.keyCode === 13) //Enter
{
if (document.getElementById('user-form').checkValidity() === false) //There is at least one validation error
{
event.preventDefault();
return false;
}
else
{
$('#save-user-button').click();
}
}
});
if (GetUriParam("changepw") === "true")
{
$('#password').focus();
}
else
{
$('#username').focus();
}
Grocy.FrontendHelpers.ValidateForm('user-form');

58
public/viewjs/users.js Normal file
View File

@ -0,0 +1,58 @@
var usersTable = $('#users-table').DataTable({
'paginate': false,
'order': [[1, 'asc']],
'columnDefs': [
{ 'orderable': false, 'targets': 0 }
],
'language': JSON.parse(L('datatables_localization')),
'scrollY': false,
'colReorder': true,
'stateSave': true
});
$("#search").on("keyup", function()
{
var value = $(this).val();
if (value === "all")
{
value = "";
}
usersTable.search(value).draw();
});
$(document).on('click', '.user-delete-button', function (e)
{
var objectName = $(e.currentTarget).attr('data-user-username');
var objectId = $(e.currentTarget).attr('data-user-id');
bootbox.confirm({
message: L('Are you sure to delete user "#1"?', objectName),
buttons: {
confirm: {
label: L('Yes'),
className: 'btn-success'
},
cancel: {
label: L('No'),
className: 'btn-danger'
}
},
callback: function(result)
{
if (result === true)
{
Grocy.Api.Get('users/delete' + objectId,
function(result)
{
window.location.href = U('/users');
},
function(xhr)
{
console.error(xhr);
}
);
}
}
});
});

View File

@ -11,10 +11,12 @@ $app->group('', function()
// Base route // Base route
$this->get('/', 'LoginControllerInstance:Root')->setName('root'); $this->get('/', 'LoginControllerInstance:Root')->setName('root');
// Login routes // Login/user routes
$this->get('/login', 'LoginControllerInstance:LoginPage')->setName('login'); $this->get('/login', 'LoginControllerInstance:LoginPage')->setName('login');
$this->post('/login', 'LoginControllerInstance:ProcessLogin')->setName('login'); $this->post('/login', 'LoginControllerInstance:ProcessLogin')->setName('login');
$this->get('/logout', 'LoginControllerInstance:Logout'); $this->get('/logout', 'LoginControllerInstance:Logout');
$this->get('/users', 'LoginControllerInstance:UsersList');
$this->get('/user/{userId}', 'LoginControllerInstance:UserEditForm');
// Stock routes // Stock routes
$this->get('/stockoverview', 'Grocy\Controllers\StockController:Overview'); $this->get('/stockoverview', 'Grocy\Controllers\StockController:Overview');
@ -69,6 +71,10 @@ $app->group('/api', function()
$this->post('/edit-object/{entity}/{objectId}', 'Grocy\Controllers\GenericEntityApiController:EditObject'); $this->post('/edit-object/{entity}/{objectId}', 'Grocy\Controllers\GenericEntityApiController:EditObject');
$this->get('/delete-object/{entity}/{objectId}', 'Grocy\Controllers\GenericEntityApiController:DeleteObject'); $this->get('/delete-object/{entity}/{objectId}', 'Grocy\Controllers\GenericEntityApiController:DeleteObject');
$this->post('/users/create', 'Grocy\Controllers\UsersApiController:CreateUser');
$this->post('/users/edit/{userId}', 'Grocy\Controllers\UsersApiController:EditUser');
$this->get('/users/delete/{userId}', 'Grocy\Controllers\UsersApiController:DeleteUser');
$this->get('/stock/add-product/{productId}/{amount}', 'Grocy\Controllers\StockApiController:AddProduct'); $this->get('/stock/add-product/{productId}/{amount}', 'Grocy\Controllers\StockApiController:AddProduct');
$this->get('/stock/consume-product/{productId}/{amount}', 'Grocy\Controllers\StockApiController:ConsumeProduct'); $this->get('/stock/consume-product/{productId}/{amount}', 'Grocy\Controllers\StockApiController:ConsumeProduct');
$this->get('/stock/inventory-product/{productId}/{newAmount}', 'Grocy\Controllers\StockApiController:InventoryProduct'); $this->get('/stock/inventory-product/{productId}/{newAmount}', 'Grocy\Controllers\StockApiController:InventoryProduct');

View File

@ -8,21 +8,38 @@ class DatabaseMigrationService extends BaseService
{ {
$this->DatabaseService->ExecuteDbStatement("CREATE TABLE IF NOT EXISTS migrations (migration INTEGER NOT NULL PRIMARY KEY UNIQUE, execution_time_timestamp DATETIME DEFAULT (datetime('now', 'localtime')))"); $this->DatabaseService->ExecuteDbStatement("CREATE TABLE IF NOT EXISTS migrations (migration INTEGER NOT NULL PRIMARY KEY UNIQUE, execution_time_timestamp DATETIME DEFAULT (datetime('now', 'localtime')))");
$migrationFiles = array(); $sqlMigrationFiles = array();
foreach (new \FilesystemIterator(__DIR__ . '/../migrations') as $file) foreach (new \FilesystemIterator(__DIR__ . '/../migrations') as $file)
{ {
$migrationFiles[$file->getBasename('.sql')] = $file->getPathname(); if ($file->getExtension() === 'sql')
{
$sqlMigrationFiles[$file->getBasename('.sql')] = $file->getPathname();
} }
ksort($migrationFiles); }
ksort($sqlMigrationFiles);
foreach($migrationFiles as $migrationNumber => $migrationFile) foreach($sqlMigrationFiles as $migrationNumber => $migrationFile)
{ {
$migrationNumber = ltrim($migrationNumber, '0'); $migrationNumber = ltrim($migrationNumber, '0');
$this->ExecuteMigrationWhenNeeded($migrationNumber, file_get_contents($migrationFile)); $this->ExecuteSqlMigrationWhenNeeded($migrationNumber, file_get_contents($migrationFile));
}
$phpMigrationFiles = array();
foreach (new \FilesystemIterator(__DIR__ . '/../migrations') as $file)
{
if ($file->getExtension() === 'php')
{
$phpMigrationFiles[$file->getBasename('.php')] = $file->getPathname();
}
}
ksort($phpMigrationFiles);
foreach($phpMigrationFiles as $migrationNumber => $migrationFile)
{
$migrationNumber = ltrim($migrationNumber, '0');
$this->ExecutePhpMigrationWhenNeeded($migrationNumber, $migrationFile);
} }
} }
private function ExecuteMigrationWhenNeeded(int $migrationId, string $sql) private function ExecuteSqlMigrationWhenNeeded(int $migrationId, string $sql)
{ {
$rowCount = $this->DatabaseService->ExecuteDbQuery('SELECT COUNT(*) FROM migrations WHERE migration = ' . $migrationId)->fetchColumn(); $rowCount = $this->DatabaseService->ExecuteDbQuery('SELECT COUNT(*) FROM migrations WHERE migration = ' . $migrationId)->fetchColumn();
if (intval($rowCount) === 0) if (intval($rowCount) === 0)
@ -31,4 +48,14 @@ class DatabaseMigrationService extends BaseService
$this->DatabaseService->ExecuteDbStatement('INSERT INTO migrations (migration) VALUES (' . $migrationId . ')'); $this->DatabaseService->ExecuteDbStatement('INSERT INTO migrations (migration) VALUES (' . $migrationId . ')');
} }
} }
private function ExecutePhpMigrationWhenNeeded(int $migrationId, string $phpFile)
{
$rowCount = $this->DatabaseService->ExecuteDbQuery('SELECT COUNT(*) FROM migrations WHERE migration = ' . $migrationId)->fetchColumn();
if (intval($rowCount) === 0)
{
include $phpFile;
$this->DatabaseService->ExecuteDbStatement('INSERT INTO migrations (migration) VALUES (' . $migrationId . ')');
}
}
} }

View File

@ -16,6 +16,8 @@ class DemoDataGeneratorService extends BaseService
$loremIpsum = 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.'; $loremIpsum = 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.';
$sql = " $sql = "
INSERT INTO users (id, username, password) VALUES (-1, '{$localizationService->Localize('Demo User')}', 'x');
INSERT INTO locations (name) VALUES ('{$localizationService->Localize('Pantry')}'); --2 INSERT INTO locations (name) VALUES ('{$localizationService->Localize('Pantry')}'); --2
INSERT INTO locations (name) VALUES ('{$localizationService->Localize('Candy cupboard')}'); --3 INSERT INTO locations (name) VALUES ('{$localizationService->Localize('Candy cupboard')}'); --3
INSERT INTO locations (name) VALUES ('{$localizationService->Localize('Tinned food cupboard')}'); --4 INSERT INTO locations (name) VALUES ('{$localizationService->Localize('Tinned food cupboard')}'); --4

View File

@ -33,11 +33,12 @@ class SessionService extends BaseService
/** /**
* @return string * @return string
*/ */
public function CreateSession() public function CreateSession($userId)
{ {
$newSessionKey = $this->GenerateSessionKey(); $newSessionKey = $this->GenerateSessionKey();
$sessionRow = $this->Database->sessions()->createRow(array( $sessionRow = $this->Database->sessions()->createRow(array(
'user_id' => $userId,
'session_key' => $newSessionKey, 'session_key' => $newSessionKey,
'expires' => date('Y-m-d H:i:s', time() + 2592000) // Default is that sessions expire in 30 days 'expires' => date('Y-m-d H:i:s', time() + 2592000) // Default is that sessions expire in 30 days
)); ));
@ -51,6 +52,16 @@ class SessionService extends BaseService
$this->Database->sessions()->where('session_key', $sessionKey)->delete(); $this->Database->sessions()->where('session_key', $sessionKey)->delete();
} }
public function GetUserBySessionKey($sessionKey)
{
$sessionRow = $this->Database->sessions()->where('session_key', $sessionKey)->fetch();
if ($sessionRow !== null)
{
return $this->Database->users($sessionRow->user_id);
}
return null;
}
private function GenerateSessionKey() private function GenerateSessionKey()
{ {
return RandomString(50); return RandomString(50);

47
services/UsersService.php Normal file
View File

@ -0,0 +1,47 @@
<?php
namespace Grocy\Services;
class UsersService extends BaseService
{
public function CreateUser(string $username, string $firstName, string $lastName, string $password)
{
$newUserRow = $this->Database->users()->createRow(array(
'username' => $username,
'first_name' => $firstName,
'last_name' => $lastName,
'password' => password_hash($password, PASSWORD_DEFAULT)
));
$newUserRow->save();
}
public function EditUser(int $userId, string $username, string $firstName, string $lastName, string $password)
{
if (!$this->UserExists($userId))
{
throw new \Exception('User does not exist');
}
$user = $this->Database->users($userId);
$user->update(array(
'username' => $username,
'first_name' => $firstName,
'last_name' => $lastName,
'password' => password_hash($password, PASSWORD_DEFAULT)
));
}
public function DeleteUser($userId)
{
$row = $this->Database->users($args['userId']);
$row->delete();
$success = $row->isClean();
return $this->ApiResponse(array('success' => $success));
}
private function UserExists($userId)
{
$userRow = $this->Database->users()->where('id = :1', $userId)->fetch();
return $userRow !== null;
}
}

View File

@ -166,10 +166,12 @@
<ul class="navbar-nav ml-auto"> <ul class="navbar-nav ml-auto">
@if(AUTHENTICATED === true && $isEmbeddedInstallation === false) @if(AUTHENTICATED === true && $isEmbeddedInstallation === false)
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle discrete-link" href="#" data-toggle="dropdown"><i class="fas fa-user"></i> {{ HTTP_USER }}</a> <a class="nav-link dropdown-toggle discrete-link" href="#" data-toggle="dropdown"><i class="fas fa-user"></i> {{ GROCY_USER_USERNAME }}</a>
<div class="dropdown-menu dropdown-menu-right"> <div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item logout-button discrete-link" href="{{ $U('/logout') }}"><i class="fas fa-sign-out-alt"></i>&nbsp;{{ $L('Logout') }}</a> <a class="dropdown-item logout-button discrete-link" href="{{ $U('/logout') }}"><i class="fas fa-sign-out-alt"></i>&nbsp;{{ $L('Logout') }}</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item logout-button discrete-link" href="{{ $U('/user/' . GROCY_USER_ID . '?changepw=true') }}"><i class="fas fa-key"></i>&nbsp;{{ $L('Change password') }}</a>
</div> </div>
</li> </li>
@endif @endif
@ -178,11 +180,13 @@
<a class="nav-link dropdown-toggle discrete-link" href="#" data-toggle="dropdown"><i class="fas fa-wrench"></i> <span class="d-inline d-lg-none">{{ $L('Settings') }}</span></a> <a class="nav-link dropdown-toggle discrete-link" href="#" data-toggle="dropdown"><i class="fas fa-wrench"></i> <span class="d-inline d-lg-none">{{ $L('Settings') }}</span></a>
<div class="dropdown-menu dropdown-menu-right"> <div class="dropdown-menu dropdown-menu-right">
@if($isEmbeddedInstallation === false)
<a class="dropdown-item discrete-link" href="{{ $U('/users') }}"><i class="fas fa-users"></i>&nbsp;{{ $L('Manage users') }}</a>
<div class="dropdown-divider"></div>
@endif
<a class="dropdown-item discrete-link" href="{{ $U('/manageapikeys') }}"><i class="fas fa-handshake"></i>&nbsp;{{ $L('Manage API keys') }}</a> <a class="dropdown-item discrete-link" href="{{ $U('/manageapikeys') }}"><i class="fas fa-handshake"></i>&nbsp;{{ $L('Manage API keys') }}</a>
<a class="dropdown-item discrete-link" target="_blank" href="{{ $U('/api') }}"><i class="fas fa-book"></i>&nbsp;{{ $L('REST API & data model documentation') }}</a> <a class="dropdown-item discrete-link" target="_blank" href="{{ $U('/api') }}"><i class="fas fa-book"></i>&nbsp;{{ $L('REST API & data model documentation') }}</a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a class="dropdown-item discrete-link" href="#" data-toggle="modal" data-target="#about-modal"><i class="fas fa-info fa-fw"></i>&nbsp;{{ $L('About grocy') }} (Version {{ $version }})</a> <a class="dropdown-item discrete-link" href="#" data-toggle="modal" data-target="#about-modal"><i class="fas fa-info fa-fw"></i>&nbsp;{{ $L('About grocy') }} (Version {{ $version }})</a>
</div> </div>
</li> </li>

56
views/userform.blade.php Normal file
View File

@ -0,0 +1,56 @@
@extends('layout.default')
@if($mode == 'edit')
@section('title', $L('Edit user'))
@else
@section('title', $L('Create user'))
@endif
@section('viewJsName', 'userform')
@section('content')
<div class="row">
<div class="col-lg-6 col-xs-12">
<h1>@yield('title')</h1>
<script>Grocy.EditMode = '{{ $mode }}';</script>
@if($mode == 'edit')
<script>Grocy.EditObjectId = {{ $user->id }};</script>
@endif
<form id="user-form" novalidate>
<div class="form-group">
<label for="username">{{ $L('Username') }}</label>
<input type="text" class="form-control" required id="username" name="username" value="@if($mode == 'edit'){{ $user->username }}@endif">
<div class="invalid-feedback">{{ $L('A username is required') }}</div>
</div>
<div class="form-group">
<label for="first_name">{{ $L('First name') }}</label>
<input type="text" class="form-control" id="first_name" name="first_name" value="@if($mode == 'edit'){{ $user->first_name }}@endif">
</div>
<div class="form-group">
<label for="last_name">{{ $L('Last name') }}</label>
<input type="text" class="form-control" id="last_name" name="last_name" value="@if($mode == 'edit'){{ $user->last_name }}@endif">
</div>
<div class="form-group">
<label for="password">{{ $L('Password') }}</label>
<input type="password" class="form-control" required id="password" name="password">
</div>
<div class="form-group">
<label for="password_confirm">{{ $L('Confirm password') }}</label>
<input type="password" class="form-control" required id="password_confirm" name="password_confirm">
<div class="invalid-feedback">{{ $L('Passwords do not match') }}</div>
</div>
<button id="save-user-button" type="submit" class="btn btn-success">{{ $L('Save') }}</button>
</form>
</div>
</div>
@stop

63
views/users.blade.php Normal file
View File

@ -0,0 +1,63 @@
@extends('layout.default')
@section('title', $L('Users'))
@section('activeNav', '')
@section('viewJsName', 'users')
@section('content')
<div class="row">
<div class="col">
<h1>
@yield('title')
<a class="btn btn-outline-dark" href="{{ $U('/user/new') }}">
<i class="fas fa-plus"></i>&nbsp;{{ $L('Add') }}
</a>
</h1>
</div>
</div>
<div class="row mt-3">
<div class="col-xs-12 col-md-6 col-xl-3">
<label for="search">{{ $L('Search') }}</label> <i class="fas fa-search"></i>
<input type="text" class="form-control" id="search">
</div>
</div>
<div class="row">
<div class="col">
<table id="users-table" class="table table-sm table-striped dt-responsive">
<thead>
<tr>
<th>#</th>
<th>{{ $L('Username') }}</th>
<th>{{ $L('First name') }}</th>
<th>{{ $L('Last name') }}</th>
</tr>
</thead>
<tbody>
@foreach($users as $user)
<tr>
<td class="fit-content">
<a class="btn btn-info btn-sm" href="{{ $U('/user/') }}{{ $user->id }}">
<i class="fas fa-edit"></i>
</a>
<a class="btn btn-danger btn-sm user-delete-button @if($user->id === GROCY_USER_ID){{ disabled }}@endif" href="#" data-user-id="{{ $user->id }}" data-user-username="{{ $user->username }}">
<i class="fas fa-trash"></i>
</a>
</td>
<td>
{{ $user->username }}
</td>
<td>
{{ $user->first_name }}
</td>
<td>
{{ $user->last_name }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@stop