mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-05-07 20:32:26 +00:00
Moved invitation from jetstream to API; Deactived moved jetstream features
This commit is contained in:
committed by
Constantin Graf
parent
555417dbbd
commit
fd8d596e9b
@@ -7,7 +7,6 @@ namespace App\Actions\Jetstream;
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\UserService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -43,10 +42,6 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
$organization->users()->attach(
|
||||
$newOrganizationMember, ['role' => $role]
|
||||
);
|
||||
|
||||
if ($role === Role::Owner->value) {
|
||||
app(UserService::class)->changeOwnership($organization, $newOrganizationMember);
|
||||
}
|
||||
});
|
||||
|
||||
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
|
||||
@@ -84,7 +79,6 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([
|
||||
Role::Owner->value,
|
||||
Role::Admin->value,
|
||||
Role::Manager->value,
|
||||
Role::Employee->value,
|
||||
|
||||
@@ -15,6 +15,7 @@ class DeleteOrganization implements DeletesTeams
|
||||
*/
|
||||
public function delete(Organization $organization): void
|
||||
{
|
||||
/** @see ValidateOrganizationDeletion */
|
||||
app(DeletionService::class)->deleteOrganization($organization);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class DeleteUser implements DeletesUsers
|
||||
{
|
||||
/**
|
||||
* Delete the given user.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function delete(User $user): void
|
||||
{
|
||||
|
||||
@@ -4,103 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use Closure;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\In;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
use Exception;
|
||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||
use Laravel\Jetstream\Mail\TeamInvitation;
|
||||
|
||||
class InviteOrganizationMember implements InvitesTeamMembers
|
||||
{
|
||||
/**
|
||||
* Invite a new team member to the given team.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
|
||||
{
|
||||
if (! app(PermissionStore::class)->has($organization, 'invitations:create')) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
$this->validate($organization, $email, $role);
|
||||
|
||||
InvitingTeamMember::dispatch($organization, $email, $role);
|
||||
|
||||
/** @var OrganizationInvitation $invitation */
|
||||
$invitation = $organization->teamInvitations()->create([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
]);
|
||||
|
||||
Mail::to($email)->send(new TeamInvitation($invitation));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the invite member operation.
|
||||
*/
|
||||
protected function validate(Organization $organization, string $email, ?string $role): void
|
||||
{
|
||||
Validator::make([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
], $this->rules($organization))->after(
|
||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
||||
)->validateWithBag('addTeamMember');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules for inviting a team member.
|
||||
*
|
||||
* @return array<string, array<ValidationRule|Rule|string|In>>
|
||||
*/
|
||||
protected function rules(Organization $organization): array
|
||||
{
|
||||
return array_filter([
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) {
|
||||
/** @var Builder<OrganizationInvitation> $builder */
|
||||
return $builder->whereBelongsTo($organization, 'organization');
|
||||
}))->withMessage(__('This user has already been invited to the team.')),
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([
|
||||
Role::Owner->value,
|
||||
Role::Admin->value,
|
||||
Role::Manager->value,
|
||||
Role::Employee->value,
|
||||
]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the user is not already on the team.
|
||||
*/
|
||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $organization, string $email): Closure
|
||||
{
|
||||
return function ($validator) use ($organization, $email) {
|
||||
$validator->errors()->addIf(
|
||||
$organization->hasRealUserWithEmail($email),
|
||||
'email',
|
||||
__('This user already belongs to the team.')
|
||||
);
|
||||
};
|
||||
throw new MovedToApiException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,50 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Exception;
|
||||
use Laravel\Jetstream\Contracts\RemovesTeamMembers;
|
||||
use Laravel\Jetstream\Events\TeamMemberRemoved;
|
||||
|
||||
class RemoveOrganizationMember implements RemovesTeamMembers
|
||||
{
|
||||
/**
|
||||
* Remove the team member from the given team.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function remove(User $user, Organization $organization, User $teamMember): void
|
||||
{
|
||||
$this->authorize($user, $organization, $teamMember);
|
||||
|
||||
$this->ensureUserDoesNotOwnTeam($teamMember, $organization);
|
||||
|
||||
$organization->removeUser($teamMember);
|
||||
|
||||
TeamMemberRemoved::dispatch($organization, $teamMember);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize that the user can remove the team member.
|
||||
*/
|
||||
protected function authorize(User $user, Organization $organization, User $teamMember): void
|
||||
{
|
||||
if (! Gate::forUser($user)->check('removeTeamMember', $organization) &&
|
||||
$user->id !== $teamMember->id) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the currently authenticated user does not own the team.
|
||||
*/
|
||||
protected function ensureUserDoesNotOwnTeam(User $teamMember, Organization $organization): void
|
||||
{
|
||||
if ($teamMember->id === $organization->owner->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'team' => [__('You may not leave a team that you created.')],
|
||||
])->errorBag('removeTeamMember');
|
||||
}
|
||||
throw new MovedToApiException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,63 +5,21 @@ declare(strict_types=1);
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use App\Service\UserService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Events\TeamMemberUpdated;
|
||||
use Exception;
|
||||
|
||||
class UpdateMemberRole
|
||||
{
|
||||
/**
|
||||
* Update the role for the given team member.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ValidationException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
|
||||
{
|
||||
if (! app(PermissionStore::class)->has($organization, 'members:change-ownership')) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
$user = User::where('id', '=', $userId)->firstOrFail();
|
||||
$member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail();
|
||||
if ($member->role === Role::Placeholder->value) {
|
||||
abort(403, 'Cannot update the role of a placeholder member.');
|
||||
}
|
||||
|
||||
Validator::make([
|
||||
'role' => $role,
|
||||
], [
|
||||
'role' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([
|
||||
Role::Owner->value,
|
||||
Role::Admin->value,
|
||||
Role::Manager->value,
|
||||
Role::Employee->value,
|
||||
]),
|
||||
],
|
||||
])->validate();
|
||||
|
||||
DB::transaction(function () use ($organization, $userId, $role, $user) {
|
||||
$organization->users()->updateExistingPivot($userId, [
|
||||
'role' => $role,
|
||||
]);
|
||||
|
||||
if ($role === Role::Owner->value) {
|
||||
app(UserService::class)->changeOwnership($organization, $user);
|
||||
}
|
||||
});
|
||||
|
||||
TeamMemberUpdated::dispatch($organization->fresh(), User::findOrFail($userId));
|
||||
throw new MovedToApiException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'user_is_already_member_of_organization';
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class MovedToApiException extends HttpException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(403, 'Moved to API');
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,18 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
|
||||
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
|
||||
use App\Http\Resources\V1\Invitation\InvitationCollection;
|
||||
use App\Http\Resources\V1\Invitation\InvitationResource;
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Service\InvitationService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||
use Laravel\Jetstream\Mail\TeamInvitation;
|
||||
|
||||
class InvitationController extends Controller
|
||||
{
|
||||
@@ -49,19 +50,18 @@ class InvitationController extends Controller
|
||||
* Invite a user to the organization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException
|
||||
*
|
||||
* @operationId invite
|
||||
*/
|
||||
public function store(Organization $organization, InvitationStoreRequest $request): JsonResponse
|
||||
public function store(Organization $organization, InvitationStoreRequest $request, InvitationService $invitationService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'invitations:create');
|
||||
|
||||
app(InvitesTeamMembers::class)->invite(
|
||||
$this->user(),
|
||||
$organization,
|
||||
$request->input('email'),
|
||||
$request->getRole()->value
|
||||
);
|
||||
$email = $request->getEmail();
|
||||
$role = $request->getRole();
|
||||
|
||||
$invitationService->inviteUser($organization, $email, $role);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
@@ -77,7 +77,8 @@ class InvitationController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'invitations:resend', $invitation);
|
||||
|
||||
Mail::to($invitation->email)->send(new TeamInvitation($invitation));
|
||||
Mail::to($invitation->email)
|
||||
->queue(new OrganizationInvitationMail($invitation));
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
@@ -21,12 +21,11 @@ use App\Models\Organization;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\InvitationService;
|
||||
use App\Service\MemberService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||
|
||||
class MemberController extends Controller
|
||||
{
|
||||
@@ -134,7 +133,7 @@ class MemberController extends Controller
|
||||
*
|
||||
* @operationId invitePlaceholder
|
||||
*/
|
||||
public function invitePlaceholder(Organization $organization, Member $member, Request $request): JsonResponse
|
||||
public function invitePlaceholder(Organization $organization, Member $member, InvitationService $invitationService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'members:invite-placeholder', $member);
|
||||
$user = $member->user;
|
||||
@@ -143,12 +142,7 @@ class MemberController extends Controller
|
||||
throw new UserNotPlaceholderApiException();
|
||||
}
|
||||
|
||||
app(InvitesTeamMembers::class)->invite(
|
||||
$this->user(),
|
||||
$organization,
|
||||
$user->email,
|
||||
Role::Employee->value,
|
||||
);
|
||||
$invitationService->inviteUser($organization, $user->email, Role::Employee);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ namespace App\Http\Requests\V1\Invitation;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property Organization $organization
|
||||
@@ -26,6 +29,10 @@ class InvitationStoreRequest extends FormRequest
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
|
||||
/** @var Builder<OrganizationInvitation> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}))->withCustomTranslation('validation.invitation_already_exists'),
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
@@ -40,4 +47,9 @@ class InvitationStoreRequest extends FormRequest
|
||||
{
|
||||
return Role::from($this->input('role'));
|
||||
}
|
||||
|
||||
public function getEmail(): string
|
||||
{
|
||||
return $this->input('email');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class OrganizationInvitationMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public OrganizationInvitation $invitation;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(OrganizationInvitation $invitation)
|
||||
{
|
||||
$this->invitation = $invitation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message.
|
||||
*/
|
||||
public function build(): self
|
||||
{
|
||||
return $this->markdown('emails.organization-invitation', [
|
||||
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
|
||||
'invitation' => $this->invitation,
|
||||
]),
|
||||
])->subject(__('Organization Invitation'));
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ class OrganizationPolicy
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->ownsTeam($organization);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +82,8 @@ class OrganizationPolicy
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->ownsTeam($organization);
|
||||
// Note: since this policy is only used for jetstream endpoints, we can return false here
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,7 +95,8 @@ class OrganizationPolicy
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->ownsTeam($organization);
|
||||
// Note: since this policy is only used for jetstream endpoints that are no longer in use, we can return false here
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ use App\Service\TimezoneService;
|
||||
use Brick\Money\Currency;
|
||||
use Brick\Money\ISOCurrencyProvider;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Fortify;
|
||||
@@ -66,6 +67,9 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'newsletter_consent' => config('auth.newsletter_consent'),
|
||||
]);
|
||||
});
|
||||
Gate::define('removeTeamMember', function (User $user, Organization $team) {
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||
|
||||
class InvitationService
|
||||
{
|
||||
/**
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException
|
||||
*/
|
||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||
{
|
||||
if (Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereRelation('user', 'email', '=', $email)
|
||||
->where('role', '!=', Role::Placeholder->value)
|
||||
->exists()) {
|
||||
throw new UserIsAlreadyMemberOfOrganizationApiException();
|
||||
}
|
||||
|
||||
InvitingTeamMember::dispatch($organization, $email, $role->value);
|
||||
|
||||
$invitation = new OrganizationInvitation();
|
||||
$invitation->email = $email;
|
||||
$invitation->role = $role->value;
|
||||
$invitation->organization()->associate($organization);
|
||||
$invitation->save();
|
||||
|
||||
Mail::to($email)->queue(new OrganizationInvitationMail($invitation));
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||
|
||||
@@ -20,6 +21,7 @@ return [
|
||||
UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',
|
||||
TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',
|
||||
InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used',
|
||||
UserIsAlreadyMemberOfOrganizationApiException::KEY => 'User is already a member of the organization',
|
||||
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',
|
||||
EntityStillInUseApiException::KEY => 'The :modelToDelete is still used by a :modelInUse and can not be deleted.',
|
||||
CanNotRemoveOwnerFromOrganization::KEY => 'Can not remove owner from organization',
|
||||
|
||||
@@ -206,6 +206,7 @@ return [
|
||||
'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',
|
||||
'client_name_already_exists' => 'A client with the same name already exists in the organization.',
|
||||
'task_name_already_exists' => 'A task with the same name already exists in the project.',
|
||||
'invitation_already_exists' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
||||
|
||||
'entities' => [
|
||||
'organization' => 'organization',
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
@component('mail::message')
|
||||
{{ __('You have been invited to join the :team team!', ['team' => $invitation->organization->name]) }}
|
||||
{{ __('You have been invited to join the :organization organization!', ['organization' => $invitation->organization->name]) }}
|
||||
|
||||
@if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::registration()))
|
||||
{{ __('If you do not have an account, you may create one by clicking the button below. After creating an account, you may click the invitation acceptance button in this email to accept the team invitation:') }}
|
||||
@@ -19,5 +19,5 @@
|
||||
{{ __('Accept Invitation') }}
|
||||
@endcomponent
|
||||
|
||||
{{ __('If you did not expect to receive an invitation to this team, you may discard this email.') }}
|
||||
{{ __('If you did not expect to receive an invitation to this organization, you may discard this email.') }}
|
||||
@endcomponent
|
||||
@@ -11,14 +11,13 @@ use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Laravel\Jetstream\Mail\TeamInvitation;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InviteTeamMemberTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_team_members_can_be_invited_to_team(): void
|
||||
public function test_team_members_can_no_longer_be_invited_to_team_over_jetstream(): void
|
||||
{
|
||||
// Arrange
|
||||
Mail::fake();
|
||||
@@ -31,54 +30,12 @@ class InviteTeamMemberTest extends TestCase
|
||||
]);
|
||||
|
||||
// Assert
|
||||
Mail::assertSent(TeamInvitation::class);
|
||||
$this->assertCount(1, $user->currentTeam->fresh()->teamInvitations);
|
||||
$response->assertStatus(403);
|
||||
$response->assertSee('Moved to API');
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_team_member_can_not_be_invited_to_team_if_already_on_team(): void
|
||||
{
|
||||
// Arrange
|
||||
Mail::fake();
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
$existingUser = User::factory()->create();
|
||||
$user->currentTeam->users()->attach($existingUser, ['role' => 'admin']);
|
||||
$this->actingAs($user);
|
||||
|
||||
// Act
|
||||
$response = $this->post('/teams/'.$user->currentTeam->id.'/members', [
|
||||
'email' => $existingUser->email,
|
||||
'role' => 'admin',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertInvalid(['email'], 'addTeamMember');
|
||||
Mail::assertNotSent(TeamInvitation::class);
|
||||
$this->assertCount(0, $user->currentTeam->fresh()->teamInvitations);
|
||||
}
|
||||
|
||||
public function test_team_member_can_be_invited_to_team_if_already_on_team_as_placeholder(): void
|
||||
{
|
||||
// Arrange
|
||||
Mail::fake();
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
$existingUser = User::factory()->create([
|
||||
'is_placeholder' => true,
|
||||
]);
|
||||
$user->currentTeam->users()->attach($existingUser, ['role' => Role::Employee->value]);
|
||||
$this->actingAs($user);
|
||||
|
||||
// Act
|
||||
$response = $this->post('/teams/'.$user->currentTeam->id.'/members', [
|
||||
'email' => $existingUser->email,
|
||||
'role' => Role::Employee->value,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
Mail::assertSent(TeamInvitation::class);
|
||||
$this->assertCount(1, $user->currentTeam->fresh()->teamInvitations);
|
||||
}
|
||||
|
||||
public function test_team_member_invitations_can_be_cancelled(): void
|
||||
public function test_team_member_invitations_can_no_longer_be_cancelled_over_jetstream(): void
|
||||
{
|
||||
// Arrange
|
||||
Mail::fake();
|
||||
@@ -94,7 +51,8 @@ class InviteTeamMemberTest extends TestCase
|
||||
$response = $this->delete('/team-invitations/'.$invitation->id);
|
||||
|
||||
// Assert
|
||||
$this->assertCount(0, $user->currentTeam->fresh()->teamInvitations);
|
||||
$response->assertStatus(403);
|
||||
$this->assertCount(1, $user->currentTeam->fresh()->teamInvitations);
|
||||
}
|
||||
|
||||
public function test_team_member_invitations_can_be_accepted(): void
|
||||
@@ -153,6 +111,7 @@ class InviteTeamMemberTest extends TestCase
|
||||
$response = $this->get($acceptUrl);
|
||||
|
||||
// Assert
|
||||
$response->assertRedirect();
|
||||
$user->refresh();
|
||||
$this->assertDatabaseMissing(User::class, ['id' => $placeholder->id]);
|
||||
$this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations);
|
||||
|
||||
@@ -12,8 +12,9 @@ class LeaveTeamTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_users_can_leave_teams(): void
|
||||
public function test_users_can_no_longer_leave_team_over_jetstream(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
|
||||
$user->currentTeam->users()->attach(
|
||||
@@ -22,19 +23,11 @@ class LeaveTeamTest extends TestCase
|
||||
|
||||
$this->actingAs($otherUser);
|
||||
|
||||
// Act
|
||||
$response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id);
|
||||
|
||||
$this->assertCount(1, $user->currentTeam->fresh()->users);
|
||||
}
|
||||
|
||||
public function test_team_owners_cant_leave_their_own_team(): void
|
||||
{
|
||||
$this->actingAs($user = User::factory()->withPersonalOrganization()->create());
|
||||
|
||||
$response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$user->id);
|
||||
|
||||
$response->assertSessionHasErrorsIn('removeTeamMember', ['team']);
|
||||
|
||||
$this->assertNotNull($user->currentTeam->fresh());
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$this->assertCount(2, $user->currentTeam->fresh()->users);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,31 +12,20 @@ class RemoveTeamMemberTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_team_members_can_be_removed_from_teams(): void
|
||||
public function test_team_members_can_no_longer_be_removed_from_teams_over_jetstream_endpoints(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->actingAs($user = User::factory()->withPersonalOrganization()->create());
|
||||
|
||||
$user->currentTeam->users()->attach(
|
||||
$otherUser = User::factory()->create(), ['role' => 'admin']
|
||||
);
|
||||
|
||||
$response = $this->withoutExceptionHandling()->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id);
|
||||
// Act
|
||||
$response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id);
|
||||
|
||||
$this->assertCount(1, $user->currentTeam->fresh()->users);
|
||||
}
|
||||
|
||||
public function test_only_team_owner_can_remove_team_members(): void
|
||||
{
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
|
||||
$user->currentTeam->users()->attach(
|
||||
$otherUser = User::factory()->create(), ['role' => 'admin']
|
||||
);
|
||||
|
||||
$this->actingAs($otherUser);
|
||||
|
||||
$response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$user->id);
|
||||
|
||||
$response->assertForbidden();
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$response->assertSee('Moved to API');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class UpdateTeamMemberRoleTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_team_member_roles_can_be_updated(): void
|
||||
public function test_team_member_roles_can_no_longer_be_updated_over_jetstream(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
@@ -28,73 +28,8 @@ class UpdateTeamMemberRoleTest extends TestCase
|
||||
'role' => Role::Employee->value,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($otherUser->fresh()->hasTeamRole(
|
||||
$user->currentTeam->fresh(), Role::Employee->value,
|
||||
));
|
||||
}
|
||||
|
||||
public function test_team_member_roles_can_not_be_updated_to_placeholder(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$user->currentTeam->users()->attach(
|
||||
$otherUser = User::factory()->create(), ['role' => 'admin']
|
||||
);
|
||||
|
||||
// Act
|
||||
$response = $this->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id, [
|
||||
'role' => 'placeholder',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($otherUser->fresh()->hasTeamRole(
|
||||
$user->currentTeam->fresh(), 'admin'
|
||||
));
|
||||
}
|
||||
|
||||
public function test_team_member_roles_can_be_updated_to_owner_which_changes_ownership(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
$this->actingAs($user);
|
||||
$otherUser = User::factory()->create();
|
||||
$user->currentTeam->users()->attach($otherUser, ['role' => 'admin']);
|
||||
|
||||
// Act
|
||||
$response = $this->withoutExceptionHandling()->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->getKey(), [
|
||||
'role' => Role::Owner->value,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertTrue($otherUser->fresh()->hasTeamRole(
|
||||
$user->currentTeam->fresh(), Role::Owner->value
|
||||
));
|
||||
$this->assertSame($user->currentTeam->fresh()->user_id, $otherUser->getKey());
|
||||
}
|
||||
|
||||
public function test_only_team_owner_can_update_team_member_roles(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->withPersonalOrganization()->create();
|
||||
|
||||
$user->currentTeam->users()->attach(
|
||||
$otherUser = User::factory()->create(), ['role' => 'admin']
|
||||
);
|
||||
|
||||
$this->actingAs($otherUser);
|
||||
|
||||
// Act
|
||||
$response = $this->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id, [
|
||||
'role' => Role::Employee->value,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(403);
|
||||
$this->assertTrue($otherUser->fresh()->hasTeamRole(
|
||||
$user->currentTeam->fresh(), 'admin'
|
||||
));
|
||||
$response->assertSee('Moved to API');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Http\Controllers\Api\V1\InvitationController;
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Member;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Jetstream\Mail\TeamInvitation;
|
||||
use Laravel\Passport\Passport;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
|
||||
@@ -107,6 +109,74 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertJsonPath('message', 'The selected role is invalid.');
|
||||
}
|
||||
|
||||
public function test_store_fails_if_user_invites_user_who_is_already_member_of_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'invitations:create',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
$member = Member::factory()->forOrganization($data->organization)->create();
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [
|
||||
'email' => $member->user->email,
|
||||
'role' => Role::Employee->value,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonPath('message', 'User is already a member of the organization');
|
||||
}
|
||||
|
||||
public function test_store_fails_if_user_invites_user_who_is_already_invited_to_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'invitations:create',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
$invitation = OrganizationInvitation::factory()->forOrganization($data->organization)->create();
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [
|
||||
'email' => $invitation->email,
|
||||
'role' => Role::Employee->value,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertInvalid([
|
||||
'email' => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
||||
]);
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_store_works_if_user_invites_user_who_is_also_a_placeholder(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'invitations:create',
|
||||
]);
|
||||
$user = User::factory()->placeholder()->create();
|
||||
$member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Placeholder)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [
|
||||
'email' => $user->email,
|
||||
'role' => Role::Employee->value,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(204);
|
||||
$invitation = OrganizationInvitation::first();
|
||||
$this->assertNotNull($invitation);
|
||||
$this->assertEquals($user->email, $invitation->email);
|
||||
$this->assertEquals(Role::Employee->value, $invitation->role);
|
||||
Mail::assertQueued(fn (OrganizationInvitationMail $mail): bool => $mail->invitation->is($invitation));
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_store_invites_user_to_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -127,6 +197,8 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract
|
||||
$this->assertNotNull($invitation);
|
||||
$this->assertEquals('test@asdf.at', $invitation->email);
|
||||
$this->assertEquals(Role::Employee->value, $invitation->role);
|
||||
Mail::assertQueued(fn (OrganizationInvitationMail $mail): bool => $mail->invitation->is($invitation));
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_resend_fails_if_user_has_no_permission_to_resend_the_invitation(): void
|
||||
@@ -182,9 +254,9 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract
|
||||
]));
|
||||
|
||||
// Assert
|
||||
Mail::assertSent(fn (TeamInvitation $mail): bool => $mail->invitation->is($invitation));
|
||||
Mail::assertNothingQueued();
|
||||
$response->assertStatus(204);
|
||||
Mail::assertQueued(fn (OrganizationInvitationMail $mail): bool => $mail->invitation->is($invitation));
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_delete_fails_if_user_has_no_permission_to_remove_invitations(): void
|
||||
|
||||
@@ -280,12 +280,11 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
{
|
||||
$data = $this->createUserWithPermission([
|
||||
'members:invite-placeholder',
|
||||
'invitations:create',
|
||||
]);
|
||||
$user = User::factory()->create([
|
||||
'is_placeholder' => true,
|
||||
]);
|
||||
$member = Member::factory()->forUser($user)->forOrganization($data->organization)->create();
|
||||
$member = Member::factory()->forUser($user)->forOrganization($data->organization)->role(Role::Placeholder)->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Mail;
|
||||
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use Tests\TestCaseWithDatabase;
|
||||
|
||||
#[CoversClass(OrganizationInvitationMail::class)]
|
||||
#[UsesClass(OrganizationInvitationMail::class)]
|
||||
class OrganizationInvitationMailTest extends TestCaseWithDatabase
|
||||
{
|
||||
public function test_mail_renders_content_correctly(): void
|
||||
{
|
||||
// Arrange
|
||||
$organization = Organization::factory()->create();
|
||||
$invitation = OrganizationInvitation::factory()->forOrganization($organization)->create();
|
||||
$mail = new OrganizationInvitationMail($invitation);
|
||||
|
||||
// Act
|
||||
$rendered = $mail->render();
|
||||
|
||||
// Assert
|
||||
$this->assertStringContainsString('You have been invited to join the '.$invitation->organization->name.' organization', $rendered);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user