mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-18 13:12:16 +01:00
Compare commits
5 Commits
feature/fr
...
c2a8eac65f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2a8eac65f | ||
|
|
28ecfc63a3 | ||
|
|
433a6f3770 | ||
|
|
0ba20fd24c | ||
|
|
3267acb161 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,3 +42,4 @@ yarn-error.log
|
|||||||
/data
|
/data
|
||||||
/config/caddy
|
/config/caddy
|
||||||
/config/composer
|
/config/composer
|
||||||
|
/AGENTS.md
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ declare(strict_types=1);
|
|||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
|
use App\Mail\VerifyUpdatedEmailMail;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||||
@@ -24,6 +27,10 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
*/
|
*/
|
||||||
public function update(User $user, array $input): void
|
public function update(User $user, array $input): void
|
||||||
{
|
{
|
||||||
|
if (isset($input['email']) && is_string($input['email'])) {
|
||||||
|
$input['email'] = Str::lower($input['email']);
|
||||||
|
}
|
||||||
|
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
'name' => [
|
'name' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -58,16 +65,17 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
$user->updateProfilePhoto($input['photo']);
|
$user->updateProfilePhoto($input['photo']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($input['email'] !== $user->email) {
|
$email = Str::lower((string) $input['email']);
|
||||||
|
|
||||||
|
if ($email !== Str::lower($user->email)) {
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
'email' => $input['email'],
|
'pending_email' => $email,
|
||||||
'email_verified_at' => null,
|
|
||||||
'timezone' => $input['timezone'],
|
'timezone' => $input['timezone'],
|
||||||
'week_start' => $input['week_start'],
|
'week_start' => $input['week_start'],
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$user->sendEmailVerificationNotification();
|
Mail::to($email)->send(new VerifyUpdatedEmailMail($user, $email));
|
||||||
} else {
|
} else {
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
|
|||||||
@@ -4,18 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
namespace App\Actions\Jetstream;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Exceptions\MovedToApiException;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\MemberService;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Illuminate\Validation\Rules\In;
|
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
|
||||||
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||||
|
|
||||||
class AddOrganizationMember implements AddsTeamMembers
|
class AddOrganizationMember implements AddsTeamMembers
|
||||||
@@ -25,70 +16,6 @@ class AddOrganizationMember implements AddsTeamMembers
|
|||||||
*/
|
*/
|
||||||
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
|
||||||
{
|
{
|
||||||
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring
|
throw new MovedToApiException;
|
||||||
|
|
||||||
$this->validate($organization, $email, $role);
|
|
||||||
|
|
||||||
$newOrganizationMember = User::query()
|
|
||||||
->where('email', $email)
|
|
||||||
->where('is_placeholder', '=', false)
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the add member operation.
|
|
||||||
*/
|
|
||||||
protected function validate(Organization $organization, string $email, ?string $role): void
|
|
||||||
{
|
|
||||||
Validator::make([
|
|
||||||
'email' => $email,
|
|
||||||
'role' => $role,
|
|
||||||
], $this->rules())->after(
|
|
||||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
|
||||||
)->validateWithBag('addTeamMember');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules for adding a team member.
|
|
||||||
*
|
|
||||||
* @return array<string, array<ValidationRule|Rule|string|In>>
|
|
||||||
*/
|
|
||||||
protected function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'email' => [
|
|
||||||
'required',
|
|
||||||
'email',
|
|
||||||
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
|
|
||||||
/** @var Builder<User> $builder */
|
|
||||||
return $builder->where('is_placeholder', '=', false);
|
|
||||||
})->withMessage(__('We were unable to find a registered user with this email address.')),
|
|
||||||
],
|
|
||||||
'role' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
Rule::in([
|
|
||||||
Role::Admin->value,
|
|
||||||
Role::Manager->value,
|
|
||||||
Role::Employee->value,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure that the user is not already on the team.
|
|
||||||
*/
|
|
||||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
|
||||||
{
|
|
||||||
return function ($validator) use ($team, $email): void {
|
|
||||||
$validator->errors()->addIf(
|
|
||||||
$team->hasRealUserWithEmail($email),
|
|
||||||
'email',
|
|
||||||
__('This user already belongs to the team.')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class CreateOrganization implements CreatesTeams
|
|||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function create(User $user, array $input): Organization
|
public function create(User $user, array $input): Organization
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class DeleteOrganization implements DeletesTeams
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Delete the given team.
|
* Delete the given team.
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function delete(Organization $organization): void
|
public function delete(Organization $organization): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ class DeleteUser implements DeletesUsers
|
|||||||
* Delete the given user.
|
* Delete the given user.
|
||||||
*
|
*
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function delete(User $user): void
|
public function delete(User $user): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ class ValidateOrganizationDeletion
|
|||||||
* @param Organization $organization Organization to be deleted
|
* @param Organization $organization Organization to be deleted
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
|
*
|
||||||
|
* @deprecated Use REST endpoint instead
|
||||||
*/
|
*/
|
||||||
public function validate(User $user, Organization $organization): void
|
public function validate(User $user, Organization $organization): void
|
||||||
{
|
{
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
|
||||||
|
{
|
||||||
|
public const string KEY = 'user_resend_email_verification_no_pending_email';
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ class ApiTokenController extends Controller
|
|||||||
/**
|
/**
|
||||||
* List all api token of the currently authenticated user
|
* List all api token of the currently authenticated user
|
||||||
*
|
*
|
||||||
* This endpoint is independent of organization.
|
* This endpoint is independent of the organization.
|
||||||
*
|
*
|
||||||
* @operationId getApiTokens
|
* @operationId getApiTokens
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Events\AfterCreateOrganization;
|
||||||
|
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
|
||||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Service\BillableRateService;
|
use App\Service\BillableRateService;
|
||||||
|
use App\Service\DeletionService;
|
||||||
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
|
use App\Service\OrganizationService;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class OrganizationController extends Controller
|
class OrganizationController extends Controller
|
||||||
{
|
{
|
||||||
@@ -80,4 +86,48 @@ class OrganizationController extends Controller
|
|||||||
|
|
||||||
return new OrganizationResource($organization, true);
|
return new OrganizationResource($organization, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create organization
|
||||||
|
*
|
||||||
|
* @operationId createOrganization
|
||||||
|
*/
|
||||||
|
public function store(OrganizationStoreRequest $request, OrganizationService $organizationService): OrganizationResource
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup($request->ip());
|
||||||
|
|
||||||
|
$currency = $ipLookupResponse?->currency;
|
||||||
|
|
||||||
|
$organization = $organizationService->createOrganization(
|
||||||
|
$request->getName(),
|
||||||
|
$user,
|
||||||
|
false,
|
||||||
|
$currency
|
||||||
|
);
|
||||||
|
|
||||||
|
$user->switchTeam($organization);
|
||||||
|
|
||||||
|
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
|
||||||
|
$organization = $organization->refresh();
|
||||||
|
AfterCreateOrganization::dispatch($organization);
|
||||||
|
|
||||||
|
return new OrganizationResource($organization, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete organization
|
||||||
|
*
|
||||||
|
* @operationId deleteOrganization
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException
|
||||||
|
*/
|
||||||
|
public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission($organization, 'organizations:delete');
|
||||||
|
|
||||||
|
$deletionService->deleteOrganization($organization);
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,26 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||||
|
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
|
||||||
|
use App\Http\Requests\V1\User\UserUpdateRequest;
|
||||||
use App\Http\Resources\V1\User\UserResource;
|
use App\Http\Resources\V1\User\UserResource;
|
||||||
|
use App\Mail\VerifyUpdatedEmailMail;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Service\DeletionService;
|
||||||
|
use App\Support\Base64File;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Get the current user
|
* Get the current user
|
||||||
*
|
*
|
||||||
* This endpoint is independent of organization.
|
* This endpoint is independent of the organization.
|
||||||
*
|
*
|
||||||
* @operationId getMe
|
* @operationId getMe
|
||||||
*
|
*
|
||||||
@@ -24,4 +35,114 @@ class UserController extends Controller
|
|||||||
|
|
||||||
return new UserResource($user);
|
return new UserResource($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current user
|
||||||
|
*
|
||||||
|
* This endpoint is independent of the organization.
|
||||||
|
*
|
||||||
|
* @operationId updateUser
|
||||||
|
*/
|
||||||
|
public function update(User $user, UserUpdateRequest $request): UserResource
|
||||||
|
{
|
||||||
|
if ($user->getKey() !== $this->user()->getKey()) {
|
||||||
|
throw new AuthorizationException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->getPhoto() !== null) {
|
||||||
|
$photo = Base64File::decode($request->getPhoto());
|
||||||
|
assert($photo !== null);
|
||||||
|
$extension = Base64File::extension($photo['mime_type']);
|
||||||
|
assert($extension !== null);
|
||||||
|
|
||||||
|
$previousPhotoPath = $user->profile_photo_path;
|
||||||
|
$photoPath = 'profile-photos/'.Str::uuid().'.'.$extension;
|
||||||
|
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
|
||||||
|
|
||||||
|
Storage::disk($photoDisk)->put($photoPath, $photo['data'], 'public');
|
||||||
|
$user->profile_photo_path = $photoPath;
|
||||||
|
|
||||||
|
if ($previousPhotoPath !== null) {
|
||||||
|
Storage::disk($photoDisk)->delete($previousPhotoPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$emailToVerify = null;
|
||||||
|
$email = $request->getEmail();
|
||||||
|
if ($email !== null && $email !== Str::lower($user->email)) {
|
||||||
|
$emailToVerify = $email;
|
||||||
|
$user->pending_email = $email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->getName() !== null) {
|
||||||
|
$user->name = $request->getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->getTimezone() !== null) {
|
||||||
|
$user->timezone = $request->getTimezone();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->getWeekStart() !== null) {
|
||||||
|
$user->week_start = $request->getWeekStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
if ($emailToVerify !== null) {
|
||||||
|
Mail::to($emailToVerify)->send(new VerifyUpdatedEmailMail($user, $emailToVerify));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UserResource($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend the pending email update verification email.
|
||||||
|
*
|
||||||
|
* This endpoint is independent of the organization.
|
||||||
|
*
|
||||||
|
* @operationId resendUserEmailVerification
|
||||||
|
*
|
||||||
|
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
|
||||||
|
* @throws UserResendEmailVerificationNoPendingEmailApiException Thrown when the user does not have a pending email to verify.
|
||||||
|
*/
|
||||||
|
public function resendEmailVerification(User $user): JsonResponse
|
||||||
|
{
|
||||||
|
if ($user->getKey() !== $this->user()->getKey()) {
|
||||||
|
throw new AuthorizationException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->pending_email === null) {
|
||||||
|
throw new UserResendEmailVerificationNoPendingEmailApiException;
|
||||||
|
}
|
||||||
|
|
||||||
|
Mail::to($user->pending_email)
|
||||||
|
->queue(new VerifyUpdatedEmailMail($user, $user->pending_email));
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the deletion of a user.
|
||||||
|
*
|
||||||
|
* This endpoint is independent of the 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class UserMembershipController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get the memberships of the current user
|
* Get the memberships of the current user
|
||||||
*
|
*
|
||||||
* This endpoint is independent of organization.
|
* This endpoint is independent of the organization.
|
||||||
*
|
*
|
||||||
* @operationId getMyMemberships
|
* @operationId getMyMemberships
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class UserTimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Get the active time entry of the current user
|
* Get the active time entry of the current user
|
||||||
*
|
*
|
||||||
* This endpoint is independent of organization.
|
* This endpoint is independent of the organization.
|
||||||
*
|
*
|
||||||
* @operationId getMyActiveTimeEntry
|
* @operationId getMyActiveTimeEntry
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,30 +4,13 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Service\DashboardService;
|
|
||||||
use App\Service\PermissionStore;
|
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
public function dashboard(): Response
|
||||||
* @throws AuthorizationException
|
|
||||||
*/
|
|
||||||
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
|
|
||||||
{
|
{
|
||||||
$user = $this->user();
|
|
||||||
$organization = $this->currentOrganization();
|
|
||||||
|
|
||||||
$latestTeamActivity = null;
|
|
||||||
if ($permissionStore->has($organization, 'time-entries:view:all')) {
|
|
||||||
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
|
|
||||||
}
|
|
||||||
|
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
|
||||||
|
|
||||||
return Inertia::render('Dashboard');
|
return Inertia::render('Dashboard');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?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 Illuminate\Support\Facades\Auth;
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
$organization = $invitation->organization;
|
||||||
|
$invitee = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->where('is_placeholder', '=', false)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// No account yet — finish on registration.
|
||||||
|
if ($invitee === null) {
|
||||||
|
if ($invitation->accepted_at === null) {
|
||||||
|
$invitation->accepted_at = now();
|
||||||
|
$invitation->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(route('register'))
|
||||||
|
->with('bannerText', __('Please create an account to finish joining the :organization organization.', [
|
||||||
|
'organization' => $organization->name,
|
||||||
|
]))
|
||||||
|
->with('bannerStyle', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
$alreadyMember = $memberService->isEmailAlreadyMember($organization, $email);
|
||||||
|
if (! $alreadyMember) {
|
||||||
|
$memberService->addMember($invitee, $organization, $role);
|
||||||
|
$invitation->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logged out — banner on /login.
|
||||||
|
if (! Auth::check()) {
|
||||||
|
return redirect(route('login'))
|
||||||
|
->with('bannerText', __('Great! You have accepted the invitation to join the :organization organization. Please log in to access it.', [
|
||||||
|
'organization' => $organization->name,
|
||||||
|
]))
|
||||||
|
->with('bannerStyle', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logged in — banner on /dashboard.
|
||||||
|
if ($alreadyMember) {
|
||||||
|
return redirect(route('dashboard'))
|
||||||
|
->with('bannerText', __('You are already a member of the :organization organization.', [
|
||||||
|
'organization' => $organization->name,
|
||||||
|
]))
|
||||||
|
->with('bannerStyle', 'danger');
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(route('dashboard'))
|
||||||
|
->with('bannerText', __('Great! You have accepted the invitation to join the :organization organization.', [
|
||||||
|
'organization' => $organization->name,
|
||||||
|
]))
|
||||||
|
->with('bannerStyle', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Http/Controllers/Web/UserController.php
Normal file
55
app/Http/Controllers/Web/UserController.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
public function verifyEmailChange(Request $request, User $user): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($request->user()?->getAuthIdentifier() !== $user->getKey()) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = $request->query('email');
|
||||||
|
if (! is_string($email)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = Str::lower($email);
|
||||||
|
|
||||||
|
if ($user->pending_email !== $email) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$emailAlreadyInUse = User::query()
|
||||||
|
->where('email', '=', $email)
|
||||||
|
->where('is_placeholder', '=', false)
|
||||||
|
->whereKeyNot($user->getKey())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($emailAlreadyInUse) {
|
||||||
|
return redirect(route('dashboard', [
|
||||||
|
'bannerStyle' => 'danger',
|
||||||
|
'bannerText' => __('The email address is already in use.'),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->email = $email;
|
||||||
|
$user->pending_email = null;
|
||||||
|
$user->email_verified_at = Carbon::now();
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return redirect(route('dashboard', [
|
||||||
|
'bannerStyle' => 'success',
|
||||||
|
'bannerText' => __('Your email address has been updated successfully.'),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,8 @@ class HandleInertiaRequests extends Middleware
|
|||||||
] : null,
|
] : null,
|
||||||
'flash' => [
|
'flash' => [
|
||||||
'message' => fn () => $request->session()->get('message'),
|
'message' => fn () => $request->session()->get('message'),
|
||||||
|
'bannerText' => fn () => $request->session()->get('bannerText'),
|
||||||
|
'bannerStyle' => fn () => $request->session()->get('bannerStyle'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ class ShareInertiaData
|
|||||||
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
|
'canUpdatePassword' => Features::enabled(Features::updatePasswords()),
|
||||||
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
|
'canUpdateProfileInformation' => Features::canUpdateProfileInformation(),
|
||||||
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
|
'hasEmailVerification' => Features::enabled(Features::emailVerification()),
|
||||||
'flash' => $request->session()->get('flash', []),
|
|
||||||
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
|
'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(),
|
||||||
'hasApiFeatures' => Jetstream::hasApiFeatures(),
|
'hasApiFeatures' => Jetstream::hasApiFeatures(),
|
||||||
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
|
'hasTeamFeatures' => Jetstream::hasTeamFeatures(),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/Http/Requests/V1/User/UserUpdateRequest.php
Normal file
88
app/Http/Requests/V1/User/UserUpdateRequest.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\V1\User;
|
||||||
|
|
||||||
|
use App\Enums\Weekday;
|
||||||
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Rules\Base64ImageRule;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property User $user User from model binding
|
||||||
|
*/
|
||||||
|
class UserUpdateRequest extends BaseFormRequest
|
||||||
|
{
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if ($this->has('email') && is_string($this->input('email'))) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => Str::lower((string) $this->input('email')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule|ValidationRule>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => [
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
],
|
||||||
|
'email' => [
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
||||||
|
/** @var Builder<User> $query */
|
||||||
|
return $query->where('is_placeholder', '=', false);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'photo' => [
|
||||||
|
'nullable',
|
||||||
|
new Base64ImageRule,
|
||||||
|
],
|
||||||
|
'timezone' => [
|
||||||
|
'timezone:all',
|
||||||
|
],
|
||||||
|
'week_start' => [
|
||||||
|
Rule::enum(Weekday::class),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->has('name') ? (string) $this->input('name') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmail(): ?string
|
||||||
|
{
|
||||||
|
return $this->has('email') ? Str::lower((string) $this->input('email')) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTimezone(): ?string
|
||||||
|
{
|
||||||
|
return $this->has('timezone') ? (string) $this->input('timezone') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWeekStart(): ?Weekday
|
||||||
|
{
|
||||||
|
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPhoto(): ?string
|
||||||
|
{
|
||||||
|
return $this->has('photo') ? (string) $this->input('photo') : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\Bus\Queueable;
|
||||||
use Illuminate\Mail\Mailable;
|
use Illuminate\Mail\Mailable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
class OrganizationInvitationMail extends Mailable
|
class OrganizationInvitationMail extends Mailable
|
||||||
@@ -32,9 +33,12 @@ class OrganizationInvitationMail extends Mailable
|
|||||||
public function build(): self
|
public function build(): self
|
||||||
{
|
{
|
||||||
return $this->markdown('emails.organization-invitation', [
|
return $this->markdown('emails.organization-invitation', [
|
||||||
'acceptUrl' => URL::signedRoute('team-invitations.accept', [
|
'acceptUrl' => URL::to(URL::signedRoute(
|
||||||
'invitation' => $this->invitation,
|
'organization-invitations.accept',
|
||||||
]),
|
['invitation' => $this->invitation->getKey()],
|
||||||
|
Carbon::now()->addDays(90),
|
||||||
|
false
|
||||||
|
)),
|
||||||
])->subject(__('Organization Invitation'));
|
])->subject(__('Organization Invitation'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
app/Mail/VerifyUpdatedEmailMail.php
Normal file
48
app/Mail/VerifyUpdatedEmailMail.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class VerifyUpdatedEmailMail extends Mailable
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public User $user;
|
||||||
|
|
||||||
|
public string $email;
|
||||||
|
|
||||||
|
public function __construct(User $user, string $email)
|
||||||
|
{
|
||||||
|
$this->user = $user;
|
||||||
|
$this->email = Str::lower($email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the message.
|
||||||
|
*/
|
||||||
|
public function build(): self
|
||||||
|
{
|
||||||
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
|
'users.verify-email-change',
|
||||||
|
Carbon::now()->addMinutes((int) config('auth.verification.expire', 60)),
|
||||||
|
[
|
||||||
|
'user' => $this->user->getKey(),
|
||||||
|
'email' => $this->email,
|
||||||
|
],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->markdown('emails.verify-updated-email', [
|
||||||
|
'verificationUrl' => URL::to($verificationUrl),
|
||||||
|
])->subject(__('Verify Email Address'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $user_id
|
* @property string $user_id
|
||||||
* @property bool $employees_can_see_billable_rates
|
* @property bool $employees_can_see_billable_rates
|
||||||
* @property bool $employees_can_manage_tasks
|
* @property bool $employees_can_manage_tasks
|
||||||
|
* @property bool $prevent_overlapping_time_entries
|
||||||
* @property User $owner
|
* @property User $owner
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $email
|
* @property string $email
|
||||||
* @property string $role
|
* @property string $role
|
||||||
* @property string $organization_id
|
* @property string $organization_id
|
||||||
|
* @property Carbon|null $accepted_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property-read Organization $organization
|
* @property-read Organization $organization
|
||||||
@@ -41,14 +42,16 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
|
|||||||
protected $table = 'organization_invitations';
|
protected $table = 'organization_invitations';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* Get the attributes that should be cast.
|
||||||
*
|
*
|
||||||
* @var array<int, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
protected $fillable = [
|
public function casts(): array
|
||||||
'email',
|
{
|
||||||
'role',
|
return [
|
||||||
];
|
'accepted_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the organization that the invitation belongs to.
|
* Get the organization that the invitation belongs to.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
|||||||
* @property string $id
|
* @property string $id
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property string $email
|
* @property string $email
|
||||||
|
* @property string|null $pending_email
|
||||||
* @property Carbon|null $email_verified_at
|
* @property Carbon|null $email_verified_at
|
||||||
* @property string|null $password
|
* @property string|null $password
|
||||||
* @property string|null $two_factor_secret
|
* @property string|null $two_factor_secret
|
||||||
@@ -105,6 +106,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'name' => 'string',
|
'name' => 'string',
|
||||||
'email' => 'string',
|
'email' => 'string',
|
||||||
|
'pending_email' => 'string',
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'is_admin' => 'boolean',
|
'is_admin' => 'boolean',
|
||||||
'is_placeholder' => 'boolean',
|
'is_placeholder' => 'boolean',
|
||||||
|
|||||||
@@ -62,18 +62,6 @@ class OrganizationPolicy
|
|||||||
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
|
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the user can add team members.
|
|
||||||
*/
|
|
||||||
public function addTeamMember(User $user, Organization $organization): bool
|
|
||||||
{
|
|
||||||
if (Filament::isServing()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether the user can update team member permissions.
|
* Determine whether the user can update team member permissions.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Listeners\RemovePlaceholder;
|
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
|
||||||
|
|
||||||
class EventServiceProvider extends ServiceProvider
|
class EventServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -21,9 +19,6 @@ class EventServiceProvider extends ServiceProvider
|
|||||||
Registered::class => [
|
Registered::class => [
|
||||||
SendEmailVerificationNotification::class,
|
SendEmailVerificationNotification::class,
|
||||||
],
|
],
|
||||||
TeamMemberAdded::class => [
|
|
||||||
RemovePlaceholder::class,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Hash;
|
|||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Inertia;
|
||||||
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
|
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
|
||||||
use Laravel\Fortify\Fortify;
|
use Laravel\Fortify\Fortify;
|
||||||
use Laravel\Fortify\Http\Responses\LoginResponse;
|
use Laravel\Fortify\Http\Responses\LoginResponse;
|
||||||
@@ -41,6 +42,14 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||||
|
|
||||||
|
Fortify::registerView(function () {
|
||||||
|
return Inertia::render('Auth/Register', [
|
||||||
|
'terms_url' => config('auth.terms_url'),
|
||||||
|
'privacy_policy_url' => config('auth.privacy_policy_url'),
|
||||||
|
'newsletter_consent' => config('auth.newsletter_consent'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
Fortify::authenticateUsing(function (Request $request): ?User {
|
Fortify::authenticateUsing(function (Request $request): ?User {
|
||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
|
|||||||
@@ -13,20 +13,18 @@ use App\Actions\Jetstream\RemoveOrganizationMember;
|
|||||||
use App\Actions\Jetstream\UpdateMemberRole;
|
use App\Actions\Jetstream\UpdateMemberRole;
|
||||||
use App\Actions\Jetstream\UpdateOrganization;
|
use App\Actions\Jetstream\UpdateOrganization;
|
||||||
use App\Actions\Jetstream\ValidateOrganizationDeletion;
|
use App\Actions\Jetstream\ValidateOrganizationDeletion;
|
||||||
use App\Enums\Role;
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Models\Member;
|
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 App\Models\User;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
use Brick\Money\Currency;
|
use Brick\Money\Currency;
|
||||||
use Brick\Money\ISOCurrencyProvider;
|
use Brick\Money\ISOCurrencyProvider;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Inertia\Inertia;
|
|
||||||
use Laravel\Fortify\Fortify;
|
|
||||||
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
|
use Laravel\Jetstream\Actions\UpdateTeamMemberRole;
|
||||||
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
|
use Laravel\Jetstream\Actions\ValidateTeamDeletion;
|
||||||
use Laravel\Jetstream\Jetstream;
|
use Laravel\Jetstream\Jetstream;
|
||||||
@@ -60,13 +58,6 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
|
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
|
||||||
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
|
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
|
||||||
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
|
app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class);
|
||||||
Fortify::registerView(function () {
|
|
||||||
return Inertia::render('Auth/Register', [
|
|
||||||
'terms_url' => config('auth.terms_url'),
|
|
||||||
'privacy_policy_url' => config('auth.privacy_policy_url'),
|
|
||||||
'newsletter_consent' => config('auth.newsletter_consent'),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
Gate::define('removeTeamMember', function (User $user, Organization $team) {
|
Gate::define('removeTeamMember', function (User $user, Organization $team) {
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
@@ -79,205 +70,10 @@ class JetstreamServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
Jetstream::defaultApiTokenPermissions([]);
|
Jetstream::defaultApiTokenPermissions([]);
|
||||||
|
|
||||||
Jetstream::role(Role::Owner->value, 'Owner', [
|
foreach (PermissionStore::roleDefinitions() as $role => $definition) {
|
||||||
'charts:view:own',
|
Jetstream::role($role, $definition['name'], $definition['permissions'])
|
||||||
'charts:view:all',
|
->description($definition['description']);
|
||||||
'projects:view',
|
}
|
||||||
'projects:view:all',
|
|
||||||
'projects:create',
|
|
||||||
'projects:update',
|
|
||||||
'projects:delete',
|
|
||||||
'project-members:view',
|
|
||||||
'project-members:create',
|
|
||||||
'project-members:update',
|
|
||||||
'project-members:delete',
|
|
||||||
'tasks:view',
|
|
||||||
'tasks:view:all',
|
|
||||||
'tasks:create',
|
|
||||||
'tasks:create:all',
|
|
||||||
'tasks:update',
|
|
||||||
'tasks:update:all',
|
|
||||||
'tasks:delete',
|
|
||||||
'tasks:delete:all',
|
|
||||||
'time-entries:view:all',
|
|
||||||
'time-entries:create:all',
|
|
||||||
'time-entries:update:all',
|
|
||||||
'time-entries:delete:all',
|
|
||||||
'time-entries:view:own',
|
|
||||||
'time-entries:create:own',
|
|
||||||
'time-entries:update:own',
|
|
||||||
'time-entries:delete:own',
|
|
||||||
'tags:view',
|
|
||||||
'tags:create',
|
|
||||||
'tags:update',
|
|
||||||
'tags:delete',
|
|
||||||
'clients:view',
|
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
|
||||||
'clients:update',
|
|
||||||
'clients:delete',
|
|
||||||
'organizations:view',
|
|
||||||
'organizations:update',
|
|
||||||
'organizations:delete',
|
|
||||||
'import',
|
|
||||||
'export',
|
|
||||||
'invitations:view',
|
|
||||||
'invitations:create',
|
|
||||||
'invitations:resend',
|
|
||||||
'invitations:remove',
|
|
||||||
'members:view',
|
|
||||||
'members:invite-placeholder',
|
|
||||||
'members:change-ownership',
|
|
||||||
'members:make-placeholder',
|
|
||||||
'members:merge-into',
|
|
||||||
'members:update',
|
|
||||||
'members:delete',
|
|
||||||
'billing',
|
|
||||||
'reports:view',
|
|
||||||
'reports:create',
|
|
||||||
'reports:update',
|
|
||||||
'reports:delete',
|
|
||||||
'invoices:view',
|
|
||||||
'invoices:create',
|
|
||||||
'invoices:update',
|
|
||||||
'invoices:download',
|
|
||||||
'invoices:delete',
|
|
||||||
'invoice-settings:view',
|
|
||||||
'invoice-settings:update',
|
|
||||||
])->description('Owner users can perform any action. There is only one owner per organization.');
|
|
||||||
|
|
||||||
Jetstream::role(Role::Admin->value, 'Administrator', [
|
|
||||||
'charts:view:own',
|
|
||||||
'charts:view:all',
|
|
||||||
'projects:view',
|
|
||||||
'projects:view:all',
|
|
||||||
'projects:create',
|
|
||||||
'projects:update',
|
|
||||||
'projects:delete',
|
|
||||||
'project-members:view',
|
|
||||||
'project-members:create',
|
|
||||||
'project-members:update',
|
|
||||||
'project-members:delete',
|
|
||||||
'tasks:view',
|
|
||||||
'tasks:view:all',
|
|
||||||
'tasks:create',
|
|
||||||
'tasks:create:all',
|
|
||||||
'tasks:update',
|
|
||||||
'tasks:update:all',
|
|
||||||
'tasks:delete',
|
|
||||||
'tasks:delete:all',
|
|
||||||
'time-entries:view:all',
|
|
||||||
'time-entries:create:all',
|
|
||||||
'time-entries:update:all',
|
|
||||||
'time-entries:delete:all',
|
|
||||||
'time-entries:view:own',
|
|
||||||
'time-entries:create:own',
|
|
||||||
'time-entries:update:own',
|
|
||||||
'time-entries:delete:own',
|
|
||||||
'tags:view',
|
|
||||||
'tags:create',
|
|
||||||
'tags:update',
|
|
||||||
'tags:delete',
|
|
||||||
'clients:view',
|
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
|
||||||
'clients:update',
|
|
||||||
'clients:delete',
|
|
||||||
'organizations:view',
|
|
||||||
'organizations:update',
|
|
||||||
'import',
|
|
||||||
'export',
|
|
||||||
'invitations:view',
|
|
||||||
'invitations:create',
|
|
||||||
'invitations:resend',
|
|
||||||
'invitations:remove',
|
|
||||||
'members:view',
|
|
||||||
'members:invite-placeholder',
|
|
||||||
'members:make-placeholder',
|
|
||||||
'members:merge-into',
|
|
||||||
'members:delete',
|
|
||||||
'members:update',
|
|
||||||
'reports:view',
|
|
||||||
'reports:create',
|
|
||||||
'reports:update',
|
|
||||||
'reports:delete',
|
|
||||||
'invoices:view',
|
|
||||||
'invoices:create',
|
|
||||||
'invoices:update',
|
|
||||||
'invoices:download',
|
|
||||||
'invoices:delete',
|
|
||||||
'invoice-settings:view',
|
|
||||||
'invoice-settings:update',
|
|
||||||
])->description('Administrator users can perform any action, except accessing the billing dashboard.');
|
|
||||||
|
|
||||||
Jetstream::role(Role::Manager->value, 'Manager', [
|
|
||||||
'charts:view:own',
|
|
||||||
'charts:view:all',
|
|
||||||
'projects:view',
|
|
||||||
'projects:view:all',
|
|
||||||
'projects:create',
|
|
||||||
'projects:update',
|
|
||||||
'projects:delete',
|
|
||||||
'project-members:view',
|
|
||||||
'project-members:create',
|
|
||||||
'project-members:update',
|
|
||||||
'project-members:delete',
|
|
||||||
'tasks:view',
|
|
||||||
'tasks:view:all',
|
|
||||||
'tasks:create',
|
|
||||||
'tasks:create:all',
|
|
||||||
'tasks:update',
|
|
||||||
'tasks:update:all',
|
|
||||||
'tasks:delete',
|
|
||||||
'tasks:delete:all',
|
|
||||||
'time-entries:view:all',
|
|
||||||
'time-entries:create:all',
|
|
||||||
'time-entries:update:all',
|
|
||||||
'time-entries:delete:all',
|
|
||||||
'time-entries:view:own',
|
|
||||||
'time-entries:create:own',
|
|
||||||
'time-entries:update:own',
|
|
||||||
'time-entries:delete:own',
|
|
||||||
'tags:view',
|
|
||||||
'tags:create',
|
|
||||||
'tags:update',
|
|
||||||
'tags:delete',
|
|
||||||
'clients:view',
|
|
||||||
'clients:view:all',
|
|
||||||
'clients:create',
|
|
||||||
'clients:update',
|
|
||||||
'clients:delete',
|
|
||||||
'organizations:view',
|
|
||||||
'invitations:view',
|
|
||||||
'members:view',
|
|
||||||
'reports:view',
|
|
||||||
'reports:create',
|
|
||||||
'reports:update',
|
|
||||||
'reports:delete',
|
|
||||||
'invoices:view',
|
|
||||||
'invoices:create',
|
|
||||||
'invoices:update',
|
|
||||||
'invoices:download',
|
|
||||||
'invoices:delete',
|
|
||||||
'invoice-settings:view',
|
|
||||||
'invoice-settings:update',
|
|
||||||
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
|
|
||||||
|
|
||||||
Jetstream::role(Role::Employee->value, 'Employee', [
|
|
||||||
'charts:view:own',
|
|
||||||
'projects:view',
|
|
||||||
'tags:view',
|
|
||||||
'tasks:view',
|
|
||||||
'clients:view',
|
|
||||||
'time-entries:view:own',
|
|
||||||
'time-entries:create:own',
|
|
||||||
'time-entries:update:own',
|
|
||||||
'time-entries:delete:own',
|
|
||||||
'organizations:view',
|
|
||||||
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
|
|
||||||
|
|
||||||
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
|
|
||||||
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
|
||||||
|
|
||||||
Jetstream::inertia()
|
Jetstream::inertia()
|
||||||
->whenRendering(
|
->whenRendering(
|
||||||
|
|||||||
37
app/Rules/Base64ImageRule.php
Normal file
37
app/Rules/Base64ImageRule.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use App\Support\Base64File;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||||
|
|
||||||
|
class Base64ImageRule implements ValidationRule
|
||||||
|
{
|
||||||
|
private const array ALLOWED_MIME_TYPES = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the validation rule.
|
||||||
|
*
|
||||||
|
* @param Closure(string): PotentiallyTranslatedString $fail
|
||||||
|
*/
|
||||||
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
$fail(__('validation.string'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = Base64File::decode($value);
|
||||||
|
if ($file === null || ! in_array($file['mime_type'], self::ALLOWED_MIME_TYPES, true)) {
|
||||||
|
$fail(__('validation.mimes', ['values' => 'jpg, png']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@ use App\Enums\Role;
|
|||||||
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Mail\OrganizationInvitationMail;
|
use App\Mail\OrganizationInvitationMail;
|
||||||
use App\Models\Member;
|
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\OrganizationInvitation;
|
use App\Models\OrganizationInvitation;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||||
|
|
||||||
@@ -21,11 +23,7 @@ class InvitationService
|
|||||||
*/
|
*/
|
||||||
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation
|
||||||
{
|
{
|
||||||
if (Member::query()
|
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
|
||||||
->whereBelongsTo($organization, 'organization')
|
|
||||||
->whereRelation('user', 'email', '=', $email)
|
|
||||||
->where('role', '!=', Role::Placeholder->value)
|
|
||||||
->exists()) {
|
|
||||||
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
throw new UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,4 +46,37 @@ class InvitationService
|
|||||||
|
|
||||||
return $invitation;
|
return $invitation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Organization>
|
||||||
|
*/
|
||||||
|
public function processAcceptedInvitations(User $user): Collection
|
||||||
|
{
|
||||||
|
$organizations = new Collection;
|
||||||
|
|
||||||
|
$invitations = OrganizationInvitation::query()
|
||||||
|
->where('email', $user->email)
|
||||||
|
->whereNotNull('accepted_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($invitations as $invitation) {
|
||||||
|
$organization = $invitation->organization;
|
||||||
|
$role = Role::tryFrom($invitation->role);
|
||||||
|
if ($role === null) {
|
||||||
|
Log::error('Invalid role in invitation', [
|
||||||
|
'invitation' => $invitation->getKey(),
|
||||||
|
'role' => $invitation->role,
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
app(MemberService::class)->addMember($user, $organization, $role);
|
||||||
|
|
||||||
|
$invitation->delete();
|
||||||
|
|
||||||
|
$organizations->push($organization);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $organizations;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Events\MemberAdded;
|
||||||
|
use App\Events\MemberAdding;
|
||||||
use App\Events\MemberRemoved;
|
use App\Events\MemberRemoved;
|
||||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||||
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
||||||
@@ -36,7 +38,8 @@ class MemberService
|
|||||||
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
||||||
{
|
{
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
AddingTeamMember::dispatch($organization, $user);
|
MemberAdding::dispatch($user, $organization, $role);
|
||||||
|
AddingTeamMember::dispatch($organization, $user); // Legacy event
|
||||||
}
|
}
|
||||||
|
|
||||||
$member = new Member;
|
$member = new Member;
|
||||||
@@ -49,14 +52,37 @@ class MemberService
|
|||||||
$user->currentOrganization()->associate($organization);
|
$user->currentOrganization()->associate($organization);
|
||||||
$user->save();
|
$user->save();
|
||||||
});
|
});
|
||||||
|
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
|
||||||
|
|
||||||
if (! $asSuperAdmin) {
|
if (! $asSuperAdmin) {
|
||||||
TeamMemberAdded::dispatch($organization, $user);
|
MemberAdded::dispatch($member, $organization, $user);
|
||||||
|
TeamMemberAdded::dispatch($organization, $user); // Legacy event
|
||||||
}
|
}
|
||||||
|
|
||||||
return $member;
|
return $member;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function mergePlaceholderMembersIntoExistingMember(Member $member, Organization $organization, User $user): void
|
||||||
|
{
|
||||||
|
$placeholders = Member::query()
|
||||||
|
->whereHas('user', function (Builder $query) use ($user): void {
|
||||||
|
/** @var Builder<User> $query */
|
||||||
|
$query->where('is_placeholder', '=', true)
|
||||||
|
->where('email', '=', $user->email);
|
||||||
|
})
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->with(['user'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($placeholders as $placeholder) {
|
||||||
|
/** @var Member $placeholder */
|
||||||
|
$placeholderUser = $placeholder->user;
|
||||||
|
$this->assignOrganizationEntitiesToDifferentMember($organization, $placeholder, $member);
|
||||||
|
$placeholder->delete();
|
||||||
|
$placeholderUser->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws CanNotRemoveOwnerFromOrganization
|
* @throws CanNotRemoveOwnerFromOrganization
|
||||||
* @throws EntityStillInUseApiException
|
* @throws EntityStillInUseApiException
|
||||||
@@ -209,4 +235,13 @@ class MemberService
|
|||||||
$this->userService->makeSureUserHasCurrentOrganization($user);
|
$this->userService->makeSureUserHasCurrentOrganization($user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isEmailAlreadyMember(Organization $organization, string $email): bool
|
||||||
|
{
|
||||||
|
return Member::query()
|
||||||
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->whereRelation('user', 'email', '=', $email)
|
||||||
|
->where('role', '!=', Role::Placeholder->value)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,238 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Laravel\Jetstream\Jetstream;
|
|
||||||
use Laravel\Jetstream\Role;
|
|
||||||
|
|
||||||
class PermissionStore
|
class PermissionStore
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, array{name: string, permissions: array<string>, description: string}>
|
||||||
|
*/
|
||||||
|
private const array ROLE_DEFINITIONS = [
|
||||||
|
'owner' => [
|
||||||
|
'name' => 'Owner',
|
||||||
|
'permissions' => [
|
||||||
|
'charts:view:own',
|
||||||
|
'charts:view:all',
|
||||||
|
'projects:view',
|
||||||
|
'projects:view:all',
|
||||||
|
'projects:create',
|
||||||
|
'projects:update',
|
||||||
|
'projects:delete',
|
||||||
|
'project-members:view',
|
||||||
|
'project-members:create',
|
||||||
|
'project-members:update',
|
||||||
|
'project-members:delete',
|
||||||
|
'tasks:view',
|
||||||
|
'tasks:view:all',
|
||||||
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
|
'time-entries:view:all',
|
||||||
|
'time-entries:create:all',
|
||||||
|
'time-entries:update:all',
|
||||||
|
'time-entries:delete:all',
|
||||||
|
'time-entries:view:own',
|
||||||
|
'time-entries:create:own',
|
||||||
|
'time-entries:update:own',
|
||||||
|
'time-entries:delete:own',
|
||||||
|
'tags:view',
|
||||||
|
'tags:create',
|
||||||
|
'tags:update',
|
||||||
|
'tags:delete',
|
||||||
|
'clients:view',
|
||||||
|
'clients:view:all',
|
||||||
|
'clients:create',
|
||||||
|
'clients:update',
|
||||||
|
'clients:delete',
|
||||||
|
'organizations:view',
|
||||||
|
'organizations:update',
|
||||||
|
'organizations:delete',
|
||||||
|
'import',
|
||||||
|
'export',
|
||||||
|
'invitations:view',
|
||||||
|
'invitations:create',
|
||||||
|
'invitations:resend',
|
||||||
|
'invitations:remove',
|
||||||
|
'members:view',
|
||||||
|
'members:invite-placeholder',
|
||||||
|
'members:change-ownership',
|
||||||
|
'members:make-placeholder',
|
||||||
|
'members:merge-into',
|
||||||
|
'members:update',
|
||||||
|
'members:delete',
|
||||||
|
'billing',
|
||||||
|
'reports:view',
|
||||||
|
'reports:create',
|
||||||
|
'reports:update',
|
||||||
|
'reports:delete',
|
||||||
|
'invoices:view',
|
||||||
|
'invoices:create',
|
||||||
|
'invoices:update',
|
||||||
|
'invoices:download',
|
||||||
|
'invoices:delete',
|
||||||
|
'invoice-settings:view',
|
||||||
|
'invoice-settings:update',
|
||||||
|
],
|
||||||
|
'description' => 'Owner users can perform any action. There is only one owner per organization.',
|
||||||
|
],
|
||||||
|
'admin' => [
|
||||||
|
'name' => 'Administrator',
|
||||||
|
'permissions' => [
|
||||||
|
'charts:view:own',
|
||||||
|
'charts:view:all',
|
||||||
|
'projects:view',
|
||||||
|
'projects:view:all',
|
||||||
|
'projects:create',
|
||||||
|
'projects:update',
|
||||||
|
'projects:delete',
|
||||||
|
'project-members:view',
|
||||||
|
'project-members:create',
|
||||||
|
'project-members:update',
|
||||||
|
'project-members:delete',
|
||||||
|
'tasks:view',
|
||||||
|
'tasks:view:all',
|
||||||
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
|
'time-entries:view:all',
|
||||||
|
'time-entries:create:all',
|
||||||
|
'time-entries:update:all',
|
||||||
|
'time-entries:delete:all',
|
||||||
|
'time-entries:view:own',
|
||||||
|
'time-entries:create:own',
|
||||||
|
'time-entries:update:own',
|
||||||
|
'time-entries:delete:own',
|
||||||
|
'tags:view',
|
||||||
|
'tags:create',
|
||||||
|
'tags:update',
|
||||||
|
'tags:delete',
|
||||||
|
'clients:view',
|
||||||
|
'clients:view:all',
|
||||||
|
'clients:create',
|
||||||
|
'clients:update',
|
||||||
|
'clients:delete',
|
||||||
|
'organizations:view',
|
||||||
|
'organizations:update',
|
||||||
|
'import',
|
||||||
|
'export',
|
||||||
|
'invitations:view',
|
||||||
|
'invitations:create',
|
||||||
|
'invitations:resend',
|
||||||
|
'invitations:remove',
|
||||||
|
'members:view',
|
||||||
|
'members:invite-placeholder',
|
||||||
|
'members:make-placeholder',
|
||||||
|
'members:merge-into',
|
||||||
|
'members:delete',
|
||||||
|
'members:update',
|
||||||
|
'reports:view',
|
||||||
|
'reports:create',
|
||||||
|
'reports:update',
|
||||||
|
'reports:delete',
|
||||||
|
'invoices:view',
|
||||||
|
'invoices:create',
|
||||||
|
'invoices:update',
|
||||||
|
'invoices:download',
|
||||||
|
'invoices:delete',
|
||||||
|
'invoice-settings:view',
|
||||||
|
'invoice-settings:update',
|
||||||
|
],
|
||||||
|
'description' => 'Administrator users can perform any action, except accessing the billing dashboard.',
|
||||||
|
],
|
||||||
|
'manager' => [
|
||||||
|
'name' => 'Manager',
|
||||||
|
'permissions' => [
|
||||||
|
'charts:view:own',
|
||||||
|
'charts:view:all',
|
||||||
|
'projects:view',
|
||||||
|
'projects:view:all',
|
||||||
|
'projects:create',
|
||||||
|
'projects:update',
|
||||||
|
'projects:delete',
|
||||||
|
'project-members:view',
|
||||||
|
'project-members:create',
|
||||||
|
'project-members:update',
|
||||||
|
'project-members:delete',
|
||||||
|
'tasks:view',
|
||||||
|
'tasks:view:all',
|
||||||
|
'tasks:create',
|
||||||
|
'tasks:create:all',
|
||||||
|
'tasks:update',
|
||||||
|
'tasks:update:all',
|
||||||
|
'tasks:delete',
|
||||||
|
'tasks:delete:all',
|
||||||
|
'time-entries:view:all',
|
||||||
|
'time-entries:create:all',
|
||||||
|
'time-entries:update:all',
|
||||||
|
'time-entries:delete:all',
|
||||||
|
'time-entries:view:own',
|
||||||
|
'time-entries:create:own',
|
||||||
|
'time-entries:update:own',
|
||||||
|
'time-entries:delete:own',
|
||||||
|
'tags:view',
|
||||||
|
'tags:create',
|
||||||
|
'tags:update',
|
||||||
|
'tags:delete',
|
||||||
|
'clients:view',
|
||||||
|
'clients:view:all',
|
||||||
|
'clients:create',
|
||||||
|
'clients:update',
|
||||||
|
'clients:delete',
|
||||||
|
'organizations:view',
|
||||||
|
'invitations:view',
|
||||||
|
'members:view',
|
||||||
|
'reports:view',
|
||||||
|
'reports:create',
|
||||||
|
'reports:update',
|
||||||
|
'reports:delete',
|
||||||
|
'invoices:view',
|
||||||
|
'invoices:create',
|
||||||
|
'invoices:update',
|
||||||
|
'invoices:download',
|
||||||
|
'invoices:delete',
|
||||||
|
'invoice-settings:view',
|
||||||
|
'invoice-settings:update',
|
||||||
|
],
|
||||||
|
'description' => 'Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).',
|
||||||
|
],
|
||||||
|
'employee' => [
|
||||||
|
'name' => 'Employee',
|
||||||
|
'permissions' => [
|
||||||
|
'charts:view:own',
|
||||||
|
'projects:view',
|
||||||
|
'tags:view',
|
||||||
|
'tasks:view',
|
||||||
|
'clients:view',
|
||||||
|
'time-entries:view:own',
|
||||||
|
'time-entries:create:own',
|
||||||
|
'time-entries:update:own',
|
||||||
|
'time-entries:delete:own',
|
||||||
|
'organizations:view',
|
||||||
|
],
|
||||||
|
'description' => 'Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.',
|
||||||
|
],
|
||||||
|
'placeholder' => [
|
||||||
|
'name' => 'Placeholder',
|
||||||
|
'permissions' => [],
|
||||||
|
'description' => 'Placeholders are used for importing data. They cannot log in and have no permissions.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array<string>>
|
||||||
|
*/
|
||||||
|
private static array $customRolePermissions = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<string, array<string>>
|
* @var array<string, array<string>>
|
||||||
*/
|
*/
|
||||||
@@ -22,6 +246,37 @@ class PermissionStore
|
|||||||
$this->permissionCache = [];
|
$this->permissionCache = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{name: string, permissions: array<string>, description: string}>
|
||||||
|
*/
|
||||||
|
public static function roleDefinitions(): array
|
||||||
|
{
|
||||||
|
return self::ROLE_DEFINITIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string> $permissions
|
||||||
|
*/
|
||||||
|
public static function registerCustomRole(string $role, array $permissions): void
|
||||||
|
{
|
||||||
|
self::$customRolePermissions[$role] = $permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resetCustomRoles(): void
|
||||||
|
{
|
||||||
|
self::$customRolePermissions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public static function permissionsForRole(string $role): array
|
||||||
|
{
|
||||||
|
return self::$customRolePermissions[$role]
|
||||||
|
?? self::ROLE_DEFINITIONS[$role]['permissions']
|
||||||
|
?? [];
|
||||||
|
}
|
||||||
|
|
||||||
public function has(Organization $organization, string $permission): bool
|
public function has(Organization $organization, string $permission): bool
|
||||||
{
|
{
|
||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
@@ -68,14 +323,11 @@ class PermissionStore
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Role|null $roleObj */
|
$permissions = self::permissionsForRole($role);
|
||||||
$roleObj = Jetstream::findRole($role);
|
|
||||||
|
|
||||||
$permissions = $roleObj->permissions ?? [];
|
|
||||||
|
|
||||||
// If the organization allows employees to manage tasks and the user is an employee,
|
// If the organization allows employees to manage tasks and the user is an employee,
|
||||||
// add the task management permissions for accessible projects
|
// add the task management permissions for accessible projects
|
||||||
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) {
|
if ($role === Role::Employee->value && $organization->employees_can_manage_tasks) {
|
||||||
$permissions = array_merge($permissions, [
|
$permissions = array_merge($permissions, [
|
||||||
'tasks:create',
|
'tasks:create',
|
||||||
'tasks:update',
|
'tasks:update',
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class UserService
|
|||||||
): User {
|
): User {
|
||||||
$user = new User;
|
$user = new User;
|
||||||
$user->name = $name;
|
$user->name = $name;
|
||||||
$user->email = $email;
|
$user->email = strtolower($email);
|
||||||
$user->password = Hash::make($password);
|
$user->password = Hash::make($password);
|
||||||
$user->timezone = $timezone;
|
$user->timezone = $timezone;
|
||||||
$user->week_start = $weekStart;
|
$user->week_start = $weekStart;
|
||||||
@@ -47,19 +47,22 @@ class UserService
|
|||||||
}
|
}
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
$organization = app(OrganizationService::class)->createOrganization(
|
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
|
||||||
$this->getOrganizationNameForUserName($user->name),
|
|
||||||
$user,
|
|
||||||
true,
|
|
||||||
$currency,
|
|
||||||
$numberFormat,
|
|
||||||
$currencyFormat,
|
|
||||||
$dateFormat,
|
|
||||||
$intervalFormat,
|
|
||||||
$timeFormat,
|
|
||||||
);
|
|
||||||
|
|
||||||
$user->ownedTeams()->save($organization);
|
if ($organizations->isEmpty()) {
|
||||||
|
$organization = app(OrganizationService::class)->createOrganization(
|
||||||
|
$this->getOrganizationNameForUserName($user->name),
|
||||||
|
$user,
|
||||||
|
true,
|
||||||
|
$currency,
|
||||||
|
$numberFormat,
|
||||||
|
$currencyFormat,
|
||||||
|
$dateFormat,
|
||||||
|
$intervalFormat,
|
||||||
|
$timeFormat,
|
||||||
|
);
|
||||||
|
$user->ownedTeams()->save($organization);
|
||||||
|
}
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|||||||
45
app/Support/Base64File.php
Normal file
45
app/Support/Base64File.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Symfony\Component\Mime\MimeTypes;
|
||||||
|
|
||||||
|
class Base64File
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{data: string, mime_type: string}|null
|
||||||
|
*/
|
||||||
|
public static function decode(string $value): ?array
|
||||||
|
{
|
||||||
|
if (str_contains($value, ',')) {
|
||||||
|
[, $value] = explode(',', $value, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = preg_replace('/\s+/', '', $value);
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = base64_decode($value, true);
|
||||||
|
if ($decoded === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($decoded);
|
||||||
|
if ($mimeType === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'data' => $decoded,
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function extension(string $mimeType): ?string
|
||||||
|
{
|
||||||
|
return MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,9 +25,24 @@ class OrganizationInvitationFactory extends Factory
|
|||||||
'email' => $this->faker->unique()->safeEmail(),
|
'email' => $this->faker->unique()->safeEmail(),
|
||||||
'role' => Role::Employee->value,
|
'role' => Role::Employee->value,
|
||||||
'organization_id' => Organization::factory(),
|
'organization_id' => Organization::factory(),
|
||||||
|
'accepted_at' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function role(Role $role): self
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'role' => $role->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function accepted(): self
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'accepted_at' => $this->faker->dateTime(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function forOrganization(Organization $organization): self
|
public function forOrganization(Organization $organization): self
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('users', function (Blueprint $table): void {
|
||||||
|
$table->string('pending_email')->nullable()->after('email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('pending_email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @throws RuntimeException
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$duplicateEmails = DB::table('users')
|
||||||
|
->selectRaw('LOWER(email) as normalized_email')
|
||||||
|
->selectRaw('COUNT(*) as user_count')
|
||||||
|
->selectRaw("STRING_AGG(id::text || ' <' || email || '>', ', ' ORDER BY email) as users")
|
||||||
|
->where('is_placeholder', false)
|
||||||
|
->groupByRaw('LOWER(email)')
|
||||||
|
->havingRaw('COUNT(*) > 1')
|
||||||
|
->orderBy('normalized_email')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($duplicateEmails->isNotEmpty()) {
|
||||||
|
$duplicateEmailMessage = $duplicateEmails
|
||||||
|
->take(20)
|
||||||
|
->map(fn (\stdClass $duplicateEmail): string => sprintf(
|
||||||
|
'%s (%d users: %s)',
|
||||||
|
$duplicateEmail->normalized_email,
|
||||||
|
$duplicateEmail->user_count,
|
||||||
|
$duplicateEmail->users,
|
||||||
|
))
|
||||||
|
->implode('; ');
|
||||||
|
|
||||||
|
$remainingDuplicateCount = $duplicateEmails->count() - 20;
|
||||||
|
$remainingDuplicateMessage = $remainingDuplicateCount > 0
|
||||||
|
? sprintf('; and %d more duplicate normalized emails', $remainingDuplicateCount)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Cannot lowercase users.email because doing so would create duplicate non-placeholder user emails and violate the unique index on users.email for non-placeholder users. Resolve these case-insensitive duplicates first: '.
|
||||||
|
$duplicateEmailMessage.
|
||||||
|
$remainingDuplicateMessage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('users')
|
||||||
|
->whereRaw('email <> LOWER(email)')
|
||||||
|
->update([
|
||||||
|
'email' => DB::raw('LOWER(email)'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
};
|
||||||
158
e2e/invitation-accept.spec.ts
Normal file
158
e2e/invitation-accept.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { expect, test } from '../playwright/fixtures';
|
||||||
|
import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
|
||||||
|
import { getInvitationAcceptUrl } from './utils/mailpit';
|
||||||
|
import { registerUser } from './utils/members';
|
||||||
|
|
||||||
|
// Invitation acceptance flows touch mail delivery + redirects.
|
||||||
|
test.describe.configure({ timeout: 45000 });
|
||||||
|
|
||||||
|
test.describe('invitation accept banners', () => {
|
||||||
|
test('shows success banner on dashboard when a logged-in registered user accepts an invitation', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const memberId = Math.floor(Math.random() * 100000);
|
||||||
|
const memberEmail = `success+${memberId}@invite-banner.test`;
|
||||||
|
|
||||||
|
// Invitee already has an account and is logged in.
|
||||||
|
const invitee = await registerUser(browser, 'Banner Success', memberEmail);
|
||||||
|
|
||||||
|
// Owner sends the invitation.
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
await page.getByRole('button', { name: 'Invite Member' }).click();
|
||||||
|
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
|
||||||
|
await page.getByLabel('Email').fill(memberEmail);
|
||||||
|
await page.getByRole('button', { name: 'Employee' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/invitations') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Invitee clicks the email link.
|
||||||
|
const acceptUrl = await getInvitationAcceptUrl(invitee.page.request, memberEmail);
|
||||||
|
await invitee.page.goto(acceptUrl);
|
||||||
|
await invitee.page.waitForURL(/\/dashboard$/);
|
||||||
|
|
||||||
|
const banner = invitee.page.getByTestId('banner');
|
||||||
|
await expect(banner).toBeVisible();
|
||||||
|
await expect(banner).toContainText(
|
||||||
|
/Great! You have accepted the invitation to join the .* organization\./
|
||||||
|
);
|
||||||
|
|
||||||
|
await invitee.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows info banner on login screen when a registered-but-logged-out invitee clicks the accept link', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const memberId = Math.floor(Math.random() * 100000);
|
||||||
|
const memberEmail = `loggedout+${memberId}@invite-banner.test`;
|
||||||
|
|
||||||
|
// Invitee has an account, but the context that clicks the link has no session.
|
||||||
|
const invitee = await registerUser(browser, 'Banner Loggedout', memberEmail);
|
||||||
|
await invitee.close();
|
||||||
|
|
||||||
|
// Owner sends the invitation.
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
await page.getByRole('button', { name: 'Invite Member' }).click();
|
||||||
|
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
|
||||||
|
await page.getByLabel('Email').fill(memberEmail);
|
||||||
|
await page.getByRole('button', { name: 'Employee' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/invitations') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Open the accept link in a fresh browser context (no session).
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const inviteePage = await context.newPage();
|
||||||
|
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
|
||||||
|
await inviteePage.goto(acceptUrl);
|
||||||
|
await inviteePage.waitForURL(/\/login$/);
|
||||||
|
|
||||||
|
const banner = inviteePage.getByTestId('banner');
|
||||||
|
await expect(banner).toBeVisible();
|
||||||
|
await expect(banner).toContainText(
|
||||||
|
/Great! You have accepted the invitation to join the .* organization\. Please log in to access it\./
|
||||||
|
);
|
||||||
|
|
||||||
|
// Logging in lands the invitee on the dashboard — they were already added silently
|
||||||
|
// by the accept controller, so the inviter's members list shows them.
|
||||||
|
await inviteePage.getByLabel('Email').fill(memberEmail);
|
||||||
|
await inviteePage.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD);
|
||||||
|
await inviteePage.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
await inviteePage.waitForURL(/\/dashboard/);
|
||||||
|
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
const memberRow = page.getByRole('row').filter({ hasText: 'Banner Loggedout' });
|
||||||
|
await expect(memberRow).toBeVisible();
|
||||||
|
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows info banner on register screen when an unregistered email accepts an invitation, then auto-joins on registration', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const memberId = Math.floor(Math.random() * 100000);
|
||||||
|
const memberEmail = `info+${memberId}@invite-banner.test`;
|
||||||
|
|
||||||
|
// Owner invites an email that has no account yet.
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
await page.getByRole('button', { name: 'Invite Member' }).click();
|
||||||
|
await expect(page.getByPlaceholder('Member Email')).toBeVisible();
|
||||||
|
await page.getByLabel('Email').fill(memberEmail);
|
||||||
|
await page.getByRole('button', { name: 'Employee' }).click();
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(response) =>
|
||||||
|
response.url().includes('/invitations') &&
|
||||||
|
response.request().method() === 'POST' &&
|
||||||
|
response.status() === 204
|
||||||
|
),
|
||||||
|
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Open the accept link in a fresh browser context (no session).
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const inviteePage = await context.newPage();
|
||||||
|
const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail);
|
||||||
|
await inviteePage.goto(acceptUrl);
|
||||||
|
await inviteePage.waitForURL(/\/register$/);
|
||||||
|
|
||||||
|
const banner = inviteePage.getByTestId('banner');
|
||||||
|
await expect(banner).toBeVisible();
|
||||||
|
await expect(banner).toContainText(
|
||||||
|
/Please create an account to finish joining the .* organization\./
|
||||||
|
);
|
||||||
|
|
||||||
|
// Complete registration — the invitee should auto-join the inviter's org
|
||||||
|
// (no fresh personal organization is created on top).
|
||||||
|
await inviteePage.getByLabel('Name').fill('Banner Info');
|
||||||
|
await inviteePage.getByLabel('Email').fill(memberEmail);
|
||||||
|
await inviteePage.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD);
|
||||||
|
await inviteePage.getByLabel('Confirm Password').fill(TEST_USER_PASSWORD);
|
||||||
|
await inviteePage.getByLabel('I agree to the Terms of').click();
|
||||||
|
await inviteePage.getByRole('button', { name: 'Register' }).click();
|
||||||
|
await inviteePage.waitForURL(/\/dashboard/);
|
||||||
|
|
||||||
|
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||||
|
const memberRow = page.getByRole('row').filter({ hasText: 'Banner Info' });
|
||||||
|
await expect(memberRow).toBeVisible();
|
||||||
|
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,7 +46,9 @@ export async function getInvitationAcceptUrl(
|
|||||||
expect(searchResult.messages.length).toBeGreaterThan(0);
|
expect(searchResult.messages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
const message = await getMessage(request, searchResult.messages[0].ID);
|
const message = await getMessage(request, searchResult.messages[0].ID);
|
||||||
const acceptUrlMatch = message.HTML.match(/href="([^"]*team-invitations[^"]*)"/);
|
const acceptUrlMatch = message.HTML.match(
|
||||||
|
/href="([^"]*(?:organization-invitations|team-invitations)[^"]*)"/
|
||||||
|
);
|
||||||
expect(acceptUrlMatch).toBeTruthy();
|
expect(acceptUrlMatch).toBeTruthy();
|
||||||
|
|
||||||
return acceptUrlMatch![1].replace(/&/g, '&');
|
return acceptUrlMatch![1].replace(/&/g, '&');
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
|||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||||
|
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
|
||||||
use App\Service\Export\ExportException;
|
use App\Service\Export\ExportException;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -49,6 +50,7 @@ return [
|
|||||||
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
|
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
|
||||||
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
|
||||||
OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.',
|
OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.',
|
||||||
|
UserResendEmailVerificationNoPendingEmailApiException::KEY => 'Resend email not possible, no pending email.',
|
||||||
],
|
],
|
||||||
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
|
||||||
];
|
];
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -7413,7 +7413,7 @@
|
|||||||
},
|
},
|
||||||
"resources/js/packages/ui": {
|
"resources/js/packages/ui": {
|
||||||
"name": "@solidtime/ui",
|
"name": "@solidtime/ui",
|
||||||
"version": "0.0.17",
|
"version": "0.0.21",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chroma-js": "^3.1.0",
|
"@types/chroma-js": "^3.1.0",
|
||||||
|
|||||||
@@ -1,36 +1,38 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watchEffect } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { usePage } from '@inertiajs/vue3';
|
import { usePage } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const ALLOWED_STYLES = ['success', 'danger', 'info', 'warning'] as const;
|
||||||
|
type BannerStyle = (typeof ALLOWED_STYLES)[number];
|
||||||
|
|
||||||
const page = usePage<{
|
const page = usePage<{
|
||||||
jetstream: {
|
flash: {
|
||||||
flash: {
|
bannerText?: string;
|
||||||
banner: string;
|
bannerStyle?: string;
|
||||||
bannerStyle: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const show = ref(true);
|
const rawStyle = page.props.flash?.bannerStyle;
|
||||||
const style = ref('success');
|
const message = page.props.flash?.bannerText ?? '';
|
||||||
const message = ref('');
|
const style: BannerStyle = (ALLOWED_STYLES as readonly string[]).includes(rawStyle ?? '')
|
||||||
|
? (rawStyle as BannerStyle)
|
||||||
|
: 'success';
|
||||||
|
|
||||||
watchEffect(async () => {
|
const show = ref(true);
|
||||||
style.value = page.props.jetstream.flash?.bannerStyle || 'success';
|
|
||||||
message.value = page.props.jetstream.flash?.banner || '';
|
|
||||||
show.value = true;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="show && message" class="bg-secondary border-b border-border-secondary">
|
<div
|
||||||
|
v-if="show && message"
|
||||||
|
data-testid="banner"
|
||||||
|
class="bg-secondary border-b border-border-secondary">
|
||||||
<div class="mx-auto py-1 px-3 sm:px-6 lg:px-8">
|
<div class="mx-auto py-1 px-3 sm:px-6 lg:px-8">
|
||||||
<div class="flex items-center justify-between flex-wrap">
|
<div class="flex items-center justify-between flex-wrap">
|
||||||
<div class="w-0 flex-1 flex items-center min-w-0">
|
<div class="w-0 flex-1 flex items-center min-w-0">
|
||||||
<span class="flex">
|
<span class="flex">
|
||||||
<svg
|
<svg
|
||||||
v-if="style == 'success'"
|
v-if="style === 'success'"
|
||||||
class="h-6 w-6 text-text-secondary"
|
class="h-6 w-6 text-text-secondary"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -44,7 +46,7 @@ watchEffect(async () => {
|
|||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
v-if="style == 'danger'"
|
v-if="style === 'danger'"
|
||||||
class="h-5 w-5 text-text-primary"
|
class="h-5 w-5 text-text-primary"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -56,6 +58,20 @@ watchEffect(async () => {
|
|||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
v-if="style === 'info'"
|
||||||
|
class="h-6 w-6 text-text-secondary"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||||
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<p class="ms-3 font-medium text-sm text-text-primary truncate">
|
<p class="ms-3 font-medium text-sm text-text-primary truncate">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
||||||
import AuthenticationCard from '@/Components/AuthenticationCard.vue';
|
import AuthenticationCard from '@/Components/AuthenticationCard.vue';
|
||||||
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
|
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
|
||||||
|
import Banner from '@/Components/Banner.vue';
|
||||||
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
|
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
|
||||||
@@ -36,6 +37,8 @@ const page = usePage<{
|
|||||||
<template>
|
<template>
|
||||||
<Head title="Log in" />
|
<Head title="Log in" />
|
||||||
|
|
||||||
|
<Banner />
|
||||||
|
|
||||||
<AuthenticationCard>
|
<AuthenticationCard>
|
||||||
<template #logo>
|
<template #logo>
|
||||||
<AuthenticationCardLogo />
|
<AuthenticationCardLogo />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
||||||
import AuthenticationCard from '@/Components/AuthenticationCard.vue';
|
import AuthenticationCard from '@/Components/AuthenticationCard.vue';
|
||||||
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
|
import AuthenticationCardLogo from '@/Components/AuthenticationCardLogo.vue';
|
||||||
|
import Banner from '@/Components/Banner.vue';
|
||||||
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
|
import Checkbox from '@/packages/ui/src/Input/Checkbox.vue';
|
||||||
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
|
import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
|
||||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||||
@@ -41,6 +42,8 @@ const page = usePage<{
|
|||||||
<template>
|
<template>
|
||||||
<Head title="Register" />
|
<Head title="Register" />
|
||||||
|
|
||||||
|
<Banner />
|
||||||
|
|
||||||
<AuthenticationCard>
|
<AuthenticationCard>
|
||||||
<template #logo>
|
<template #logo>
|
||||||
<AuthenticationCardLogo />
|
<AuthenticationCardLogo />
|
||||||
|
|||||||
BIN
resources/testfiles/test.png
Normal file
BIN
resources/testfiles/test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -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,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,20 +1,6 @@
|
|||||||
@component('mail::message')
|
@component('mail::message')
|
||||||
{{ __('You have been invited to join the :organization organization!', ['organization' => $invitation->organization->name]) }}
|
{{ __('You have been invited to join the :organization organization!', ['organization' => $invitation->organization->name]) }}
|
||||||
|
|
||||||
@if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::registration()))
|
|
||||||
{{ __('If you do not have an account, you may create one by clicking the button below. After creating an account, you may click the invitation acceptance button in this email to accept the team invitation:') }}
|
|
||||||
|
|
||||||
@component('mail::button', ['url' => route('register')])
|
|
||||||
{{ __('Create Account') }}
|
|
||||||
@endcomponent
|
|
||||||
|
|
||||||
{{ __('If you already have an account, you may accept this invitation by clicking the button below:') }}
|
|
||||||
|
|
||||||
@else
|
|
||||||
{{ __('You may accept this invitation by clicking the button below:') }}
|
|
||||||
@endif
|
|
||||||
|
|
||||||
|
|
||||||
@component('mail::button', ['url' => $acceptUrl])
|
@component('mail::button', ['url' => $acceptUrl])
|
||||||
{{ __('Accept Invitation') }}
|
{{ __('Accept Invitation') }}
|
||||||
@endcomponent
|
@endcomponent
|
||||||
|
|||||||
9
resources/views/emails/verify-updated-email.blade.php
Normal file
9
resources/views/emails/verify-updated-email.blade.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@component('mail::message')
|
||||||
|
{{ __('Please verify your new email address for your solidtime account.') }}
|
||||||
|
|
||||||
|
@component('mail::button', ['url' => $verificationUrl])
|
||||||
|
{{ __('Verify Email Address') }}
|
||||||
|
@endcomponent
|
||||||
|
|
||||||
|
{{ __('If you did not request this change, you may discard this email.') }}
|
||||||
|
@endcomponent
|
||||||
@@ -42,8 +42,10 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
|
|||||||
])->group(static function (): void {
|
])->group(static function (): void {
|
||||||
// Organization routes
|
// Organization routes
|
||||||
Route::name('organizations.')->group(static function (): void {
|
Route::name('organizations.')->group(static function (): void {
|
||||||
|
Route::post('/organizations', [OrganizationController::class, 'store'])->name('store');
|
||||||
Route::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show');
|
Route::get('/organizations/{organization}', [OrganizationController::class, 'show'])->name('show');
|
||||||
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update');
|
Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update');
|
||||||
|
Route::delete('/organizations/{organization}', [OrganizationController::class, 'destroy'])->name('destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Member routes
|
// Member routes
|
||||||
@@ -59,6 +61,9 @@ 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::post('/users/{user}/resend-email-verification', [UserController::class, 'resendEmailVerification'])->name('resend-email-verification');
|
||||||
|
Route::delete('/users/{user}', [UserController::class, 'destroy'])->name('destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Api token routes
|
// Api token routes
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ 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 App\Http\Controllers\Web\UserController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Laravel\Jetstream\Jetstream;
|
use Laravel\Jetstream\Jetstream;
|
||||||
@@ -83,3 +85,14 @@ 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');
|
||||||
|
|
||||||
|
Route::get('/users/{user}/verify-email-change', [UserController::class, 'verifyEmailChange'])
|
||||||
|
->middleware(['auth:web', config('jetstream.auth_session'), 'signed:relative'])
|
||||||
|
->name('users.verify-email-change');
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class InviteTeamMemberTest extends TestCase
|
|||||||
Mail::fake();
|
Mail::fake();
|
||||||
$placeholder = User::factory()->placeholder()->create();
|
$placeholder = User::factory()->placeholder()->create();
|
||||||
$owner = User::factory()->withPersonalOrganization()->create();
|
$owner = User::factory()->withPersonalOrganization()->create();
|
||||||
$placeholderMember = Member::factory()->forOrganization($owner->currentTeam)->forUser($placeholder)->create();
|
$placeholderMember = Member::factory()->role(Role::Placeholder)->forOrganization($owner->currentTeam)->forUser($placeholder)->create();
|
||||||
|
|
||||||
$timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forMember($placeholderMember)->createMany(5);
|
$timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forMember($placeholderMember)->createMany(5);
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
|
use App\Mail\VerifyUpdatedEmailMail;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ProfileInformationTest extends TestCase
|
class ProfileInformationTest extends TestCase
|
||||||
@@ -30,7 +33,9 @@ class ProfileInformationTest extends TestCase
|
|||||||
public function test_profile_information_can_be_updated(): void
|
public function test_profile_information_can_be_updated(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create([
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
]);
|
||||||
$timezone = app(TimezoneService::class)->getTimezones()[0];
|
$timezone = app(TimezoneService::class)->getTimezones()[0];
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
@@ -50,4 +55,120 @@ class ProfileInformationTest extends TestCase
|
|||||||
$this->assertEquals($timezone, $user->timezone);
|
$this->assertEquals($timezone, $user->timezone);
|
||||||
$this->assertEquals(Weekday::Sunday, $user->week_start);
|
$this->assertEquals(Weekday::Sunday, $user->week_start);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_email_update_keeps_current_email_verified_until_new_email_is_verified(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mail::fake();
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'current@example.com',
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]);
|
||||||
|
$timezone = app(TimezoneService::class)->getTimezones()[0];
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->put('/user/profile-information', [
|
||||||
|
'name' => 'Test Name',
|
||||||
|
'email' => 'New.Email@Example.com',
|
||||||
|
'timezone' => $timezone,
|
||||||
|
'week_start' => Weekday::Sunday->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertValid(errorBag: 'updateProfileInformation');
|
||||||
|
$user = $user->fresh();
|
||||||
|
$this->assertEquals('current@example.com', $user->email);
|
||||||
|
$this->assertEquals('new.email@example.com', $user->pending_email);
|
||||||
|
$this->assertNotNull($user->email_verified_at);
|
||||||
|
Mail::assertSent(VerifyUpdatedEmailMail::class, function (VerifyUpdatedEmailMail $mail): bool {
|
||||||
|
return $mail->hasTo('new.email@example.com') && $mail->email === 'new.email@example.com';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_pending_email_can_be_verified(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'current@example.com',
|
||||||
|
'pending_email' => 'new.email@example.com',
|
||||||
|
]);
|
||||||
|
$this->actingAs($user);
|
||||||
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
|
'users.verify-email-change',
|
||||||
|
now()->addMinutes(60),
|
||||||
|
[
|
||||||
|
'user' => $user->getKey(),
|
||||||
|
'email' => 'new.email@example.com',
|
||||||
|
],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->get($verificationUrl);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertRedirect(route('dashboard', [
|
||||||
|
'bannerStyle' => 'success',
|
||||||
|
'bannerText' => 'Your email address has been updated successfully.',
|
||||||
|
]));
|
||||||
|
$user = $user->fresh();
|
||||||
|
$this->assertEquals('new.email@example.com', $user->email);
|
||||||
|
$this->assertNull($user->pending_email);
|
||||||
|
$this->assertNotNull($user->email_verified_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_profile_update_does_not_clear_pending_email_when_email_is_unchanged(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'current@example.com',
|
||||||
|
'pending_email' => 'new.email@example.com',
|
||||||
|
]);
|
||||||
|
$timezone = app(TimezoneService::class)->getTimezones()[0];
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->put('/user/profile-information', [
|
||||||
|
'name' => 'Updated Name',
|
||||||
|
'email' => 'current@example.com',
|
||||||
|
'timezone' => $timezone,
|
||||||
|
'week_start' => Weekday::Sunday->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertValid(errorBag: 'updateProfileInformation');
|
||||||
|
$user = $user->fresh();
|
||||||
|
$this->assertEquals('Updated Name', $user->name);
|
||||||
|
$this->assertEquals('current@example.com', $user->email);
|
||||||
|
$this->assertEquals('new.email@example.com', $user->pending_email);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stale_pending_email_verification_link_is_rejected(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'current@example.com',
|
||||||
|
'pending_email' => 'newer@example.com',
|
||||||
|
]);
|
||||||
|
$this->actingAs($user);
|
||||||
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
|
'users.verify-email-change',
|
||||||
|
now()->addMinutes(60),
|
||||||
|
[
|
||||||
|
'user' => $user->getKey(),
|
||||||
|
'email' => 'older@example.com',
|
||||||
|
],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->get($verificationUrl);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertForbidden();
|
||||||
|
$user = $user->fresh();
|
||||||
|
$this->assertEquals('current@example.com', $user->email);
|
||||||
|
$this->assertEquals('newer@example.com', $user->pending_email);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,21 +8,21 @@ use App\Enums\Role;
|
|||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Events\NewsletterRegistered;
|
use App\Events\NewsletterRegistered;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
|
use App\Models\OrganizationInvitation;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Providers\RouteServiceProvider;
|
use App\Providers\RouteServiceProvider;
|
||||||
use App\Service\IpLookup\IpLookupResponseDto;
|
use App\Service\IpLookup\IpLookupResponseDto;
|
||||||
use App\Service\IpLookup\IpLookupServiceContract;
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Laravel\Fortify\Features;
|
use Laravel\Fortify\Features;
|
||||||
use Laravel\Jetstream\Jetstream;
|
use Laravel\Jetstream\Jetstream;
|
||||||
use Tests\TestCase;
|
use Tests\TestCaseWithDatabase;
|
||||||
|
use TiMacDonald\Log\LogEntry;
|
||||||
|
|
||||||
class RegistrationTest extends TestCase
|
class RegistrationTest extends TestCaseWithDatabase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_registration_screen_can_be_rendered(): void
|
public function test_registration_screen_can_be_rendered(): void
|
||||||
{
|
{
|
||||||
if (! Features::enabled(Features::registration())) {
|
if (! Features::enabled(Features::registration())) {
|
||||||
@@ -346,4 +346,82 @@ class RegistrationTest extends TestCase
|
|||||||
$this->assertAuthenticated();
|
$this->assertAuthenticated();
|
||||||
$response->assertRedirect(RouteServiceProvider::HOME);
|
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_registration_does_not_create_private_organization_if_invite_was_accepted_for_the_email_with_the_registration_email(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
$organizationInvitation = OrganizationInvitation::factory()
|
||||||
|
->forOrganization($user->organization)
|
||||||
|
->role(Role::Employee)
|
||||||
|
->accepted()
|
||||||
|
->create([
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->post('/register', [
|
||||||
|
'name' => 'Test User',
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
'password' => 'password',
|
||||||
|
'password_confirmation' => 'password',
|
||||||
|
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertAuthenticated();
|
||||||
|
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||||
|
$newUser = User::where('email', 'test@example.com')->first();
|
||||||
|
$this->assertNotNull($newUser);
|
||||||
|
$this->assertDatabaseMissing(OrganizationInvitation::class, [
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
]);
|
||||||
|
$organizations = $newUser->organizations;
|
||||||
|
$this->assertCount(1, $organizations);
|
||||||
|
$this->assertSame($user->organization->id, $organizations->first()->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_registration_logs_and_skips_accepted_invitation_with_invalid_role(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
$organizationInvitation = OrganizationInvitation::factory()
|
||||||
|
->forOrganization($user->organization)
|
||||||
|
->accepted()
|
||||||
|
->create([
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
'role' => 'invalid-role',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->post('/register', [
|
||||||
|
'name' => 'Test User',
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
'password' => 'password',
|
||||||
|
'password_confirmation' => 'password',
|
||||||
|
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertAuthenticated();
|
||||||
|
$response->assertRedirect(RouteServiceProvider::HOME);
|
||||||
|
Log::assertLogged(fn (LogEntry $log) => $log->level === 'error'
|
||||||
|
&& $log->message === 'Invalid role in invitation'
|
||||||
|
&& $log->context === [
|
||||||
|
'invitation' => $organizationInvitation->getKey(),
|
||||||
|
'role' => 'invalid-role',
|
||||||
|
]);
|
||||||
|
$newUser = User::where('email', 'test@example.com')->firstOrFail();
|
||||||
|
$this->assertDatabaseHas(OrganizationInvitation::class, [
|
||||||
|
'id' => $organizationInvitation->getKey(),
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
'role' => 'invalid-role',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseMissing(Member::class, [
|
||||||
|
'organization_id' => $user->organization->getKey(),
|
||||||
|
'user_id' => $newUser->getKey(),
|
||||||
|
]);
|
||||||
|
$organizations = $newUser->organizations;
|
||||||
|
$this->assertCount(1, $organizations);
|
||||||
|
$this->assertNotSame($user->organization->id, $organizations->first()->id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ abstract class TestCase extends BaseTestCase
|
|||||||
|
|
||||||
protected function mockPrivateStorage(): void
|
protected function mockPrivateStorage(): void
|
||||||
{
|
{
|
||||||
Storage::fake(config('filesystems.default'));
|
Storage::fake(config('filesystems.private'));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function mockPublicStorage(): void
|
protected function mockPublicStorage(): void
|
||||||
@@ -50,6 +50,7 @@ abstract class TestCase extends BaseTestCase
|
|||||||
{
|
{
|
||||||
// Note: It is necessary to clear the permission cache after each test, since the "scoped singletons" are not reset between tests.
|
// Note: It is necessary to clear the permission cache after each test, since the "scoped singletons" are not reset between tests.
|
||||||
app(PermissionStore::class)->clear();
|
app(PermissionStore::class)->clear();
|
||||||
|
PermissionStore::resetCustomRoles();
|
||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Enums\Role;
|
|||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -26,6 +27,7 @@ abstract class TestCaseWithDatabase extends TestCase
|
|||||||
$roleName = 'custom-test-'.Str::uuid();
|
$roleName = 'custom-test-'.Str::uuid();
|
||||||
Jetstream::role($roleName, 'Custom Test', $permissions)
|
Jetstream::role($roleName, 'Custom Test', $permissions)
|
||||||
->description('Role custom for testing');
|
->description('Role custom for testing');
|
||||||
|
PermissionStore::registerCustomRole($roleName, $permissions);
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
if ($isOwner) {
|
if ($isOwner) {
|
||||||
$organization = Organization::factory()->withOwner($user)->create();
|
$organization = Organization::factory()->withOwner($user)->create();
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Endpoint\Api\V1;
|
namespace Tests\Unit\Endpoint\Api\V1;
|
||||||
|
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Events\AfterCreateOrganization;
|
||||||
use App\Http\Controllers\Api\V1\OrganizationController;
|
use App\Http\Controllers\Api\V1\OrganizationController;
|
||||||
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Service\BillableRateService;
|
use App\Service\BillableRateService;
|
||||||
|
use App\Service\IpLookup\IpLookupResponseDto;
|
||||||
|
use App\Service\IpLookup\IpLookupServiceContract;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Testing\Fluent\AssertableJson;
|
||||||
use Laravel\Passport\Passport;
|
use Laravel\Passport\Passport;
|
||||||
use Mockery\MockInterface;
|
use Mockery\MockInterface;
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
@@ -93,6 +99,121 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertJsonPath('data.billable_rate', null);
|
$response->assertJsonPath('data.billable_rate', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_store_endpoint_creates_new_organization(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
$organizationFake = Organization::factory()->make();
|
||||||
|
Event::fake([
|
||||||
|
AfterCreateOrganization::class,
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.organizations.store'), [
|
||||||
|
'name' => $organizationFake->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$response->assertJson(fn (AssertableJson $json) => $json
|
||||||
|
->has('data')
|
||||||
|
->where('data.name', $organizationFake->name)
|
||||||
|
->where('data.is_personal', false)
|
||||||
|
->where('data.currency', config('app.localization.default_currency'))
|
||||||
|
->etc()
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var Organization $newOrganization */
|
||||||
|
$newOrganization = Organization::query()->where('name', $organizationFake->name)->firstOrFail();
|
||||||
|
$this->assertTrue($newOrganization->owner->is($data->user));
|
||||||
|
$this->assertSame($newOrganization->getKey(), $data->user->fresh()->current_team_id);
|
||||||
|
$this->assertDatabaseHas(Member::class, [
|
||||||
|
'organization_id' => $newOrganization->getKey(),
|
||||||
|
'user_id' => $data->user->getKey(),
|
||||||
|
'role' => Role::Owner->value,
|
||||||
|
]);
|
||||||
|
Event::assertDispatched(AfterCreateOrganization::class, function (AfterCreateOrganization $event) use ($newOrganization): bool {
|
||||||
|
return $event->organization->is($newOrganization);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_store_endpoint_uses_ip_lookup_currency_for_new_organization(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
$this->mock(IpLookupServiceContract::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('lookup')
|
||||||
|
->once()
|
||||||
|
->andReturn(new IpLookupResponseDto(null, null, 'USD'));
|
||||||
|
});
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.organizations.store'), [
|
||||||
|
'name' => 'Test Organization',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$response->assertJsonPath('data.currency', 'USD');
|
||||||
|
$this->assertDatabaseHas(Organization::class, [
|
||||||
|
'name' => 'Test Organization',
|
||||||
|
'currency' => 'USD',
|
||||||
|
'user_id' => $data->user->getKey(),
|
||||||
|
'personal_team' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_store_endpoint_fails_if_name_is_missing(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.organizations.store'), []);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(422);
|
||||||
|
$response->assertJsonValidationErrors(['name']);
|
||||||
|
$this->assertDatabaseCount(Organization::class, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_store_endpoint_fails_if_name_is_not_a_string(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.organizations.store'), [
|
||||||
|
'name' => ['Test Organization'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(422);
|
||||||
|
$response->assertJsonValidationErrors(['name']);
|
||||||
|
$this->assertDatabaseCount(Organization::class, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_store_endpoint_fails_if_name_is_too_long(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.organizations.store'), [
|
||||||
|
'name' => str_repeat('a', 256),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(422);
|
||||||
|
$response->assertJsonValidationErrors(['name']);
|
||||||
|
$this->assertDatabaseCount(Organization::class, 1);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_organizations(): void
|
public function test_update_endpoint_fails_if_user_has_no_permission_to_update_organizations(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -260,4 +381,51 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract
|
|||||||
'billable_rate' => $organizationFake->billable_rate,
|
'billable_rate' => $organizationFake->billable_rate,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_delete_endpoint_if_user_does_not_have_permission(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_endpoint_fails_with_not_found_if_id_is_not_uuid(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'organizations:delete',
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->deleteJson(route('api.v1.organizations.destroy', ['not-uuid']));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_delete_endpoint_can_delete_organization(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$this->mockPrivateStorage();
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'organizations:delete',
|
||||||
|
]);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->deleteJson(route('api.v1.organizations.destroy', [$data->organization->getKey()]));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertNoContent();
|
||||||
|
$this->assertDatabaseMissing(Organization::class, [
|
||||||
|
'id' => $data->organization->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Unit\Endpoint\Api\V1;
|
namespace Tests\Unit\Endpoint\Api\V1;
|
||||||
|
|
||||||
|
use App\Enums\Weekday;
|
||||||
|
use App\Mail\VerifyUpdatedEmailMail;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Laravel\Passport\Passport;
|
use Laravel\Passport\Passport;
|
||||||
|
|
||||||
class UserEndpointTest extends ApiEndpointTestAbstract
|
class UserEndpointTest extends ApiEndpointTestAbstract
|
||||||
@@ -40,4 +45,349 @@ class UserEndpointTest extends ApiEndpointTestAbstract
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_update_changes_user_name_timezone_and_week_start(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
|
||||||
|
'name' => 'Updated Name',
|
||||||
|
'timezone' => 'America/New_York',
|
||||||
|
'week_start' => Weekday::Sunday->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertSuccessful();
|
||||||
|
$response->assertJson([
|
||||||
|
'data' => [
|
||||||
|
'id' => $data->user->getKey(),
|
||||||
|
'name' => 'Updated Name',
|
||||||
|
'timezone' => 'America/New_York',
|
||||||
|
'week_start' => Weekday::Sunday->value,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $data->user->fresh();
|
||||||
|
$this->assertSame('Updated Name', $user->name);
|
||||||
|
$this->assertSame('America/New_York', $user->timezone);
|
||||||
|
$this->assertSame(Weekday::Sunday, $user->week_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_does_not_change_user_fields_that_are_not_given(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
$data->user->name = 'Original Name';
|
||||||
|
$data->user->timezone = 'Europe/Vienna';
|
||||||
|
$data->user->week_start = Weekday::Monday;
|
||||||
|
$data->user->save();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), []);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertSuccessful();
|
||||||
|
$response->assertJson([
|
||||||
|
'data' => [
|
||||||
|
'id' => $data->user->getKey(),
|
||||||
|
'name' => 'Original Name',
|
||||||
|
'timezone' => 'Europe/Vienna',
|
||||||
|
'week_start' => Weekday::Monday->value,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $data->user->fresh();
|
||||||
|
$this->assertSame('Original Name', $user->name);
|
||||||
|
$this->assertSame('Europe/Vienna', $user->timezone);
|
||||||
|
$this->assertSame(Weekday::Monday, $user->week_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_email_stores_pending_email_and_sends_verification_email(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mail::fake();
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
$data->user->email = 'current@example.com';
|
||||||
|
$data->user->email_verified_at = now();
|
||||||
|
$data->user->save();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
|
||||||
|
'email' => 'New.Email@Example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertSuccessful();
|
||||||
|
|
||||||
|
$user = $data->user->fresh();
|
||||||
|
$this->assertSame('current@example.com', $user->email);
|
||||||
|
$this->assertSame('new.email@example.com', $user->pending_email);
|
||||||
|
$this->assertNotNull($user->email_verified_at);
|
||||||
|
Mail::assertSent(VerifyUpdatedEmailMail::class, function (VerifyUpdatedEmailMail $mail): bool {
|
||||||
|
return $mail->hasTo('new.email@example.com') && $mail->email === 'new.email@example.com';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_resend_email_verification_sends_pending_email_verification_email(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mail::fake();
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
$data->user->pending_email = 'new.email@example.com';
|
||||||
|
$data->user->save();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.users.resend-email-verification', $data->user->getKey()));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertNoContent();
|
||||||
|
Mail::assertNotSent(VerifyUpdatedEmailMail::class);
|
||||||
|
Mail::assertQueued(VerifyUpdatedEmailMail::class, function (VerifyUpdatedEmailMail $mail): bool {
|
||||||
|
return $mail->hasTo('new.email@example.com') && $mail->email === 'new.email@example.com';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_resend_email_verification_fails_if_given_id_is_not_the_authenticated_user(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mail::fake();
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
$otherData = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($otherData->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.users.resend-email-verification', $data->user->getKey()));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertForbidden();
|
||||||
|
Mail::assertNotSent(VerifyUpdatedEmailMail::class);
|
||||||
|
Mail::assertNotQueued(VerifyUpdatedEmailMail::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_resend_email_verification_fails_without_pending_email(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
$data->user->pending_email = null;
|
||||||
|
$data->user->save();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->postJson(route('api.v1.users.resend-email-verification', $data->user->getKey()));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(400);
|
||||||
|
$response->assertJson([
|
||||||
|
'error' => true,
|
||||||
|
'key' => 'user_resend_email_verification_no_pending_email',
|
||||||
|
'message' => 'Resend email not possible, no pending email.',
|
||||||
|
]);
|
||||||
|
Mail::assertNotSent(VerifyUpdatedEmailMail::class);
|
||||||
|
Mail::assertNotQueued(VerifyUpdatedEmailMail::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_changes_user_photo_from_base64_encoded_image(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
|
||||||
|
$previousPhotoPath = 'profile-photos/previous.png';
|
||||||
|
$photo = file_get_contents(resource_path('testfiles/test.png'));
|
||||||
|
$this->assertIsString($photo);
|
||||||
|
Storage::fake($photoDisk);
|
||||||
|
Storage::disk($photoDisk)->put($previousPhotoPath, 'previous photo');
|
||||||
|
$data->user->profile_photo_path = $previousPhotoPath;
|
||||||
|
$data->user->save();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
|
||||||
|
'photo' => base64_encode($photo),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertSuccessful();
|
||||||
|
|
||||||
|
$user = $data->user->fresh();
|
||||||
|
$this->assertNotNull($user->profile_photo_path);
|
||||||
|
$this->assertNotSame($previousPhotoPath, $user->profile_photo_path);
|
||||||
|
$this->assertStringStartsWith('profile-photos/', $user->profile_photo_path);
|
||||||
|
$this->assertStringEndsWith('.png', $user->profile_photo_path);
|
||||||
|
Storage::disk($photoDisk)->assertExists($user->profile_photo_path);
|
||||||
|
Storage::disk($photoDisk)->assertMissing($previousPhotoPath);
|
||||||
|
$this->assertSame($photo, Storage::disk($photoDisk)->get($user->profile_photo_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_fails_if_name_is_not_a_string(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
|
||||||
|
'name' => 123,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
$response->assertJsonValidationErrors(['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_fails_if_name_is_too_long(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
|
||||||
|
'name' => str_repeat('a', 256),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
$response->assertJsonValidationErrors(['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_fails_if_timezone_is_invalid(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
|
||||||
|
'timezone' => 'not-a-timezone',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
$response->assertJsonValidationErrors(['timezone']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_fails_if_week_start_is_invalid(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
|
||||||
|
'week_start' => 'not-a-weekday',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
$response->assertJsonValidationErrors(['week_start']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_fails_if_photo_is_not_a_string(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
|
||||||
|
'photo' => 123,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
$response->assertJsonValidationErrors(['photo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_fails_if_photo_is_not_base64_encoded(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
|
||||||
|
'photo' => 'not base64 encoded',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
$response->assertJsonValidationErrors(['photo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_fails_if_photo_is_not_a_jpg_or_png(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission();
|
||||||
|
$csv = file_get_contents(resource_path('testfiles/generic_projects_import_test_1.csv'));
|
||||||
|
$this->assertIsString($csv);
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
|
||||||
|
'photo' => base64_encode($csv),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
$response->assertJsonValidationErrors(['photo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
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()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
254
tests/Unit/Endpoint/Web/OrganizationInvitationEndpointTest.php
Normal file
254
tests/Unit/Endpoint/Web/OrganizationInvitationEndpointTest.php
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<?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'));
|
||||||
|
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
|
||||||
|
$response->assertSessionHas('bannerStyle', 'info');
|
||||||
|
$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'));
|
||||||
|
$response->assertSessionHas('bannerText', 'Please create an account to finish joining the '.$user->organization->name.' organization.');
|
||||||
|
$response->assertSessionHas('bannerStyle', 'info');
|
||||||
|
$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'));
|
||||||
|
$response->assertSessionHas('bannerText', 'Great! You have accepted the invitation to join the '.$user->organization->name.' organization.');
|
||||||
|
$response->assertSessionHas('bannerStyle', 'success');
|
||||||
|
$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_accepting_invitation_while_logged_out_redirects_to_login(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = $this->createUserWithPermission();
|
||||||
|
$invitee = User::factory()->create([
|
||||||
|
'email' => 'invitee@example.com',
|
||||||
|
]);
|
||||||
|
$invitation = OrganizationInvitation::factory()
|
||||||
|
->forOrganization($user->organization)
|
||||||
|
->create([
|
||||||
|
'role' => Role::Employee->value,
|
||||||
|
'email' => $invitee->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Act (no actingAs — request is unauthenticated)
|
||||||
|
$acceptUrl = URL::to(URL::temporarySignedRoute(
|
||||||
|
'organization-invitations.accept',
|
||||||
|
now()->addMinutes(60),
|
||||||
|
[$invitation->getKey()],
|
||||||
|
false
|
||||||
|
));
|
||||||
|
$response = $this->get($acceptUrl);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertValid();
|
||||||
|
$response->assertRedirect(route('login'));
|
||||||
|
$response->assertSessionHas('bannerText', 'Great! You have accepted the invitation to join the '.$user->organization->name.' organization. Please log in to access it.');
|
||||||
|
$response->assertSessionHas('bannerStyle', 'success');
|
||||||
|
// Member was added silently — invitation is consumed.
|
||||||
|
$this->assertDatabaseHas(Member::class, [
|
||||||
|
'user_id' => $invitee->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'));
|
||||||
|
$response->assertSessionHas('bannerText', 'You are already a member of the '.$user->organization->name.' organization.');
|
||||||
|
$response->assertSessionHas('bannerStyle', 'danger');
|
||||||
|
}
|
||||||
|
|
||||||
|
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'));
|
||||||
|
$response->assertSessionHas('bannerText', 'Great! You have accepted the invitation to join the '.$user->organization->name.' organization.');
|
||||||
|
$response->assertSessionHas('bannerStyle', 'success');
|
||||||
|
$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" 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();
|
$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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
53
tests/Unit/Mail/VerifyUpdatedEmailMailTest.php
Normal file
53
tests/Unit/Mail/VerifyUpdatedEmailMailTest.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Mail;
|
||||||
|
|
||||||
|
use App\Mail\VerifyUpdatedEmailMail;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use Tests\TestCaseWithDatabase;
|
||||||
|
|
||||||
|
#[CoversClass(VerifyUpdatedEmailMail::class)]
|
||||||
|
class VerifyUpdatedEmailMailTest extends TestCaseWithDatabase
|
||||||
|
{
|
||||||
|
public function test_mail_renders_content_correctly(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$mail = new VerifyUpdatedEmailMail($user, 'New.Email@Example.com');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$rendered = $mail->render();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertEquals('new.email@example.com', $mail->email);
|
||||||
|
$this->assertStringContainsString('Please verify your new email address', $rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_mail_uses_relative_signed_verification_url(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Carbon::setTestNow('2026-05-21 12:00:00');
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$mail = new VerifyUpdatedEmailMail($user, 'new.email@example.com');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$rendered = $mail->render();
|
||||||
|
$expectedPath = URL::temporarySignedRoute(
|
||||||
|
'users.verify-email-change',
|
||||||
|
now()->addMinutes((int) config('auth.verification.expire', 60)),
|
||||||
|
[
|
||||||
|
'user' => $user->getKey(),
|
||||||
|
'email' => 'new.email@example.com',
|
||||||
|
],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$this->assertStringContainsString(e(URL::to($expectedPath)), $rendered);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ use App\Models\Organization;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Service\PermissionStore;
|
use App\Service\PermissionStore;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Laravel\Jetstream\Jetstream;
|
|
||||||
use PHPUnit\Framework\Attributes\CoversClass;
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -122,7 +121,7 @@ class PermissionStoreTest extends TestCase
|
|||||||
$result = $permissionStore->getPermissions($organization);
|
$result = $permissionStore->getPermissions($organization);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
$this->assertSame(Jetstream::findRole(Role::Employee->value)->permissions, $result);
|
$this->assertSame(PermissionStore::permissionsForRole(Role::Employee->value), $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_employee_does_not_have_task_permissions_by_default(): void
|
public function test_employee_does_not_have_task_permissions_by_default(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user