mirror of
https://github.com/grocy/grocy.git
synced 2026-10-16 21:00:04 +02:00
Optimized session handling
This commit is contained in:
parent
acd061e2cd
commit
b6c07a51ce
|
|
@ -1,6 +1,6 @@
|
|||
- New feature: Tasks / To-do list
|
||||
- Renamed habits to chores as this is more what it is about
|
||||
- Products can now be organized in product groups, this group is also used to group the items on the shopping list (you can use this to optimize your way in the supermarket for example)
|
||||
- Added an option to stay logged in permanently (checkbox on the login page)
|
||||
- Added an option to stay logged in permanently ("Remember Me" checkbox on the login page)
|
||||
- When the database was changed externally, the current page is automatically reloaded when there was no input for at least 50 seconds
|
||||
- Fixed some minor UI bugs
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
- Fixed a problem that the user settings were not properly initialized for the frontend JS part when not logged only (so potentially affected only the login page)
|
||||
- Fixed an issue that the shopping list did not load when a plural translation for a quantity unit was missing
|
||||
- Fixed that tooltips were visible forever when consuming all products on the stock overview page
|
||||
- Fixed that login did not work when "Stay logged in permanently" was set and Grocy runs on a 32-bit system (thanks @matejdro)
|
||||
- Fixed that login did not work when "Remember Me" was set and Grocy runs on a 32-bit system (thanks @matejdro)
|
||||
- Fixed page reloads when "Auto reload on external changes" is enabled and there is unsaved form data (the detection did not work for forms in modal dialogs, e. g. when adding a entry to the meal plan)
|
||||
- Fixed (again) that the product picker did not work properly when the product name contains single quotes
|
||||
- Fixed that a entered barcode on the product edit page was only saved when "adding" it to the barcodes list by pressing `TAB` (is now automatically added to the list also when just leaving the field)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
- Fixed that dropdown filters for tables maybe did not work after reordering columns
|
||||
- Fixed that auto night mode over midnight did not always work
|
||||
- Fixed that the labels of context-/more-menu items were not readable in Night Mode (thanks @corbolais)
|
||||
- Fixed that the "Stay logged in permanently" checkbox on the login page had no effect (thanks @0)
|
||||
- Fixed that the "Remember Me" checkbox on the login page had no effect (thanks @0)
|
||||
|
||||
### API
|
||||
- New endpoint `/chores/{choreIdToKeep}/merge/{choreIdToRemove}` for merging chores
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
> ⚠️ Authentication middleware was reorganized, review your `AUTH_CLASS` setting (see the default reference in `config-dist.php` as usual)
|
||||
|
||||
> ⚠️ This update invalidates all sessions (means you will have to log in again)
|
||||
|
||||
### New Feature: xxxx
|
||||
|
||||
- xxx
|
||||
|
|
@ -57,6 +59,7 @@
|
|||
|
||||
### General
|
||||
|
||||
- It's now possible to view (and delete) active sessions of a user (new button per user on the "Manage users" page)
|
||||
- Fixed accent insensitive searching using the general table search field was broken
|
||||
- Fixed that it wasn't possible to log in using passwords containing special escape sequences (e.g. `<<`)
|
||||
- Fixed that the initially created location and quantity units weren't localized (only applies to new installations)
|
||||
|
|
|
|||
|
|
@ -17,10 +17,24 @@ class SystemApiController extends BaseApiController
|
|||
$constants = get_defined_constants();
|
||||
|
||||
// Some GROCY_* constants are not really config settings and therefore should not be exposed
|
||||
unset($constants['GROCY_AUTHENTICATED'], $constants['GROCY_DATAPATH'], $constants['GROCY_IS_EMBEDDED_INSTALL'], $constants['GROCY_USER_ID']);
|
||||
unset(
|
||||
$constants['GROCY_AUTHENTICATED'],
|
||||
$constants['GROCY_DATAPATH'],
|
||||
$constants['GROCY_IS_EMBEDDED_INSTALL'],
|
||||
$constants['GROCY_REVERSE_PROXY_AUTH_HEADER'],
|
||||
$constants['GROCY_REVERSE_PROXY_AUTH_USE_ENV'],
|
||||
$constants['GROCY_LDAP_ADDRESS'],
|
||||
$constants['GROCY_LDAP_BASE_DN'],
|
||||
$constants['GROCY_LDAP_BIND_DN'],
|
||||
$constants['GROCY_LDAP_BIND_PW'],
|
||||
$constants['GROCY_LDAP_USER_FILTER'],
|
||||
$constants['GROCY_LDAP_UID_ATTR'],
|
||||
$constants['GROCY_USER_USERNAME'],
|
||||
$constants['GROCY_USER_PICTURE_FILE_NAME'],
|
||||
$constants['GROCY_USER_ID']
|
||||
);
|
||||
|
||||
$returnArray = [];
|
||||
|
||||
foreach ($constants as $constant => $value)
|
||||
{
|
||||
if (substr($constant, 0, 6) === 'GROCY_')
|
||||
|
|
|
|||
|
|
@ -15,7 +15,12 @@ class LoginController extends BaseController
|
|||
|
||||
public function Logout(Request $request, Response $response, array $args)
|
||||
{
|
||||
SessionService::GetInstance()->RemoveSession($_COOKIE[SessionService::SESSION_COOKIE_NAME]);
|
||||
SessionService::GetInstance()->DeleteToken(SessionService::SESSION_TOKEN_TYPE_ACCESS, $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS]);
|
||||
if (isset($_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME]))
|
||||
{
|
||||
SessionService::GetInstance()->DeleteToken(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME]);
|
||||
}
|
||||
|
||||
return $response->withRedirect($this->AppContainer->get('UrlManager')->ConstructUrl('/'));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace Grocy\Controllers;
|
||||
|
||||
use Grocy\Controllers\Users\User;
|
||||
use Grocy\Services\SessionService;
|
||||
use Grocy\Services\UserfieldsService;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
|
@ -73,4 +74,32 @@ class UsersController extends BaseController
|
|||
'userfieldValues' => UserfieldsService::GetInstance()->GetAllValues('users')
|
||||
]);
|
||||
}
|
||||
|
||||
public function SessionList(Request $request, Response $response, array $args)
|
||||
{
|
||||
if ($args['userId'] == GROCY_USER_ID)
|
||||
{
|
||||
User::CheckPermission($request, User::PERMISSION_USERS_EDIT_SELF);
|
||||
}
|
||||
else
|
||||
{
|
||||
User::CheckPermission($request, User::PERMISSION_USERS_EDIT);
|
||||
}
|
||||
|
||||
$thisSessionTokenHashes = [];
|
||||
if (isset($_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS]))
|
||||
{
|
||||
$thisSessionTokenHashes[] = hash('sha256', $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS]);
|
||||
}
|
||||
if (isset($_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME]))
|
||||
{
|
||||
$thisSessionTokenHashes[] = hash('sha256', $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME]);
|
||||
}
|
||||
|
||||
return $this->RenderPage($response, 'managesessions', [
|
||||
'user' => $this->DB->users($args['userId']),
|
||||
'thisSessionTokenHashes' => $thisSessionTokenHashes,
|
||||
'sessionRows' => $this->DB->sessions()->where('user_id', $args['userId'])->orderBy('id')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5189,29 +5189,6 @@
|
|||
"next_estimated_charge_time": "2999-12-31 23:59:59"
|
||||
}
|
||||
},
|
||||
"Session": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"session_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"last_used": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"row_created_timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"User": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -5956,13 +5933,15 @@
|
|||
"battery_charge_cycles",
|
||||
"product_barcodes_view",
|
||||
"permission_hierarchy",
|
||||
"uihelper_shopping_list"
|
||||
"uihelper_shopping_list",
|
||||
"sessions"
|
||||
]
|
||||
},
|
||||
"ExposedEntityNoListing": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"api_keys"
|
||||
"api_keys",
|
||||
"sessions"
|
||||
]
|
||||
},
|
||||
"ExposedEntityNoEdit": {
|
||||
|
|
@ -5980,7 +5959,8 @@
|
|||
"battery_charge_cycles",
|
||||
"product_barcodes_view",
|
||||
"permission_hierarchy",
|
||||
"uihelper_shopping_list"
|
||||
"uihelper_shopping_list",
|
||||
"sessions"
|
||||
]
|
||||
},
|
||||
"ExposedEntityNoDelete": {
|
||||
|
|
|
|||
|
|
@ -4,22 +4,29 @@ namespace Grocy\Helpers;
|
|||
|
||||
class EInvalidConfig extends \Exception
|
||||
{
|
||||
public function __construct(string $message)
|
||||
{
|
||||
$message .= '<br><br>----------<br>Check your "config.php" file (which is in your data directory: "' . realpath(GROCY_DATAPATH) . '").<br>Defaults can be found in "config-dist.php" (which is in your main Grocy directory: "' . realpath(__DIR__ . '/../') . '").';
|
||||
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
||||
class ConfigurationValidator
|
||||
{
|
||||
public function validateConfig()
|
||||
{
|
||||
self::checkMode();
|
||||
self::checkDefaultLocale();
|
||||
self::checkCurrencyFormat();
|
||||
self::checkFirstDayOfWeek();
|
||||
self::checkEntryPage();
|
||||
self::checkMealplanFirstDayOfWeek();
|
||||
self::checkAutoNightModeRange();
|
||||
self::CheckMode();
|
||||
self::CheckDefaultLocale();
|
||||
self::CheckCurrencyFormat();
|
||||
self::CheckFirstDayOfWeek();
|
||||
self::CheckEntryPage();
|
||||
self::CheckMealplanFirstDayOfWeek();
|
||||
self::CheckAutoNightModeRange();
|
||||
self::CheckAuthClass();
|
||||
}
|
||||
|
||||
private function checkMode()
|
||||
private function CheckMode()
|
||||
{
|
||||
$allowedModes = ['production', 'dev', 'demo', 'prerelease'];
|
||||
if (!in_array(GROCY_MODE, $allowedModes))
|
||||
|
|
@ -28,7 +35,7 @@ class ConfigurationValidator
|
|||
}
|
||||
}
|
||||
|
||||
private function checkDefaultLocale()
|
||||
private function CheckDefaultLocale()
|
||||
{
|
||||
if (!file_exists(__DIR__ . '/../localization/' . GROCY_DEFAULT_LOCALE))
|
||||
{
|
||||
|
|
@ -36,7 +43,7 @@ class ConfigurationValidator
|
|||
}
|
||||
}
|
||||
|
||||
private function checkFirstDayOfWeek()
|
||||
private function CheckFirstDayOfWeek()
|
||||
{
|
||||
if (!(GROCY_CALENDAR_FIRST_DAY_OF_WEEK == '' ||
|
||||
(is_numeric(GROCY_CALENDAR_FIRST_DAY_OF_WEEK) && GROCY_CALENDAR_FIRST_DAY_OF_WEEK >= 0 && GROCY_CALENDAR_FIRST_DAY_OF_WEEK <= 6)))
|
||||
|
|
@ -45,7 +52,7 @@ class ConfigurationValidator
|
|||
}
|
||||
}
|
||||
|
||||
private function checkCurrencyFormat()
|
||||
private function CheckCurrencyFormat()
|
||||
{
|
||||
if (!(preg_match('/^([A-z]){3}$/', GROCY_CURRENCY)))
|
||||
{
|
||||
|
|
@ -53,7 +60,7 @@ class ConfigurationValidator
|
|||
}
|
||||
}
|
||||
|
||||
private function checkEntryPage()
|
||||
private function CheckEntryPage()
|
||||
{
|
||||
$allowedPages = ['stock', 'shoppinglist', 'recipes', 'chores', 'tasks', 'batteries', 'equipment', 'calendar', 'mealplan'];
|
||||
if (!in_array(GROCY_ENTRY_PAGE, $allowedPages))
|
||||
|
|
@ -62,7 +69,7 @@ class ConfigurationValidator
|
|||
}
|
||||
}
|
||||
|
||||
private function checkMealplanFirstDayOfWeek()
|
||||
private function CheckMealplanFirstDayOfWeek()
|
||||
{
|
||||
if (!(GROCY_MEAL_PLAN_FIRST_DAY_OF_WEEK == '' ||
|
||||
(is_numeric(GROCY_MEAL_PLAN_FIRST_DAY_OF_WEEK) && GROCY_MEAL_PLAN_FIRST_DAY_OF_WEEK >= -1 && GROCY_MEAL_PLAN_FIRST_DAY_OF_WEEK <= 6)))
|
||||
|
|
@ -71,7 +78,7 @@ class ConfigurationValidator
|
|||
}
|
||||
}
|
||||
|
||||
private function checkAutoNightModeRange()
|
||||
private function CheckAutoNightModeRange()
|
||||
{
|
||||
global $GROCY_DEFAULT_USER_SETTINGS;
|
||||
if (!(preg_match('/^(?:2[0-3]|[01][0-9]):[0-5][0-9]$/', $GROCY_DEFAULT_USER_SETTINGS['auto_night_mode_time_range_from'])))
|
||||
|
|
@ -83,4 +90,12 @@ class ConfigurationValidator
|
|||
throw new EInvalidConfig('auto_night_mode_time_range_to is not in HH:mm format (' . $GROCY_DEFAULT_USER_SETTINGS['auto_night_mode_time_range_to'] . ')');
|
||||
}
|
||||
}
|
||||
|
||||
private function CheckAuthClass()
|
||||
{
|
||||
if (!class_exists(GROCY_AUTH_CLASS))
|
||||
{
|
||||
throw new EInvalidConfig('Configured AUTH_CLASS "' . GROCY_AUTH_CLASS . '" does not exist');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,17 +106,6 @@ function GetClassConstants($className, $prefix = null)
|
|||
}
|
||||
}
|
||||
|
||||
function RandomString($length, $allowedChars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ')
|
||||
{
|
||||
$randomString = '';
|
||||
for ($i = 0; $i < $length; $i++)
|
||||
{
|
||||
$randomString .= $allowedChars[rand(0, strlen($allowedChars) - 1)];
|
||||
}
|
||||
|
||||
return $randomString;
|
||||
}
|
||||
|
||||
function IsAssociativeArray(array $array)
|
||||
{
|
||||
$keys = array_keys($array);
|
||||
|
|
@ -278,3 +267,8 @@ function EmptyFolder($folderPath)
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function GetClientUserAgent()
|
||||
{
|
||||
return isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -667,10 +667,7 @@ msgstr ""
|
|||
msgid "Are you sure you want to delete product group \"%s\"?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stay logged in permanently"
|
||||
msgstr ""
|
||||
|
||||
msgid "When not set, you will get logged out at latest after 30 days"
|
||||
msgid "Remember Me"
|
||||
msgstr ""
|
||||
|
||||
msgid "Status"
|
||||
|
|
@ -1618,7 +1615,7 @@ msgstr ""
|
|||
msgid "Clear filter"
|
||||
msgstr ""
|
||||
|
||||
msgid "Permissions for user %s"
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Are you sure you want to remove full permissions for yourself?"
|
||||
|
|
@ -2483,3 +2480,24 @@ msgid "Pack"
|
|||
msgid_plural "Packs"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "Manage user sessions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sessions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Token type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Token hash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Client"
|
||||
msgstr ""
|
||||
|
||||
msgid "This session"
|
||||
msgstr ""
|
||||
|
||||
msgid "Are you sure you want to delete the selected session?"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -74,10 +74,52 @@ abstract class BaseAuthMiddleware extends BaseMiddleware
|
|||
}
|
||||
}
|
||||
|
||||
protected static function SetSessionCookie(string $sessionKey)
|
||||
protected static function SetSessionCookie(int $tokenType, string $token)
|
||||
{
|
||||
// Cookie never expires, session validity is up to SessionService
|
||||
setcookie(SessionService::SESSION_COOKIE_NAME, $sessionKey, PHP_INT_SIZE == 4 ? PHP_INT_MAX : PHP_INT_MAX >> 32);
|
||||
switch ($tokenType)
|
||||
{
|
||||
case SessionService::SESSION_TOKEN_TYPE_ACCESS:
|
||||
setcookie(SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS, $token, [
|
||||
'expires' => 0, // Browser session end
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
'path' => '/'
|
||||
]);
|
||||
break;
|
||||
|
||||
case SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME:
|
||||
setcookie(SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME, $token, [
|
||||
'expires' => time() + SessionService::SESSION_TOKEN_EXPIRATION_SECONDS_REMEMBER_ME,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
'path' => '/'
|
||||
]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected static function RemoveSessionCookie(int $tokenType)
|
||||
{
|
||||
switch ($tokenType)
|
||||
{
|
||||
case SessionService::SESSION_TOKEN_TYPE_ACCESS:
|
||||
setcookie(SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS, '', [
|
||||
'expires' => time() - 3600,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
'path' => '/'
|
||||
]);
|
||||
break;
|
||||
|
||||
case SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME:
|
||||
setcookie(SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME, '', [
|
||||
'expires' => time() - 3600,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
'path' => '/'
|
||||
]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -44,12 +44,18 @@ class DefaultAuthMiddleware extends BaseAuthMiddleware
|
|||
|
||||
$user = $db->users()->where('username', $postParams['username'])->fetch();
|
||||
$inputPassword = $postParams['password'];
|
||||
$stayLoggedInPermanently = $postParams['stay_logged_in'] == 'on';
|
||||
$rememberMe = isset($postParams['remember_me']) && $postParams['remember_me'] == 'on';
|
||||
|
||||
if ($user !== null && password_verify($inputPassword, $user->password))
|
||||
{
|
||||
$sessionKey = SessionService::GetInstance()->CreateSession($user->id, $stayLoggedInPermanently);
|
||||
self::SetSessionCookie($sessionKey);
|
||||
$token = SessionService::GetInstance()->CreateToken(SessionService::SESSION_TOKEN_TYPE_ACCESS, $user->id, GetClientUserAgent());
|
||||
self::SetSessionCookie(SessionService::SESSION_TOKEN_TYPE_ACCESS, $token);
|
||||
|
||||
if ($rememberMe)
|
||||
{
|
||||
$token = SessionService::GetInstance()->CreateToken(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $user->id, GetClientUserAgent());
|
||||
self::SetSessionCookie(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $token);
|
||||
}
|
||||
|
||||
if (password_needs_rehash($user->password, PASSWORD_ARGON2ID))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -74,8 +74,15 @@ class LdapAuthMiddleware extends BaseAuthMiddleware
|
|||
$user = UsersService::GetInstance()->CreateUser($ldapUidAttribute, $ldapFirstName, $ldapLastName, '');
|
||||
}
|
||||
|
||||
$sessionKey = SessionService::GetInstance()->CreateSession($user->id, $postParams['stay_logged_in'] == 'on');
|
||||
self::SetSessionCookie($sessionKey);
|
||||
$token = SessionService::GetInstance()->CreateToken(SessionService::SESSION_TOKEN_TYPE_ACCESS, $user->id, GetClientUserAgent());
|
||||
self::SetSessionCookie(SessionService::SESSION_TOKEN_TYPE_ACCESS, $token);
|
||||
|
||||
$rememberMe = isset($postParams['remember_me']) && $postParams['remember_me'] == 'on';
|
||||
if ($rememberMe)
|
||||
{
|
||||
$token = SessionService::GetInstance()->CreateToken(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $user->id, GetClientUserAgent());
|
||||
self::SetSessionCookie(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $token);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class ReverseProxyAuthMiddleware extends BaseAuthMiddleware
|
|||
{
|
||||
define('GROCY_EXTERNALLY_MANAGED_AUTHENTICATION', true);
|
||||
|
||||
// Try to use regular API Key authentiaction (applies when the reverse proxy is configured to be bypassed for API routes)
|
||||
// Try to use regular API Key authentication (applies when the reverse proxy is configured to be bypassed for API routes)
|
||||
if ($this->IsApiRoute)
|
||||
{
|
||||
$auth = new ApiKeyAuthMiddleware($this->AppContainer, $this->ResponseFactory);
|
||||
|
|
|
|||
|
|
@ -11,14 +11,33 @@ class SessionAuthMiddleware extends BaseAuthMiddleware
|
|||
{
|
||||
$sessionService = SessionService::GetInstance();
|
||||
|
||||
if (isset($_COOKIE[SessionService::SESSION_COOKIE_NAME]) && $sessionService->IsValidSession($_COOKIE[SessionService::SESSION_COOKIE_NAME]))
|
||||
// First check if the access token is valid
|
||||
if (isset($_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS]) && $sessionService->ValidateToken(SessionService::SESSION_TOKEN_TYPE_ACCESS, $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS]))
|
||||
{
|
||||
return $sessionService->GetUserBySessionKey($_COOKIE[SessionService::SESSION_COOKIE_NAME]);
|
||||
return $sessionService->GetUserByToken(SessionService::SESSION_TOKEN_TYPE_ACCESS, $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_ACCESS]);
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
// We have no valid access token => remove the cookie and check if we have a valid remember me token
|
||||
self::RemoveSessionCookie(SessionService::SESSION_TOKEN_TYPE_ACCESS);
|
||||
if (isset($_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME]) && $sessionService->ValidateToken(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME]))
|
||||
{
|
||||
$user = $sessionService->GetUserByToken(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME]);
|
||||
if ($user !== null)
|
||||
{
|
||||
// Remember me token was valid => create a new access token and extend the lifetime of the remember me token cookie
|
||||
$token = $sessionService->CreateToken(SessionService::SESSION_TOKEN_TYPE_ACCESS, $user->id, GetClientUserAgent());
|
||||
self::SetSessionCookie(SessionService::SESSION_TOKEN_TYPE_ACCESS, $token);
|
||||
self::SetSessionCookie(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME, $_COOKIE[SessionService::SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME]);
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
// Remember me token was also not valid => remove the cookie
|
||||
self::RemoveSessionCookie(SessionService::SESSION_TOKEN_TYPE_REMEMBER_ME);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function ProcessLogin(array $postParams)
|
||||
|
|
|
|||
|
|
@ -3,4 +3,4 @@ CREATE TABLE sessions (
|
|||
session_key TEXT NOT NULL UNIQUE,
|
||||
expires DATETIME,
|
||||
row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,5 +16,4 @@ CREATE TABLE sessions (
|
|||
expires DATETIME,
|
||||
last_used DATETIME,
|
||||
row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
|
||||
);
|
||||
|
|
|
|||
17
migrations/0256.sql
Normal file
17
migrations/0256.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
DROP TABLE sessions;
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_type TINYINT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires DATETIME NOT NULL,
|
||||
last_used DATETIME,
|
||||
client_info TEXT,
|
||||
row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime'))
|
||||
);
|
||||
|
||||
CREATE INDEX ix_sessions_performance1 ON sessions (
|
||||
token_type,
|
||||
token_hash
|
||||
);
|
||||
|
|
@ -94,7 +94,7 @@ class DemoBarcodeLookupPlugin extends BaseBarcodeLookupPlugin
|
|||
}
|
||||
|
||||
return [
|
||||
'name' => 'LookedUpProduct_' . RandomString(5),
|
||||
'name' => 'LookedUpProduct_' . bin2hex(random_bytes(4)),
|
||||
'location_id' => $locationId,
|
||||
'qu_id_purchase' => $quId,
|
||||
'qu_id_stock' => $quId,
|
||||
|
|
|
|||
67
public/viewjs/managesessions.js
Normal file
67
public/viewjs/managesessions.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
var sessionsTable = $('#sessions-table').DataTable({
|
||||
'order': [[5, 'desc']],
|
||||
'columnDefs': [
|
||||
{ 'orderable': false, 'targets': 0 },
|
||||
{ 'searchable': false, "targets": 0 }
|
||||
].concat($.fn.dataTable.defaults.columnDefs)
|
||||
});
|
||||
$('#sessions-table tbody').removeClass("d-none");
|
||||
sessionsTable.columns.adjust().draw();
|
||||
|
||||
$("#search").on("keyup", Delay(function ()
|
||||
{
|
||||
var value = $(this).val();
|
||||
if (value === "all")
|
||||
{
|
||||
value = "";
|
||||
}
|
||||
|
||||
sessionsTable.search(value).draw();
|
||||
}, Grocy.FormFocusDelay));
|
||||
|
||||
$("#clear-filter-button").on("click", function ()
|
||||
{
|
||||
$("#search").val("");
|
||||
sessionsTable.search("").draw();
|
||||
});
|
||||
|
||||
$(document).on('click', '.session-delete-button', function (e)
|
||||
{
|
||||
var button = $(e.currentTarget);
|
||||
var objectId = button.attr('data-session-id');
|
||||
|
||||
bootbox.confirm({
|
||||
message: __t('Are you sure you want to delete the selected session?'),
|
||||
closeButton: false,
|
||||
className: "text-break",
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: __t('Yes'),
|
||||
className: 'btn-success'
|
||||
},
|
||||
cancel: {
|
||||
label: __t('No'),
|
||||
className: 'btn-danger'
|
||||
}
|
||||
},
|
||||
callback: function (result)
|
||||
{
|
||||
if (result === true)
|
||||
{
|
||||
Grocy.Api.Delete('objects/sessions/' + objectId, {},
|
||||
function (result)
|
||||
{
|
||||
animateCSS("#session-" + objectId + "-row", "fadeOut", function ()
|
||||
{
|
||||
$("#session-" + objectId + "-row").addClass("d-none").remove();
|
||||
});
|
||||
},
|
||||
function (xhr)
|
||||
{
|
||||
console.error(xhr);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -55,6 +55,7 @@ $app->group('', function (RouteCollectorProxy $group)
|
|||
$group->get('/users', [UsersController::class, 'UsersList']);
|
||||
$group->get('/user/{userId}', [UsersController::class, 'UserEditForm']);
|
||||
$group->get('/user/{userId}/permissions', [UsersController::class, 'PermissionList']);
|
||||
$group->get('/user/{userId}/sessions', [UsersController::class, 'SessionList']);
|
||||
$group->get('/usersettings', [UsersController::class, 'UserSettings']);
|
||||
|
||||
// Stock master data routes
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class ApiKeyService extends BaseService
|
|||
|
||||
public function CreateApiKey(string $keyType = self::API_KEY_TYPE_DEFAULT, ?string $description = null)
|
||||
{
|
||||
$newApiKey = $this->GenerateKey();
|
||||
$newApiKey = bin2hex(random_bytes(24));
|
||||
|
||||
$apiKeyRow = $this->DB->api_keys()->createRow([
|
||||
'api_key' => $newApiKey,
|
||||
|
|
@ -97,9 +97,4 @@ class ApiKeyService extends BaseService
|
|||
{
|
||||
$this->DB->api_keys()->where('api_key', $apiKey)->delete();
|
||||
}
|
||||
|
||||
private function GenerateKey()
|
||||
{
|
||||
return RandomString(50);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ class ApplicationService extends BaseService
|
|||
'sqlite_version' => $sqliteVersion,
|
||||
'db_version' => $this->DB->migrations()->max('migration'),
|
||||
'os' => php_uname('s') . ' ' . php_uname('r') . ' ' . php_uname('v') . ' ' . php_uname('m'),
|
||||
'client' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown'
|
||||
'client' => GetClientUserAgent()
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ class DatabaseMigrationService extends BaseService
|
|||
{
|
||||
DatabaseService::GetInstance()->ExecuteDbStatement('VACUUM');
|
||||
}
|
||||
|
||||
SessionService::GetInstance()->DeleteExpiredTokens();
|
||||
}
|
||||
|
||||
private function ExecutePhpMigrationWhenNeeded(int $migrationId, string $phpFile, int &$migrationCounter)
|
||||
|
|
|
|||
|
|
@ -4,37 +4,78 @@ namespace Grocy\Services;
|
|||
|
||||
class SessionService extends BaseService
|
||||
{
|
||||
const SESSION_COOKIE_NAME = 'grocy_session';
|
||||
const int SESSION_TOKEN_TYPE_ACCESS = 1;
|
||||
const int SESSION_TOKEN_TYPE_REMEMBER_ME = 2;
|
||||
const string SESSION_TOKEN_COOKIE_NAME_ACCESS = 'grocy_session_access_token';
|
||||
const string SESSION_TOKEN_COOKIE_NAME_REMEMBER_ME = 'grocy_session_remember_me_token';
|
||||
const int SESSION_TOKEN_EXPIRATION_SECONDS_ACCESS = 864000; // 10 Days
|
||||
const int SESSION_TOKEN_EXPIRATION_SECONDS_REMEMBER_ME = 15552000; // 180 Days
|
||||
|
||||
public function CreateSession($userId, $stayLoggedInPermanently = false)
|
||||
public function CreateToken(int $tokenType, int $userId, string $clientInfo)
|
||||
{
|
||||
$newSessionKey = $this->GenerateKey();
|
||||
$expires = date('Y-m-d H:i:s', time() + 2592000);
|
||||
$newToken = bin2hex(random_bytes(32));
|
||||
|
||||
// Default is that sessions expire in 30 days
|
||||
if ($stayLoggedInPermanently === true)
|
||||
$expires = date('Y-m-d H:i:s');
|
||||
switch ($tokenType)
|
||||
{
|
||||
$expires = date('Y-m-d H:i:s', PHP_INT_SIZE == 4 ? PHP_INT_MAX : PHP_INT_MAX >> 32); // Never
|
||||
case self::SESSION_TOKEN_TYPE_ACCESS:
|
||||
$expires = date('Y-m-d H:i:s', time() + self::SESSION_TOKEN_EXPIRATION_SECONDS_ACCESS);
|
||||
break;
|
||||
|
||||
case self::SESSION_TOKEN_TYPE_REMEMBER_ME:
|
||||
$expires = date('Y-m-d H:i:s', time() + self::SESSION_TOKEN_EXPIRATION_SECONDS_REMEMBER_ME);
|
||||
break;
|
||||
}
|
||||
|
||||
$sessionRow = $this->DB->sessions()->createRow([
|
||||
'user_id' => $userId,
|
||||
'session_key' => $newSessionKey,
|
||||
'expires' => $expires
|
||||
'token_type' => $tokenType,
|
||||
'token_hash' => hash('sha256', $newToken),
|
||||
'expires' => $expires,
|
||||
'client_info' => $clientInfo,
|
||||
'last_used' => date('Y-m-d H:i:s', time())
|
||||
]);
|
||||
$sessionRow->save();
|
||||
|
||||
return $newSessionKey;
|
||||
$this->DeleteExpiredTokens();
|
||||
|
||||
return $newToken;
|
||||
}
|
||||
|
||||
public function GetDefaultUser()
|
||||
public function DeleteToken(int $tokenType, string $token)
|
||||
{
|
||||
return $this->DB->users()->orderBy('id')->limit(1)->fetch();
|
||||
$this->DB->sessions()->where('token_type = :1 AND token_hash = :2', $tokenType, hash('sha256', $token))->delete();
|
||||
}
|
||||
|
||||
public function GetUserBySessionKey($sessionKey)
|
||||
public function ValidateToken(int $tokenType, string $token)
|
||||
{
|
||||
$sessionRow = $this->DB->sessions()->where('session_key', $sessionKey)->fetch();
|
||||
if (empty($token))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$sessionRow = $this->DB->sessions()->where('token_type = :1 AND token_hash = :2 AND expires > :3', $tokenType, hash('sha256', $token), date('Y-m-d H:i:s', time()))->fetch();
|
||||
if ($sessionRow !== null)
|
||||
{
|
||||
// This should not change the database file modification time as this is used
|
||||
// to determine if REALLY something has changed
|
||||
$dbModTime = DatabaseService::GetInstance()->GetDbChangedTime();
|
||||
$sessionRow->update([
|
||||
'last_used' => date('Y-m-d H:i:s', time())
|
||||
]);
|
||||
DatabaseService::GetInstance()->SetDbChangedTime($dbModTime);
|
||||
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function GetUserByToken(int $tokenType, string $token)
|
||||
{
|
||||
$sessionRow = $this->DB->sessions()->where('token_type = :1 AND token_hash = :2', $tokenType, hash('sha256', $token))->fetch();
|
||||
if ($sessionRow !== null)
|
||||
{
|
||||
return $this->DB->users($sessionRow->user_id);
|
||||
|
|
@ -43,41 +84,13 @@ class SessionService extends BaseService
|
|||
return null;
|
||||
}
|
||||
|
||||
public function IsValidSession($sessionKey)
|
||||
public function GetDefaultUser()
|
||||
{
|
||||
if ($sessionKey === null || empty($sessionKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
$sessionRow = $this->DB->sessions()->where('session_key = :1 AND expires > :2', $sessionKey, date('Y-m-d H:i:s', time()))->fetch();
|
||||
if ($sessionRow !== null)
|
||||
{
|
||||
// This should not change the database file modification time as this is used
|
||||
// to determine if REALLY something has changed
|
||||
$dbModTime = DatabaseService::GetInstance()->GetDbChangedTime();
|
||||
$sessionRow->update([
|
||||
'last_used' => date('Y-m-d H:i:s', time())
|
||||
]);
|
||||
DatabaseService::GetInstance()->SetDbChangedTime($dbModTime);
|
||||
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return $this->DB->users()->orderBy('id')->limit(1)->fetch();
|
||||
}
|
||||
|
||||
public function RemoveSession($sessionKey)
|
||||
public function DeleteExpiredTokens()
|
||||
{
|
||||
$this->DB->sessions()->where('session_key', $sessionKey)->delete();
|
||||
}
|
||||
|
||||
private function GenerateKey()
|
||||
{
|
||||
return RandomString(50);
|
||||
$this->DB->sessions()->where('expires < :1', date('Y-m-d H:i:s', time()))->delete();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,15 +41,11 @@
|
|||
<div class="custom-control custom-checkbox">
|
||||
<input type="checkbox"
|
||||
class="form-check-input custom-control-input"
|
||||
id="stay_logged_in"
|
||||
name="stay_logged_in">
|
||||
id="remember_me"
|
||||
name="remember_me">
|
||||
<label class="form-check-label custom-control-label"
|
||||
for="stay_logged_in">
|
||||
{{ $__t('Stay logged in permanently') }}
|
||||
<i class="fa-solid fa-question-circle text-muted"
|
||||
data-toggle="tooltip"
|
||||
data-trigger="hover click"
|
||||
title="{{ $__t('When not set, you will get logged out at latest after 30 days') }}"></i>
|
||||
for="remember_me">
|
||||
{{ $__t('Remember Me') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
122
views/managesessions.blade.php
Normal file
122
views/managesessions.blade.php
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
@php require_frontend_packages(['datatables', 'animatecss']); @endphp
|
||||
|
||||
@extends('layout.default')
|
||||
|
||||
@section('title', $__t('Sessions'))
|
||||
|
||||
@section('content')
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="title-related-links">
|
||||
<h2 class="title">
|
||||
@yield('title')
|
||||
<span class="text-muted small">{{ $__t('User') }} <strong>{{ GetUserDisplayName($user) }}</strong></span>
|
||||
</h2>
|
||||
<div class="float-right @if($embedded) pr-5 @endif">
|
||||
<button class="btn btn-outline-dark d-md-none mt-2 order-1 order-md-3"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
data-target="#table-filter-row">
|
||||
<i class="fa-solid fa-filter"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-dark d-md-none mt-2 order-1 order-md-3"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
data-target="#related-links">
|
||||
<i class="fa-solid fa-ellipsis-v"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-2">
|
||||
|
||||
<div class="row collapse d-md-flex"
|
||||
id="table-filter-row">
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text"><i class="fa-solid fa-search"></i></span>
|
||||
</div>
|
||||
<input type="text"
|
||||
id="search"
|
||||
class="form-control"
|
||||
placeholder="{{ $__t('Search') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="float-right">
|
||||
<button id="clear-filter-button"
|
||||
class="btn btn-sm btn-outline-info"
|
||||
data-toggle="tooltip"
|
||||
title="{{ $__t('Clear filter') }}">
|
||||
<i class="fa-solid fa-filter-circle-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<table id="sessions-table"
|
||||
class="table table-sm table-striped nowrap w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="border-right"><a class="text-muted change-table-columns-visibility-button"
|
||||
data-toggle="tooltip"
|
||||
title="{{ $__t('Table options') }}"
|
||||
data-table-selector="#sessions-table"
|
||||
href="#"><i class="fa-solid fa-eye"></i></a>
|
||||
</th>
|
||||
<th class="allow-grouping">{{ $__t('Token type') }}</th>
|
||||
<th>{{ $__t('Token hash') }}</th>
|
||||
<th>{{ $__t('Created') }}</th>
|
||||
<th>{{ $__t('Expires') }}</th>
|
||||
<th>{{ $__t('Last used') }}</th>
|
||||
<th>{{ $__t('Client') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="d-none">
|
||||
@foreach($sessionRows as $sessionRow)
|
||||
<tr id="session-{{ $sessionRow->id }}-row">
|
||||
<td class="fit-content border-right">
|
||||
<a class="btn btn-danger btn-sm session-delete-button"
|
||||
href="#"
|
||||
data-session-id="{{ $sessionRow->id }}"
|
||||
data-toggle="tooltip"
|
||||
title="{{ $__t('Delete this item') }}">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
@if($sessionRow->token_type == 1) access @elseif($sessionRow->token_type == 2) remember_me @endif
|
||||
</td>
|
||||
<td>
|
||||
{{ substr($sessionRow->token_hash, 0, 8) }} @if(in_array($sessionRow->token_hash, $thisSessionTokenHashes)) <span class="badge badge-info">{{ $__t('This session') }}</span> @endif
|
||||
</td>
|
||||
<td>
|
||||
{{ $sessionRow->row_created_timestamp }}
|
||||
<time class="timeago timeago-contextual"
|
||||
datetime="{{ $sessionRow->row_created_timestamp }}"></time>
|
||||
</td>
|
||||
<td>
|
||||
{{ $sessionRow->expires }}
|
||||
<time class="timeago timeago-contextual"
|
||||
datetime="{{ $sessionRow->expires }}"></time>
|
||||
</td>
|
||||
<td>
|
||||
@if(empty($sessionRow->last_used)){{ $__t('never') }}@else{{ $sessionRow->last_used }}@endif
|
||||
<time class="timeago timeago-contextual"
|
||||
datetime="{{ $sessionRow->last_used }}"></time>
|
||||
</td>
|
||||
<td>
|
||||
{{ $sessionRow->client_info }}
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@stop
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
@extends('layout.default')
|
||||
|
||||
@section('title', $__t('Permissions for user %s', GetUserDisplayName($user)))
|
||||
@section('title', $__t('Permissions'))
|
||||
|
||||
@push('pageScripts')
|
||||
<script>
|
||||
|
|
@ -19,7 +19,10 @@
|
|||
@section('content')
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h2 class="title">@yield('title')</h2>
|
||||
<h2 class="title">
|
||||
@yield('title')
|
||||
<span class="text-muted small">{{ $__t('User') }} <strong>{{ GetUserDisplayName($user) }}</strong></span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -92,20 +92,26 @@
|
|||
@foreach($users as $user)
|
||||
<tr>
|
||||
<td class="fit-content border-right">
|
||||
@if(!GROCY_IS_EMBEDDED_INSTALL && !GROCY_DISABLE_AUTH)
|
||||
<a class="btn btn-primary btn-sm"
|
||||
href="{{ $U('/user/' . $user->id . '/permissions') }}"
|
||||
data-toggle="tooltip"
|
||||
title="{{ $__t('Configure user permissions') }}">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</a>
|
||||
<a class="btn btn-primary btn-sm"
|
||||
href="{{ $U('/user/' . $user->id . '/sessions') }}"
|
||||
data-toggle="tooltip"
|
||||
title="{{ $__t('Manage user sessions') }}">
|
||||
<i class="fa-solid fa-tower-broadcast"></i>
|
||||
</a>
|
||||
@endif
|
||||
<a class="btn btn-info btn-sm"
|
||||
href="{{ $U('/user/') }}{{ $user->id }}"
|
||||
data-toggle="tooltip"
|
||||
title="{{ $__t('Edit this item') }}">
|
||||
<i class="fa-solid fa-edit"></i>
|
||||
</a>
|
||||
@if(!GROCY_IS_EMBEDDED_INSTALL && !GROCY_DISABLE_AUTH)
|
||||
<a class="btn btn-info btn-sm"
|
||||
href="{{ $U('/user/' . $user->id . '/permissions') }}"
|
||||
data-toggle="tooltip"
|
||||
title="{{ $__t('Configure user permissions') }}">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</a>
|
||||
@endif
|
||||
<a class="btn btn-danger btn-sm user-delete-button @if($user->id == GROCY_USER_ID) disabled @endif"
|
||||
href="#"
|
||||
data-user-id="{{ $user->id }}"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user