Added functionality to join organisations and the optional approval process

This commit is contained in:
2022-02-15 16:53:23 +00:00
parent 130c5ec9bb
commit 3735e1e9ce
8 changed files with 868 additions and 0 deletions

View File

@@ -216,6 +216,73 @@ function bindOrganisationButtons(el, options) {
}); });
}); });
// Join organisation button
el.find('.js-organisation-join').click(function(e) {
e.preventDefault();
var data = {};
data[site.csrf.keys.name] = site.csrf.name;
data[site.csrf.keys.value] = site.csrf.value;
var url = site.uri.public + '/api/organisations/o/' + $(this).data('slug') + '/members';
var debugAjax = (typeof site !== "undefined") && site.debug.ajax;
return $.ajax({
type: "POST",
url: url,
data: data,
dataType: debugAjax ? 'html' : 'json',
}).fail(function(jqXHR) {
// Error messages
if (debugAjax && jqXHR.responseText) {
document.write(jqXHR.responseText);
document.close();
} else {
console.log("Error (" + jqXHR.status + "): " + jqXHR.responseText);
// Display errors on failure
// TODO: ufAlerts widget should have a 'destroy' method
if (!$("#alerts-page").data('ufAlerts')) {
$("#alerts-page").ufAlerts();
} else {
$("#alerts-page").ufAlerts('clear');
}
$("#alerts-page").ufAlerts('fetch').ufAlerts('render');
}
return jqXHR;
}).done(function(response) {
window.location.reload();
});
});
// Cancel a registration request
el.find('.js-organisation-cancelJoin').click(function(e) {
e.preventDefault();
$("body").ufModal({
sourceUrl: site.uri.public + "/modals/organisations/o/" + $(this).data('slug') + "/members/confirm-cancel",
ajaxParams: {
slug: $(this).data('slug')
},
msgTarget: $("#alerts-page")
});
$("body").on('renderSuccess.ufModal', function() {
var modal = $(this).ufModal('getModal');
var form = modal.find('.js-form');
form.ufForm()
.on("submitSuccess.ufForm", function() {
// Navigate or reload page on success
if (options.delete_redirect) window.location.href = options.delete_redirect;
else window.location.reload();
});
});
});
// Leave organisation button // Leave organisation button
el.find('.js-organisation-leave').click(function(e) { el.find('.js-organisation-leave').click(function(e) {
e.preventDefault(); e.preventDefault();

View File

@@ -24,7 +24,9 @@ return [
'timeout' => -1, 'timeout' => -1,
], ],
'membership' => [ 'membership' => [
'require_approval' => true,
'single_membership' => false, 'single_membership' => false,
'timeout' => -1,
], ],
], ],
]; ];

View File

@@ -28,6 +28,8 @@ return [
'EDIT' => 'Edit organistion', 'EDIT' => 'Edit organistion',
'UPDATE' => 'Details updated for organistion <strong>{{name}}</strong>', 'UPDATE' => 'Details updated for organistion <strong>{{name}}</strong>',
'JOIN_SUCCESSFUL' => 'Successfully joined organisation <strong>{{name}}</strong>',
'LEAVE' => 'Leave organisation', 'LEAVE' => 'Leave organisation',
'LEAVE_CONFIRM' => 'Are you sure you want to leave the organisation <strong>{{name}}</strong>?', 'LEAVE_CONFIRM' => 'Are you sure you want to leave the organisation <strong>{{name}}</strong>?',
'LEAVE_YES' => 'Yes, leave organisation.', 'LEAVE_YES' => 'Yes, leave organisation.',
@@ -56,6 +58,8 @@ return [
'PERMENENT_DELETE_YES' => 'Yes, permenently delete organisation', 'PERMENENT_DELETE_YES' => 'Yes, permenently delete organisation',
'PERMENENT_DELETION_SUCCESSFUL' => 'Successfully permenently deleted organisation <strong>{{name}}</strong>', 'PERMENENT_DELETION_SUCCESSFUL' => 'Successfully permenently deleted organisation <strong>{{name}}</strong>',
'NOT_A_MEMBER' => 'You are not a member of organisation <strong>{{name}}</strong>.',
'NAME' => [ 'NAME' => [
1 => 'Organisation name', 1 => 'Organisation name',
@@ -82,6 +86,22 @@ return [
'APPROVE' => 'Approve organisation registration', 'APPROVE' => 'Approve organisation registration',
'DENY' => 'Deny organisation registration', 'DENY' => 'Deny organisation registration',
], ],
'JOIN_REQUEST' => [
'SUBMIT_SUCCESSFUL' => 'Request to join organisation <strong>{{name}}</strong> sent',
'CANCEL' => 'Cancel request to join organisation',
'CANCEL_CONFIRM' => 'Are you sure you want to cancel your request to join the organisation <strong>{{name}}</strong>?',
'CANCEL_YES' => 'Yes, cancel request',
'CANCEL_SUCCESSFUL' => 'Successfully cancelled request to join organisation <strong>{{name}}</strong>',
'ALREADY_MEMBER' => 'You are already a member of the organisation <strong>{{name}}</strong>.',
'REQUEST_PENDING' => 'You have already requested to join organisation <strong>{{name}}</strong>. Your request is awaiting approval.',
'NO_REQUEST' => 'You have no pending requests to join organisation <strong>{{name}}</strong>.',
'TOKEN_NOT_FOUND' => 'User join request token does not exist / user join request has already been accepted/rejected.',
'ACCEPTED' => 'You have successfully accepted the request from user <strong>{{user_name}}</strong> to join organisation <strong>{{organisation_name}}</strong>.',
'REJECTED' => 'You have successfully rejected the request from user <strong>{{user_name}}</strong> to join organisation <strong>{{organisation_name}}</strong>.',
],
], ],
'MEMBER' => [ 'MEMBER' => [

View File

@@ -12,13 +12,26 @@ use UserFrosting\Sprinkle\Core\Util\NoCache;
/* /*
* Routes for administrative organisation member management. * Routes for administrative organisation member management.
*/ */
$app->group('/organisations/members', function () {
$this->get('/accept', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:acceptToken');
$this->get('/reject', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:rejectToken');
})->add('authGuard')->add(new NoCache());
$app->group('/api/organisations/o/{slug}/members', function () { $app->group('/api/organisations/o/{slug}/members', function () {
$this->get('', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:getList'); $this->get('', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:getList');
$this->post('', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:join');
$this->delete('', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:leave'); $this->delete('', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:leave');
$this->delete('/cancel', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:cancel');
$this->put('/m/{user_name}/accept', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:accept');
$this->put('/m/{user_name}/reject', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:reject');
})->add('authGuard')->add(new NoCache()); })->add('authGuard')->add(new NoCache());
$app->group('/modals/organisations/o/{slug}/members', function () { $app->group('/modals/organisations/o/{slug}/members', function () {
$this->get('/confirm-leave', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:getModalConfirmLeave'); $this->get('/confirm-leave', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:getModalConfirmLeave');
$this->get('/confirm-cancel', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationMembersController:getModalConfirmCancel');
})->add('authGuard')->add(new NoCache()); })->add('authGuard')->add(new NoCache());

View File

@@ -0,0 +1,6 @@
---
token:
validators:
required:
label: validation token
message: VALIDATION.REQUIRED

View File

@@ -17,10 +17,16 @@ use UserFrosting\Fortress\RequestDataTransformer;
use UserFrosting\Fortress\RequestSchema; use UserFrosting\Fortress\RequestSchema;
use UserFrosting\Fortress\ServerSideValidator; use UserFrosting\Fortress\ServerSideValidator;
use UserFrosting\Sprinkle\Organisations\Database\Models\Organisation; use UserFrosting\Sprinkle\Organisations\Database\Models\Organisation;
use UserFrosting\Sprinkle\Organisations\Database\Models\OrganisationMember;
use UserFrosting\Sprinkle\Core\Controller\SimpleController; use UserFrosting\Sprinkle\Core\Controller\SimpleController;
use UserFrosting\Support\Exception\BadRequestException; use UserFrosting\Support\Exception\BadRequestException;
use UserFrosting\Support\Exception\ForbiddenException; use UserFrosting\Support\Exception\ForbiddenException;
use UserFrosting\Support\Exception\NotFoundException; use UserFrosting\Support\Exception\NotFoundException;
use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface;
use UserFrosting\Sprinkle\Organisations\Database\Models\User;
use UserFrosting\Sprinkle\Organisations\Database\Models\Interfaces\OrganisationInterface;
use UserFrosting\Sprinkle\Core\Mail\TwigMailMessage;
use UserFrosting\Sprinkle\Core\Mail\EmailRecipient;
/** /**
* Controller class for organisation member-related requests, including listing members, CRUD for members, etc. * Controller class for organisation member-related requests, including listing members, CRUD for members, etc.
@@ -29,6 +35,199 @@ use UserFrosting\Support\Exception\NotFoundException;
*/ */
class OrganisationMembersController extends SimpleController class OrganisationMembersController extends SimpleController
{ {
/**
* Processes the request to join an organisation:
* 1. The user is not already a member of the organisation or pending;
* 2. The user has permission to join organisations;
* 3. The submitted data is valid.
* This route requires authentication.
*
* Request type: POST
*
* @param Request $request
* @param Response $response
* @param array $args
*
* @throws ForbiddenException If user is not authorized to access page
*/
public function join(Request $request, Response $response, $args)
{
$organisation = $this->getOrganisationFromParams($args);
// If the organisation doesn't exist, return 404
if (!$organisation) {
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 page
if (!$authorizer->checkAccess($currentUser, 'join_organisation')) {
throw new ForbiddenException();
}
/** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */
$ms = $this->ci->alerts;
// Check if the user is a member of the organisation, pending or no relation at all
$memberCheck = $organisation->members()->where('user_id', $currentUser->id)->withPivot('flag_approved')->first();
if ($memberCheck) {
if ($memberCheck->pivot->flag_approved) {
$ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.ALREADY_MEMBER', [
'name' => $organisation->name
]);
} else {
$ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.REQUEST_PENDING', [
'name' => $organisation->name
]);
}
return $response->withJson([], 400);
}
/** @var \UserFrosting\Support\Repository\Repository $config */
$config = $this->ci->config;
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper;
// All checks passed! log events/activities and create organisation
// Begin transaction - DB will be rolled back if an exception occurs
Capsule::transaction(function () use ($classMapper, $ms, $organisation, $currentUser, $config) {
$organisation->members()->attach($currentUser->id, [
'flag_admin' => false,
'flag_approved' => !$config['organisation']['membership']['require_approval'],
]);
$organisation->save();
// Create activity record
if ($config['organisation']['membership']['require_approval']) {
$this->ci->userActivityLogger->info("User {$currentUser->user_name} requested to join organisation {$organisation->name}.", [
'type' => 'organisation_join',
'user_id' => $currentUser->id,
]);
$timeout = $this->ci->config['organisation.membership.timeout'];
// Find the mapping
$tokenOwner = $classMapper->getClassMapping('organisation_member')::query()
->where('organisation_id', $organisation->id)
->where('user_id', $currentUser->id)
->first();
// Try to generate a new approval request
$approval = $this->ci->repoOrganisationMembershipApproval->create($tokenOwner, $timeout);
$this->sendApprovalEmail($currentUser, $organisation, $approval->getToken());
$ms->addMessageTranslated('success', 'ORGANISATION.JOIN_REQUEST.SUBMIT_SUCCESSFUL', [
'name' => $organisation->name
]);
} else {
$this->ci->userActivityLogger->info("User {$currentUser->user_name} has joined organisation {$organisation->name}.", [
'type' => 'organisation_join',
'user_id' => $currentUser->id,
]);
$ms->addMessageTranslated('success', 'ORGANISATION.JOIN_SUCCESSFUL', [
'name' => $organisation->name
]);
}
});
return $response->withJson([], 200);
}
/**
* Processes the request to cancel a join organisation request.
*
* Before doing so, checks that:
* 1. The submitted data is valid.
* 2. The user has a pending join request/is not already a member.
* This route requires authentication.
*
* Request type: DELETE
*
* @param Request $request
* @param Response $response
* @param array $args
*
* @throws NotFoundException If organisation is not found
* @throws ForbiddenException If user is not authorized to access page
* @throws BadRequestException
*/
public function cancel(Request $request, Response $response, $args)
{
$organisation = $this->getOrganisationFromParams($args);
// If the organisation doesn't exist, return 404
if (!$organisation) {
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;
/** @var \UserFrosting\Support\Repository\Repository $config */
$config = $this->ci->config;
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper;
/** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */
$ms = $this->ci->alerts;
// Check if the user is a member of the organisation, pending or no relation at all
$memberCheck = $organisation->members()->where('user_id', $currentUser->id)->withPivot('flag_approved')->first();
if ($memberCheck) {
if ($memberCheck->pivot->flag_approved) {
$ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.ALREADY_MEMBER', [
'name' => $organisation->name
]);
return $response->withJson([], 400);
}
} else {
$ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.NO_REQUEST', [
'name' => $organisation->name
]);
return $response->withJson([], 400);
}
// Begin transaction - DB will be rolled back if an exception occurs
Capsule::transaction(function () use ($organisation, $currentUser) {
$organisation->members()->detach($currentUser->id);
if ($config['organisation']['membership']['require_approval']) {
// Find the mapping
$tokenOwner = $classMapper->getClassMapping('organisation_member')::query()
->where('organisation_id', $organisation->id)
->where('user_id', $currentUser->id)
->first();
$approval = $this->ci->repoOrganisationMembershipApproval->removeExisting($tokenOwner);
}
// Create activity record
$this->ci->userActivityLogger->info("User {$currentUser->user_name} cancelled the request to join the organisation {$organisation->name}.", [
'type' => 'organisation_join',
'user_id' => $currentUser->id,
]);
});
$ms->addMessageTranslated('success', 'ORGANISATION.JOIN_REQUEST.CANCEL_SUCCESSFUL', [
'name' => $organisation->name,
]);
return $response->withJson([], 200);
}
/** /**
* Processes the request to leave an organisation. * Processes the request to leave an organisation.
@@ -77,6 +276,15 @@ class OrganisationMembersController extends SimpleController
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper; $classMapper = $this->ci->classMapper;
// Check if the user is a member of the organisation, pending or no relation at all
$memberCheck = $organisation->members()->where('user_id', $currentUser->id)->withPivot('flag_approved')->first();
if (!$memberCheck || !$memberCheck->pivot->flag_approved) {
$ms->addMessageTranslated('danger', 'ORGANISATION.NOT_A_MEMBER', [
'name' => $organisation->name
]);
return $response->withJson([], 400);
}
// Begin transaction - DB will be rolled back if an exception occurs // Begin transaction - DB will be rolled back if an exception occurs
Capsule::transaction(function () use ($organisation, $currentUser) { Capsule::transaction(function () use ($organisation, $currentUser) {
$currentUser->organisations()->detach($organisation->id); $currentUser->organisations()->detach($organisation->id);
@@ -98,6 +306,270 @@ class OrganisationMembersController extends SimpleController
return $response->withJson([], 200); return $response->withJson([], 200);
} }
/**
* Accepts a request to join organisation.
*
* Processes the request from the organisation details page, checking that:
* 1. The organisation exists;
* 2. The user exists;
* 3. The user is not already a member;
* 4. The currentUser has permission to accept;
* This route requires authorization.
*
* AuthGuard: true
* Route: /organisations/o/{slug}/members/accept
* Route Name: {none}
* Request type: GET
*
* @param Request $request
* @param Response $response
* @param array $args
*/
public function accept(Request $request, Response $response, $args)
{
// Fetch the organisation from the params
$organisation = $this->getOrganisationFromParams($args);
// Fetch the user from the params
$user = $this->getUserFromParams($args);
// If the organisation/user doesn't exist, return 404
if (!$organisation || !$user) {
throw new NotFoundException();
}
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper;
// Find the mapping
$tokenOwner = $classMapper->getClassMapping('organisation_member')::query()
->where('organisation_id', $organisation->id)
->where('user_id', $user->id)
->first();
// Process the acceptance emails etc
if (!$this->processAcceptToken($tokenOwner)) {
return $response->withJson([], 400);
}
return $response->withJson([], 200);
}
/**
* Accepts a request to join organisation by using an emailed token.
*
* Processes the request from the organisation details page, checking that:
* 1. The token is valid;
* 2. There is a currentUser logged in that has permission to accept;
* 3. The organisation exists;
* 4. The user exists;
* 3. The user is not already a member;
* This route requires authorization.
*
* AuthGuard: true
* Route: /organisations/members/accept
* Route Name: {none}
* Request type: GET
*
* @param Request $request
* @param Response $response
* @param array $args
*/
public function acceptToken(Request $request, Response $response, $args)
{
/** @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, 'approve_organisation_membership', [
'organisation' => $organisation
])) {
throw new ForbiddenException();
}
/** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */
$ms = $this->ci->alerts;
// GET parameters
$params = $request->getQueryParams();
// Load request schema
$schema = new RequestSchema('schema://requests/organisation/membership-verify.yaml');
// Whitelist and set parameter defaults
$transformer = new RequestDataTransformer($schema);
$data = $transformer->transform($params);
// Validate, and halt on validation errors. This is a GET request, so we redirect on validation error.
$validator = new ServerSideValidator($schema, $this->ci->translator);
if (!$validator->validate($data)) {
$ms->addValidationErrors($validator);
return $response->withRedirect($this->ci->router->pathFor('dashboard'));
}
// Find the token owner if valid
$owner_id = $this->ci->repoOrganisationMembershipApproval->findOwner($data['token']);
if (!$owner_id) {
$ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.TOKEN_NOT_FOUND');
return $response->withRedirect($this->ci->router->pathFor('dashboard'));
}
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper;
// Fetch the mapping
$tokenOwner = $classMapper->getClassMapping('organisation_member')::query()
->where('map_id', $owner_id)
->first();
// Process the acceptance emails etc
if (!$this->processAcceptToken($tokenOwner)) {
return $response->withRedirect($this->ci->router->pathFor('dashboard'));
}
// Forward to organisation page
return $response->withRedirect($this->ci->router->pathFor('dashboard'));
}
/**
* Rejects a request to join organisation.
*
* Processes the request from the organisation details page, checking that:
* 1. The organisation exists;
* 2. The user exists;
* 3. The user is not already a member;
* 4. The currentUser has permission to reject;
* This route requires authorization.
*
* AuthGuard: true
* Route: /organisations/o/{slug}/members/reject
* Route Name: {none}
* Request type: GET
*
* @param Request $request
* @param Response $response
* @param array $args
*/
public function reject(Request $request, Response $response, $args)
{
// Fetch the organisation from the params
$organisation = $this->getOrganisationFromParams($args);
// Fetch the user from the params
$user = $this->getUserFromParams($args);
// If the organisation/user doesn't exist, return 404
if (!$organisation || !$user) {
throw new NotFoundException();
}
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper;
// Find the mapping
$tokenOwner = $classMapper->getClassMapping('organisation_member')::query()
->where('organisation_id', $organisation->id)
->where('user_id', $user->id)
->first();
// Process the acceptance emails etc
if (!$this->processRejectToken($tokenOwner)) {
return $response->withJson([], 400);
}
return $response->withJson([], 200);
}
/**
* Rejects a request to join organisation by using an emailed token.
*
* Processes the request from the organisation details page, checking that:
* 1. The token is valid;
* 2. There is a currentUser logged in that has permission to reject;
* 3. The organisation exists;
* 4. The user exists;
* 3. The user is not already a member;
* This route requires authorization.
*
* AuthGuard: true
* Route: /organisations/members/reject
* Route Name: {none}
* Request type: GET
*
* @param Request $request
* @param Response $response
* @param array $args
*/
public function rejectToken(Request $request, Response $response, $args)
{
/** @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, 'approve_organisation_membership', [
'organisation' => $organisation
])) {
throw new ForbiddenException();
}
/** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */
$ms = $this->ci->alerts;
// GET parameters
$params = $request->getQueryParams();
// Load request schema
$schema = new RequestSchema('schema://requests/organisation/membership-verify.yaml');
// Whitelist and set parameter defaults
$transformer = new RequestDataTransformer($schema);
$data = $transformer->transform($params);
// Validate, and halt on validation errors. This is a GET request, so we redirect on validation error.
$validator = new ServerSideValidator($schema, $this->ci->translator);
if (!$validator->validate($data)) {
$ms->addValidationErrors($validator);
return $response->withRedirect($this->ci->router->pathFor('dashboard'));
}
// Find the token owner if valid
$owner_id = $this->ci->repoOrganisationMembershipApproval->findOwner($data['token']);
if (!$owner_id) {
$ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.TOKEN_NOT_FOUND');
return $response->withRedirect($this->ci->router->pathFor('dashboard'));
}
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper;
// Fetch the mapping
$tokenOwner = $classMapper->getClassMapping('organisation_member')::query()
->where('map_id', $owner_id)
->first();
// Process the rejectance emails etc
if (!$this->processRejectToken($tokenOwner)) {
return $response->withRedirect($this->ci->router->pathFor('dashboard'));
}
// Forward to organisation page
return $response->withRedirect($this->ci->router->pathFor('dashboard'));
}
/** /**
* Returns a list of organisation members. * Returns a list of organisation members.
* *
@@ -195,6 +667,228 @@ class OrganisationMembersController extends SimpleController
]); ]);
} }
/**
* Get cancel join request confirmation modal.
*
* @param Request $request
* @param Response $response
* @param array $args
*
* @throws NotFoundException If organisation is not found
* @throws ForbiddenException If user is not authorized to access page
* @throws BadRequestException
*/
public function getModalConfirmCancel(Request $request, Response $response, $args)
{
// GET parameters
$params = $request->getQueryParams();
$organisation = $this->getOrganisationFromParams($params);
// If the organisation no longer exists, forward to main organisation listing page
if (!$organisation) {
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 page
if (!$authorizer->checkAccess($currentUser, 'join_organisation', [
'organisation' => $organisation,
])) {
throw new ForbiddenException();
}
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper;
return $this->ci->view->render($response, 'modals/confirm-cancel-organisation-join.html.twig', [
'organisation' => $organisation,
'form' => [
'action' => "api/organisations/o/{$organisation->slug}/members/cancel",
],
]);
}
/**
* Send approval email for specified organisation and confirmation to user.
*
* @param UserInterface $requester The user to send the confirmation of registration to
* @param UserInterface $organisation The organisation to send the email for
*/
protected function sendApprovalEmail(UserInterface $requester, OrganisationInterface $organisation, $token)
{
$timeout = $this->ci->config['organisation.membership.timeout'];
// Create and send approval email
$message = new TwigMailMessage($this->ci->view, 'mail/organisation-membership-request.html.twig');
$message->from($this->ci->config['address_book.admin'])
->addParams([
'requester' => $requester,
'organisation' => $organisation,
'token' => $token,
'approval_expiration' => ($timeout > 0 ? floor($timeout / 86400) . ' days' : false),
]);
$recipientsQuery = $organisation->administrators();
if ($recipientsQuery->count() == 0) {
$role = $this->ci->classMapper->getClassMapping('role')::where('slug', 'organisations-admin')->with('users')->first();
if ($role->users()->count() == 0) {
$role = $this->ci->classMapper->getClassMapping('role')::where('slug', 'site-admin')->with('users')->first();
}
$recipientsQuery = $role->users();
}
$recipients = $recipientsQuery->get();
foreach($recipients as $recipient) {
$message->addEmailRecipient(new EmailRecipient($recipient->email, $recipient->full_name));
$message->addParams([ 'recipient' => $recipient ]);
$this->ci->mailer->send($message);
$message->addParams([ 'recipient' => null ]);
$message->clearRecipients();
}
}
/**
* Send approved email for specified organisation.
*
* @param UserInterface $requester The user to send the approved notice to
* @param UserInterface $organisation The organisation to send the email for
*/
protected function sendAcceptedEmail(UserInterface $requester, OrganisationInterface $organisation)
{
$message = new TwigMailMessage($this->ci->view, 'mail/organisation-membership-accepted.html.twig');
$message->from($this->ci->config['address_book.admin'])
->addParams([
'recipient' => $requester,
'organisation' => $organisation
]);
$message->addEmailRecipient(new EmailRecipient($requester->email, $requester->full_name));
$this->ci->mailer->send($message);
}
/**
* Send rejected email for specified organisation.
*
* @param UserInterface $requester The user to send the rejection notice to
* @param UserInterface $organisation The organisation to send the email for
*/
protected function sendRejectedEmail(UserInterface $requester, OrganisationInterface $organisation)
{
$message = new TwigMailMessage($this->ci->view, 'mail/organisation-membership-rejected.html.twig');
$message->from($this->ci->config['address_book.admin'])
->addParams([
'recipient' => $requester,
'organisation' => $organisation
]);
$message->addEmailRecipient(new EmailRecipient($requester->email, $requester->full_name));
$this->ci->mailer->send($message);
}
protected function processAcceptToken($tokenOwner)
{
/** @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, 'accept_organisation_membership')) {
throw new ForbiddenException();
}
/** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */
$ms = $this->ci->alerts;
// Try and complete the token, bail if not found
$verification = $this->ci->repoOrganisationMembershipApproval->completeForOwner($tokenOwner, ['approved' => true, 'approver_id' => $currentUser->id]);
if (!$verification) {
$ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.TOKEN_NOT_FOUND');
return false;
}
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper;
$organisation = $tokenOwner->organisation()->first();
$requester = $tokenOwner->user()->first();
$this->sendAcceptedEmail($requester, $organisation);
$this->ci->userActivityLogger->info("User {$currentUser->user_name} approved the request for user {$requester->user_name} to join organisation {$organisation->name}.", [
'type' => 'organisation_member_approved',
'user_id' => $currentUser->id,
]);
$ms->addMessageTranslated('success', 'ORGANISATION.JOIN_REQUEST.ACCEPTED', [
'user_name' => $requester->user_name,
'organisation_name' => $organisation->name
]);
return true;
}
protected function processRejectToken($tokenOwner)
{
/** @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, 'accept_organisation_membership')) {
throw new ForbiddenException();
}
/** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */
$ms = $this->ci->alerts;
// Try and complete the token, bail if not found
$verification = $this->ci->repoOrganisationMembershipApproval->completeForOwner($tokenOwner, ['approved' => false, 'approver_id' => $currentUser->id]);
if (!$verification) {
$ms->addMessageTranslated('danger', 'ORGANISATION.JOIN_REQUEST.TOKEN_NOT_FOUND');
return false;
}
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper;
$organisation = $tokenOwner->organisation()->first();
$requester = $tokenOwner->user()->first();
$this->sendRejectedEmail($requester, $organisation);
$this->ci->userActivityLogger->info("User {$currentUser->user_name} rejected the request for user {$requester->user_name} to join organisation {$organisation->name}.", [
'type' => 'organisation_member_approved',
'user_id' => $currentUser->id,
]);
$ms->addMessageTranslated('success', 'ORGANISATION.JOIN_REQUEST.REJECTED', [
'user_name' => $requester->user_name,
'organisation_name' => $organisation->name
]);
return true;
}
/** /**
* Get organisation from params. * Get organisation from params.
@@ -237,4 +931,46 @@ class OrganisationMembersController extends SimpleController
return $organisation; return $organisation;
} }
/**
* Get User instance from params.
*
* @param string[] $params
*
* @throws BadRequestException
*
* @return User|null
*/
protected function getUserFromParams(array $params): ?User
{
// Load the request schema
$schema = new RequestSchema('schema://requests/user/get-by-username.yaml');
// 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;
}
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper;
// Get the user to delete
$user = $classMapper->getClassMapping('user')::where('user_name', $data['user_name'])
->first();
return $user;
}
} }

View File

@@ -111,6 +111,12 @@ class OrganisationPermissions extends BaseSeed
'conditions' => 'always()', 'conditions' => 'always()',
'description' => 'Allows members to leave organisations.', 'description' => 'Allows members to leave organisations.',
]), ]),
'join_organisation' => new Permission([
'slug' => 'join_organisation',
'name' => 'Join organisation',
'conditions' => 'always()',
'description' => 'Allows members to join organisations.',
]),
'delete_organisation' => new Permission([ 'delete_organisation' => new Permission([
'slug' => 'delete_organisation', 'slug' => 'delete_organisation',
'name' => 'Delete organisation', 'name' => 'Delete organisation',
@@ -227,6 +233,7 @@ class OrganisationPermissions extends BaseSeed
$permissions['uri_organisation_own']->id, $permissions['uri_organisation_own']->id,
$permissions['view_organisation_field_own']->id, $permissions['view_organisation_field_own']->id,
$permissions['update_organisation_field_own']->id, $permissions['update_organisation_field_own']->id,
$permissions['join_organisation']->id,
$permissions['leave_organisation']->id, $permissions['leave_organisation']->id,
$permissions['register_organisation']->id, $permissions['register_organisation']->id,
]); ]);

View File

@@ -0,0 +1,17 @@
{% extends "modals/modal.html.twig" %}
{% block modal_title %}{{translate("ORGANISATION.JOIN_REQUEST.CANCEL")}}{% endblock %}
{% block modal_body %}
<form class="js-form" method="delete" action="{{site.uri.public}}/{{form.action}}">
{% include "forms/csrf.html.twig" %}
<div class="js-form-alerts">
</div>
<h4>{{translate("ORGANISATION.JOIN_REQUEST.CANCEL_CONFIRM", {name: organisation.name})}}{% if delete_hard %}<br><small>{{translate("ACTION_CANNOT_UNDONE")}}</small>{% endif %}</h4>
<br>
<div class="btn-group-action">
<button type="submit" class="btn btn-danger btn-lg btn-block">{{translate("ORGANISATION.JOIN_REQUEST.CANCEL_YES")}}</button>
<button type="button" class="btn btn-default btn-lg btn-block" data-dismiss="modal">{{translate("CANCEL")}}</button>
</div>
</form>
{% endblock %}