From bde3ec7a755d7d4dfd5a622dc94d0b0173d4a62e Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Thu, 21 May 2026 23:22:09 +0200 Subject: [PATCH] Migrate permission away from Jetstream; Moved update user to REST API --- .../Fortify/UpdateUserProfileInformation.php | 16 +- ...VerificationNoPendingEmailApiException.php | 10 + .../Controllers/Api/V1/ApiTokenController.php | 2 +- .../Controllers/Api/V1/UserController.php | 96 +++++- .../Api/V1/UserMembershipController.php | 2 +- .../Api/V1/UserTimeEntryController.php | 2 +- app/Http/Controllers/Web/UserController.php | 55 ++++ .../Requests/V1/User/UserUpdateRequest.php | 88 ++++++ app/Mail/VerifyUpdatedEmailMail.php | 48 +++ app/Models/User.php | 2 + app/Providers/FortifyServiceProvider.php | 9 + app/Providers/JetstreamServiceProvider.php | 214 +------------ app/Rules/Base64ImageRule.php | 37 +++ app/Service/PermissionStore.php | 266 +++++++++++++++- app/Support/Base64File.php | 45 +++ ...00001_add_pending_email_to_users_table.php | 30 ++ lang/en/exceptions.php | 2 + resources/testfiles/test.png | Bin 0 -> 18453 bytes .../emails/verify-updated-email.blade.php | 9 + routes/api.php | 2 + routes/web.php | 5 + tests/Feature/ProfileInformationTest.php | 123 +++++++- tests/TestCase.php | 1 + tests/TestCaseWithDatabase.php | 2 + .../Unit/Endpoint/Api/V1/UserEndpointTest.php | 296 ++++++++++++++++++ .../Unit/Mail/VerifyUpdatedEmailMailTest.php | 53 ++++ tests/Unit/Service/PermissionStoreTest.php | 3 +- 27 files changed, 1190 insertions(+), 228 deletions(-) create mode 100644 app/Exceptions/Api/UserResendEmailVerificationNoPendingEmailApiException.php create mode 100644 app/Http/Controllers/Web/UserController.php create mode 100644 app/Http/Requests/V1/User/UserUpdateRequest.php create mode 100644 app/Mail/VerifyUpdatedEmailMail.php create mode 100644 app/Rules/Base64ImageRule.php create mode 100644 app/Support/Base64File.php create mode 100644 database/migrations/2026_05_21_000001_add_pending_email_to_users_table.php create mode 100644 resources/testfiles/test.png create mode 100644 resources/views/emails/verify-updated-email.blade.php create mode 100644 tests/Unit/Mail/VerifyUpdatedEmailMailTest.php diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index ccc2a496..4bd09c0d 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -5,9 +5,12 @@ declare(strict_types=1); namespace App\Actions\Fortify; use App\Enums\Weekday; +use App\Mail\VerifyUpdatedEmailMail; use App\Models\User; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; @@ -24,6 +27,10 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation */ 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, [ 'name' => [ 'required', @@ -58,16 +65,17 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation $user->updateProfilePhoto($input['photo']); } - if ($input['email'] !== $user->email) { + $email = Str::lower((string) $input['email']); + + if ($email !== Str::lower($user->email)) { $user->forceFill([ 'name' => $input['name'], - 'email' => $input['email'], - 'email_verified_at' => null, + 'pending_email' => $email, 'timezone' => $input['timezone'], 'week_start' => $input['week_start'], ])->save(); - $user->sendEmailVerificationNotification(); + Mail::to($email)->send(new VerifyUpdatedEmailMail($user, $email)); } else { $user->forceFill([ 'name' => $input['name'], diff --git a/app/Exceptions/Api/UserResendEmailVerificationNoPendingEmailApiException.php b/app/Exceptions/Api/UserResendEmailVerificationNoPendingEmailApiException.php new file mode 100644 index 00000000..ef8d442a --- /dev/null +++ b/app/Exceptions/Api/UserResendEmailVerificationNoPendingEmailApiException.php @@ -0,0 +1,10 @@ +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 organization. + * This endpoint is independent of the organization. * * @operationId deleteUser * diff --git a/app/Http/Controllers/Api/V1/UserMembershipController.php b/app/Http/Controllers/Api/V1/UserMembershipController.php index ea7a92a3..e1bb0ad0 100644 --- a/app/Http/Controllers/Api/V1/UserMembershipController.php +++ b/app/Http/Controllers/Api/V1/UserMembershipController.php @@ -14,7 +14,7 @@ class UserMembershipController extends Controller /** * Get the memberships of the current user * - * This endpoint is independent of organization. + * This endpoint is independent of the organization. * * @operationId getMyMemberships * diff --git a/app/Http/Controllers/Api/V1/UserTimeEntryController.php b/app/Http/Controllers/Api/V1/UserTimeEntryController.php index 7c69fce4..79cefecc 100644 --- a/app/Http/Controllers/Api/V1/UserTimeEntryController.php +++ b/app/Http/Controllers/Api/V1/UserTimeEntryController.php @@ -17,7 +17,7 @@ class UserTimeEntryController extends Controller /** * Get the active time entry of the current user * - * This endpoint is independent of organization. + * This endpoint is independent of the organization. * * @operationId getMyActiveTimeEntry */ diff --git a/app/Http/Controllers/Web/UserController.php b/app/Http/Controllers/Web/UserController.php new file mode 100644 index 00000000..e2e4750a --- /dev/null +++ b/app/Http/Controllers/Web/UserController.php @@ -0,0 +1,55 @@ +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.'), + ])); + } +} diff --git a/app/Http/Requests/V1/User/UserUpdateRequest.php b/app/Http/Requests/V1/User/UserUpdateRequest.php new file mode 100644 index 00000000..d9ef8b91 --- /dev/null +++ b/app/Http/Requests/V1/User/UserUpdateRequest.php @@ -0,0 +1,88 @@ +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> + */ + 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 $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; + } +} diff --git a/app/Mail/VerifyUpdatedEmailMail.php b/app/Mail/VerifyUpdatedEmailMail.php new file mode 100644 index 00000000..76552a19 --- /dev/null +++ b/app/Mail/VerifyUpdatedEmailMail.php @@ -0,0 +1,48 @@ +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')); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 76c5115d..2b0f0bfe 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -36,6 +36,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @property string $id * @property string $name * @property string $email + * @property string|null $pending_email * @property Carbon|null $email_verified_at * @property string|null $password * @property string|null $two_factor_secret @@ -105,6 +106,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M protected $casts = [ 'name' => 'string', 'email' => 'string', + 'pending_email' => 'string', 'email_verified_at' => 'datetime', 'is_admin' => 'boolean', 'is_placeholder' => 'boolean', diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 2006f452..a10d375b 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; +use Inertia\Inertia; use Laravel\Fortify\Contracts\TwoFactorLoginResponse; use Laravel\Fortify\Fortify; use Laravel\Fortify\Http\Responses\LoginResponse; @@ -41,6 +42,14 @@ class FortifyServiceProvider extends ServiceProvider Fortify::updateUserPasswordsUsing(UpdateUserPassword::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 { /** @var User|null $user */ $user = User::query() diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index cc8c3c96..5f51f00f 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -13,20 +13,18 @@ use App\Actions\Jetstream\RemoveOrganizationMember; use App\Actions\Jetstream\UpdateMemberRole; use App\Actions\Jetstream\UpdateOrganization; use App\Actions\Jetstream\ValidateOrganizationDeletion; -use App\Enums\Role; use App\Enums\Weekday; use App\Models\Member; use App\Models\Organization; use App\Models\OrganizationInvitation; use App\Models\User; +use App\Service\PermissionStore; use App\Service\TimezoneService; use Brick\Money\Currency; use Brick\Money\ISOCurrencyProvider; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; -use Inertia\Inertia; -use Laravel\Fortify\Fortify; use Laravel\Jetstream\Actions\UpdateTeamMemberRole; use Laravel\Jetstream\Actions\ValidateTeamDeletion; use Laravel\Jetstream\Jetstream; @@ -60,13 +58,6 @@ class JetstreamServiceProvider extends ServiceProvider Jetstream::useTeamInvitationModel(OrganizationInvitation::class); app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::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) { return false; }); @@ -79,205 +70,10 @@ class JetstreamServiceProvider extends ServiceProvider { Jetstream::defaultApiTokenPermissions([]); - Jetstream::role(Role::Owner->value, 'Owner', [ - '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.'); - - 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.'); + foreach (PermissionStore::roleDefinitions() as $role => $definition) { + Jetstream::role($role, $definition['name'], $definition['permissions']) + ->description($definition['description']); + } Jetstream::inertia() ->whenRendering( diff --git a/app/Rules/Base64ImageRule.php b/app/Rules/Base64ImageRule.php new file mode 100644 index 00000000..1c09f7dd --- /dev/null +++ b/app/Rules/Base64ImageRule.php @@ -0,0 +1,37 @@ + 'jpg, png'])); + } + } +} diff --git a/app/Service/PermissionStore.php b/app/Service/PermissionStore.php index d346691c..74000f95 100644 --- a/app/Service/PermissionStore.php +++ b/app/Service/PermissionStore.php @@ -4,14 +4,238 @@ declare(strict_types=1); namespace App\Service; +use App\Enums\Role; use App\Models\Organization; use App\Models\User; use Illuminate\Support\Facades\Auth; -use Laravel\Jetstream\Jetstream; -use Laravel\Jetstream\Role; class PermissionStore { + /** + * @var array, 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> + */ + private static array $customRolePermissions = []; + /** * @var array> */ @@ -22,6 +246,37 @@ class PermissionStore $this->permissionCache = []; } + /** + * @return array, description: string}> + */ + public static function roleDefinitions(): array + { + return self::ROLE_DEFINITIONS; + } + + /** + * @param array $permissions + */ + public static function registerCustomRole(string $role, array $permissions): void + { + self::$customRolePermissions[$role] = $permissions; + } + + public static function resetCustomRoles(): void + { + self::$customRolePermissions = []; + } + + /** + * @return array + */ + public static function permissionsForRole(string $role): array + { + return self::$customRolePermissions[$role] + ?? self::ROLE_DEFINITIONS[$role]['permissions'] + ?? []; + } + public function has(Organization $organization, string $permission): bool { /** @var User|null $user */ @@ -68,14 +323,11 @@ class PermissionStore return []; } - /** @var Role|null $roleObj */ - $roleObj = Jetstream::findRole($role); - - $permissions = $roleObj->permissions ?? []; + $permissions = self::permissionsForRole($role); // If the organization allows employees to manage tasks and the user is an employee, // 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, [ 'tasks:create', 'tasks:update', diff --git a/app/Support/Base64File.php b/app/Support/Base64File.php new file mode 100644 index 00000000..5a94422b --- /dev/null +++ b/app/Support/Base64File.php @@ -0,0 +1,45 @@ +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; + } +} diff --git a/database/migrations/2026_05_21_000001_add_pending_email_to_users_table.php b/database/migrations/2026_05_21_000001_add_pending_email_to_users_table.php new file mode 100644 index 00000000..d4336666 --- /dev/null +++ b/database/migrations/2026_05_21_000001_add_pending_email_to_users_table.php @@ -0,0 +1,30 @@ +string('pending_email')->nullable()->after('email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('pending_email'); + }); + } +}; diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index fda420e6..485eb145 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -23,6 +23,7 @@ use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException; use App\Exceptions\Api\UserNotPlaceholderApiException; +use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException; use App\Service\Export\ExportException; return [ @@ -49,6 +50,7 @@ return [ 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.', 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.', ]; diff --git a/resources/testfiles/test.png b/resources/testfiles/test.png new file mode 100644 index 0000000000000000000000000000000000000000..f844619a0d4f53e71e81b05c8e5f0ff1279cab84 GIT binary patch literal 18453 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 DfU(8* literal 0 HcmV?d00001 diff --git a/resources/views/emails/verify-updated-email.blade.php b/resources/views/emails/verify-updated-email.blade.php new file mode 100644 index 00000000..ba73fb7b --- /dev/null +++ b/resources/views/emails/verify-updated-email.blade.php @@ -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 diff --git a/routes/api.php b/routes/api.php index af539fc7..d80795e9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -61,6 +61,8 @@ Route::prefix('v1')->name('v1.')->group(static function (): void { // User routes Route::name('users.')->group(static function (): void { Route::get('/users/me', [UserController::class, 'me'])->name('me'); + Route::put('/users/{user}', [UserController::class, 'update'])->name('update'); + Route::post('/users/{user}/resend-email-verification', [UserController::class, 'resendEmailVerification'])->name('resend-email-verification'); Route::delete('/users/{user}', [UserController::class, 'destroy'])->name('destroy'); }); diff --git a/routes/web.php b/routes/web.php index 9c1f297a..2107b3bd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -5,6 +5,7 @@ declare(strict_types=1); use App\Http\Controllers\Web\DashboardController; use App\Http\Controllers\Web\HomeController; use App\Http\Controllers\Web\OrganizationInvitationController; +use App\Http\Controllers\Web\UserController; use Illuminate\Support\Facades\Route; use Inertia\Inertia; use Laravel\Jetstream\Jetstream; @@ -91,3 +92,7 @@ Route::get('/team-invitations/{invitation}', [OrganizationInvitationController:: 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'); diff --git a/tests/Feature/ProfileInformationTest.php b/tests/Feature/ProfileInformationTest.php index fee045cb..5ce277f8 100644 --- a/tests/Feature/ProfileInformationTest.php +++ b/tests/Feature/ProfileInformationTest.php @@ -5,9 +5,12 @@ declare(strict_types=1); namespace Tests\Feature; use App\Enums\Weekday; +use App\Mail\VerifyUpdatedEmailMail; use App\Models\User; use App\Service\TimezoneService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\URL; use Tests\TestCase; class ProfileInformationTest extends TestCase @@ -30,7 +33,9 @@ class ProfileInformationTest extends TestCase public function test_profile_information_can_be_updated(): void { // Arrange - $user = User::factory()->create(); + $user = User::factory()->create([ + 'email' => 'test@example.com', + ]); $timezone = app(TimezoneService::class)->getTimezones()[0]; $this->actingAs($user); @@ -50,4 +55,120 @@ class ProfileInformationTest extends TestCase $this->assertEquals($timezone, $user->timezone); $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); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 9cc1ccaf..f3179d19 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -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. app(PermissionStore::class)->clear(); + PermissionStore::resetCustomRoles(); parent::tearDown(); } diff --git a/tests/TestCaseWithDatabase.php b/tests/TestCaseWithDatabase.php index 1929c2d9..7dcb7f9e 100644 --- a/tests/TestCaseWithDatabase.php +++ b/tests/TestCaseWithDatabase.php @@ -8,6 +8,7 @@ use App\Enums\Role; use App\Models\Member; use App\Models\Organization; use App\Models\User; +use App\Service\PermissionStore; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -26,6 +27,7 @@ abstract class TestCaseWithDatabase extends TestCase $roleName = 'custom-test-'.Str::uuid(); Jetstream::role($roleName, 'Custom Test', $permissions) ->description('Role custom for testing'); + PermissionStore::registerCustomRole($roleName, $permissions); $user = User::factory()->create(); if ($isOwner) { $organization = Organization::factory()->withOwner($user)->create(); diff --git a/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php b/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php index c113a317..ec36b38d 100644 --- a/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php @@ -4,7 +4,11 @@ declare(strict_types=1); 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; class UserEndpointTest extends ApiEndpointTestAbstract @@ -42,6 +46,298 @@ 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 diff --git a/tests/Unit/Mail/VerifyUpdatedEmailMailTest.php b/tests/Unit/Mail/VerifyUpdatedEmailMailTest.php new file mode 100644 index 00000000..0d821545 --- /dev/null +++ b/tests/Unit/Mail/VerifyUpdatedEmailMailTest.php @@ -0,0 +1,53 @@ +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); + } +} diff --git a/tests/Unit/Service/PermissionStoreTest.php b/tests/Unit/Service/PermissionStoreTest.php index ad98cf27..9c0f0077 100644 --- a/tests/Unit/Service/PermissionStoreTest.php +++ b/tests/Unit/Service/PermissionStoreTest.php @@ -9,7 +9,6 @@ use App\Models\Organization; use App\Models\User; use App\Service\PermissionStore; use Illuminate\Foundation\Testing\RefreshDatabase; -use Laravel\Jetstream\Jetstream; use PHPUnit\Framework\Attributes\CoversClass; use Tests\TestCase; @@ -122,7 +121,7 @@ class PermissionStoreTest extends TestCase $result = $permissionStore->getPermissions($organization); // 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