Updated invitation flow, Moved jetstream function to REST endpoints; Lower case email

This commit is contained in:
Constantin Graf
2026-02-25 17:28:34 +01:00
committed by Constantin Graf
parent d732064f31
commit a30d192ea2
37 changed files with 942 additions and 209 deletions

1
.gitignore vendored
View File

@@ -42,3 +42,4 @@ yarn-error.log
/data /data
/config/caddy /config/caddy
/config/composer /config/composer
/AGENTS.md

View File

@@ -4,18 +4,9 @@ declare(strict_types=1);
namespace App\Actions\Jetstream; namespace App\Actions\Jetstream;
use App\Enums\Role; use App\Exceptions\MovedToApiException;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; 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; use Laravel\Jetstream\Contracts\AddsTeamMembers;
class AddOrganizationMember implements 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 public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
{ {
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring throw new MovedToApiException;
$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<string, array<ValidationRule|Rule|string|In>>
*/
protected function rules(): array
{
return [
'email' => [
'required',
'email',
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
/** @var Builder<User> $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.')
);
};
} }
} }

View File

@@ -25,6 +25,8 @@ class CreateOrganization implements CreatesTeams
* *
* @throws AuthorizationException * @throws AuthorizationException
* @throws ValidationException * @throws ValidationException
*
* @deprecated Use REST endpoint instead
*/ */
public function create(User $user, array $input): Organization public function create(User $user, array $input): Organization
{ {

View File

@@ -12,6 +12,8 @@ class DeleteOrganization implements DeletesTeams
{ {
/** /**
* Delete the given team. * Delete the given team.
*
* @deprecated Use REST endpoint instead
*/ */
public function delete(Organization $organization): void public function delete(Organization $organization): void
{ {

View File

@@ -16,6 +16,8 @@ class DeleteUser implements DeletesUsers
* Delete the given user. * Delete the given user.
* *
* @throws ValidationException * @throws ValidationException
*
* @deprecated Use REST endpoint instead
*/ */
public function delete(User $user): void public function delete(User $user): void
{ {

View File

@@ -18,6 +18,8 @@ class ValidateOrganizationDeletion
* @param Organization $organization Organization to be deleted * @param Organization $organization Organization to be deleted
* *
* @throws AuthorizationException * @throws AuthorizationException
*
* @deprecated Use REST endpoint instead
*/ */
public function validate(User $user, Organization $organization): void public function validate(User $user, Organization $organization): void
{ {

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
class MemberAdded
{
use Dispatchable;
public Member $member;
public Organization $organization;
public User $user;
public function __construct(Member $member, Organization $organization, User $user)
{
$this->member = $member;
$this->organization = $organization;
$this->user = $user;
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Enums\Role;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
class MemberAdding
{
use Dispatchable;
public User $user;
public Organization $organization;
public Role $role;
public function __construct(User $user, Organization $organization, Role $role)
{
$this->user = $user;
$this->organization = $organization;
$this->role = $role;
}
}

View File

@@ -5,11 +5,17 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Role; 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\Requests\V1\Organization\OrganizationUpdateRequest;
use App\Http\Resources\V1\Organization\OrganizationResource; use App\Http\Resources\V1\Organization\OrganizationResource;
use App\Models\Organization; use App\Models\Organization;
use App\Service\BillableRateService; use App\Service\BillableRateService;
use App\Service\DeletionService;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\OrganizationService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class OrganizationController extends Controller class OrganizationController extends Controller
{ {
@@ -80,4 +86,48 @@ class OrganizationController extends Controller
return new OrganizationResource($organization, true); 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);
}
} }

View File

@@ -4,8 +4,12 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Http\Resources\V1\User\UserResource; use App\Http\Resources\V1\User\UserResource;
use App\Models\User;
use App\Service\DeletionService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class UserController extends Controller class UserController extends Controller
{ {
@@ -24,4 +28,29 @@ class UserController extends Controller
return new UserResource($user); 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);
}
} }

View File

@@ -4,30 +4,13 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web; 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\Inertia;
use Inertia\Response; use Inertia\Response;
class DashboardController extends Controller class DashboardController extends Controller
{ {
/** public function dashboard(): Response
* @throws AuthorizationException
*/
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): 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'); return Inertia::render('Dashboard');
} }
} }

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Enums\Role;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Service\MemberService;
use Illuminate\Http\RedirectResponse;
use RuntimeException;
class OrganizationInvitationController extends Controller
{
public function accept(OrganizationInvitation $invitation, MemberService $memberService): RedirectResponse
{
$email = strtolower($invitation->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,
]),
]));
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Organization;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
/**
* @property Organization $organization Organization from model binding
*/
class OrganizationStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
],
];
}
public function getName(): string
{
return (string) $this->input('name');
}
}

View File

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Models\Member;
use App\Models\User;
use App\Service\MemberService;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Jetstream\Events\TeamMemberAdded;
class RemovePlaceholder
{
/**
* Handle the event.
*/
public function handle(TeamMemberAdded $event): void
{
$memberService = app(MemberService::class);
$member = Member::query()
->whereBelongsTo($event->team, 'organization')
->whereBelongsTo($event->user, 'user')
->firstOrFail();
$placeholders = Member::query()
->whereHas('user', function (Builder $query) use ($event): void {
/** @var Builder<User> $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();
}
}
}

View File

@@ -8,6 +8,7 @@ use App\Models\OrganizationInvitation;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
class OrganizationInvitationMail extends Mailable class OrganizationInvitationMail extends Mailable
@@ -32,9 +33,12 @@ class OrganizationInvitationMail extends Mailable
public function build(): self public function build(): self
{ {
return $this->markdown('emails.organization-invitation', [ return $this->markdown('emails.organization-invitation', [
'acceptUrl' => URL::signedRoute('team-invitations.accept', [ 'acceptUrl' => URL::to(URL::signedRoute(
'invitation' => $this->invitation, 'organization-invitations.accept',
]), ['invitation' => $this->invitation->getKey()],
Carbon::now()->addDays(90),
false
)),
])->subject(__('Organization Invitation')); ])->subject(__('Organization Invitation'));
} }
} }

View File

@@ -36,6 +36,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $user_id * @property string $user_id
* @property bool $employees_can_see_billable_rates * @property bool $employees_can_see_billable_rates
* @property bool $employees_can_manage_tasks * @property bool $employees_can_manage_tasks
* @property bool $prevent_overlapping_time_entries
* @property User $owner * @property User $owner
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at

View File

@@ -18,6 +18,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $email * @property string $email
* @property string $role * @property string $role
* @property string $organization_id * @property string $organization_id
* @property Carbon|null $accepted_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property-read Organization $organization * @property-read Organization $organization
@@ -41,14 +42,16 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
protected $table = 'organization_invitations'; protected $table = 'organization_invitations';
/** /**
* The attributes that are mass assignable. * Get the attributes that should be cast.
* *
* @var array<int, string> * @return array<string, string>
*/ */
protected $fillable = [ public function casts(): array
'email', {
'role', return [
]; 'accepted_at' => 'datetime',
];
}
/** /**
* Get the organization that the invitation belongs to. * Get the organization that the invitation belongs to.

View File

@@ -62,18 +62,6 @@ class OrganizationPolicy
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update'); 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. * Determine whether the user can update team member permissions.
*/ */

View File

@@ -4,11 +4,9 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Listeners\RemovePlaceholder;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Laravel\Jetstream\Events\TeamMemberAdded;
class EventServiceProvider extends ServiceProvider class EventServiceProvider extends ServiceProvider
{ {
@@ -21,9 +19,6 @@ class EventServiceProvider extends ServiceProvider
Registered::class => [ Registered::class => [
SendEmailVerificationNotification::class, SendEmailVerificationNotification::class,
], ],
TeamMemberAdded::class => [
RemovePlaceholder::class,
],
]; ];
/** /**

View File

@@ -8,9 +8,11 @@ use App\Enums\Role;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException; use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Mail\OrganizationInvitationMail; use App\Mail\OrganizationInvitationMail;
use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Laravel\Jetstream\Events\InvitingTeamMember; use Laravel\Jetstream\Events\InvitingTeamMember;
@@ -21,11 +23,7 @@ class InvitationService
*/ */
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
{ {
if (Member::query() if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
->whereBelongsTo($organization, 'organization')
->whereRelation('user', 'email', '=', $email)
->where('role', '!=', Role::Placeholder->value)
->exists()) {
throw new UserIsAlreadyMemberOfOrganizationApiException; throw new UserIsAlreadyMemberOfOrganizationApiException;
} }
@@ -48,4 +46,37 @@ class InvitationService
return $invitation; return $invitation;
} }
/**
* @return Collection<int, Organization>
*/
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;
}
} }

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\Role; use App\Enums\Role;
use App\Events\MemberAdded;
use App\Events\MemberAdding;
use App\Events\MemberRemoved; use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
@@ -36,7 +38,8 @@ class MemberService
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
{ {
if (! $asSuperAdmin) { if (! $asSuperAdmin) {
AddingTeamMember::dispatch($organization, $user); MemberAdding::dispatch($user, $organization, $role);
AddingTeamMember::dispatch($organization, $user); // Legacy event
} }
$member = new Member; $member = new Member;
@@ -49,14 +52,37 @@ class MemberService
$user->currentOrganization()->associate($organization); $user->currentOrganization()->associate($organization);
$user->save(); $user->save();
}); });
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
if (! $asSuperAdmin) { if (! $asSuperAdmin) {
TeamMemberAdded::dispatch($organization, $user); MemberAdded::dispatch($member, $organization, $user);
TeamMemberAdded::dispatch($organization, $user); // Legacy event
} }
return $member; 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<User> $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 CanNotRemoveOwnerFromOrganization
* @throws EntityStillInUseApiException * @throws EntityStillInUseApiException
@@ -209,4 +235,13 @@ class MemberService
$this->userService->makeSureUserHasCurrentOrganization($user); $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();
}
} }

View File

@@ -38,7 +38,7 @@ class UserService
): User { ): User {
$user = new User; $user = new User;
$user->name = $name; $user->name = $name;
$user->email = $email; $user->email = strtolower($email);
$user->password = Hash::make($password); $user->password = Hash::make($password);
$user->timezone = $timezone; $user->timezone = $timezone;
$user->week_start = $weekStart; $user->week_start = $weekStart;
@@ -47,19 +47,22 @@ class UserService
} }
$user->save(); $user->save();
$organization = app(OrganizationService::class)->createOrganization( $organizations = app(InvitationService::class)->processAcceptedInvitations($user);
$this->getOrganizationNameForUserName($user->name),
$user,
true,
$currency,
$numberFormat,
$currencyFormat,
$dateFormat,
$intervalFormat,
$timeFormat,
);
$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; return $user;
} }

View File

@@ -25,9 +25,24 @@ class OrganizationInvitationFactory extends Factory
'email' => $this->faker->unique()->safeEmail(), 'email' => $this->faker->unique()->safeEmail(),
'role' => Role::Employee->value, 'role' => Role::Employee->value,
'organization_id' => Organization::factory(), '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 public function forOrganization(Organization $organization): self
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('organization_invitations', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@@ -1,7 +1,8 @@
@component('mail::message') @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:') }} {{ __('You can create a new API token in your profile:') }}

View File

@@ -1,8 +1,7 @@
@component('mail::message') @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:') }} {{ __('You can create a new API token in your profile:') }}

View File

@@ -1,20 +1,6 @@
@component('mail::message') @component('mail::message')
{{ __('You have been invited to join the :organization organization!', ['organization' => $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:') }}
@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]) @component('mail::button', ['url' => $acceptUrl])
{{ __('Accept Invitation') }} {{ __('Accept Invitation') }}
@endcomponent @endcomponent

View File

@@ -42,8 +42,10 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
])->group(static function (): void { ])->group(static function (): void {
// Organization routes // Organization routes
Route::name('organizations.')->group(static function (): void { 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::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show');
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update'); Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update');
Route::delete('/organizations/{organization}', [OrganizationController::class, 'destroy'])->name('destroy');
}); });
// Member routes // Member routes
@@ -59,6 +61,8 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
// User routes // User routes
Route::name('users.')->group(static function (): void { Route::name('users.')->group(static function (): void {
Route::get('/users/me', [UserController::class, 'me'])->name('me'); 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 // Api token routes

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Http\Controllers\Web\DashboardController; use App\Http\Controllers\Web\DashboardController;
use App\Http\Controllers\Web\HomeController; use App\Http\Controllers\Web\HomeController;
use App\Http\Controllers\Web\OrganizationInvitationController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Jetstream;
@@ -83,3 +84,10 @@ Route::middleware([
})->name('import'); })->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');

View File

@@ -88,7 +88,7 @@ class InviteTeamMemberTest extends TestCase
Mail::fake(); Mail::fake();
$placeholder = User::factory()->placeholder()->create(); $placeholder = User::factory()->placeholder()->create();
$owner = User::factory()->withPersonalOrganization()->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); $timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forMember($placeholderMember)->createMany(5);

View File

@@ -8,21 +8,21 @@ use App\Enums\Role;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Events\NewsletterRegistered; use App\Events\NewsletterRegistered;
use App\Models\Member; use App\Models\Member;
use App\Models\OrganizationInvitation;
use App\Models\User; use App\Models\User;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use App\Service\IpLookup\IpLookupResponseDto; use App\Service\IpLookup\IpLookupResponseDto;
use App\Service\IpLookup\IpLookupServiceContract; use App\Service\IpLookup\IpLookupServiceContract;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
use Laravel\Jetstream\Jetstream; 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 public function test_registration_screen_can_be_rendered(): void
{ {
if (! Features::enabled(Features::registration())) { if (! Features::enabled(Features::registration())) {
@@ -346,4 +346,82 @@ class RegistrationTest extends TestCase
$this->assertAuthenticated(); $this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME); $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);
}
} }

View File

@@ -38,7 +38,7 @@ abstract class TestCase extends BaseTestCase
protected function mockPrivateStorage(): void protected function mockPrivateStorage(): void
{ {
Storage::fake(config('filesystems.default')); Storage::fake(config('filesystems.private'));
} }
protected function mockPublicStorage(): void protected function mockPublicStorage(): void

View File

@@ -5,9 +5,15 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1; namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\Role; use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Http\Controllers\Api\V1\OrganizationController; use App\Http\Controllers\Api\V1\OrganizationController;
use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Service\BillableRateService; 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 Laravel\Passport\Passport;
use Mockery\MockInterface; use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesClass;
@@ -93,6 +99,121 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('data.billable_rate', null); $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 public function test_update_endpoint_fails_if_user_has_no_permission_to_update_organizations(): void
{ {
// Arrange // Arrange
@@ -260,4 +381,51 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
'billable_rate' => $organizationFake->billable_rate, '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(),
]);
}
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1; namespace Tests\Unit\Endpoint\Api\V1;
use App\Models\User;
use Laravel\Passport\Passport; use Laravel\Passport\Passport;
class UserEndpointTest extends ApiEndpointTestAbstract 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()]);
}
} }

View File

@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Endpoint\Web;
use App\Enums\Role;
use App\Http\Controllers\Web\OrganizationInvitationController;
use App\Models\Member;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Service\MemberService;
use Illuminate\Support\Facades\URL;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(OrganizationInvitationController::class)]
#[CoversClass(MemberService::class)]
class OrganizationInvitationEndpointTest extends EndpointTestAbstract
{
public function test_legacy_url_still_works(): void
{
// Arrange
$user = $this->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();
}
}

View File

@@ -28,6 +28,6 @@ class AuthApiTokenExpirationReminderMailTest extends TestCaseWithDatabase
$rendered = $mail->render(); $rendered = $mail->render();
// Assert // Assert
$this->assertStringContainsString('The API token "TEST" expired.', $rendered); $this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered);
} }
} }

View File

@@ -28,6 +28,6 @@ class AuthApiTokenExpiredMailTest extends TestCaseWithDatabase
$rendered = $mail->render(); $rendered = $mail->render();
// Assert // Assert
$this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered); $this->assertStringContainsString('The API token "TEST" expired.', $rendered);
} }
} }