diff --git a/asset-bundles.json b/asset-bundles.json index cb1b91c..fa94f8b 100644 --- a/asset-bundles.json +++ b/asset-bundles.json @@ -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" + ] } } } diff --git a/assets/avsdev/css/organisations.css b/assets/avsdev/css/organisations.css new file mode 100644 index 0000000..6ae8f87 --- /dev/null +++ b/assets/avsdev/css/organisations.css @@ -0,0 +1,3 @@ +.organisation-admin { + color: #ffc107 !important; +} \ No newline at end of file diff --git a/assets/avsdev/js/pages/organisation.js b/assets/avsdev/js/pages/organisation.js index d04ccfa..02b3693 100644 --- a/assets/avsdev/js/pages/organisation.js +++ b/assets/avsdev/js/pages/organisation.js @@ -20,5 +20,6 @@ $(document).ready(function() { // Bind user table buttons $("#widget-organisation-members").on("pagerComplete.ufTable", function () { bindUserButtons($(this)); + bindUserButtonsExtra($(this)); }); }); diff --git a/assets/avsdev/js/pages/user.js b/assets/avsdev/js/pages/user.js new file mode 100644 index 0000000..22603f0 --- /dev/null +++ b/assets/avsdev/js/pages/user.js @@ -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 }); +}); diff --git a/assets/avsdev/js/pages/users.js b/assets/avsdev/js/pages/users.js new file mode 100644 index 0000000..b88032c --- /dev/null +++ b/assets/avsdev/js/pages/users.js @@ -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)); + }); +}); diff --git a/assets/avsdev/js/widgets/users.js b/assets/avsdev/js/widgets/users.js new file mode 100644 index 0000000..444081f --- /dev/null +++ b/assets/avsdev/js/widgets/users.js @@ -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(); + }); + }); + }); +} diff --git a/locale/en_US/messages.php b/locale/en_US/messages.php index cb8426c..b9b1300 100644 --- a/locale/en_US/messages.php +++ b/locale/en_US/messages.php @@ -40,6 +40,8 @@ return [ 'ADMIN_COUNT' => '# Admins', 'SELF' => 'My Organisations', + 'MANAGE' => 'Manage Organisations', + 'ASSIGN_NEW' => 'Assign to organisation', 'NAME' => [ 1 => 'Organisation name', diff --git a/routes/users.php b/routes/users.php new file mode 100644 index 0000000..2c5bef4 --- /dev/null +++ b/routes/users.php @@ -0,0 +1,23 @@ +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()); diff --git a/schema/requests/user/edit-field.yaml b/schema/requests/user/edit-field.yaml new file mode 100644 index 0000000..0357ca2 --- /dev/null +++ b/schema/requests/user/edit-field.yaml @@ -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 diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php new file mode 100644 index 0000000..b05ee9c --- /dev/null +++ b/src/Controller/UserController.php @@ -0,0 +1,294 @@ +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); + } +} diff --git a/src/Database/Models/Organisation.php b/src/Database/Models/Organisation.php index 14bb5a8..9737969 100644 --- a/src/Database/Models/Organisation.php +++ b/src/Database/Models/Organisation.php @@ -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); + }); + } } diff --git a/src/Database/Models/User.php b/src/Database/Models/User.php index d3ad146..9f6a7a0 100644 --- a/src/Database/Models/User.php +++ b/src/Database/Models/User.php @@ -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');; } /** diff --git a/templates/modals/user-manage-organisations.html.twig b/templates/modals/user-manage-organisations.html.twig new file mode 100644 index 0000000..284e623 --- /dev/null +++ b/templates/modals/user-manage-organisations.html.twig @@ -0,0 +1,80 @@ +{% extends "modals/modal.html.twig" %} + +{% block modal_title %}{{translate("ORGANISATION.MANAGE")}}{% endblock %} + +{% block modal_body %} +
+ +{# This contains a series of + + +{% endverbatim %} + + + +{% endblock %} diff --git a/templates/tables/users.html.twig b/templates/tables/users.html.twig new file mode 100644 index 0000000..8cd7d5b --- /dev/null +++ b/templates/tables/users.html.twig @@ -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 %} +| {{translate('USER')}} | +{{translate("ORGANISATION", 2)}} | + {% if 'last_activity' in table.columns %} +{{translate("ACTIVITY.LAST")}} | + {% endif %} + {% if 'via_roles' in table.columns %} +{{translate('PERMISSION.VIA_ROLES')}} | + {% endif %} +{{translate("STATUS")}} | +{{translate("ACTIONS")}} | +
|---|