From 3267acb1615bad72a9f7820033b671cbcbc743e7 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Wed, 25 Feb 2026 17:28:34 +0100 Subject: [PATCH] Updated invitation flow, Moved jetstream function to REST endpoints; Lower case email --- .gitignore | 1 + .../Jetstream/AddOrganizationMember.php | 77 +----- app/Actions/Jetstream/CreateOrganization.php | 2 + app/Actions/Jetstream/DeleteOrganization.php | 2 + app/Actions/Jetstream/DeleteUser.php | 2 + .../ValidateOrganizationDeletion.php | 2 + app/Events/MemberAdded.php | 28 +++ app/Events/MemberAdding.php | 28 +++ .../Api/V1/OrganizationController.php | 50 ++++ .../Controllers/Api/V1/UserController.php | 29 +++ .../Controllers/Web/DashboardController.php | 19 +- .../Web/OrganizationInvitationController.php | 64 +++++ .../Organization/OrganizationStoreRequest.php | 35 +++ app/Listeners/RemovePlaceholder.php | 43 ---- app/Mail/OrganizationInvitationMail.php | 10 +- app/Models/Organization.php | 1 + app/Models/OrganizationInvitation.php | 15 +- app/Policies/OrganizationPolicy.php | 12 - app/Providers/EventServiceProvider.php | 5 - app/Service/InvitationService.php | 43 +++- app/Service/MemberService.php | 39 +++- app/Service/UserService.php | 29 +-- .../OrganizationInvitationFactory.php | 15 ++ ...d_at_to_organization_invitations_table.php | 30 +++ .../auth-api-expiration-reminder.blade.php | 3 +- .../emails/auth-api-token-expired.blade.php | 3 +- .../emails/organization-invitation.blade.php | 14 -- routes/api.php | 4 + routes/web.php | 8 + tests/Feature/InviteTeamMemberTest.php | 2 +- tests/Feature/RegistrationTest.php | 88 ++++++- tests/TestCase.php | 2 +- .../Api/V1/OrganizationEndpointTest.php | 168 +++++++++++++ .../Unit/Endpoint/Api/V1/UserEndpointTest.php | 54 +++++ .../OrganizationInvitationEndpointTest.php | 220 ++++++++++++++++++ ...AuthApiTokenExpirationReminderMailTest.php | 2 +- .../Unit/Mail/AuthApiTokenExpiredMailTest.php | 2 +- 37 files changed, 942 insertions(+), 209 deletions(-) create mode 100644 app/Events/MemberAdded.php create mode 100644 app/Events/MemberAdding.php create mode 100644 app/Http/Controllers/Web/OrganizationInvitationController.php create mode 100644 app/Http/Requests/V1/Organization/OrganizationStoreRequest.php delete mode 100644 app/Listeners/RemovePlaceholder.php create mode 100644 database/migrations/2026_02_11_143746_add_accepted_at_to_organization_invitations_table.php create mode 100644 tests/Unit/Endpoint/Web/OrganizationInvitationEndpointTest.php diff --git a/.gitignore b/.gitignore index aeeb3ff7..45e43f2a 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ yarn-error.log /data /config/caddy /config/composer +/AGENTS.md diff --git a/app/Actions/Jetstream/AddOrganizationMember.php b/app/Actions/Jetstream/AddOrganizationMember.php index 7c8cc0f6..d831799e 100644 --- a/app/Actions/Jetstream/AddOrganizationMember.php +++ b/app/Actions/Jetstream/AddOrganizationMember.php @@ -4,18 +4,9 @@ declare(strict_types=1); namespace App\Actions\Jetstream; -use App\Enums\Role; +use App\Exceptions\MovedToApiException; use App\Models\Organization; use App\Models\User; -use App\Service\MemberService; -use Closure; -use Illuminate\Contracts\Validation\ValidationRule; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\Gate; -use Illuminate\Support\Facades\Validator; -use Illuminate\Validation\Rule; -use Illuminate\Validation\Rules\In; -use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Laravel\Jetstream\Contracts\AddsTeamMembers; class AddOrganizationMember implements AddsTeamMembers @@ -25,70 +16,6 @@ class AddOrganizationMember implements AddsTeamMembers */ public function add(User $owner, Organization $organization, string $email, ?string $role = null): void { - Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring - - $this->validate($organization, $email, $role); - - $newOrganizationMember = User::query() - ->where('email', $email) - ->where('is_placeholder', '=', false) - ->firstOrFail(); - - app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role)); - } - - /** - * Validate the add member operation. - */ - protected function validate(Organization $organization, string $email, ?string $role): void - { - Validator::make([ - 'email' => $email, - 'role' => $role, - ], $this->rules())->after( - $this->ensureUserIsNotAlreadyOnTeam($organization, $email) - )->validateWithBag('addTeamMember'); - } - - /** - * Get the validation rules for adding a team member. - * - * @return array> - */ - protected function rules(): array - { - return [ - 'email' => [ - 'required', - 'email', - ExistsEloquent::make(User::class, 'email', function (Builder $builder) { - /** @var Builder $builder */ - return $builder->where('is_placeholder', '=', false); - })->withMessage(__('We were unable to find a registered user with this email address.')), - ], - 'role' => [ - 'required', - 'string', - Rule::in([ - Role::Admin->value, - Role::Manager->value, - Role::Employee->value, - ]), - ], - ]; - } - - /** - * Ensure that the user is not already on the team. - */ - protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure - { - return function ($validator) use ($team, $email): void { - $validator->errors()->addIf( - $team->hasRealUserWithEmail($email), - 'email', - __('This user already belongs to the team.') - ); - }; + throw new MovedToApiException; } } diff --git a/app/Actions/Jetstream/CreateOrganization.php b/app/Actions/Jetstream/CreateOrganization.php index 13981abc..d81760e6 100644 --- a/app/Actions/Jetstream/CreateOrganization.php +++ b/app/Actions/Jetstream/CreateOrganization.php @@ -25,6 +25,8 @@ class CreateOrganization implements CreatesTeams * * @throws AuthorizationException * @throws ValidationException + * + * @deprecated Use REST endpoint instead */ public function create(User $user, array $input): Organization { diff --git a/app/Actions/Jetstream/DeleteOrganization.php b/app/Actions/Jetstream/DeleteOrganization.php index a33e62d1..ec48e5ee 100644 --- a/app/Actions/Jetstream/DeleteOrganization.php +++ b/app/Actions/Jetstream/DeleteOrganization.php @@ -12,6 +12,8 @@ class DeleteOrganization implements DeletesTeams { /** * Delete the given team. + * + * @deprecated Use REST endpoint instead */ public function delete(Organization $organization): void { diff --git a/app/Actions/Jetstream/DeleteUser.php b/app/Actions/Jetstream/DeleteUser.php index 4385b3f5..062bf82d 100644 --- a/app/Actions/Jetstream/DeleteUser.php +++ b/app/Actions/Jetstream/DeleteUser.php @@ -16,6 +16,8 @@ class DeleteUser implements DeletesUsers * Delete the given user. * * @throws ValidationException + * + * @deprecated Use REST endpoint instead */ public function delete(User $user): void { diff --git a/app/Actions/Jetstream/ValidateOrganizationDeletion.php b/app/Actions/Jetstream/ValidateOrganizationDeletion.php index e9eb7abd..7a9d36eb 100644 --- a/app/Actions/Jetstream/ValidateOrganizationDeletion.php +++ b/app/Actions/Jetstream/ValidateOrganizationDeletion.php @@ -18,6 +18,8 @@ class ValidateOrganizationDeletion * @param Organization $organization Organization to be deleted * * @throws AuthorizationException + * + * @deprecated Use REST endpoint instead */ public function validate(User $user, Organization $organization): void { diff --git a/app/Events/MemberAdded.php b/app/Events/MemberAdded.php new file mode 100644 index 00000000..f7ab63b7 --- /dev/null +++ b/app/Events/MemberAdded.php @@ -0,0 +1,28 @@ +member = $member; + $this->organization = $organization; + $this->user = $user; + } +} diff --git a/app/Events/MemberAdding.php b/app/Events/MemberAdding.php new file mode 100644 index 00000000..3aea036d --- /dev/null +++ b/app/Events/MemberAdding.php @@ -0,0 +1,28 @@ +user = $user; + $this->organization = $organization; + $this->role = $role; + } +} diff --git a/app/Http/Controllers/Api/V1/OrganizationController.php b/app/Http/Controllers/Api/V1/OrganizationController.php index 4475acc1..925fc728 100644 --- a/app/Http/Controllers/Api/V1/OrganizationController.php +++ b/app/Http/Controllers/Api/V1/OrganizationController.php @@ -5,11 +5,17 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; use App\Enums\Role; +use App\Events\AfterCreateOrganization; +use App\Http\Requests\V1\Organization\OrganizationStoreRequest; use App\Http\Requests\V1\Organization\OrganizationUpdateRequest; use App\Http\Resources\V1\Organization\OrganizationResource; use App\Models\Organization; use App\Service\BillableRateService; +use App\Service\DeletionService; +use App\Service\IpLookup\IpLookupServiceContract; +use App\Service\OrganizationService; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Http\JsonResponse; class OrganizationController extends Controller { @@ -80,4 +86,48 @@ class OrganizationController extends Controller return new OrganizationResource($organization, true); } + + /** + * Create organization + * + * @operationId createOrganization + */ + public function store(OrganizationStoreRequest $request, OrganizationService $organizationService): OrganizationResource + { + $user = $this->user(); + $ipLookupResponse = app(IpLookupServiceContract::class)->lookup($request->ip()); + + $currency = $ipLookupResponse?->currency; + + $organization = $organizationService->createOrganization( + $request->getName(), + $user, + false, + $currency + ); + + $user->switchTeam($organization); + + // Note: The refresh is necessary for currently unknown reasons. Do not remove it. + $organization = $organization->refresh(); + AfterCreateOrganization::dispatch($organization); + + return new OrganizationResource($organization, true); + } + + /** + * Delete organization + * + * @operationId deleteOrganization + * + * @throws AuthorizationException + */ + public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse + { + $this->checkPermission($organization, 'organizations:delete'); + + $deletionService->deleteOrganization($organization); + + return response()->json(null, 204); + } } diff --git a/app/Http/Controllers/Api/V1/UserController.php b/app/Http/Controllers/Api/V1/UserController.php index d4338916..f835cbb7 100644 --- a/app/Http/Controllers/Api/V1/UserController.php +++ b/app/Http/Controllers/Api/V1/UserController.php @@ -4,8 +4,12 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Http\Resources\V1\User\UserResource; +use App\Models\User; +use App\Service\DeletionService; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Http\JsonResponse; class UserController extends Controller { @@ -24,4 +28,29 @@ class UserController extends Controller return new UserResource($user); } + + /** + * Handles the deletion of a user. + * + * This endpoint is independent of organization. + * + * @operationId deleteUser + * + * @param User $user The user instance to be deleted. + * @param DeletionService $deletionService The service responsible for performing the user deletion. + * @return JsonResponse A JSON response with a 204 No Content status upon successful deletion. + * + * @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted. + * @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members. + */ + public function destroy(User $user, DeletionService $deletionService): JsonResponse + { + if ($user->getKey() !== $this->user()->getKey()) { + throw new AuthorizationException; + } + + $deletionService->deleteUser($user); + + return response()->json(null, 204); + } } diff --git a/app/Http/Controllers/Web/DashboardController.php b/app/Http/Controllers/Web/DashboardController.php index ccb4c15a..d2947243 100644 --- a/app/Http/Controllers/Web/DashboardController.php +++ b/app/Http/Controllers/Web/DashboardController.php @@ -4,30 +4,13 @@ declare(strict_types=1); namespace App\Http\Controllers\Web; -use App\Enums\Role; -use App\Service\DashboardService; -use App\Service\PermissionStore; -use Illuminate\Auth\Access\AuthorizationException; use Inertia\Inertia; use Inertia\Response; class DashboardController extends Controller { - /** - * @throws AuthorizationException - */ - public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response + public function dashboard(): Response { - $user = $this->user(); - $organization = $this->currentOrganization(); - - $latestTeamActivity = null; - if ($permissionStore->has($organization, 'time-entries:view:all')) { - $latestTeamActivity = $dashboardService->latestTeamActivity($organization); - } - - $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; - return Inertia::render('Dashboard'); } } diff --git a/app/Http/Controllers/Web/OrganizationInvitationController.php b/app/Http/Controllers/Web/OrganizationInvitationController.php new file mode 100644 index 00000000..63148b1c --- /dev/null +++ b/app/Http/Controllers/Web/OrganizationInvitationController.php @@ -0,0 +1,64 @@ +email); + $role = Role::tryFrom($invitation->role); + if ($role === null || $role === Role::Owner || $role === Role::Placeholder) { + throw new RuntimeException('Invalid role'); + } + + $newOrganizationMember = User::query() + ->where('email', $email) + ->where('is_placeholder', '=', false) + ->first(); + + if ($newOrganizationMember === null) { + if ($invitation->accepted_at === null) { + $invitation->accepted_at = now(); + $invitation->save(); + } + + return redirect(route('register', [ + 'bannerStyle' => 'info', + 'bannerText' => __('Please create an account to finish joining the :organization organization.', [ + 'organization' => $invitation->organization->name, + ]), + ])); + } else { + $organization = $invitation->organization; + if ($memberService->isEmailAlreadyMember($organization, $email)) { + return redirect(route('dashboard', [ + 'bannerStyle' => 'danger', + 'bannerText' => __('You are already a member of the :organization organization.', [ + 'organization' => $organization->name, + ]), + ])); + } + + $memberService->addMember($newOrganizationMember, $organization, $role); + + $invitation->delete(); + + return redirect(route('dashboard', [ + 'bannerStyle' => 'success', + 'bannerText' => __('Great! You have accepted the invitation to join the :organization organization.', [ + 'organization' => $invitation->organization->name, + ]), + ])); + } + } +} diff --git a/app/Http/Requests/V1/Organization/OrganizationStoreRequest.php b/app/Http/Requests/V1/Organization/OrganizationStoreRequest.php new file mode 100644 index 00000000..6083c624 --- /dev/null +++ b/app/Http/Requests/V1/Organization/OrganizationStoreRequest.php @@ -0,0 +1,35 @@ +> + */ + public function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + 'max:255', + ], + ]; + } + + public function getName(): string + { + return (string) $this->input('name'); + } +} diff --git a/app/Listeners/RemovePlaceholder.php b/app/Listeners/RemovePlaceholder.php deleted file mode 100644 index 4b932db3..00000000 --- a/app/Listeners/RemovePlaceholder.php +++ /dev/null @@ -1,43 +0,0 @@ -whereBelongsTo($event->team, 'organization') - ->whereBelongsTo($event->user, 'user') - ->firstOrFail(); - $placeholders = Member::query() - ->whereHas('user', function (Builder $query) use ($event): void { - /** @var Builder $query */ - $query->where('is_placeholder', '=', true) - ->where('email', '=', $event->user->email); - }) - ->whereBelongsTo($event->team, 'organization') - ->with(['user']) - ->get(); - - foreach ($placeholders as $placeholder) { - /** @var Member $placeholder */ - $placeholderUser = $placeholder->user; - $memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member); - $placeholder->delete(); - $placeholderUser->delete(); - } - } -} diff --git a/app/Mail/OrganizationInvitationMail.php b/app/Mail/OrganizationInvitationMail.php index 8a8dfa8d..21150500 100644 --- a/app/Mail/OrganizationInvitationMail.php +++ b/app/Mail/OrganizationInvitationMail.php @@ -8,6 +8,7 @@ use App\Models\OrganizationInvitation; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\URL; class OrganizationInvitationMail extends Mailable @@ -32,9 +33,12 @@ class OrganizationInvitationMail extends Mailable public function build(): self { return $this->markdown('emails.organization-invitation', [ - 'acceptUrl' => URL::signedRoute('team-invitations.accept', [ - 'invitation' => $this->invitation, - ]), + 'acceptUrl' => URL::to(URL::signedRoute( + 'organization-invitations.accept', + ['invitation' => $this->invitation->getKey()], + Carbon::now()->addDays(90), + false + )), ])->subject(__('Organization Invitation')); } } diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 3cfab262..61c8663f 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -36,6 +36,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @property string $user_id * @property bool $employees_can_see_billable_rates * @property bool $employees_can_manage_tasks + * @property bool $prevent_overlapping_time_entries * @property User $owner * @property Carbon|null $created_at * @property Carbon|null $updated_at diff --git a/app/Models/OrganizationInvitation.php b/app/Models/OrganizationInvitation.php index 75e99bdf..63e8f21d 100644 --- a/app/Models/OrganizationInvitation.php +++ b/app/Models/OrganizationInvitation.php @@ -18,6 +18,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @property string $email * @property string $role * @property string $organization_id + * @property Carbon|null $accepted_at * @property Carbon|null $updated_at * @property Carbon|null $created_at * @property-read Organization $organization @@ -41,14 +42,16 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl protected $table = 'organization_invitations'; /** - * The attributes that are mass assignable. + * Get the attributes that should be cast. * - * @var array + * @return array */ - protected $fillable = [ - 'email', - 'role', - ]; + public function casts(): array + { + return [ + 'accepted_at' => 'datetime', + ]; + } /** * Get the organization that the invitation belongs to. diff --git a/app/Policies/OrganizationPolicy.php b/app/Policies/OrganizationPolicy.php index c0c1bc62..5658d6b7 100644 --- a/app/Policies/OrganizationPolicy.php +++ b/app/Policies/OrganizationPolicy.php @@ -62,18 +62,6 @@ class OrganizationPolicy return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update'); } - /** - * Determine whether the user can add team members. - */ - public function addTeamMember(User $user, Organization $organization): bool - { - if (Filament::isServing()) { - return true; - } - - return true; - } - /** * Determine whether the user can update team member permissions. */ diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 4dc848b5..8ad1695f 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -4,11 +4,9 @@ declare(strict_types=1); namespace App\Providers; -use App\Listeners\RemovePlaceholder; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; -use Laravel\Jetstream\Events\TeamMemberAdded; class EventServiceProvider extends ServiceProvider { @@ -21,9 +19,6 @@ class EventServiceProvider extends ServiceProvider Registered::class => [ SendEmailVerificationNotification::class, ], - TeamMemberAdded::class => [ - RemovePlaceholder::class, - ], ]; /** diff --git a/app/Service/InvitationService.php b/app/Service/InvitationService.php index 4fc92c7a..06b106c4 100644 --- a/app/Service/InvitationService.php +++ b/app/Service/InvitationService.php @@ -8,9 +8,11 @@ use App\Enums\Role; use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Mail\OrganizationInvitationMail; -use App\Models\Member; use App\Models\Organization; use App\Models\OrganizationInvitation; +use App\Models\User; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; use Laravel\Jetstream\Events\InvitingTeamMember; @@ -21,11 +23,7 @@ class InvitationService */ 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()) { + if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) { throw new UserIsAlreadyMemberOfOrganizationApiException; } @@ -48,4 +46,37 @@ class InvitationService return $invitation; } + + /** + * @return Collection + */ + public function processAcceptedInvitations(User $user): Collection + { + $organizations = new Collection; + + $invitations = OrganizationInvitation::query() + ->where('email', $user->email) + ->whereNotNull('accepted_at') + ->get(); + + foreach ($invitations as $invitation) { + $organization = $invitation->organization; + $role = Role::tryFrom($invitation->role); + if ($role === null) { + Log::error('Invalid role in invitation', [ + 'invitation' => $invitation->getKey(), + 'role' => $invitation->role, + ]); + + continue; + } + app(MemberService::class)->addMember($user, $organization, $role); + + $invitation->delete(); + + $organizations->push($organization); + } + + return $organizations; + } } diff --git a/app/Service/MemberService.php b/app/Service/MemberService.php index 5c0c2267..0e6e2dbc 100644 --- a/app/Service/MemberService.php +++ b/app/Service/MemberService.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Service; use App\Enums\Role; +use App\Events\MemberAdded; +use App\Events\MemberAdding; use App\Events\MemberRemoved; use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed; @@ -36,7 +38,8 @@ class MemberService public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member { if (! $asSuperAdmin) { - AddingTeamMember::dispatch($organization, $user); + MemberAdding::dispatch($user, $organization, $role); + AddingTeamMember::dispatch($organization, $user); // Legacy event } $member = new Member; @@ -49,14 +52,37 @@ class MemberService $user->currentOrganization()->associate($organization); $user->save(); }); + $this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user); if (! $asSuperAdmin) { - TeamMemberAdded::dispatch($organization, $user); + MemberAdded::dispatch($member, $organization, $user); + TeamMemberAdded::dispatch($organization, $user); // Legacy event } return $member; } + private function mergePlaceholderMembersIntoExistingMember(Member $member, Organization $organization, User $user): void + { + $placeholders = Member::query() + ->whereHas('user', function (Builder $query) use ($user): void { + /** @var Builder $query */ + $query->where('is_placeholder', '=', true) + ->where('email', '=', $user->email); + }) + ->whereBelongsTo($organization, 'organization') + ->with(['user']) + ->get(); + + foreach ($placeholders as $placeholder) { + /** @var Member $placeholder */ + $placeholderUser = $placeholder->user; + $this->assignOrganizationEntitiesToDifferentMember($organization, $placeholder, $member); + $placeholder->delete(); + $placeholderUser->delete(); + } + } + /** * @throws CanNotRemoveOwnerFromOrganization * @throws EntityStillInUseApiException @@ -209,4 +235,13 @@ class MemberService $this->userService->makeSureUserHasCurrentOrganization($user); } } + + public function isEmailAlreadyMember(Organization $organization, string $email): bool + { + return Member::query() + ->whereBelongsTo($organization, 'organization') + ->whereRelation('user', 'email', '=', $email) + ->where('role', '!=', Role::Placeholder->value) + ->exists(); + } } diff --git a/app/Service/UserService.php b/app/Service/UserService.php index 1cae3307..f2765777 100644 --- a/app/Service/UserService.php +++ b/app/Service/UserService.php @@ -38,7 +38,7 @@ class UserService ): User { $user = new User; $user->name = $name; - $user->email = $email; + $user->email = strtolower($email); $user->password = Hash::make($password); $user->timezone = $timezone; $user->week_start = $weekStart; @@ -47,19 +47,22 @@ class UserService } $user->save(); - $organization = app(OrganizationService::class)->createOrganization( - $this->getOrganizationNameForUserName($user->name), - $user, - true, - $currency, - $numberFormat, - $currencyFormat, - $dateFormat, - $intervalFormat, - $timeFormat, - ); + $organizations = app(InvitationService::class)->processAcceptedInvitations($user); - $user->ownedTeams()->save($organization); + if ($organizations->isEmpty()) { + $organization = app(OrganizationService::class)->createOrganization( + $this->getOrganizationNameForUserName($user->name), + $user, + true, + $currency, + $numberFormat, + $currencyFormat, + $dateFormat, + $intervalFormat, + $timeFormat, + ); + $user->ownedTeams()->save($organization); + } return $user; } diff --git a/database/factories/OrganizationInvitationFactory.php b/database/factories/OrganizationInvitationFactory.php index a4b2377f..90896fe4 100644 --- a/database/factories/OrganizationInvitationFactory.php +++ b/database/factories/OrganizationInvitationFactory.php @@ -25,9 +25,24 @@ class OrganizationInvitationFactory extends Factory 'email' => $this->faker->unique()->safeEmail(), 'role' => Role::Employee->value, 'organization_id' => Organization::factory(), + 'accepted_at' => null, ]; } + public function role(Role $role): self + { + return $this->state(fn (array $attributes) => [ + 'role' => $role->value, + ]); + } + + public function accepted(): self + { + return $this->state(fn (array $attributes): array => [ + 'accepted_at' => $this->faker->dateTime(), + ]); + } + public function forOrganization(Organization $organization): self { return $this->state(fn (array $attributes) => [ diff --git a/database/migrations/2026_02_11_143746_add_accepted_at_to_organization_invitations_table.php b/database/migrations/2026_02_11_143746_add_accepted_at_to_organization_invitations_table.php new file mode 100644 index 00000000..7f0f4155 --- /dev/null +++ b/database/migrations/2026_02_11_143746_add_accepted_at_to_organization_invitations_table.php @@ -0,0 +1,30 @@ +timestamp('accepted_at')->nullable()->after('email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('organization_invitations', function (Blueprint $table): void { + $table->dropColumn('accepted_at'); + }); + } +}; diff --git a/resources/views/emails/auth-api-expiration-reminder.blade.php b/resources/views/emails/auth-api-expiration-reminder.blade.php index 4dcc6ce6..124007ba 100644 --- a/resources/views/emails/auth-api-expiration-reminder.blade.php +++ b/resources/views/emails/auth-api-expiration-reminder.blade.php @@ -1,7 +1,8 @@ @component('mail::message') -{{ __('The API token ":token" expired.', ['token' => $tokenName]) }} +{{ __('The API token ":token" will expire in 7 days!', ['token' => $tokenName]) }} +{{ __('Please make sure to create a new API token and use the new one instead before it expires to avoid any disruptions in service.') }} {{ __('You can create a new API token in your profile:') }} diff --git a/resources/views/emails/auth-api-token-expired.blade.php b/resources/views/emails/auth-api-token-expired.blade.php index 124007ba..4dcc6ce6 100644 --- a/resources/views/emails/auth-api-token-expired.blade.php +++ b/resources/views/emails/auth-api-token-expired.blade.php @@ -1,8 +1,7 @@ @component('mail::message') -{{ __('The API token ":token" will expire in 7 days!', ['token' => $tokenName]) }} +{{ __('The API token ":token" expired.', ['token' => $tokenName]) }} -{{ __('Please make sure to create a new API token and use the new one instead before it expires to avoid any disruptions in service.') }} {{ __('You can create a new API token in your profile:') }} diff --git a/resources/views/emails/organization-invitation.blade.php b/resources/views/emails/organization-invitation.blade.php index 8ff09eb1..41419037 100644 --- a/resources/views/emails/organization-invitation.blade.php +++ b/resources/views/emails/organization-invitation.blade.php @@ -1,20 +1,6 @@ @component('mail::message') {{ __('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:') }} - -@component('mail::button', ['url' => route('register')]) -{{ __('Create Account') }} -@endcomponent - -{{ __('If you already have an account, you may accept this invitation by clicking the button below:') }} - -@else -{{ __('You may accept this invitation by clicking the button below:') }} -@endif - - @component('mail::button', ['url' => $acceptUrl]) {{ __('Accept Invitation') }} @endcomponent diff --git a/routes/api.php b/routes/api.php index a09eac0a..efe402c4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -42,8 +42,10 @@ Route::prefix('v1')->name('v1.')->group(static function (): void { ])->group(static function (): void { // Organization routes Route::name('organizations.')->group(static function (): void { + Route::post('/organizations', [OrganizationController::class, 'store'])->name('store'); Route::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show'); Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update'); + Route::delete('/organizations/{organization}', [OrganizationController::class, 'destroy'])->name('destroy'); }); // Member routes @@ -59,6 +61,8 @@ Route::prefix('v1')->name('v1.')->group(static function (): void { // User routes Route::name('users.')->group(static function (): void { Route::get('/users/me', [UserController::class, 'me'])->name('me'); + Route::put('/users/{user}', [UserController::class, 'update'])->name('update'); + Route::delete('/users/{user}', [UserController::class, 'destroy'])->name('destroy'); }); // Api token routes diff --git a/routes/web.php b/routes/web.php index 14bc2e23..9c1f297a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ declare(strict_types=1); use App\Http\Controllers\Web\DashboardController; use App\Http\Controllers\Web\HomeController; +use App\Http\Controllers\Web\OrganizationInvitationController; use Illuminate\Support\Facades\Route; use Inertia\Inertia; use Laravel\Jetstream\Jetstream; @@ -83,3 +84,10 @@ Route::middleware([ })->name('import'); }); + +Route::get('/team-invitations/{invitation}', [OrganizationInvitationController::class, 'accept']) + ->middleware(['signed']) + ->name('team-invitations.accept'); // Note: legacy naming +Route::get('/organization-invitations/{invitation}', [OrganizationInvitationController::class, 'accept']) + ->middleware(['signed:relative']) + ->name('organization-invitations.accept'); diff --git a/tests/Feature/InviteTeamMemberTest.php b/tests/Feature/InviteTeamMemberTest.php index 3c622445..cff02996 100644 --- a/tests/Feature/InviteTeamMemberTest.php +++ b/tests/Feature/InviteTeamMemberTest.php @@ -88,7 +88,7 @@ class InviteTeamMemberTest extends TestCase Mail::fake(); $placeholder = User::factory()->placeholder()->create(); $owner = User::factory()->withPersonalOrganization()->create(); - $placeholderMember = Member::factory()->forOrganization($owner->currentTeam)->forUser($placeholder)->create(); + $placeholderMember = Member::factory()->role(Role::Placeholder)->forOrganization($owner->currentTeam)->forUser($placeholder)->create(); $timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forMember($placeholderMember)->createMany(5); diff --git a/tests/Feature/RegistrationTest.php b/tests/Feature/RegistrationTest.php index 66e027ee..01cce7e4 100644 --- a/tests/Feature/RegistrationTest.php +++ b/tests/Feature/RegistrationTest.php @@ -8,21 +8,21 @@ use App\Enums\Role; use App\Enums\Weekday; use App\Events\NewsletterRegistered; use App\Models\Member; +use App\Models\OrganizationInvitation; use App\Models\User; use App\Providers\RouteServiceProvider; use App\Service\IpLookup\IpLookupResponseDto; use App\Service\IpLookup\IpLookupServiceContract; -use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Log; use Laravel\Fortify\Features; use Laravel\Jetstream\Jetstream; -use Tests\TestCase; +use Tests\TestCaseWithDatabase; +use TiMacDonald\Log\LogEntry; -class RegistrationTest extends TestCase +class RegistrationTest extends TestCaseWithDatabase { - use RefreshDatabase; - public function test_registration_screen_can_be_rendered(): void { if (! Features::enabled(Features::registration())) { @@ -346,4 +346,82 @@ class RegistrationTest extends TestCase $this->assertAuthenticated(); $response->assertRedirect(RouteServiceProvider::HOME); } + + public function test_registration_does_not_create_private_organization_if_invite_was_accepted_for_the_email_with_the_registration_email(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $organizationInvitation = OrganizationInvitation::factory() + ->forOrganization($user->organization) + ->role(Role::Employee) + ->accepted() + ->create([ + 'email' => 'test@example.com', + ]); + + // Act + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(RouteServiceProvider::HOME); + $newUser = User::where('email', 'test@example.com')->first(); + $this->assertNotNull($newUser); + $this->assertDatabaseMissing(OrganizationInvitation::class, [ + 'email' => 'test@example.com', + ]); + $organizations = $newUser->organizations; + $this->assertCount(1, $organizations); + $this->assertSame($user->organization->id, $organizations->first()->id); + } + + public function test_registration_logs_and_skips_accepted_invitation_with_invalid_role(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $organizationInvitation = OrganizationInvitation::factory() + ->forOrganization($user->organization) + ->accepted() + ->create([ + 'email' => 'test@example.com', + 'role' => 'invalid-role', + ]); + + // Act + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + ]); + + // Assert + $this->assertAuthenticated(); + $response->assertRedirect(RouteServiceProvider::HOME); + Log::assertLogged(fn (LogEntry $log) => $log->level === 'error' + && $log->message === 'Invalid role in invitation' + && $log->context === [ + 'invitation' => $organizationInvitation->getKey(), + 'role' => 'invalid-role', + ]); + $newUser = User::where('email', 'test@example.com')->firstOrFail(); + $this->assertDatabaseHas(OrganizationInvitation::class, [ + 'id' => $organizationInvitation->getKey(), + 'email' => 'test@example.com', + 'role' => 'invalid-role', + ]); + $this->assertDatabaseMissing(Member::class, [ + 'organization_id' => $user->organization->getKey(), + 'user_id' => $newUser->getKey(), + ]); + $organizations = $newUser->organizations; + $this->assertCount(1, $organizations); + $this->assertNotSame($user->organization->id, $organizations->first()->id); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 95b21835..9cc1ccaf 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -38,7 +38,7 @@ abstract class TestCase extends BaseTestCase protected function mockPrivateStorage(): void { - Storage::fake(config('filesystems.default')); + Storage::fake(config('filesystems.private')); } protected function mockPublicStorage(): void diff --git a/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php index 72ed9c53..48bf7a8c 100644 --- a/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php @@ -5,9 +5,15 @@ declare(strict_types=1); namespace Tests\Unit\Endpoint\Api\V1; use App\Enums\Role; +use App\Events\AfterCreateOrganization; use App\Http\Controllers\Api\V1\OrganizationController; +use App\Models\Member; use App\Models\Organization; use App\Service\BillableRateService; +use App\Service\IpLookup\IpLookupResponseDto; +use App\Service\IpLookup\IpLookupServiceContract; +use Illuminate\Support\Facades\Event; +use Illuminate\Testing\Fluent\AssertableJson; use Laravel\Passport\Passport; use Mockery\MockInterface; use PHPUnit\Framework\Attributes\UsesClass; @@ -93,6 +99,121 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract $response->assertJsonPath('data.billable_rate', null); } + public function test_store_endpoint_creates_new_organization(): void + { + // Arrange + $data = $this->createUserWithPermission(); + $organizationFake = Organization::factory()->make(); + Event::fake([ + AfterCreateOrganization::class, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.organizations.store'), [ + 'name' => $organizationFake->name, + ]); + + // Assert + $response->assertStatus(201); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', $organizationFake->name) + ->where('data.is_personal', false) + ->where('data.currency', config('app.localization.default_currency')) + ->etc() + ); + + /** @var Organization $newOrganization */ + $newOrganization = Organization::query()->where('name', $organizationFake->name)->firstOrFail(); + $this->assertTrue($newOrganization->owner->is($data->user)); + $this->assertSame($newOrganization->getKey(), $data->user->fresh()->current_team_id); + $this->assertDatabaseHas(Member::class, [ + 'organization_id' => $newOrganization->getKey(), + 'user_id' => $data->user->getKey(), + 'role' => Role::Owner->value, + ]); + Event::assertDispatched(AfterCreateOrganization::class, function (AfterCreateOrganization $event) use ($newOrganization): bool { + return $event->organization->is($newOrganization); + }); + } + + public function test_store_endpoint_uses_ip_lookup_currency_for_new_organization(): void + { + // Arrange + $data = $this->createUserWithPermission(); + $this->mock(IpLookupServiceContract::class, function (MockInterface $mock): void { + $mock->shouldReceive('lookup') + ->once() + ->andReturn(new IpLookupResponseDto(null, null, 'USD')); + }); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.organizations.store'), [ + 'name' => 'Test Organization', + ]); + + // Assert + $response->assertStatus(201); + $response->assertJsonPath('data.currency', 'USD'); + $this->assertDatabaseHas(Organization::class, [ + 'name' => 'Test Organization', + 'currency' => 'USD', + 'user_id' => $data->user->getKey(), + 'personal_team' => false, + ]); + } + + public function test_store_endpoint_fails_if_name_is_missing(): void + { + // Arrange + $data = $this->createUserWithPermission(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.organizations.store'), []); + + // Assert + $response->assertStatus(422); + $response->assertJsonValidationErrors(['name']); + $this->assertDatabaseCount(Organization::class, 1); + } + + public function test_store_endpoint_fails_if_name_is_not_a_string(): void + { + // Arrange + $data = $this->createUserWithPermission(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.organizations.store'), [ + 'name' => ['Test Organization'], + ]); + + // Assert + $response->assertStatus(422); + $response->assertJsonValidationErrors(['name']); + $this->assertDatabaseCount(Organization::class, 1); + } + + public function test_store_endpoint_fails_if_name_is_too_long(): void + { + // Arrange + $data = $this->createUserWithPermission(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.organizations.store'), [ + 'name' => str_repeat('a', 256), + ]); + + // Assert + $response->assertStatus(422); + $response->assertJsonValidationErrors(['name']); + $this->assertDatabaseCount(Organization::class, 1); + } + public function test_update_endpoint_fails_if_user_has_no_permission_to_update_organizations(): void { // Arrange @@ -260,4 +381,51 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract 'billable_rate' => $organizationFake->billable_rate, ]); } + + public function test_delete_endpoint_if_user_does_not_have_permission(): void + { + // Arrange + $data = $this->createUserWithPermission(); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()])); + + // Assert + $response->assertForbidden(); + } + + public function test_delete_endpoint_fails_with_not_found_if_id_is_not_uuid(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'organizations:delete', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.organizations.destroy', ['not-uuid'])); + + // Assert + $response->assertNotFound(); + } + + public function test_delete_endpoint_can_delete_organization(): void + { + // Arrange + $this->mockPrivateStorage(); + $data = $this->createUserWithPermission([ + 'organizations:delete', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()])); + + // Assert + $response->assertNoContent(); + $this->assertDatabaseMissing(Organization::class, [ + 'id' => $data->organization->getKey(), + ]); + } } diff --git a/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php b/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php index cd710f75..c113a317 100644 --- a/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Unit\Endpoint\Api\V1; +use App\Models\User; use Laravel\Passport\Passport; class UserEndpointTest extends ApiEndpointTestAbstract @@ -40,4 +41,57 @@ class UserEndpointTest extends ApiEndpointTestAbstract ], ]); } + + public function test_delete_fails_if_given_user_is_not_the_authenticated_user(): void + { + // Arrange + $data = $this->createUserWithPermission(); + $otherData = $this->createUserWithPermission(); + Passport::actingAs($otherData->user); + + // Act + $response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey())); + + // Assert + $response->assertForbidden(); + } + + public function test_delete_fails_if_not_authenticated(): void + { + // Arrange + $data = $this->createUserWithPermission(); + + // Act + $response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey())); + + // Assert + $response->assertUnauthorized(); + } + + public function test_delete_fails_if_user_does_not_exist(): void + { + // Arrange + $data = $this->createUserWithPermission(); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.users.destroy', 'not-valid')); + + // Assert + $response->assertNotFound(); + } + + public function test_delete_removes_user(): void + { + // Arrange + $data = $this->createUserWithPermission(); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey())); + + // Assert + $response->assertNoContent(); + $this->assertDatabaseMissing(User::class, ['id' => $data->user->getKey()]); + } } diff --git a/tests/Unit/Endpoint/Web/OrganizationInvitationEndpointTest.php b/tests/Unit/Endpoint/Web/OrganizationInvitationEndpointTest.php new file mode 100644 index 00000000..6c8e4dde --- /dev/null +++ b/tests/Unit/Endpoint/Web/OrganizationInvitationEndpointTest.php @@ -0,0 +1,220 @@ +createUserWithPermission(); + $invitation = OrganizationInvitation::factory() + ->forOrganization($user->organization) + ->create(); + + // Act + $acceptUrl = URL::temporarySignedRoute( + 'team-invitations.accept', + now()->addMinutes(60), + [$invitation->getKey()] + ); + $response = $this->get($acceptUrl); + + // Assert + $response->assertValid(); + $response->assertRedirect(route('register', [ + 'bannerStyle' => 'info', + 'bannerText' => 'Please create an account to finish joining the '.$user->organization->name.' organization.', + ])); + $invitation->refresh(); + $this->assertNotNull($invitation->accepted_at); + } + + public function test_can_accept_invitation_without_an_account_with_the_email_address_and_redirects_to_registration(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $invitation = OrganizationInvitation::factory() + ->forOrganization($user->organization) + ->create(); + + // Act + $acceptUrl = URl::to(URL::temporarySignedRoute( + 'organization-invitations.accept', + now()->addMinutes(60), + [$invitation->getKey()], + false + )); + $response = $this->get($acceptUrl); + + // Assert + $response->assertValid(); + $response->assertRedirect(route('register', [ + 'bannerStyle' => 'info', + 'bannerText' => 'Please create an account to finish joining the '.$user->organization->name.' organization.', + ])); + $invitation->refresh(); + $this->assertNotNull($invitation->accepted_at); + } + + public function test_can_accept_invitation_with_an_account_with_the_email_address_and_redirects_to_dashboard(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $user2 = $this->createUserWithPermission(); + $invitation = OrganizationInvitation::factory() + ->forOrganization($user->organization) + ->create([ + 'role' => Role::Employee->value, + 'email' => $user2->user->email, + ]); + $this->actingAs($user2->user); + + // Act + $acceptUrl = URl::to(URL::temporarySignedRoute( + 'organization-invitations.accept', + now()->addMinutes(60), + [$invitation->getKey()], + false + )); + $response = $this->get($acceptUrl); + + // Assert + $response->assertValid(); + $response->assertRedirect(route('dashboard', [ + 'bannerStyle' => 'success', + 'bannerText' => 'Great! You have accepted the invitation to join the '.$user->organization->name.' organization.', + ])); + $this->assertDatabaseHas(Member::class, [ + 'user_id' => $user2->user->getKey(), + 'organization_id' => $user->organization->getKey(), + 'role' => Role::Employee->value, + ]); + $this->assertDatabaseMissing(OrganizationInvitation::class, [ + 'id' => $invitation->getKey(), + ]); + } + + public function test_fails_if_user_is_already_member_of_the_organization(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $user2 = $this->createUserWithPermission(); + $invitation = OrganizationInvitation::factory() + ->forOrganization($user->organization) + ->create([ + 'role' => Role::Employee->value, + 'email' => $user2->user->email, + ]); + Member::factory()->forOrganization($user->organization)->forUser($user2->user)->create(); + $this->actingAs($user2->user); + + // Act + $acceptUrl = URl::to(URL::temporarySignedRoute( + 'organization-invitations.accept', + now()->addMinutes(60), + [$invitation->getKey()], + false + )); + $response = $this->get($acceptUrl); + + // Assert + $response->assertValid(); + $response->assertRedirect(route('dashboard', [ + 'bannerStyle' => 'danger', + 'bannerText' => 'You are already a member of the '.$user->organization->name.' organization.', + ])); + } + + public function test_accepting_invitation_with_existing_account_migrates_data_of_placeholder_users_with_same_email_to_new_member(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $user2 = $this->createUserWithPermission(); + $invitation = OrganizationInvitation::factory() + ->forOrganization($user->organization) + ->create([ + 'role' => Role::Employee->value, + 'email' => $user2->user->email, + ]); + $placeholder1 = User::factory()->placeholder()->create([ + 'email' => $user2->user->email, + ]); + $placeholder1Member = Member::factory()->forOrganization($user->organization)->forUser($placeholder1)->role(Role::Placeholder)->create(); + $placeholder2 = User::factory()->placeholder()->create([ + 'email' => $user2->user->email, + ]); + $placeholder2Member = Member::factory()->forOrganization($user->organization)->forUser($placeholder2)->role(Role::Placeholder)->create(); + + $this->actingAs($user2->user); + + // Act + $acceptUrl = URl::to(URL::temporarySignedRoute( + 'organization-invitations.accept', + now()->addMinutes(60), + [$invitation->getKey()], + false + )); + $response = $this->get($acceptUrl); + + // Assert + $response->assertValid(); + $response->assertRedirect(route('dashboard', [ + 'bannerStyle' => 'success', + 'bannerText' => 'Great! You have accepted the invitation to join the '.$user->organization->name.' organization.', + ])); + $this->assertDatabaseHas(Member::class, [ + 'user_id' => $user2->user->getKey(), + 'organization_id' => $user->organization->getKey(), + 'role' => Role::Employee->value, + ]); + $this->assertDatabaseMissing(User::class, [ + 'id' => $placeholder1->getKey(), + ]); + $this->assertDatabaseMissing(User::class, [ + 'id' => $placeholder2->getKey(), + ]); + $this->assertDatabaseMissing(Member::class, [ + 'id' => $placeholder1Member->getKey(), + ]); + $this->assertDatabaseMissing(Member::class, [ + 'id' => $placeholder2Member->getKey(), + ]); + $this->assertDatabaseMissing(OrganizationInvitation::class, [ + 'id' => $invitation->getKey(), + ]); + } + + public function test_fails_with_invalid_signature(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $invitation = OrganizationInvitation::factory() + ->forOrganization($user->organization) + ->create(); + + // Act + $response = $this->get(URL::temporarySignedRoute( + 'organization-invitations.accept', + now()->addMinutes(60), + [$invitation->getKey()]). + '?invalid' + ); + + // Assert + $response->assertForbidden(); + } +} diff --git a/tests/Unit/Mail/AuthApiTokenExpirationReminderMailTest.php b/tests/Unit/Mail/AuthApiTokenExpirationReminderMailTest.php index 91bc4eb1..51e2b70b 100644 --- a/tests/Unit/Mail/AuthApiTokenExpirationReminderMailTest.php +++ b/tests/Unit/Mail/AuthApiTokenExpirationReminderMailTest.php @@ -28,6 +28,6 @@ class AuthApiTokenExpirationReminderMailTest extends TestCaseWithDatabase $rendered = $mail->render(); // Assert - $this->assertStringContainsString('The API token "TEST" expired.', $rendered); + $this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered); } } diff --git a/tests/Unit/Mail/AuthApiTokenExpiredMailTest.php b/tests/Unit/Mail/AuthApiTokenExpiredMailTest.php index 68c328a1..1bf01514 100644 --- a/tests/Unit/Mail/AuthApiTokenExpiredMailTest.php +++ b/tests/Unit/Mail/AuthApiTokenExpiredMailTest.php @@ -28,6 +28,6 @@ class AuthApiTokenExpiredMailTest extends TestCaseWithDatabase $rendered = $mail->render(); // Assert - $this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered); + $this->assertStringContainsString('The API token "TEST" expired.', $rendered); } }