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 00000000..f844619a Binary files /dev/null and b/resources/testfiles/test.png differ 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