mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-18 13:12:16 +01:00
Compare commits
1 Commits
feature/pu
...
3267acb161
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3267acb161 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,3 +42,4 @@ yarn-error.log
|
||||
/data
|
||||
/config/caddy
|
||||
/config/composer
|
||||
/AGENTS.md
|
||||
|
||||
@@ -4,18 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\In;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||
|
||||
class AddOrganizationMember implements AddsTeamMembers
|
||||
@@ -25,70 +16,6 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
*/
|
||||
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||
{
|
||||
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring
|
||||
|
||||
$this->validate($organization, $email, $role);
|
||||
|
||||
$newOrganizationMember = User::query()
|
||||
->where('email', $email)
|
||||
->where('is_placeholder', '=', false)
|
||||
->firstOrFail();
|
||||
|
||||
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the add member operation.
|
||||
*/
|
||||
protected function validate(Organization $organization, string $email, ?string $role): void
|
||||
{
|
||||
Validator::make([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
], $this->rules())->after(
|
||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
||||
)->validateWithBag('addTeamMember');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules for adding a team member.
|
||||
*
|
||||
* @return array<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.')
|
||||
);
|
||||
};
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ class CreateOrganization implements CreatesTeams
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ValidationException
|
||||
*
|
||||
* @deprecated Use REST endpoint instead
|
||||
*/
|
||||
public function create(User $user, array $input): Organization
|
||||
{
|
||||
|
||||
@@ -12,6 +12,8 @@ class DeleteOrganization implements DeletesTeams
|
||||
{
|
||||
/**
|
||||
* Delete the given team.
|
||||
*
|
||||
* @deprecated Use REST endpoint instead
|
||||
*/
|
||||
public function delete(Organization $organization): void
|
||||
{
|
||||
|
||||
@@ -16,6 +16,8 @@ class DeleteUser implements DeletesUsers
|
||||
* Delete the given user.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*
|
||||
* @deprecated Use REST endpoint instead
|
||||
*/
|
||||
public function delete(User $user): void
|
||||
{
|
||||
|
||||
@@ -18,6 +18,8 @@ class ValidateOrganizationDeletion
|
||||
* @param Organization $organization Organization to be deleted
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @deprecated Use REST endpoint instead
|
||||
*/
|
||||
public function validate(User $user, Organization $organization): void
|
||||
{
|
||||
|
||||
28
app/Events/MemberAdded.php
Normal file
28
app/Events/MemberAdded.php
Normal 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;
|
||||
}
|
||||
}
|
||||
28
app/Events/MemberAdding.php
Normal file
28
app/Events/MemberAdding.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,17 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\DeletionService;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\OrganizationService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class OrganizationController extends Controller
|
||||
{
|
||||
@@ -80,4 +86,48 @@ class OrganizationController extends Controller
|
||||
|
||||
return new OrganizationResource($organization, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization
|
||||
*
|
||||
* @operationId createOrganization
|
||||
*/
|
||||
public function store(OrganizationStoreRequest $request, OrganizationService $organizationService): OrganizationResource
|
||||
{
|
||||
$user = $this->user();
|
||||
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup($request->ip());
|
||||
|
||||
$currency = $ipLookupResponse?->currency;
|
||||
|
||||
$organization = $organizationService->createOrganization(
|
||||
$request->getName(),
|
||||
$user,
|
||||
false,
|
||||
$currency
|
||||
);
|
||||
|
||||
$user->switchTeam($organization);
|
||||
|
||||
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
|
||||
$organization = $organization->refresh();
|
||||
AfterCreateOrganization::dispatch($organization);
|
||||
|
||||
return new OrganizationResource($organization, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete organization
|
||||
*
|
||||
* @operationId deleteOrganization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'organizations:delete');
|
||||
|
||||
$deletionService->deleteOrganization($organization);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||
use App\Http\Resources\V1\User\UserResource;
|
||||
use App\Models\User;
|
||||
use App\Service\DeletionService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
@@ -24,4 +28,29 @@ class UserController extends Controller
|
||||
|
||||
return new UserResource($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the deletion of a user.
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
*
|
||||
* @operationId deleteUser
|
||||
*
|
||||
* @param User $user The user instance to be deleted.
|
||||
* @param DeletionService $deletionService The service responsible for performing the user deletion.
|
||||
* @return JsonResponse A JSON response with a 204 No Content status upon successful deletion.
|
||||
*
|
||||
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
|
||||
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
|
||||
*/
|
||||
public function destroy(User $user, DeletionService $deletionService): JsonResponse
|
||||
{
|
||||
if ($user->getKey() !== $this->user()->getKey()) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
$deletionService->deleteUser($user);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,30 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Service\DashboardService;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
|
||||
public function dashboard(): Response
|
||||
{
|
||||
$user = $this->user();
|
||||
$organization = $this->currentOrganization();
|
||||
|
||||
$latestTeamActivity = null;
|
||||
if ($permissionStore->has($organization, 'time-entries:view:all')) {
|
||||
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
||||
}
|
||||
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
return Inertia::render('Dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class OrganizationInvitationMail extends Mailable
|
||||
@@ -32,9 +33,12 @@ class OrganizationInvitationMail extends Mailable
|
||||
public function build(): self
|
||||
{
|
||||
return $this->markdown('emails.organization-invitation', [
|
||||
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
|
||||
'invitation' => $this->invitation,
|
||||
]),
|
||||
'acceptUrl' => URL::to(URL::signedRoute(
|
||||
'organization-invitations.accept',
|
||||
['invitation' => $this->invitation->getKey()],
|
||||
Carbon::now()->addDays(90),
|
||||
false
|
||||
)),
|
||||
])->subject(__('Organization Invitation'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $user_id
|
||||
* @property bool $employees_can_see_billable_rates
|
||||
* @property bool $employees_can_manage_tasks
|
||||
* @property bool $prevent_overlapping_time_entries
|
||||
* @property User $owner
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
|
||||
@@ -18,6 +18,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $email
|
||||
* @property string $role
|
||||
* @property string $organization_id
|
||||
* @property Carbon|null $accepted_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property-read Organization $organization
|
||||
@@ -41,14 +42,16 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
|
||||
protected $table = 'organization_invitations';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @var array<int, string>
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'email',
|
||||
'role',
|
||||
];
|
||||
public function casts(): array
|
||||
{
|
||||
return [
|
||||
'accepted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the organization that the invitation belongs to.
|
||||
|
||||
@@ -62,18 +62,6 @@ class OrganizationPolicy
|
||||
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can add team members.
|
||||
*/
|
||||
public function addTeamMember(User $user, Organization $organization): bool
|
||||
{
|
||||
if (Filament::isServing()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update team member permissions.
|
||||
*/
|
||||
|
||||
@@ -4,11 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\RemovePlaceholder;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -21,9 +19,6 @@ class EventServiceProvider extends ServiceProvider
|
||||
Registered::class => [
|
||||
SendEmailVerificationNotification::class,
|
||||
],
|
||||
TeamMemberAdded::class => [
|
||||
RemovePlaceholder::class,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,9 +8,11 @@ use App\Enums\Role;
|
||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||
|
||||
@@ -21,11 +23,7 @@ class InvitationService
|
||||
*/
|
||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||
{
|
||||
if (Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereRelation('user', 'email', '=', $email)
|
||||
->where('role', '!=', Role::Placeholder->value)
|
||||
->exists()) {
|
||||
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||
}
|
||||
|
||||
@@ -48,4 +46,37 @@ class InvitationService
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<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,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\MemberAdded;
|
||||
use App\Events\MemberAdding;
|
||||
use App\Events\MemberRemoved;
|
||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||
@@ -36,7 +38,8 @@ class MemberService
|
||||
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
||||
{
|
||||
if (! $asSuperAdmin) {
|
||||
AddingTeamMember::dispatch($organization, $user);
|
||||
MemberAdding::dispatch($user, $organization, $role);
|
||||
AddingTeamMember::dispatch($organization, $user); // Legacy event
|
||||
}
|
||||
|
||||
$member = new Member;
|
||||
@@ -49,14 +52,37 @@ class MemberService
|
||||
$user->currentOrganization()->associate($organization);
|
||||
$user->save();
|
||||
});
|
||||
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
|
||||
|
||||
if (! $asSuperAdmin) {
|
||||
TeamMemberAdded::dispatch($organization, $user);
|
||||
MemberAdded::dispatch($member, $organization, $user);
|
||||
TeamMemberAdded::dispatch($organization, $user); // Legacy event
|
||||
}
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
private function mergePlaceholderMembersIntoExistingMember(Member $member, Organization $organization, User $user): void
|
||||
{
|
||||
$placeholders = Member::query()
|
||||
->whereHas('user', function (Builder $query) use ($user): void {
|
||||
/** @var Builder<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 EntityStillInUseApiException
|
||||
@@ -209,4 +235,13 @@ class MemberService
|
||||
$this->userService->makeSureUserHasCurrentOrganization($user);
|
||||
}
|
||||
}
|
||||
|
||||
public function isEmailAlreadyMember(Organization $organization, string $email): bool
|
||||
{
|
||||
return Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereRelation('user', 'email', '=', $email)
|
||||
->where('role', '!=', Role::Placeholder->value)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class UserService
|
||||
): User {
|
||||
$user = new User;
|
||||
$user->name = $name;
|
||||
$user->email = $email;
|
||||
$user->email = strtolower($email);
|
||||
$user->password = Hash::make($password);
|
||||
$user->timezone = $timezone;
|
||||
$user->week_start = $weekStart;
|
||||
@@ -47,19 +47,22 @@ class UserService
|
||||
}
|
||||
$user->save();
|
||||
|
||||
$organization = app(OrganizationService::class)->createOrganization(
|
||||
$this->getOrganizationNameForUserName($user->name),
|
||||
$user,
|
||||
true,
|
||||
$currency,
|
||||
$numberFormat,
|
||||
$currencyFormat,
|
||||
$dateFormat,
|
||||
$intervalFormat,
|
||||
$timeFormat,
|
||||
);
|
||||
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
if ($organizations->isEmpty()) {
|
||||
$organization = app(OrganizationService::class)->createOrganization(
|
||||
$this->getOrganizationNameForUserName($user->name),
|
||||
$user,
|
||||
true,
|
||||
$currency,
|
||||
$numberFormat,
|
||||
$currencyFormat,
|
||||
$dateFormat,
|
||||
$intervalFormat,
|
||||
$timeFormat,
|
||||
);
|
||||
$user->ownedTeams()->save($organization);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
@@ -25,9 +25,24 @@ class OrganizationInvitationFactory extends Factory
|
||||
'email' => $this->faker->unique()->safeEmail(),
|
||||
'role' => Role::Employee->value,
|
||||
'organization_id' => Organization::factory(),
|
||||
'accepted_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function role(Role $role): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'role' => $role->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function accepted(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'accepted_at' => $this->faker->dateTime(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function forOrganization(Organization $organization): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -6,7 +6,6 @@ import { formatCentsWithOrganizationDefaults } from './utils/money';
|
||||
import {
|
||||
createProjectViaApi,
|
||||
createPublicProjectViaApi,
|
||||
createProjectMemberViaApi,
|
||||
createTaskViaApi,
|
||||
createClientViaApi,
|
||||
createTimeEntryViaApi,
|
||||
@@ -218,59 +217,6 @@ test('test that creating a non-billable project works', async ({ page }) => {
|
||||
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||
});
|
||||
|
||||
test('test that creating a public project via the modal works', async ({ page }) => {
|
||||
const newProjectName = 'Public Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await goToProjectsOverview(page);
|
||||
await page.getByRole('button', { name: 'Create Project' }).click();
|
||||
await page.getByLabel('Project Name').fill(newProjectName);
|
||||
|
||||
// Visibility defaults to Private — switch it to Public
|
||||
await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private');
|
||||
await page.getByRole('dialog').locator('#visibility').click();
|
||||
await page.getByRole('option', { name: 'Public' }).click();
|
||||
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Create Project' }).click(),
|
||||
page.waitForResponse(
|
||||
async (response) =>
|
||||
response.url().includes('/projects') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 201 &&
|
||||
(await response.json()).data.is_public === true
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(page.getByTestId('project_table')).toContainText(newProjectName);
|
||||
});
|
||||
|
||||
test('test that changing a project to public via the edit modal works', async ({ page, ctx }) => {
|
||||
const newProjectName = 'Edit Visibility Project ' + Math.floor(1 + Math.random() * 10000);
|
||||
await createProjectViaApi(ctx, { name: newProjectName });
|
||||
|
||||
await goToProjectsOverview(page);
|
||||
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const projectRow = page.getByRole('row').filter({ hasText: newProjectName }).first();
|
||||
await projectRow.getByRole('button').click();
|
||||
await page.locator(`[aria-label='Edit Project ${newProjectName}']`).click();
|
||||
|
||||
// Loaded as Private — switch it to Public
|
||||
await expect(page.getByRole('dialog').locator('#visibility')).toContainText('Private');
|
||||
await page.getByRole('dialog').locator('#visibility').click();
|
||||
await page.getByRole('option', { name: 'Public' }).click();
|
||||
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Update Project' }).click(),
|
||||
page.waitForResponse(
|
||||
async (response) =>
|
||||
response.url().includes('/projects/') &&
|
||||
response.request().method() === 'PUT' &&
|
||||
response.status() === 200 &&
|
||||
(await response.json()).data.is_public === true
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that switching from custom rate to default rate clears billable rate', async ({
|
||||
page,
|
||||
ctx,
|
||||
@@ -979,39 +925,6 @@ test.describe('Employee Projects Restrictions', () => {
|
||||
employee.page.locator(`[aria-label='Delete Project ${projectName}']`)
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('employee does not see private projects they are not a member of', async ({
|
||||
ctx,
|
||||
employee,
|
||||
}) => {
|
||||
const publicName = 'EmpPublicVisible ' + Math.floor(Math.random() * 10000);
|
||||
const privateName = 'EmpPrivateHidden ' + Math.floor(Math.random() * 10000);
|
||||
await createPublicProjectViaApi(ctx, { name: publicName });
|
||||
// createProjectViaApi defaults to is_public: false (private); the employee is not a member
|
||||
await createProjectViaApi(ctx, { name: privateName });
|
||||
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The public project is visible — confirms the list has loaded
|
||||
await expect(employee.page.getByText(publicName)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The private project the employee is not a member of must not appear
|
||||
await expect(employee.page.getByText(privateName)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('employee can see a private project they are a member of', async ({ ctx, employee }) => {
|
||||
const projectName = 'EmpPrivateMember ' + Math.floor(Math.random() * 10000);
|
||||
const project = await createProjectViaApi(ctx, { name: projectName });
|
||||
// Add the employee as a project member so the private project becomes visible to them
|
||||
await createProjectMemberViaApi(ctx, project.id, { member_id: employee.memberId });
|
||||
|
||||
await employee.page.goto(PLAYWRIGHT_BASE_URL + '/projects');
|
||||
await expect(employee.page.getByTestId('projects_view')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The private project is visible because the employee is a member
|
||||
await expect(employee.page.getByText(projectName)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Employee Billable Rate Visibility', () => {
|
||||
|
||||
@@ -19,7 +19,6 @@ import { Field, FieldGroup, FieldLabel } from '@/packages/ui/src/field';
|
||||
import ProjectBillableRateModal from '@/packages/ui/src/Project/ProjectBillableRateModal.vue';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
|
||||
import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibilitySelect.vue';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
@@ -45,7 +44,6 @@ const project = ref<CreateProjectBody>({
|
||||
billable_rate: props.originalProject.billable_rate,
|
||||
is_billable: props.originalProject.is_billable,
|
||||
estimated_time: props.originalProject.estimated_time,
|
||||
is_public: props.originalProject.is_public,
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
@@ -128,7 +126,6 @@ async function submitBillableRate() {
|
||||
v-if="isAllowedToPerformPremiumAction()"
|
||||
v-model="project.estimated_time"
|
||||
@submit="submit()"></EstimatedTimeSection>
|
||||
<ProjectVisibilitySelect v-model="project.is_public"></ProjectVisibilitySelect>
|
||||
</FieldGroup>
|
||||
</template>
|
||||
<template #footer>
|
||||
|
||||
@@ -13,8 +13,7 @@ export type SortColumn =
|
||||
| 'spent_time'
|
||||
| 'progress'
|
||||
| 'billable_rate'
|
||||
| 'status'
|
||||
| 'visibility';
|
||||
| 'status';
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
import { canCreateProjects } from '@/utils/permissions';
|
||||
import type { CreateProjectBody, Project, Client, CreateClientBody } from '@/packages/api/src';
|
||||
@@ -103,10 +102,6 @@ const columns = computed(() => [
|
||||
id: 'status',
|
||||
accessorFn: (row: Project) => (row.is_archived ? 1 : 0),
|
||||
},
|
||||
{
|
||||
id: 'visibility',
|
||||
accessorFn: (row: Project) => (row.is_public ? 1 : 0),
|
||||
},
|
||||
]);
|
||||
|
||||
// Columns with sortDescFirst get desc as default direction on first click.
|
||||
@@ -154,7 +149,7 @@ async function createClient(client: CreateClientBody): Promise<Client | undefine
|
||||
}
|
||||
|
||||
const gridTemplate = computed(() => {
|
||||
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) minmax(120px, auto) 80px;`;
|
||||
return `grid-template-columns: minmax(300px, 1fr) minmax(150px, auto) minmax(140px, auto) minmax(130px, auto) ${props.showBillableRate ? 'minmax(130px, auto)' : ''} minmax(120px, auto) 80px;`;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -176,7 +171,7 @@ const gridTemplate = computed(() => {
|
||||
:sort-direction="props.sortDirection"
|
||||
:desc-first-columns="descFirstColumns"
|
||||
@sort="handleSort"></ProjectTableHeading>
|
||||
<div v-if="sortedProjects.length === 0" class="col-span-full py-24 text-center">
|
||||
<div v-if="sortedProjects.length === 0" class="col-span-5 py-24 text-center">
|
||||
<FolderPlusIcon class="w-8 text-icon-default inline pb-2"></FolderPlusIcon>
|
||||
<h3 class="text-text-primary font-semibold">
|
||||
{{
|
||||
|
||||
@@ -86,14 +86,6 @@ function isChevronUp(column: SortColumn): boolean {
|
||||
<ChevronUpIcon v-else-if="isChevronUp('status')" class="w-4 h-4" />
|
||||
<span v-else class="w-4 h-4"></span>
|
||||
</div>
|
||||
<div
|
||||
class="px-3 py-1.5 text-left text-text-tertiary cursor-pointer hover:bg-secondary hover:text-text-primary transition-colors select-none flex items-center gap-1"
|
||||
@click="handleSort('visibility')">
|
||||
Visibility
|
||||
<ChevronDownIcon v-if="isChevronDown('visibility')" class="w-4 h-4" />
|
||||
<ChevronUpIcon v-else-if="isChevronUp('visibility')" class="w-4 h-4" />
|
||||
<span v-else class="w-4 h-4"></span>
|
||||
</div>
|
||||
<div class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
PencilSquareIcon,
|
||||
ArchiveBoxIcon as ArchiveBoxIconSolid,
|
||||
TrashIcon,
|
||||
GlobeAltIcon,
|
||||
LockClosedIcon,
|
||||
} from '@heroicons/vue/20/solid';
|
||||
import { useClientsQuery } from '@/utils/useClientsQuery';
|
||||
import { useTasksQuery } from '@/utils/useTasksQuery';
|
||||
@@ -143,17 +141,6 @@ const showEditProjectModal = ref(false);
|
||||
<span>Active</span>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-text-primary flex space-x-1.5 items-center font-medium">
|
||||
<template v-if="project.is_public">
|
||||
<GlobeAltIcon class="w-4 text-icon-default"></GlobeAltIcon>
|
||||
<span>Public</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<LockClosedIcon class="w-4 text-icon-default"></LockClosedIcon>
|
||||
<span>Private</span>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<ProjectMoreOptionsDropdown
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { GlobeAltIcon } from '@heroicons/vue/16/solid';
|
||||
import { DropdownMenuItem } from '@/packages/ui/src';
|
||||
import BaseFilterBadge from './BaseFilterBadge.vue';
|
||||
|
||||
type VisibilityValue = 'public' | 'private' | 'all';
|
||||
|
||||
const props = defineProps<{
|
||||
value: VisibilityValue;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [];
|
||||
'update:value': [value: VisibilityValue];
|
||||
}>();
|
||||
|
||||
const visibilityOptions = [
|
||||
{ id: 'public' as const, name: 'Public' },
|
||||
{ id: 'private' as const, name: 'Private' },
|
||||
];
|
||||
|
||||
const label = computed(() => {
|
||||
return visibilityOptions.find((opt) => opt.id === props.value)?.name ?? 'Visibility';
|
||||
});
|
||||
|
||||
function updateVisibility(visibility: VisibilityValue) {
|
||||
emit('update:value', visibility);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseFilterBadge
|
||||
:icon="GlobeAltIcon"
|
||||
:label="label"
|
||||
filter-name="Visibility"
|
||||
@remove="emit('remove')">
|
||||
<DropdownMenuItem
|
||||
v-for="option in visibilityOptions"
|
||||
:key="option.id"
|
||||
:class="[value === option.id && 'bg-accent text-accent-foreground']"
|
||||
@click="updateVisibility(option.id)">
|
||||
{{ option.name }}
|
||||
</DropdownMenuItem>
|
||||
</BaseFilterBadge>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { UserGroupIcon, CheckCircleIcon, GlobeAltIcon } from '@heroicons/vue/16/solid';
|
||||
import { UserGroupIcon, CheckCircleIcon } from '@heroicons/vue/16/solid';
|
||||
import ListFilterIcon from '@/packages/ui/src/Icons/ListFilterIcon.vue';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -19,7 +19,6 @@ import { NO_CLIENT_ID } from './constants';
|
||||
|
||||
export interface ProjectFilters {
|
||||
status: 'active' | 'archived' | 'all';
|
||||
visibility: 'public' | 'private' | 'all';
|
||||
clientIds: string[];
|
||||
}
|
||||
|
||||
@@ -37,11 +36,6 @@ const statusOptions = [
|
||||
{ id: 'archived' as const, name: 'Archived' },
|
||||
];
|
||||
|
||||
const visibilityOptions = [
|
||||
{ id: 'public' as const, name: 'Public' },
|
||||
{ id: 'private' as const, name: 'Private' },
|
||||
];
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
function updateStatus(status: 'active' | 'archived' | 'all') {
|
||||
@@ -52,14 +46,6 @@ function updateStatus(status: 'active' | 'archived' | 'all') {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function updateVisibility(visibility: 'public' | 'private' | 'all') {
|
||||
emit('update:filters', {
|
||||
...props.filters,
|
||||
visibility,
|
||||
});
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function toggleClient(clientId: string) {
|
||||
const clientIds = props.filters.clientIds.includes(clientId)
|
||||
? props.filters.clientIds.filter((id) => id !== clientId)
|
||||
@@ -83,11 +69,7 @@ function toggleNoClient() {
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return (
|
||||
props.filters.status !== 'all' ||
|
||||
props.filters.visibility !== 'all' ||
|
||||
props.filters.clientIds.length > 0
|
||||
);
|
||||
return props.filters.status !== 'all' || props.filters.clientIds.length > 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -120,25 +102,6 @@ const hasActiveFilters = computed(() => {
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<!-- Visibility Filter -->
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger class="gap-2">
|
||||
<GlobeAltIcon class="h-4 w-4 text-icon-default" />
|
||||
<span>Visibility</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
v-for="option in visibilityOptions"
|
||||
:key="option.id"
|
||||
:class="[
|
||||
filters.visibility === option.id && 'bg-accent text-accent-foreground',
|
||||
]"
|
||||
@click="updateVisibility(option.id)">
|
||||
{{ option.name }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<!-- Client Filter -->
|
||||
<DropdownMenuSub v-if="clients.length > 0">
|
||||
<DropdownMenuSubTrigger class="gap-2">
|
||||
|
||||
@@ -109,7 +109,7 @@ const shownTasks = computed(() => {
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="px-4 space-x-1">
|
||||
<div class="px-4">
|
||||
<Badge v-if="project?.billable_rate">
|
||||
{{ billableRateFormatted }}
|
||||
/ h
|
||||
@@ -118,7 +118,6 @@ const shownTasks = computed(() => {
|
||||
Default Rate
|
||||
</Badge>
|
||||
<Badge v-if="!project?.is_billable"> Non-Billable </Badge>
|
||||
<Badge>{{ project?.is_public ? 'Public' : 'Private' }}</Badge>
|
||||
</div>
|
||||
</nav>
|
||||
<div>
|
||||
|
||||
@@ -20,7 +20,6 @@ import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import ProjectsFilterDropdown from '@/Components/Common/Project/ProjectsFilterDropdown.vue';
|
||||
import ProjectStatusFilterBadge from '@/Components/Common/Project/ProjectStatusFilterBadge.vue';
|
||||
import ProjectVisibilityFilterBadge from '@/Components/Common/Project/ProjectVisibilityFilterBadge.vue';
|
||||
import ProjectClientFilterBadge from '@/Components/Common/Project/ProjectClientFilterBadge.vue';
|
||||
import { NO_CLIENT_ID } from '@/Components/Common/Project/constants';
|
||||
import type { SortColumn, SortDirection } from '@/Components/Common/Project/ProjectTable.vue';
|
||||
@@ -37,7 +36,6 @@ interface ProjectTableState {
|
||||
filters: {
|
||||
clientIds: string[];
|
||||
status: 'active' | 'archived' | 'all';
|
||||
visibility: 'public' | 'private' | 'all';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,7 +47,6 @@ const tableState = useStorage<ProjectTableState>(
|
||||
filters: {
|
||||
clientIds: [],
|
||||
status: 'all',
|
||||
visibility: 'all',
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
@@ -72,14 +69,6 @@ const filteredProjects = computed(() => {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Visibility filter
|
||||
if (tableState.value.filters.visibility === 'public' && !project.is_public) {
|
||||
return false;
|
||||
}
|
||||
if (tableState.value.filters.visibility === 'private' && project.is_public) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Client filter
|
||||
const hasClientFilter = tableState.value.filters.clientIds.length > 0;
|
||||
if (hasClientFilter) {
|
||||
@@ -102,10 +91,6 @@ function removeStatusFilter() {
|
||||
tableState.value.filters.status = 'all';
|
||||
}
|
||||
|
||||
function removeVisibilityFilter() {
|
||||
tableState.value.filters.visibility = 'all';
|
||||
}
|
||||
|
||||
function removeClientFilter() {
|
||||
tableState.value.filters.clientIds = [];
|
||||
}
|
||||
@@ -167,15 +152,6 @@ const showBillableRate = computed(() => {
|
||||
tableState.filters.status = $event as 'active' | 'archived' | 'all'
|
||||
" />
|
||||
|
||||
<ProjectVisibilityFilterBadge
|
||||
v-if="tableState.filters.visibility !== 'all'"
|
||||
data-testid="visibility-filter-badge"
|
||||
:value="tableState.filters.visibility"
|
||||
@remove="removeVisibilityFilter"
|
||||
@update:value="
|
||||
tableState.filters.visibility = $event as 'public' | 'private' | 'all'
|
||||
" />
|
||||
|
||||
<ProjectClientFilterBadge
|
||||
v-if="tableState.filters.clientIds.length > 0"
|
||||
data-testid="client-filter-badge"
|
||||
|
||||
@@ -15,7 +15,6 @@ import { UserCircleIcon } from '@heroicons/vue/20/solid';
|
||||
import EstimatedTimeSection from '@/packages/ui/src/EstimatedTimeSection.vue';
|
||||
import { Field, FieldGroup, FieldLabel } from '../field';
|
||||
import ProjectEditBillableSection from '@/packages/ui/src/Project/ProjectEditBillableSection.vue';
|
||||
import ProjectVisibilitySelect from '@/packages/ui/src/Project/ProjectVisibilitySelect.vue';
|
||||
import type { Client } from '@/packages/api/src';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
@@ -42,7 +41,6 @@ const project = ref<CreateProjectBody>({
|
||||
billable_rate: null,
|
||||
is_billable: false,
|
||||
estimated_time: null,
|
||||
is_public: false,
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
@@ -55,7 +53,6 @@ async function submit() {
|
||||
billable_rate: null,
|
||||
is_billable: false,
|
||||
estimated_time: null,
|
||||
is_public: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,7 +123,6 @@ const currentClientName = computed(() => {
|
||||
v-if="enableEstimatedTime"
|
||||
v-model="project.estimated_time"
|
||||
@submit="submit()"></EstimatedTimeSection>
|
||||
<ProjectVisibilitySelect v-model="project.is_public"></ProjectVisibilitySelect>
|
||||
</FieldGroup>
|
||||
</template>
|
||||
<template #footer>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '..';
|
||||
import { Field, FieldDescription, FieldLabel } from '../field';
|
||||
import { GlobeAltIcon } from '@heroicons/vue/20/solid';
|
||||
|
||||
const isPublic = defineModel<boolean>({ default: false });
|
||||
|
||||
const visibility = computed({
|
||||
get: () => (isPublic.value ? 'public' : 'private'),
|
||||
set: (value: string) => {
|
||||
isPublic.value = value === 'public';
|
||||
},
|
||||
});
|
||||
|
||||
const description = computed(() =>
|
||||
isPublic.value
|
||||
? 'This project is visible to all members of the organization.'
|
||||
: 'This project is only visible to its project members.'
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Field>
|
||||
<FieldLabel :icon="GlobeAltIcon" for="visibility">Visibility</FieldLabel>
|
||||
<Select v-model="visibility">
|
||||
<SelectTrigger id="visibility">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="private">Private</SelectItem>
|
||||
<SelectItem value="public">Public</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>{{ description }}</FieldDescription>
|
||||
</Field>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -31,7 +31,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-14 xl:pt-24 justify-center overflow-auto'
|
||||
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-20 xl:pt-32 justify-center overflow-auto'
|
||||
)
|
||||
">
|
||||
<DialogContent
|
||||
|
||||
@@ -12,7 +12,7 @@ const props = defineProps<{
|
||||
data-slot="field-group"
|
||||
:class="
|
||||
cn(
|
||||
'group/field-group @container/field-group flex w-full flex-col gap-6 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||
props.class
|
||||
)
|
||||
">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
@component('mail::message')
|
||||
|
||||
{{ __('The API token ":token" expired.', ['token' => $tokenName]) }}
|
||||
{{ __('The API token ":token" will expire in 7 days!', ['token' => $tokenName]) }}
|
||||
|
||||
{{ __('Please make sure to create a new API token and use the new one instead before it expires to avoid any disruptions in service.') }}
|
||||
|
||||
{{ __('You can create a new API token in your profile:') }}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
@component('mail::message')
|
||||
|
||||
{{ __('The API token ":token" will expire in 7 days!', ['token' => $tokenName]) }}
|
||||
{{ __('The API token ":token" expired.', ['token' => $tokenName]) }}
|
||||
|
||||
{{ __('Please make sure to create a new API token and use the new one instead before it expires to avoid any disruptions in service.') }}
|
||||
|
||||
{{ __('You can create a new API token in your profile:') }}
|
||||
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
@component('mail::message')
|
||||
{{ __('You have been invited to join the :organization organization!', ['organization' => $invitation->organization->name]) }}
|
||||
|
||||
@if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::registration()))
|
||||
{{ __('If you do not have an account, you may create one by clicking the button below. After creating an account, you may click the invitation acceptance button in this email to accept the team invitation:') }}
|
||||
|
||||
@component('mail::button', ['url' => route('register')])
|
||||
{{ __('Create Account') }}
|
||||
@endcomponent
|
||||
|
||||
{{ __('If you already have an account, you may accept this invitation by clicking the button below:') }}
|
||||
|
||||
@else
|
||||
{{ __('You may accept this invitation by clicking the button below:') }}
|
||||
@endif
|
||||
|
||||
|
||||
@component('mail::button', ['url' => $acceptUrl])
|
||||
{{ __('Accept Invitation') }}
|
||||
@endcomponent
|
||||
|
||||
@@ -42,8 +42,10 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
|
||||
])->group(static function (): void {
|
||||
// Organization routes
|
||||
Route::name('organizations.')->group(static function (): void {
|
||||
Route::post('/organizations', [OrganizationController::class, 'store'])->name('store');
|
||||
Route::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show');
|
||||
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update');
|
||||
Route::delete('/organizations/{organization}', [OrganizationController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Member routes
|
||||
@@ -59,6 +61,8 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
|
||||
// User routes
|
||||
Route::name('users.')->group(static function (): void {
|
||||
Route::get('/users/me', [UserController::class, 'me'])->name('me');
|
||||
Route::put('/users/{user}', [UserController::class, 'update'])->name('update');
|
||||
Route::delete('/users/{user}', [UserController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// Api token routes
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Web\DashboardController;
|
||||
use App\Http\Controllers\Web\HomeController;
|
||||
use App\Http\Controllers\Web\OrganizationInvitationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
@@ -83,3 +84,10 @@ Route::middleware([
|
||||
})->name('import');
|
||||
|
||||
});
|
||||
|
||||
Route::get('/team-invitations/{invitation}', [OrganizationInvitationController::class, 'accept'])
|
||||
->middleware(['signed'])
|
||||
->name('team-invitations.accept'); // Note: legacy naming
|
||||
Route::get('/organization-invitations/{invitation}', [OrganizationInvitationController::class, 'accept'])
|
||||
->middleware(['signed:relative'])
|
||||
->name('organization-invitations.accept');
|
||||
|
||||
@@ -88,7 +88,7 @@ class InviteTeamMemberTest extends TestCase
|
||||
Mail::fake();
|
||||
$placeholder = User::factory()->placeholder()->create();
|
||||
$owner = User::factory()->withPersonalOrganization()->create();
|
||||
$placeholderMember = Member::factory()->forOrganization($owner->currentTeam)->forUser($placeholder)->create();
|
||||
$placeholderMember = Member::factory()->role(Role::Placeholder)->forOrganization($owner->currentTeam)->forUser($placeholder)->create();
|
||||
|
||||
$timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forMember($placeholderMember)->createMany(5);
|
||||
|
||||
|
||||
@@ -8,21 +8,21 @@ use App\Enums\Role;
|
||||
use App\Enums\Weekday;
|
||||
use App\Events\NewsletterRegistered;
|
||||
use App\Models\Member;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use App\Service\IpLookup\IpLookupResponseDto;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Tests\TestCase;
|
||||
use Tests\TestCaseWithDatabase;
|
||||
use TiMacDonald\Log\LogEntry;
|
||||
|
||||
class RegistrationTest extends TestCase
|
||||
class RegistrationTest extends TestCaseWithDatabase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_registration_screen_can_be_rendered(): void
|
||||
{
|
||||
if (! Features::enabled(Features::registration())) {
|
||||
@@ -346,4 +346,82 @@ class RegistrationTest extends TestCase
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||
}
|
||||
|
||||
public function test_registration_does_not_create_private_organization_if_invite_was_accepted_for_the_email_with_the_registration_email(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$organizationInvitation = OrganizationInvitation::factory()
|
||||
->forOrganization($user->organization)
|
||||
->role(Role::Employee)
|
||||
->accepted()
|
||||
->create([
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||
$newUser = User::where('email', 'test@example.com')->first();
|
||||
$this->assertNotNull($newUser);
|
||||
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
$organizations = $newUser->organizations;
|
||||
$this->assertCount(1, $organizations);
|
||||
$this->assertSame($user->organization->id, $organizations->first()->id);
|
||||
}
|
||||
|
||||
public function test_registration_logs_and_skips_accepted_invitation_with_invalid_role(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = $this->createUserWithPermission();
|
||||
$organizationInvitation = OrganizationInvitation::factory()
|
||||
->forOrganization($user->organization)
|
||||
->accepted()
|
||||
->create([
|
||||
'email' => 'test@example.com',
|
||||
'role' => 'invalid-role',
|
||||
]);
|
||||
|
||||
// Act
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||
Log::assertLogged(fn (LogEntry $log) => $log->level === 'error'
|
||||
&& $log->message === 'Invalid role in invitation'
|
||||
&& $log->context === [
|
||||
'invitation' => $organizationInvitation->getKey(),
|
||||
'role' => 'invalid-role',
|
||||
]);
|
||||
$newUser = User::where('email', 'test@example.com')->firstOrFail();
|
||||
$this->assertDatabaseHas(OrganizationInvitation::class, [
|
||||
'id' => $organizationInvitation->getKey(),
|
||||
'email' => 'test@example.com',
|
||||
'role' => 'invalid-role',
|
||||
]);
|
||||
$this->assertDatabaseMissing(Member::class, [
|
||||
'organization_id' => $user->organization->getKey(),
|
||||
'user_id' => $newUser->getKey(),
|
||||
]);
|
||||
$organizations = $newUser->organizations;
|
||||
$this->assertCount(1, $organizations);
|
||||
$this->assertNotSame($user->organization->id, $organizations->first()->id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ abstract class TestCase extends BaseTestCase
|
||||
|
||||
protected function mockPrivateStorage(): void
|
||||
{
|
||||
Storage::fake(config('filesystems.default'));
|
||||
Storage::fake(config('filesystems.private'));
|
||||
}
|
||||
|
||||
protected function mockPublicStorage(): void
|
||||
|
||||
@@ -5,9 +5,15 @@ declare(strict_types=1);
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Http\Controllers\Api\V1\OrganizationController;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\IpLookup\IpLookupResponseDto;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Testing\Fluent\AssertableJson;
|
||||
use Laravel\Passport\Passport;
|
||||
use Mockery\MockInterface;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
@@ -93,6 +99,121 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertJsonPath('data.billable_rate', null);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_new_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
$organizationFake = Organization::factory()->make();
|
||||
Event::fake([
|
||||
AfterCreateOrganization::class,
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.organizations.store'), [
|
||||
'name' => $organizationFake->name,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(201);
|
||||
$response->assertJson(fn (AssertableJson $json) => $json
|
||||
->has('data')
|
||||
->where('data.name', $organizationFake->name)
|
||||
->where('data.is_personal', false)
|
||||
->where('data.currency', config('app.localization.default_currency'))
|
||||
->etc()
|
||||
);
|
||||
|
||||
/** @var Organization $newOrganization */
|
||||
$newOrganization = Organization::query()->where('name', $organizationFake->name)->firstOrFail();
|
||||
$this->assertTrue($newOrganization->owner->is($data->user));
|
||||
$this->assertSame($newOrganization->getKey(), $data->user->fresh()->current_team_id);
|
||||
$this->assertDatabaseHas(Member::class, [
|
||||
'organization_id' => $newOrganization->getKey(),
|
||||
'user_id' => $data->user->getKey(),
|
||||
'role' => Role::Owner->value,
|
||||
]);
|
||||
Event::assertDispatched(AfterCreateOrganization::class, function (AfterCreateOrganization $event) use ($newOrganization): bool {
|
||||
return $event->organization->is($newOrganization);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_store_endpoint_uses_ip_lookup_currency_for_new_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
$this->mock(IpLookupServiceContract::class, function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('lookup')
|
||||
->once()
|
||||
->andReturn(new IpLookupResponseDto(null, null, 'USD'));
|
||||
});
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.organizations.store'), [
|
||||
'name' => 'Test Organization',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('data.currency', 'USD');
|
||||
$this->assertDatabaseHas(Organization::class, [
|
||||
'name' => 'Test Organization',
|
||||
'currency' => 'USD',
|
||||
'user_id' => $data->user->getKey(),
|
||||
'personal_team' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_fails_if_name_is_missing(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.organizations.store'), []);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['name']);
|
||||
$this->assertDatabaseCount(Organization::class, 1);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_fails_if_name_is_not_a_string(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.organizations.store'), [
|
||||
'name' => ['Test Organization'],
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['name']);
|
||||
$this->assertDatabaseCount(Organization::class, 1);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_fails_if_name_is_too_long(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.organizations.store'), [
|
||||
'name' => str_repeat('a', 256),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['name']);
|
||||
$this->assertDatabaseCount(Organization::class, 1);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_organizations(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -260,4 +381,51 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
|
||||
'billable_rate' => $organizationFake->billable_rate,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_delete_endpoint_if_user_does_not_have_permission(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_delete_endpoint_fails_with_not_found_if_id_is_not_uuid(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'organizations:delete',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.organizations.destroy', ['not-uuid']));
|
||||
|
||||
// Assert
|
||||
$response->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_delete_endpoint_can_delete_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->mockPrivateStorage();
|
||||
$data = $this->createUserWithPermission([
|
||||
'organizations:delete',
|
||||
]);
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertNoContent();
|
||||
$this->assertDatabaseMissing(Organization::class, [
|
||||
'id' => $data->organization->getKey(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,22 +308,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_show_endpoint_fails_if_employee_tries_to_access_public_project(): void
|
||||
{
|
||||
// Arrange
|
||||
// Employees do not have the projects:view:all permission that the show endpoint requires,
|
||||
// so they are forbidden even from public projects (they list them via the index endpoint instead).
|
||||
$data = $this->createUserWithRole(Role::Employee);
|
||||
$publicProject = Project::factory()->forOrganization($data->organization)->isPublic()->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->getJson(route('api.v1.projects.show', [$data->organization->getKey(), $publicProject->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_store_endpoint_fails_if_user_has_no_permission_to_create_projects(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -343,29 +327,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_store_endpoint_fails_if_user_is_employee(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithRole(Role::Employee);
|
||||
$projectFake = Project::factory()->forOrganization($data->organization)->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||
'name' => $projectFake->name,
|
||||
'color' => $projectFake->color,
|
||||
'client_id' => null,
|
||||
'is_billable' => $projectFake->is_billable,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseMissing(Project::class, [
|
||||
'name' => $projectFake->name,
|
||||
'organization_id' => $data->organization->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_highest_possible_billable_rate_can_be_stored_in_database(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -707,124 +668,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_public_project(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:create',
|
||||
]);
|
||||
$projectFake = Project::factory()->forOrganization($data->organization)->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||
'name' => $projectFake->name,
|
||||
'color' => $projectFake->color,
|
||||
'client_id' => null,
|
||||
'is_billable' => $projectFake->is_billable,
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(201);
|
||||
$response->assertJson(fn (AssertableJson $json) => $json
|
||||
->has('data')
|
||||
->where('data.is_public', true)
|
||||
->etc()
|
||||
);
|
||||
$this->assertDatabaseHas(Project::class, [
|
||||
'name' => $projectFake->name,
|
||||
'organization_id' => $projectFake->organization_id,
|
||||
'is_public' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_private_project_if_is_public_is_false(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:create',
|
||||
]);
|
||||
$projectFake = Project::factory()->forOrganization($data->organization)->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||
'name' => $projectFake->name,
|
||||
'color' => $projectFake->color,
|
||||
'client_id' => null,
|
||||
'is_billable' => $projectFake->is_billable,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(201);
|
||||
$response->assertJson(fn (AssertableJson $json) => $json
|
||||
->has('data')
|
||||
->where('data.is_public', false)
|
||||
->etc()
|
||||
);
|
||||
$this->assertDatabaseHas(Project::class, [
|
||||
'name' => $projectFake->name,
|
||||
'organization_id' => $projectFake->organization_id,
|
||||
'is_public' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_creates_private_project_by_default_if_is_public_is_not_given(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:create',
|
||||
]);
|
||||
$projectFake = Project::factory()->forOrganization($data->organization)->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||
'name' => $projectFake->name,
|
||||
'color' => $projectFake->color,
|
||||
'client_id' => null,
|
||||
'is_billable' => $projectFake->is_billable,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(201);
|
||||
$response->assertJson(fn (AssertableJson $json) => $json
|
||||
->has('data')
|
||||
->where('data.is_public', false)
|
||||
->etc()
|
||||
);
|
||||
$this->assertDatabaseHas(Project::class, [
|
||||
'name' => $projectFake->name,
|
||||
'organization_id' => $projectFake->organization_id,
|
||||
'is_public' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_endpoint_fails_if_is_public_is_not_boolean(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:create',
|
||||
]);
|
||||
$projectFake = Project::factory()->forOrganization($data->organization)->make();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [
|
||||
'name' => $projectFake->name,
|
||||
'color' => $projectFake->color,
|
||||
'client_id' => null,
|
||||
'is_billable' => $projectFake->is_billable,
|
||||
'is_public' => 'public',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['is_public']);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_fails_if_user_is_not_part_of_project_organization(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -870,30 +713,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_update_endpoint_fails_if_user_is_employee(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithRole(Role::Employee);
|
||||
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
|
||||
$this->assertBillableRateServiceIsUnused();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'name' => 'Employee Updated Name',
|
||||
'color' => $project->color,
|
||||
'client_id' => null,
|
||||
'is_billable' => $project->is_billable,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseMissing(Project::class, [
|
||||
'id' => $project->getKey(),
|
||||
'name' => 'Employee Updated Name',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_can_update_project_if_project_name_already_exists_in_organization_but_with_different_client(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -1138,120 +957,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
$this->assertFalse($project->is_archived);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_can_make_a_private_project_public(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:update',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->isPrivate()->create();
|
||||
$this->assertBillableRateServiceIsUnused();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'name' => $project->name,
|
||||
'color' => $project->color,
|
||||
'is_billable' => $project->is_billable,
|
||||
'client_id' => null,
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(fn (AssertableJson $json) => $json
|
||||
->has('data')
|
||||
->where('data.is_public', true)
|
||||
->etc()
|
||||
);
|
||||
$project->refresh();
|
||||
$this->assertTrue($project->is_public);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_can_make_a_public_project_private(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:update',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
|
||||
$this->assertBillableRateServiceIsUnused();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'name' => $project->name,
|
||||
'color' => $project->color,
|
||||
'is_billable' => $project->is_billable,
|
||||
'client_id' => null,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(fn (AssertableJson $json) => $json
|
||||
->has('data')
|
||||
->where('data.is_public', false)
|
||||
->etc()
|
||||
);
|
||||
$project->refresh();
|
||||
$this->assertFalse($project->is_public);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_keeps_project_visibility_if_is_public_is_not_given(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:update',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
|
||||
$this->assertBillableRateServiceIsUnused();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'name' => $project->name,
|
||||
'color' => $project->color,
|
||||
'is_billable' => $project->is_billable,
|
||||
'client_id' => null,
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(fn (AssertableJson $json) => $json
|
||||
->has('data')
|
||||
->where('data.is_public', true)
|
||||
->etc()
|
||||
);
|
||||
$project->refresh();
|
||||
$this->assertTrue($project->is_public);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_fails_if_is_public_is_not_boolean(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission([
|
||||
'projects:update',
|
||||
]);
|
||||
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [
|
||||
'name' => $project->name,
|
||||
'color' => $project->color,
|
||||
'is_billable' => $project->is_billable,
|
||||
'client_id' => null,
|
||||
'is_public' => 'public',
|
||||
]);
|
||||
|
||||
// Assert
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['is_public']);
|
||||
$project->refresh();
|
||||
$this->assertTrue($project->is_public);
|
||||
}
|
||||
|
||||
public function test_update_endpoint_ignores_estimated_time_if_pro_features_are_disabled(): void
|
||||
{
|
||||
// Arrange
|
||||
@@ -1470,23 +1175,6 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_user_is_employee(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithRole(Role::Employee);
|
||||
$project = Project::factory()->forOrganization($data->organization)->isPublic()->create();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.projects.destroy', [$data->organization->getKey(), $project->getKey()]));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseHas(Project::class, [
|
||||
'id' => $project->getKey(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint_fails_if_project_is_still_in_use_by_a_task(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
class UserEndpointTest extends ApiEndpointTestAbstract
|
||||
@@ -40,4 +41,57 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_delete_fails_if_given_user_is_not_the_authenticated_user(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
$otherData = $this->createUserWithPermission();
|
||||
Passport::actingAs($otherData->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
|
||||
|
||||
// Assert
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_delete_fails_if_not_authenticated(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
|
||||
|
||||
// Assert
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_delete_fails_if_user_does_not_exist(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.users.destroy', 'not-valid'));
|
||||
|
||||
// Assert
|
||||
$response->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_delete_removes_user(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->createUserWithPermission();
|
||||
Passport::actingAs($data->user);
|
||||
|
||||
// Act
|
||||
$response = $this->deleteJson(route('api.v1.users.destroy', $data->user->getKey()));
|
||||
|
||||
// Assert
|
||||
$response->assertNoContent();
|
||||
$this->assertDatabaseMissing(User::class, ['id' => $data->user->getKey()]);
|
||||
}
|
||||
}
|
||||
|
||||
220
tests/Unit/Endpoint/Web/OrganizationInvitationEndpointTest.php
Normal file
220
tests/Unit/Endpoint/Web/OrganizationInvitationEndpointTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,6 @@ class AuthApiTokenExpirationReminderMailTest extends TestCaseWithDatabase
|
||||
$rendered = $mail->render();
|
||||
|
||||
// Assert
|
||||
$this->assertStringContainsString('The API token "TEST" expired.', $rendered);
|
||||
$this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,6 @@ class AuthApiTokenExpiredMailTest extends TestCaseWithDatabase
|
||||
$rendered = $mail->render();
|
||||
|
||||
// Assert
|
||||
$this->assertStringContainsString('The API token "TEST" will expire in 7 days!', $rendered);
|
||||
$this->assertStringContainsString('The API token "TEST" expired.', $rendered);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user