Compare commits

..

6 Commits

Author SHA1 Message Date
Constantin Graf
99aa7ed450 Updated invitation flow, Moved jetstream function to REST endpoints; Lower case email 2026-02-25 17:28:34 +01:00
Gregor Vostrak
f582adab0d fix time entries incorrectly not updating in calendar
the synced snapDuration cause incorrect noops on updates f.e. 15:55-16:00 on a 15 minute snap
2026-02-24 19:38:55 +01:00
Gregor Vostrak
c60cff04ce fix calendar flickering on move for non-aligned entries
this is a trade-off where for non grid aligned entries, the cursor position is a bit off, but data and visual are stil in sync. otherwise fc overrides height on drag, causing flickers.
2026-02-24 15:30:18 +01:00
Gregor Vostrak
cae41e4b4f improve visual snapping boundaries 2026-02-24 14:02:18 +01:00
Gregor Vostrak
8973be9dab filament minor version update 2026-02-24 13:43:21 +01:00
Gregor Vostrak
2a0b8d31e6 add calendar settings + custom visual snapping 2026-02-24 12:41:15 +01:00
38 changed files with 1703 additions and 880 deletions

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;
} }

1562
composer.lock generated

File diff suppressed because it is too large Load Diff

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

@@ -235,8 +235,8 @@ function handleDateSelect(arg: { start: Date; end: Date }) {
.utc() .utc()
.tz(getUserTimezone(), true); .tz(getUserTimezone(), true);
const endLocal = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true); const endLocal = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true);
const snappedStart = snapToGrid(startLocal, snap); const snappedStart = snapStartToGrid(startLocal, snap);
let snappedEnd = snapToGrid(endLocal, snap); let snappedEnd = snapEndToGrid(endLocal, snap);
if (!snappedEnd.isAfter(snappedStart)) { if (!snappedEnd.isAfter(snappedStart)) {
snappedEnd = snappedStart.add(snap, 'minute'); snappedEnd = snappedStart.add(snap, 'minute');
} }
@@ -255,10 +255,17 @@ function handleEventClick(arg: EventClickArg) {
showEditTimeEntryModal.value = true; showEditTimeEntryModal.value = true;
} }
// Snap a dayjs time to the nearest snap interval boundary // Snap a dayjs time down to the previous snap boundary (for start times)
function snapToGrid(time: Dayjs, snapMinutes: number): Dayjs { function snapStartToGrid(time: Dayjs, snapMinutes: number): Dayjs {
const minutes = time.hour() * 60 + time.minute(); const minutes = time.hour() * 60 + time.minute();
const snapped = Math.round(minutes / snapMinutes) * snapMinutes; const snapped = Math.floor(minutes / snapMinutes) * snapMinutes;
return time.startOf('day').add(snapped, 'minute');
}
// Snap a dayjs time up to the next snap boundary (for end times)
function snapEndToGrid(time: Dayjs, snapMinutes: number): Dayjs {
const minutes = time.hour() * 60 + time.minute();
const snapped = Math.ceil(minutes / snapMinutes) * snapMinutes;
return time.startOf('day').add(snapped, 'minute'); return time.startOf('day').add(snapped, 'minute');
} }
@@ -291,7 +298,7 @@ async function handleEventDrop(arg: EventDropArg) {
.utc() .utc()
.tz(getUserTimezone(), true) .tz(getUserTimezone(), true)
.second(0); .second(0);
const snappedStart = snapToGrid(startLocal, snap); const snappedStart = snapStartToGrid(startLocal, snap);
const durationMs = getLocalizedDayJs(timeEntry.end).diff(getLocalizedDayJs(timeEntry.start)); const durationMs = getLocalizedDayJs(timeEntry.end).diff(getLocalizedDayJs(timeEntry.start));
const snappedEnd = snappedStart.add(durationMs, 'millisecond'); const snappedEnd = snappedStart.add(durationMs, 'millisecond');
// Set FC event to snapped position immediately to avoid flash // Set FC event to snapped position immediately to avoid flash
@@ -325,8 +332,8 @@ async function handleEventResize(arg: EventChangeArg) {
const startChanged = !newStartLocal.isSame(origStartLocal, 'minute'); const startChanged = !newStartLocal.isSame(origStartLocal, 'minute');
// Snap only the changed edge once, reuse for both setDates and API update // Snap only the changed edge once, reuse for both setDates and API update
const snappedStart = startChanged ? snapToGrid(newStartLocal, snap) : null; const snappedStart = startChanged ? snapStartToGrid(newStartLocal, snap) : null;
const snappedEnd = !startChanged && !ext.isRunning ? snapToGrid(newEndLocal, snap) : null; const snappedEnd = !startChanged && !ext.isRunning ? snapEndToGrid(newEndLocal, snap) : null;
// Set FC event to snapped position immediately to avoid flash. // Set FC event to snapped position immediately to avoid flash.
// Use the original event date for the edge that wasn't resized. // Use the original event date for the edge that wasn't resized.
@@ -747,6 +754,10 @@ onUnmounted(() => {
border: 1px solid var(--primary); border: 1px solid var(--primary);
} }
.fullcalendar :deep(.fc-event-mirror) {
pointer-events: none;
}
.fullcalendar :deep(.fc-scrollgrid) { .fullcalendar :deep(.fc-scrollgrid) {
border: 1px solid var(--border); border: 1px solid var(--border);
border-left: 1px solid transparent; border-left: 1px solid transparent;

View File

@@ -32,6 +32,9 @@ export function useVisualSnap({
function findMirrorHarness(calendarEl: HTMLElement) { function findMirrorHarness(calendarEl: HTMLElement) {
const mirror = calendarEl.querySelector('.fc-event-mirror') as HTMLElement | null; const mirror = calendarEl.querySelector('.fc-event-mirror') as HTMLElement | null;
const harness = mirror?.closest('.fc-timegrid-event-harness') as HTMLElement | null; const harness = mirror?.closest('.fc-timegrid-event-harness') as HTMLElement | null;
if (harness) {
harness.style.pointerEvents = 'none';
}
return { mirror, harness }; return { mirror, harness };
} }
@@ -81,8 +84,8 @@ export function useVisualSnap({
const top = parseFloat(harness.style.top) || 0; const top = parseFloat(harness.style.top) || 0;
const endPos = -(parseFloat(harness.style.bottom) || 0); const endPos = -(parseFloat(harness.style.bottom) || 0);
const snappedTop = Math.round(top / snapPx) * snapPx; const snappedTop = Math.floor(top / snapPx) * snapPx;
const snappedEnd = Math.round(endPos / snapPx) * snapPx; const snappedEnd = Math.ceil(endPos / snapPx) * snapPx;
const clampedEnd = Math.max(snappedTop + snapPx, snappedEnd); const clampedEnd = Math.max(snappedTop + snapPx, snappedEnd);
harness.style.top = snappedTop + 'px'; harness.style.top = snappedTop + 'px';
harness.style.bottom = -clampedEnd + 'px'; harness.style.bottom = -clampedEnd + 'px';
@@ -99,7 +102,7 @@ export function useVisualSnap({
const top = parseFloat(harness.style.top) || 0; const top = parseFloat(harness.style.top) || 0;
const endPos = -(parseFloat(harness.style.bottom) || 0); const endPos = -(parseFloat(harness.style.bottom) || 0);
const height = endPos - top; const height = endPos - top;
const snappedTop = Math.round(top / snapPx) * snapPx; const snappedTop = Math.floor(top / snapPx) * snapPx;
harness.style.top = snappedTop + 'px'; harness.style.top = snappedTop + 'px';
harness.style.bottom = -(snappedTop + height) + 'px'; harness.style.bottom = -(snappedTop + height) + 'px';
}); });
@@ -135,12 +138,12 @@ export function useVisualSnap({
} }
if (resizeEdge === 'bottom') { if (resizeEdge === 'bottom') {
const snappedEnd = Math.round(endPos / snapPx) * snapPx; const snappedEnd = Math.ceil(endPos / snapPx) * snapPx;
const clampedEnd = Math.max(top + snapPx, snappedEnd); const clampedEnd = Math.max(top + snapPx, snappedEnd);
harness.style.bottom = -clampedEnd + 'px'; harness.style.bottom = -clampedEnd + 'px';
if (mirror) updateMirrorDurationLabel(mirror, top, clampedEnd, snapPx); if (mirror) updateMirrorDurationLabel(mirror, top, clampedEnd, snapPx);
} else if (resizeEdge === 'top') { } else if (resizeEdge === 'top') {
const snappedTop = Math.round(top / snapPx) * snapPx; const snappedTop = Math.floor(top / snapPx) * snapPx;
const clampedTop = Math.min(endPos - snapPx, snappedTop); const clampedTop = Math.min(endPos - snapPx, snappedTop);
harness.style.top = clampedTop + 'px'; harness.style.top = clampedTop + 'px';
if (mirror) updateMirrorDurationLabel(mirror, clampedTop, endPos, snapPx); if (mirror) updateMirrorDurationLabel(mirror, clampedTop, endPos, snapPx);

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,19 @@ 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 Laravel\Fortify\Features; use Laravel\Fortify\Features;
use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Jetstream;
use Tests\TestCase; use Tests\TestCaseWithDatabase;
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 +344,37 @@ 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);
}
} }

View File

@@ -260,4 +260,52 @@ 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
$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(),
]);
}
// LAST state: organization store, remove update update
} }

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);
} }
} }