Provide ability for admins to manage user organisation membership & admin rights

- Required overwriting a bunch of base code to slot in the handling
- Admins are shown with yellow text in their organisation label
This commit is contained in:
2022-02-08 18:10:20 +00:00
parent 609454def2
commit 9d62749914
14 changed files with 763 additions and 1 deletions

View File

@@ -18,6 +18,7 @@
"js/pages/organisation": {
"scripts": [
"userfrosting/js/widgets/users.js",
"avsdev/js/widgets/users.js",
"avsdev/js/widgets/organisations.js",
"avsdev/js/pages/organisation.js"
]
@@ -27,6 +28,32 @@
"avsdev/js/widgets/organisations.js",
"avsdev/js/pages/organisations.js"
]
},
"js/pages/user": {
"scripts": [
"userfrosting/js/widgets/users.js",
"userfrosting/js/pages/user.js",
"avsdev/js/widgets/users.js",
"avsdev/js/pages/user.js"
]
},
"js/pages/users": {
"scripts": [
"userfrosting/js/widgets/users.js",
"userfrosting/js/pages/users.js",
"avsdev/js/widgets/users.js",
"avsdev/js/pages/users.js"
]
},
"css/admin": {
"styles": [
"font-starcraft/css/font-starcraft.css",
"vendor/tablesorter/dist/css/theme.bootstrap.min.css",
"vendor/tablesorter/dist/css/jquery.tablesorter.pager.min.css",
"userfrosting/css/tablesorter-reflow.css",
"userfrosting/css/tablesorter-custom.css",
"avsdev/css/organisations.css"
]
}
}
}

View File

@@ -0,0 +1,3 @@
.organisation-admin {
color: #ffc107 !important;
}

View File

@@ -20,5 +20,6 @@ $(document).ready(function() {
// Bind user table buttons
$("#widget-organisation-members").on("pagerComplete.ufTable", function () {
bindUserButtons($(this));
bindUserButtonsExtra($(this));
});
});

View File

@@ -0,0 +1,13 @@
/**
* Page-specific Javascript file. Should generally be included as a separate asset bundle in your page template.
* example: {{ assets.js('js/pages/sign-in-or-register') | raw }}
*
* This script depends on uf-table.js, moment.js, handlebars-helpers.js
*
* Target page: /users/u/{user_name}
*/
$(document).ready(function() {
// Control buttons
bindUserButtonsExtra($("#view-user"), { delete_redirect: page.delete_redirect });
});

View File

@@ -0,0 +1,15 @@
/**
* Page-specific Javascript file. Should generally be included as a separate asset bundle in your page template.
* example: {{ assets.js('js/pages/sign-in-or-register') | raw }}
*
* This script depends on widgets/users.js, uf-table.js, moment.js, handlebars-helpers.js
*
* Target page: /users
*/
$(document).ready(function() {
// Bind table buttons
$("#widget-users").on("pagerComplete.ufTable", function () {
bindUserButtonsExtra($(this));
});
});

View File

@@ -0,0 +1,56 @@
/**
* Link extra user action buttons in addition to the base ones.
* @param {module:jQuery} el jQuery wrapped element to target.
* @param {{delete_redirect: string}} options Options used to modify behaviour of button actions.
*/
function bindUserButtonsExtra(el, options) {
// Manage user organisations button
el.find('.js-user-organisations').click(function(e) {
e.preventDefault();
var userName = $(this).data('user_name');
$("body").ufModal({
sourceUrl: site.uri.public + "/modals/users/organisations",
ajaxParams: {
user_name: userName
},
msgTarget: $("#alerts-page")
});
$("body").on('renderSuccess.ufModal', function(data) {
var modal = $(this).ufModal('getModal');
var form = modal.find('.js-form');
// Set up collection widget
var organisationWidget = modal.find('.js-form-organisations');
organisationWidget.ufCollection({
dropdown: {
ajax: {
url: site.uri.public + '/api/organisations'
},
placeholder: "Select an organisation"
},
dropdownTemplate: modal.find('#user-organisations-select-option').html(),
rowTemplate: modal.find('#user-organisations-row').html()
});
// Get current organisations and add to widget
$.getJSON(site.uri.public + '/api/users/u/' + userName + '/organisations')
.done(function(data) {
$.each(data.rows, function(idx, organisation) {
organisation.text = organisation.name;
organisationWidget.ufCollection('addRow', organisation);
});
});
// Set up form for submission
form.ufForm()
.on("submitSuccess.ufForm", function() {
// Reload page on success
window.location.reload();
});
});
});
}

View File

@@ -40,6 +40,8 @@ return [
'ADMIN_COUNT' => '# Admins',
'SELF' => 'My Organisations',
'MANAGE' => 'Manage Organisations',
'ASSIGN_NEW' => 'Assign to organisation',
'NAME' => [
1 => 'Organisation name',

23
routes/users.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
/*
* AVSDev UF Organisations (https://avsdev.uk)
*
* @link https://git.avsdev.uk/avsdev/sprinkle-organisations
* @license https://git.avsdev.uk/avsdev/sprinkle-organisations/blob/master/LICENSE.md (LGPL-3.0 License)
*/
use UserFrosting\Sprinkle\Core\Util\NoCache;
/*
* Extra routes for administrative user management.
*/
$app->group('/api/users', function () {
$this->get('/u/{user_name}/organisations', 'UserFrosting\Sprinkle\Organisations\Controller\UserController:getOrganisations');
$this->put('/u/{user_name}/{field}', 'UserFrosting\Sprinkle\Organisations\Controller\UserController:updateField');
})->add('authGuard')->add(new NoCache());
$app->group('/modals/users', function () {
$this->get('/organisations', 'UserFrosting\Sprinkle\Organisations\Controller\UserController:getModalEditOrganisations');
})->add('authGuard')->add(new NoCache());

View File

@@ -0,0 +1,64 @@
---
first_name:
validators:
length:
label: "&FIRST_NAME"
min: 1
max: 20
message: VALIDATE.LENGTH_RANGE
last_name:
validators:
length:
label: "&LAST_NAME"
min: 1
max: 30
message: VALIDATE.LENGTH_RANGE
email:
validators:
length:
label: "&EMAIL"
min: 1
max: 150
message: VALIDATE.LENGTH_RANGE
email:
message: VALIDATE.INVALID_EMAIL
locale:
validators:
length:
label: "&LOCALE"
min: 1
max: 10
message: VALIDATE.LENGTH_RANGE
group_id:
validators:
integer:
message: VALIDATE.INTEGER
flag_enabled:
validators:
member_of:
values:
- '0'
- '1'
message: VALIDATE.BOOLEAN
flag_verified:
validators:
member_of:
values:
- '0'
- '1'
message: VALIDATE.BOOLEAN
password:
validators:
length:
label: "&PASSWORD"
min: 12
max: 100
message: VALIDATE.LENGTH_RANGE
roles:
validators:
array:
message: VALIDATE.ARRAY
organisations:
validators:
array:
message: VALIDATE.ARRAY

View File

@@ -0,0 +1,294 @@
<?php
/*
* AVSDev UF Organisations (https://avsdev.uk)
*
* @link https://git.avsdev.uk/avsdev/sprinkle-organisations
* @license https://git.avsdev.uk/avsdev/sprinkle-organisations/blob/master/LICENSE.md (LGPL-3.0 License)
*/
namespace UserFrosting\Sprinkle\Organisations\Controller;
use Carbon\Carbon;
use Illuminate\Database\Capsule\Manager as Capsule;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use UserFrosting\Fortress\Adapter\JqueryValidationAdapter;
use UserFrosting\Fortress\RequestDataTransformer;
use UserFrosting\Fortress\RequestSchema;
use UserFrosting\Fortress\ServerSideValidator;
use UserFrosting\Sprinkle\Account\Database\Models\User;
use UserFrosting\Sprinkle\Account\Facades\Password;
use UserFrosting\Sprinkle\Core\Controller\SimpleController;
use UserFrosting\Sprinkle\Core\Mail\EmailRecipient;
use UserFrosting\Sprinkle\Core\Mail\TwigMailMessage;
use UserFrosting\Support\Exception\BadRequestException;
use UserFrosting\Support\Exception\ForbiddenException;
use UserFrosting\Support\Exception\NotFoundException;
use UserFrosting\Sprinkle\Admin\Controller\UserController as UFUserController;
/**
* Controller class for extending user-related requests, including joining/leaving organisations, etc.
*
* @author Craig Williams (https://avsdev.uk)
*/
class UserController extends UFUserController
{
/**
* Renders the modal form for editing a user's organisations.
*
* This does NOT render a complete page. Instead, it renders the HTML for the form, which can be embedded in other pages.
* This page requires authentication.
*
* Request type: GET
*
* @param Request $request
* @param Response $response
* @param string[] $args
*
* @throws NotFoundException If user is not found
* @throws ForbiddenException If user is not authorized to access page
*/
public function getModalEditOrganisations(Request $request, Response $response, array $args)
{
// GET parameters
$params = $request->getQueryParams();
$user = $this->getUserFromParams($params);
// If the user doesn't exist, return 404
if (!$user) {
throw new NotFoundException();
}
/** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */
$authorizer = $this->ci->authorizer;
/** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */
$currentUser = $this->ci->currentUser;
// Access-controlled resource - check that currentUser has permission to edit "organisations" field for this user
if (!$authorizer->checkAccess($currentUser, 'update_user_field', [
'user' => $user,
'fields' => ['organisations'],
])) {
throw new ForbiddenException();
}
return $this->ci->view->render($response, 'modals/user-manage-organisations.html.twig', [
'user' => $user,
]);
}
/**
* Returns organisations associated with a single user.
*
* This page requires authentication.
* Request type: GET
*
* @param Request $request
* @param Response $response
* @param string[] $args
*
* @throws NotFoundException If user is not found
* @throws ForbiddenException If user is not authorized to access page
*/
public function getOrganisations(Request $request, Response $response, array $args)
{
$user = $this->getUserFromParams($args);
// If the user doesn't exist, return 404
if (!$user) {
throw new NotFoundException();
}
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper;
// GET parameters
$params = $request->getQueryParams();
/** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */
$authorizer = $this->ci->authorizer;
/** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */
$currentUser = $this->ci->currentUser;
// Access-controlled page
if (!$authorizer->checkAccess($currentUser, 'view_user_field', [
'user' => $user,
'property' => 'organisations',
])) {
throw new ForbiddenException();
}
$sprunje = $classMapper->createInstance('organisation_sprunje', $classMapper, $params);
$sprunje->extendQuery(function ($query) use ($user) {
return $query->forUser($user->id);
});
// Be careful how you consume this data - it has not been escaped and contains untrusted user-supplied content.
// For example, if you plan to insert it into an HTML DOM, you must escape it on the client side (or use client-side templating).
return $sprunje->toResponse($response);
}
/**
* {@inheritdoc}
*/
public function updateField(Request $request, Response $response, array $args)
{
// Get the username from the URL
$user = $this->getUserFromParams($args);
if (!$user) {
throw new NotFoundException();
}
// Get key->value pair from URL and request body
$fieldName = $args['field'];
/** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */
$authorizer = $this->ci->authorizer;
/** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */
$currentUser = $this->ci->currentUser;
// Access-controlled resource - check that currentUser has permission to edit the specified field for this user
if (!$authorizer->checkAccess($currentUser, 'update_user_field', [
'user' => $user,
'fields' => [$fieldName],
])) {
throw new ForbiddenException();
}
/** @var \UserFrosting\Support\Repository\Repository $config */
$config = $this->ci->config;
// Only the master account can edit the master account!
if (
($user->id == $config['reserved_user_ids.master']) &&
($currentUser->id != $config['reserved_user_ids.master'])
) {
throw new ForbiddenException();
}
// Get PUT parameters: value
$put = $request->getParsedBody();
// Make sure data is part of $_PUT data, default to empty value otherwise
if (isset($put[$fieldName])) {
$fieldData = $put[$fieldName];
} else {
if ($fieldName == 'roles' || $fieldName == 'organisations') {
$fieldData = [];
} else {
throw new BadRequestException();
}
}
// Create and validate key -> value pair
$params = [
$fieldName => $fieldData,
];
// Load the request schema
$schema = new RequestSchema('schema://requests/user/edit-field.yaml');
$schema->set('password.validators.length.min', $config['site.password.length.min']);
$schema->set('password.validators.length.max', $config['site.password.length.max']);
// Whitelist and set parameter defaults
$transformer = new RequestDataTransformer($schema);
$data = $transformer->transform($params);
// Validate, and throw exception on validation errors.
$validator = new ServerSideValidator($schema, $this->ci->translator);
if (!$validator->validate($data)) {
// TODO: encapsulate the communication of error messages from ServerSideValidator to the BadRequestException
$e = new BadRequestException();
foreach ($validator->errors() as $idx => $field) {
foreach ($field as $eidx => $error) {
$e->addUserMessage($error);
}
}
throw $e;
}
// Get validated and transformed value
$fieldValue = $data[$fieldName];
/** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */
$ms = $this->ci->alerts;
// Special checks and transformations for certain fields
if ($fieldName == 'flag_enabled') {
// Check that we are not disabling the master account
if (
($user->id == $config['reserved_user_ids.master']) &&
($fieldValue == '0')
) {
$e = new BadRequestException();
$e->addUserMessage('DISABLE_MASTER');
throw $e;
} elseif (
($user->id == $currentUser->id) &&
($fieldValue == '0')
) {
$e = new BadRequestException();
$e->addUserMessage('DISABLE_SELF');
throw $e;
}
} elseif ($fieldName == 'password') {
$fieldValue = Password::hash($fieldValue);
}
// Begin transaction - DB will be rolled back if an exception occurs
Capsule::transaction(function () use ($fieldName, $fieldValue, $user, $currentUser) {
if ($fieldName == 'roles') {
$newRoles = collect($fieldValue)->pluck('role_id')->all();
$user->roles()->sync($newRoles);
} else if ($fieldName == 'organisations') {
$newOrganisations = [];
foreach ($fieldValue as $field) {
$newOrganisations[$field['organisation_id']] = ['flag_admin' => $field['flag_admin'] == 1];
}
$user->organisations()->sync($newOrganisations);
} else {
$user->$fieldName = $fieldValue;
$user->save();
}
// Create activity record
$this->ci->userActivityLogger->info("User {$currentUser->user_name} updated property '$fieldName' for user {$user->user_name}.", [
'type' => 'account_update_field',
'user_id' => $currentUser->id,
]);
});
// Add success messages
if ($fieldName == 'flag_enabled') {
if ($fieldValue == '1') {
$ms->addMessageTranslated('success', 'ENABLE_SUCCESSFUL', [
'user_name' => $user->user_name,
]);
} else {
$ms->addMessageTranslated('success', 'DISABLE_SUCCESSFUL', [
'user_name' => $user->user_name,
]);
}
} elseif ($fieldName == 'flag_verified') {
$ms->addMessageTranslated('success', 'MANUALLY_ACTIVATED', [
'user_name' => $user->user_name,
]);
} else {
$ms->addMessageTranslated('success', 'DETAILS_UPDATED', [
'user_name' => $user->user_name,
]);
}
return $response->withJson([], 200);
}
}

View File

@@ -254,4 +254,20 @@ class Organisation extends Model implements OrganisationInterface
$join->on('admin_counts.organisation_id', '=', 'organisations.id');
});
}
/**
* Query scope to get all organisations a specific user is a member of.
*
* @param Builder $query
* @param int $userId
*
* @return Builder
*/
public function scopeForUser($query, $userId)
{
return $query->join('organisation_members', function ($join) use ($userId) {
$join->on('organisation_members.organisation_id', 'organisations.id')
->where('user_id', $userId);
});
}
}

View File

@@ -37,7 +37,7 @@ class User extends UFUser
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = static::$ci->classMapper;
return $this->belongsToMany($classMapper->getClassMapping('organisation'), 'organisation_members', 'user_id', 'organisation_id');
return $this->belongsToMany($classMapper->getClassMapping('organisation'), 'organisation_members', 'user_id', 'organisation_id')->withPivot('flag_admin');;
}
/**

View File

@@ -0,0 +1,80 @@
{% extends "modals/modal.html.twig" %}
{% block modal_title %}{{translate("ORGANISATION.MANAGE")}}{% endblock %}
{% block modal_body %}
<form class="js-form" method="PUT" action="{{site.uri.public}}/api/users/u/{{user.user_name}}/organisations">
{% include "forms/csrf.html.twig" %}
<div class="js-form-alerts">
</div>
<div class="js-form-organisations">
<table class="table table-striped">
<thead>
<tr>
<th>{{translate("NAME")}}</th>
<th>{{translate("DESCRIPTION")}}</th>
<th>{{translate("REMOVE")}}</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<div class="padding-bottom">
<label>{{translate("ORGANISATION.ASSIGN_NEW")}}:</label>
<select class="form-control js-select-new" type="text">
<option></option>
</select>
</div>
</div>
<br>
<div class="row">
<div class="col-xs-8 col-sm-4">
<button type="submit" class="btn btn-block btn-lg btn-success">{{translate("UPDATE")}}</button>
</div>
<div class="col-xs-4 col-sm-3 pull-right">
<button type="button" class="btn btn-block btn-lg btn-link" data-dismiss="modal">{{translate('CANCEL')}}</button>
</div>
</div>
</form>
{# This contains a series of <script> blocks, each of which is a client-side Handlebars template.
# Note that these are NOT Twig templates, although the syntax is similar. We wrap them in the `verbatim` tag,
# so that Twig will output them directly into the DOM instead of trying to treat them like Twig templates.
#
# These templates require handlebars-helpers.js, moment.js
#}
{% verbatim %}
<script id="user-organisations-select-option" type="text/x-handlebars-template">
<div>
<strong>
{{name}}
</strong>
<br>
{{description}}
</div>
</script>
<script id="user-organisations-row" type="text/x-handlebars-template">
<tr class="uf-collection-row">
<td>
{{name}}
<input type="hidden" name="organisations[{{ rownum }}][organisation_id]" value="{{id}}">
</td>
<td>
{{description}}
</td>
<td>
<input type="checkbox" name="organisations[{{ rownum }}][flag_admin]" {{#if flag_admin}}checked{{/if}}>
</td>
<td>
<button type="button" class="btn btn-link btn-trash js-delete-row pull-right" title="Delete"> <i class="fas fa-trash"></i> </button>
</td>
</tr>
</script>
{% endverbatim %}
<!-- Include validation rules -->
<script>
{% include "pages/partials/page.js.twig" %}
</script>
{% endblock %}

View File

@@ -0,0 +1,168 @@
{# This partial template renders a table of users, to be populated with rows via an AJAX request.
# This extends a generic template for paginated tables.
#
# Note that this template contains a "skeleton" table with an empty table body, and then a block of Handlebars templates which are used
# to render the table cells with the data from the AJAX request.
#}
{% extends "tables/table-paginated.html.twig" %}
{% block table %}
<table id="{{table.id}}" class="tablesorter table table-bordered table-hover table-striped" data-sortlist="{{table.sortlist}}">
<thead>
<tr>
<th class="sorter-metatext" data-column-name="name" data-column-template="#user-table-column-info" data-priority="1">{{translate('USER')}} <i class="fas fa-sort"></i></th>
<th class="filter-metatext" data-column-name="organisations" data-column-template="#user-table-column-organisations" data-priority="2">{{translate("ORGANISATION", 2)}} <i class="fas fa-sort"></i></th>
{% if 'last_activity' in table.columns %}
<th class="sorter-metanum" data-column-name="last_activity" data-column-template="#user-table-column-last-activity" data-priority="3">{{translate("ACTIVITY.LAST")}} <i class="fas fa-sort"></i></th>
{% endif %}
{% if 'via_roles' in table.columns %}
<th data-column-template="#user-table-column-via-roles" data-sorter="false" data-filter="false" data-priority="1">{{translate('PERMISSION.VIA_ROLES')}}</th>
{% endif %}
<th class="filter-select filter-metatext" data-column-name="status" data-column-template="#user-table-column-status" data-priority="2">{{translate("STATUS")}} <i class="fas fa-sort"></i></th>
<th data-column-name="actions" data-column-template="#user-table-column-actions" data-sorter="false" data-filter="false" data-priority="1">{{translate("ACTIONS")}}</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
{% endblock %}
{% block table_cell_templates %}
{# This contains a series of <script> blocks, each of which is a client-side Handlebars template.
# Note that these are NOT Twig templates, although the syntax is similar. We wrap them in the `verbatim` tag,
# so that Twig will output them directly into the DOM instead of trying to treat them like Twig templates.
#
# These templates require handlebars-helpers.js, moment.js
#}
{% verbatim %}
<script id="user-table-column-info" type="text/x-handlebars-template">
<td data-text="{{row.last_name}}">
<strong>
<a href="{{site.uri.public}}/users/u/{{row.user_name}}">{{row.first_name}} {{row.last_name}} ({{row.user_name}})</a>
</strong>
<div class="js-copy-container">
<span class="js-copy-target">{{row.email}}</span>
<button class="btn btn-xs uf-copy-trigger js-copy-trigger"><i class="fas fa-copy"></i></button>
</div>
</td>
</script>
<script id="user-table-column-organisations" type="text/x-handlebars-template">
{{#if row.organisations.length }}
<td style="line-height: 2em;">
{{#each row.organisations }}
<a href="{% endverbatim %}{{site.uri.public}}{% verbatim %}/organisations/o/{{this.slug}}" class="label label-primary {{#if this.pivot.flag_admin }}organisation-admin{{/if}}" title="{{this.description}}" data-text="{{this.name}}" style="font-size: 100%;">{{this.name}}</a><br>
{{/each}}
</td>
{{ else }}
<td></td>
{{/if }}
</script>
<script id="user-table-column-last-activity" type="text/x-handlebars-template">
{{#if row.last_activity }}
<td data-num="{{dateFormat row.last_activity.occurred_at format='x'}}">
{{dateFormat row.last_activity.occurred_at format="dddd"}}<br>{{dateFormat row.last_activity.occurred_at format="MMM Do, YYYY h:mm a"}}
<br>
<i>{{row.last_activity.description}}</i>
</td>
{{ else }}
<td data-num="0">
<i>{% endverbatim %}{{translate("UNKNOWN")}}{% verbatim %}</i>
</td>
{{/if }}
</script>
<script id="user-table-column-status" type="text/x-handlebars-template">
<td
{{#ifx row.flag_enabled '==' 0 }}
data-text="disabled"
{{ else }}
{{#ifx row.flag_verified '==' 0 }}
data-text="unactivated"
{{ else }}
data-text="active"
{{/ifx }}
{{/ifx }}
>
{{#ifx row.flag_enabled '==' 0 }}
<span class="text-muted">
{% endverbatim %}{{translate("DISABLED")}}{% verbatim %}
</span>
{{ else }}
{{#ifx row.flag_verified '==' 0 }}
<span class="text-yellow">
{% endverbatim %}{{translate("UNACTIVATED")}}{% verbatim %}
</span>
{{ else }}
<span>
{% endverbatim %}{{translate("ACTIVE")}}{% verbatim %}
</span>
{{/ifx }}
{{/ifx }}
</td>
</script>
<script id="user-table-column-actions" type="text/x-handlebars-template">
<td class="uf-table-fit-width">
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown">{% endverbatim %}{{translate("ACTIONS")}}{% verbatim %}<span class="caret"></span></button>
<ul class="dropdown-menu dropdown-menu-right-responsive" role="menu">
{{#ifx row.flag_verified '==' 0 }}
<li>
<a href="#" data-user_name="{{row.user_name}}" class="js-user-activate">
<i class="fas fa-bolt"></i> {% endverbatim %}{{translate("USER.ACTIVATE")}}{% verbatim %}
</a>
</li>
{{/ifx }}
<li>
<a href="#" data-user_name="{{row.user_name}}" class="js-user-edit">
<i class="fas fa-edit"></i> {% endverbatim %}{{translate("USER.EDIT")}}{% verbatim %}
</a>
</li>
<li>
<a href="#" data-user_name="{{row.user_name}}" class="js-user-roles">
<i class="fas fa-id-card"></i> {% endverbatim %}{{translate("ROLE.MANAGE")}}{% verbatim %}
</a>
</li>
<li>
<a href="#" data-user_name="{{row.user_name}}" class="js-user-organisations">
<i class="fas fa-sitemap"></i> {% endverbatim %}{{translate("ORGANISATION.MANAGE")}}{% verbatim %}
</a>
</li>
<li>
<a href="#" data-user_name="{{row.user_name}}" class="js-user-password">
<i class="fas fa-key"></i> {% endverbatim %}{{translate("USER.ADMIN.CHANGE_PASSWORD")}}{% verbatim %}
</a>
</li>
<li>
{{#ifx row.flag_enabled '==' 1 }}
<a href="#" data-user_name="{{row.user_name}}" class="js-user-disable">
<i class="fas fa-minus-circle"></i> {% endverbatim %}{{translate("USER.DISABLE")}}{% verbatim %}
</a>
{{ else }}
<a href="#" data-user_name="{{row.user_name}}" class="js-user-enable">
<i class="fas fa-plus-circle"></i> {% endverbatim %}{{translate("USER.ENABLE")}}{% verbatim %}
</a>
{{/ifx }}
</li>
<li>
<a href="#" data-user_name="{{row.user_name}}" class="js-user-delete">
<i class="fas fa-trash-alt"></i> {% endverbatim %}{{translate("USER.DELETE")}}{% verbatim %}
</a>
</li>
</ul>
</div>
</td>
</script>
<script id="user-table-column-via-roles" type="text/x-handlebars-template">
<td>
{{#each row.roles_via }}
<a href="{% endverbatim %}{# Handlebars can't access variables in the global scope, so we have to use Twig to insert the base url #}{{site.uri.public}}{% verbatim %}/roles/r/{{this.slug}}" class="label label-primary" title="{{this.description}}">{{this.name}}</a>
{{/each}}
</td>
</script>
{% endverbatim %}
{% endblock %}