mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-18 21:22:15 +01:00
Compare commits
5 Commits
99aa7ed450
...
feature/e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
785c8b939f | ||
|
|
b2fa07b38b | ||
|
|
5b053bc2c1 | ||
|
|
b775aaf1df | ||
|
|
84c4750c9b |
@@ -37,6 +37,8 @@ If you have a **feature request**, please [**create a discussion**](https://gith
|
|||||||
|
|
||||||
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||||
|
|
||||||
|
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
|
||||||
|
|
||||||
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
|
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
|
||||||
|
|
||||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||||
|
|||||||
@@ -4,9 +4,18 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
use App\Exceptions\MovedToApiException;
|
use App\Enums\Role;
|
||||||
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
|
||||||
@@ -16,6 +25,70 @@ 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
|
||||||
{
|
{
|
||||||
throw new MovedToApiException;
|
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring
|
||||||
|
|
||||||
|
$this->validate($organization, $email, $role);
|
||||||
|
|
||||||
|
$newOrganizationMember = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->where('is_placeholder', '=', false)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the add member operation.
|
||||||
|
*/
|
||||||
|
protected function validate(Organization $organization, string $email, ?string $role): void
|
||||||
|
{
|
||||||
|
Validator::make([
|
||||||
|
'email' => $email,
|
||||||
|
'role' => $role,
|
||||||
|
], $this->rules())->after(
|
||||||
|
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
||||||
|
)->validateWithBag('addTeamMember');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules for adding a team member.
|
||||||
|
*
|
||||||
|
* @return array<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.')
|
||||||
|
);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,17 +5,11 @@ 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
|
||||||
{
|
{
|
||||||
@@ -86,48 +80,4 @@ 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,8 @@ 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
|
||||||
{
|
{
|
||||||
@@ -28,29 +24,4 @@ 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,30 @@ 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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
<?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,
|
|
||||||
]),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
43
app/Listeners/RemovePlaceholder.php
Normal file
43
app/Listeners/RemovePlaceholder.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ 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
|
||||||
@@ -33,12 +32,9 @@ 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::to(URL::signedRoute(
|
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
|
||||||
'organization-invitations.accept',
|
'invitation' => $this->invitation,
|
||||||
['invitation' => $this->invitation->getKey()],
|
]),
|
||||||
Carbon::now()->addDays(90),
|
|
||||||
false
|
|
||||||
)),
|
|
||||||
])->subject(__('Organization Invitation'));
|
])->subject(__('Organization Invitation'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ 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
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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
|
||||||
@@ -42,16 +41,14 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
|
|||||||
protected $table = 'organization_invitations';
|
protected $table = 'organization_invitations';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* The attributes that are mass assignable.
|
||||||
*
|
*
|
||||||
* @return array<string, string>
|
* @var array<int, string>
|
||||||
*/
|
*/
|
||||||
public function casts(): array
|
protected $fillable = [
|
||||||
{
|
'email',
|
||||||
return [
|
'role',
|
||||||
'accepted_at' => 'datetime',
|
|
||||||
];
|
];
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the organization that the invitation belongs to.
|
* Get the organization that the invitation belongs to.
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ 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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ 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
|
||||||
{
|
{
|
||||||
@@ -19,6 +21,9 @@ class EventServiceProvider extends ServiceProvider
|
|||||||
Registered::class => [
|
Registered::class => [
|
||||||
SendEmailVerificationNotification::class,
|
SendEmailVerificationNotification::class,
|
||||||
],
|
],
|
||||||
|
TeamMemberAdded::class => [
|
||||||
|
RemovePlaceholder::class,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ 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;
|
||||||
|
|
||||||
@@ -23,7 +21,11 @@ class InvitationService
|
|||||||
*/
|
*/
|
||||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||||
{
|
{
|
||||||
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
if (Member::query()
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->whereRelation('user', 'email', '=', $email)
|
||||||
|
->where('role', '!=', Role::Placeholder->value)
|
||||||
|
->exists()) {
|
||||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,37 +48,4 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ 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;
|
||||||
@@ -38,8 +36,7 @@ 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) {
|
||||||
MemberAdding::dispatch($user, $organization, $role);
|
AddingTeamMember::dispatch($organization, $user);
|
||||||
AddingTeamMember::dispatch($organization, $user); // Legacy event
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$member = new Member;
|
$member = new Member;
|
||||||
@@ -52,37 +49,14 @@ class MemberService
|
|||||||
$user->currentOrganization()->associate($organization);
|
$user->currentOrganization()->associate($organization);
|
||||||
$user->save();
|
$user->save();
|
||||||
});
|
});
|
||||||
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
|
|
||||||
|
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
MemberAdded::dispatch($member, $organization, $user);
|
TeamMemberAdded::dispatch($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
|
||||||
@@ -235,13 +209,4 @@ 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class UserService
|
|||||||
): User {
|
): User {
|
||||||
$user = new User;
|
$user = new User;
|
||||||
$user->name = $name;
|
$user->name = $name;
|
||||||
$user->email = strtolower($email);
|
$user->email = $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,9 +47,6 @@ class UserService
|
|||||||
}
|
}
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
|
||||||
|
|
||||||
if ($organizations->isEmpty()) {
|
|
||||||
$organization = app(OrganizationService::class)->createOrganization(
|
$organization = app(OrganizationService::class)->createOrganization(
|
||||||
$this->getOrganizationNameForUserName($user->name),
|
$this->getOrganizationNameForUserName($user->name),
|
||||||
$user,
|
$user,
|
||||||
@@ -61,8 +58,8 @@ class UserService
|
|||||||
$intervalFormat,
|
$intervalFormat,
|
||||||
$timeFormat,
|
$timeFormat,
|
||||||
);
|
);
|
||||||
|
|
||||||
$user->ownedTeams()->save($organization);
|
$user->ownedTeams()->save($organization);
|
||||||
}
|
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,24 +25,9 @@ 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) => [
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<?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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -608,7 +608,7 @@ test('test that billable icon shows dollar sign for USD currency on time entry r
|
|||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
await updateOrganizationCurrencyViaWeb(ctx, 'USD');
|
await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');
|
||||||
await goToTimeOverview(page);
|
await goToTimeOverview(page);
|
||||||
await createEmptyTimeEntry(page);
|
await createEmptyTimeEntry(page);
|
||||||
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
|
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
|
||||||
@@ -621,7 +621,7 @@ test('test that billable icon shows euro sign for EUR currency on time entry row
|
|||||||
page,
|
page,
|
||||||
ctx,
|
ctx,
|
||||||
}) => {
|
}) => {
|
||||||
await updateOrganizationCurrencyViaWeb(ctx, 'EUR');
|
await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');
|
||||||
await goToTimeOverview(page);
|
await goToTimeOverview(page);
|
||||||
await createEmptyTimeEntry(page);
|
await createEmptyTimeEntry(page);
|
||||||
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
|
const timeEntryRow = page.locator('[data-testid="time_entry_row"]').first();
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ test('test that starting and stopping a timer without description and project wo
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('test that billable icon shows dollar sign for USD currency', async ({ page, ctx }) => {
|
test('test that billable icon shows dollar sign for USD currency', async ({ page, ctx }) => {
|
||||||
await updateOrganizationCurrencyViaWeb(ctx, 'USD');
|
await updateOrganizationCurrencyViaWeb(page, ctx, 'USD');
|
||||||
await goToDashboard(page);
|
await goToDashboard(page);
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
|
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
|
||||||
@@ -39,7 +39,7 @@ test('test that billable icon shows dollar sign for USD currency', async ({ page
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('test that billable icon shows euro sign for EUR currency', async ({ page, ctx }) => {
|
test('test that billable icon shows euro sign for EUR currency', async ({ page, ctx }) => {
|
||||||
await updateOrganizationCurrencyViaWeb(ctx, 'EUR');
|
await updateOrganizationCurrencyViaWeb(page, ctx, 'EUR');
|
||||||
await goToDashboard(page);
|
await goToDashboard(page);
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
|
const billableButton = page.getByRole('button', { name: 'Non Billable' }).first();
|
||||||
|
|||||||
@@ -16,12 +16,59 @@ export interface TestContext {
|
|||||||
// Auth helpers
|
// Auth helpers
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async function getApiHeaders(page: Page): Promise<Record<string, string>> {
|
/**
|
||||||
const cookies = await page.context().cookies();
|
* Create a Passport API token by calling the token endpoint from the browser.
|
||||||
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
|
*
|
||||||
|
* The browser's native fetch includes the laravel_token cookie (set by
|
||||||
|
* CreateFreshApiToken during the dashboard page load), so authentication
|
||||||
|
* is handled by the browser's own cookie jar. The returned Bearer token is
|
||||||
|
* then used for all subsequent API calls, making them independent of cookie state.
|
||||||
|
*
|
||||||
|
* If the first attempt returns 401 (Octane hasn't fully committed the session yet),
|
||||||
|
* we reload the page to trigger a fresh CreateFreshApiToken and retry.
|
||||||
|
*/
|
||||||
|
async function createApiToken(page: Page): Promise<string> {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const result = await page.evaluate(async (baseUrl) => {
|
||||||
|
const xsrfCookie = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='));
|
||||||
|
const xsrfToken = xsrfCookie
|
||||||
|
? decodeURIComponent(xsrfCookie.split('=').slice(1).join('='))
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/v1/users/me/api-tokens`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-XSRF-TOKEN': xsrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: 'playwright-test' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
return body.data.access_token as string;
|
||||||
|
}, PLAYWRIGHT_BASE_URL);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload to get a fresh laravel_token cookie and retry.
|
||||||
|
// networkidle gives Octane time to fully commit the session.
|
||||||
|
await page.reload({ waitUntil: 'networkidle' });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Failed to create API token after retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearerHeaders(token: string): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
...(xsrfCookie ? { 'X-XSRF-TOKEN': decodeURIComponent(xsrfCookie.value) } : {}),
|
Authorization: `Bearer ${token}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,8 +77,10 @@ async function getApiHeaders(page: Page): Promise<Record<string, string>> {
|
|||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function setupTestContext(page: Page): Promise<TestContext> {
|
export async function setupTestContext(page: Page): Promise<TestContext> {
|
||||||
|
const token = await createApiToken(page);
|
||||||
const request = page.request;
|
const request = page.request;
|
||||||
const headers = await getApiHeaders(page);
|
const headers = bearerHeaders(token);
|
||||||
|
|
||||||
const orgId = await getOrganizationId(request, headers);
|
const orgId = await getOrganizationId(request, headers);
|
||||||
const memberId = await getCurrentMemberId(request, orgId, headers);
|
const memberId = await getCurrentMemberId(request, orgId, headers);
|
||||||
return { request: createAuthenticatedRequest(request, headers), orgId, memberId };
|
return { request: createAuthenticatedRequest(request, headers), orgId, memberId };
|
||||||
@@ -491,11 +540,17 @@ export async function updateOrganizationSettingViaApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateOrganizationCurrencyViaWeb(
|
export async function updateOrganizationCurrencyViaWeb(
|
||||||
|
page: Page,
|
||||||
ctx: TestContext,
|
ctx: TestContext,
|
||||||
currency: string,
|
currency: string,
|
||||||
name: string = 'Test Organization'
|
name: string = 'Test Organization'
|
||||||
) {
|
) {
|
||||||
const response = await ctx.request.put(`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`, {
|
const cookies = await page.context().cookies();
|
||||||
|
const xsrfCookie = cookies.find((c) => c.name === 'XSRF-TOKEN');
|
||||||
|
const xsrfToken = xsrfCookie ? decodeURIComponent(xsrfCookie.value) : '';
|
||||||
|
|
||||||
|
const response = await page.request.put(`${PLAYWRIGHT_BASE_URL}/teams/${ctx.orgId}`, {
|
||||||
|
headers: { 'X-XSRF-TOKEN': xsrfToken },
|
||||||
data: { name, currency },
|
data: { name, currency },
|
||||||
});
|
});
|
||||||
expect(response.status()).toBe(200);
|
expect(response.status()).toBe(200);
|
||||||
|
|||||||
@@ -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:') }}
|
||||||
|
|
||||||
|
|||||||
@@ -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:') }}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
@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
|
||||||
|
|||||||
@@ -42,10 +42,8 @@ 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
|
||||||
@@ -61,8 +59,6 @@ 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
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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;
|
||||||
@@ -84,10 +83,3 @@ 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');
|
|
||||||
|
|||||||
@@ -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()->role(Role::Placeholder)->forOrganization($owner->currentTeam)->forUser($placeholder)->create();
|
$placeholderMember = Member::factory()->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);
|
||||||
|
|
||||||
|
|||||||
@@ -8,19 +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 Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
use Laravel\Jetstream\Jetstream;
|
use Laravel\Jetstream\Jetstream;
|
||||||
use Tests\TestCaseWithDatabase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class RegistrationTest extends TestCaseWithDatabase
|
class RegistrationTest extends TestCase
|
||||||
{
|
{
|
||||||
|
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())) {
|
||||||
@@ -344,37 +346,4 @@ class RegistrationTest extends TestCaseWithDatabase
|
|||||||
$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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,52 +260,4 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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
|
||||||
@@ -41,57 +40,4 @@ 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()]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -28,6 +28,6 @@ class AuthApiTokenExpirationReminderMailTest 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,6 @@ class AuthApiTokenExpiredMailTest 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user