mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 05:22:44 +01:00
Compare commits
2 Commits
433a6f3770
...
c2a8eac65f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2a8eac65f | ||
|
|
28ecfc63a3 |
@@ -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'],
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'user_resend_email_verification_no_pending_email';
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class ApiTokenController extends Controller
|
||||
/**
|
||||
* List all api token of the currently authenticated user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId getApiTokens
|
||||
*
|
||||
|
||||
@@ -5,18 +5,25 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
|
||||
use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException;
|
||||
use App\Http\Requests\V1\User\UserUpdateRequest;
|
||||
use App\Http\Resources\V1\User\UserResource;
|
||||
use App\Mail\VerifyUpdatedEmailMail;
|
||||
use App\Models\User;
|
||||
use App\Service\DeletionService;
|
||||
use App\Support\Base64File;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get the current user
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId getMe
|
||||
*
|
||||
@@ -29,10 +36,95 @@ class UserController extends Controller
|
||||
return new UserResource($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current user
|
||||
*
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId updateUser
|
||||
*/
|
||||
public function update(User $user, UserUpdateRequest $request): UserResource
|
||||
{
|
||||
if ($user->getKey() !== $this->user()->getKey()) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
if ($request->getPhoto() !== null) {
|
||||
$photo = Base64File::decode($request->getPhoto());
|
||||
assert($photo !== null);
|
||||
$extension = Base64File::extension($photo['mime_type']);
|
||||
assert($extension !== null);
|
||||
|
||||
$previousPhotoPath = $user->profile_photo_path;
|
||||
$photoPath = 'profile-photos/'.Str::uuid().'.'.$extension;
|
||||
$photoDisk = (string) config('jetstream.profile_photo_disk', 'public');
|
||||
|
||||
Storage::disk($photoDisk)->put($photoPath, $photo['data'], 'public');
|
||||
$user->profile_photo_path = $photoPath;
|
||||
|
||||
if ($previousPhotoPath !== null) {
|
||||
Storage::disk($photoDisk)->delete($previousPhotoPath);
|
||||
}
|
||||
}
|
||||
|
||||
$emailToVerify = null;
|
||||
$email = $request->getEmail();
|
||||
if ($email !== null && $email !== Str::lower($user->email)) {
|
||||
$emailToVerify = $email;
|
||||
$user->pending_email = $email;
|
||||
}
|
||||
|
||||
if ($request->getName() !== null) {
|
||||
$user->name = $request->getName();
|
||||
}
|
||||
|
||||
if ($request->getTimezone() !== null) {
|
||||
$user->timezone = $request->getTimezone();
|
||||
}
|
||||
|
||||
if ($request->getWeekStart() !== null) {
|
||||
$user->week_start = $request->getWeekStart();
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
if ($emailToVerify !== null) {
|
||||
Mail::to($emailToVerify)->send(new VerifyUpdatedEmailMail($user, $emailToVerify));
|
||||
}
|
||||
|
||||
return new UserResource($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend the pending email update verification email.
|
||||
*
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId resendUserEmailVerification
|
||||
*
|
||||
* @throws AuthorizationException Thrown when the authenticated user does not match the user whose email is pending verification.
|
||||
* @throws UserResendEmailVerificationNoPendingEmailApiException Thrown when the user does not have a pending email to verify.
|
||||
*/
|
||||
public function resendEmailVerification(User $user): JsonResponse
|
||||
{
|
||||
if ($user->getKey() !== $this->user()->getKey()) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
if ($user->pending_email === null) {
|
||||
throw new UserResendEmailVerificationNoPendingEmailApiException;
|
||||
}
|
||||
|
||||
Mail::to($user->pending_email)
|
||||
->queue(new VerifyUpdatedEmailMail($user, $user->pending_email));
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the deletion of a user.
|
||||
*
|
||||
* This endpoint is independent of organization.
|
||||
* This endpoint is independent of the organization.
|
||||
*
|
||||
* @operationId deleteUser
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
55
app/Http/Controllers/Web/UserController.php
Normal file
55
app/Http/Controllers/Web/UserController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function verifyEmailChange(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
if ($request->user()?->getAuthIdentifier() !== $user->getKey()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$email = $request->query('email');
|
||||
if (! is_string($email)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$email = Str::lower($email);
|
||||
|
||||
if ($user->pending_email !== $email) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$emailAlreadyInUse = User::query()
|
||||
->where('email', '=', $email)
|
||||
->where('is_placeholder', '=', false)
|
||||
->whereKeyNot($user->getKey())
|
||||
->exists();
|
||||
|
||||
if ($emailAlreadyInUse) {
|
||||
return redirect(route('dashboard', [
|
||||
'bannerStyle' => 'danger',
|
||||
'bannerText' => __('The email address is already in use.'),
|
||||
]));
|
||||
}
|
||||
|
||||
$user->email = $email;
|
||||
$user->pending_email = null;
|
||||
$user->email_verified_at = Carbon::now();
|
||||
$user->save();
|
||||
|
||||
return redirect(route('dashboard', [
|
||||
'bannerStyle' => 'success',
|
||||
'bannerText' => __('Your email address has been updated successfully.'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
88
app/Http/Requests/V1/User/UserUpdateRequest.php
Normal file
88
app/Http/Requests/V1/User/UserUpdateRequest.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\User;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use App\Models\User;
|
||||
use App\Rules\Base64ImageRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
|
||||
/**
|
||||
* @property User $user User from model binding
|
||||
*/
|
||||
class UserUpdateRequest extends BaseFormRequest
|
||||
{
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('email') && is_string($this->input('email'))) {
|
||||
$this->merge([
|
||||
'email' => Str::lower((string) $this->input('email')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|\Illuminate\Contracts\Validation\Rule|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'email' => [
|
||||
'email',
|
||||
'max:255',
|
||||
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
||||
/** @var Builder<User> $query */
|
||||
return $query->where('is_placeholder', '=', false);
|
||||
}),
|
||||
],
|
||||
'photo' => [
|
||||
'nullable',
|
||||
new Base64ImageRule,
|
||||
],
|
||||
'timezone' => [
|
||||
'timezone:all',
|
||||
],
|
||||
'week_start' => [
|
||||
Rule::enum(Weekday::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->has('name') ? (string) $this->input('name') : null;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->has('email') ? Str::lower((string) $this->input('email')) : null;
|
||||
}
|
||||
|
||||
public function getTimezone(): ?string
|
||||
{
|
||||
return $this->has('timezone') ? (string) $this->input('timezone') : null;
|
||||
}
|
||||
|
||||
public function getWeekStart(): ?Weekday
|
||||
{
|
||||
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
|
||||
}
|
||||
|
||||
public function getPhoto(): ?string
|
||||
{
|
||||
return $this->has('photo') ? (string) $this->input('photo') : null;
|
||||
}
|
||||
}
|
||||
48
app/Mail/VerifyUpdatedEmailMail.php
Normal file
48
app/Mail/VerifyUpdatedEmailMail.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class VerifyUpdatedEmailMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public User $user;
|
||||
|
||||
public string $email;
|
||||
|
||||
public function __construct(User $user, string $email)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->email = Str::lower($email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message.
|
||||
*/
|
||||
public function build(): self
|
||||
{
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'users.verify-email-change',
|
||||
Carbon::now()->addMinutes((int) config('auth.verification.expire', 60)),
|
||||
[
|
||||
'user' => $this->user->getKey(),
|
||||
'email' => $this->email,
|
||||
],
|
||||
false
|
||||
);
|
||||
|
||||
return $this->markdown('emails.verify-updated-email', [
|
||||
'verificationUrl' => URL::to($verificationUrl),
|
||||
])->subject(__('Verify Email Address'));
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property string $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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
37
app/Rules/Base64ImageRule.php
Normal file
37
app/Rules/Base64ImageRule.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\Base64File;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
class Base64ImageRule implements ValidationRule
|
||||
{
|
||||
private const array ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
];
|
||||
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* @param Closure(string): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
$fail(__('validation.string'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$file = Base64File::decode($value);
|
||||
if ($file === null || ! in_array($file['mime_type'], self::ALLOWED_MIME_TYPES, true)) {
|
||||
$fail(__('validation.mimes', ['values' => 'jpg, png']));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, array{name: string, permissions: array<string>, description: string}>
|
||||
*/
|
||||
private const array ROLE_DEFINITIONS = [
|
||||
'owner' => [
|
||||
'name' => 'Owner',
|
||||
'permissions' => [
|
||||
'charts:view:own',
|
||||
'charts:view:all',
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
'projects:create',
|
||||
'projects:update',
|
||||
'projects:delete',
|
||||
'project-members:view',
|
||||
'project-members:create',
|
||||
'project-members:update',
|
||||
'project-members:delete',
|
||||
'tasks:view',
|
||||
'tasks:view:all',
|
||||
'tasks:create',
|
||||
'tasks:create:all',
|
||||
'tasks:update',
|
||||
'tasks:update:all',
|
||||
'tasks:delete',
|
||||
'tasks:delete:all',
|
||||
'time-entries:view:all',
|
||||
'time-entries:create:all',
|
||||
'time-entries:update:all',
|
||||
'time-entries:delete:all',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'tags:view',
|
||||
'tags:create',
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
'organizations:view',
|
||||
'organizations:update',
|
||||
'organizations:delete',
|
||||
'import',
|
||||
'export',
|
||||
'invitations:view',
|
||||
'invitations:create',
|
||||
'invitations:resend',
|
||||
'invitations:remove',
|
||||
'members:view',
|
||||
'members:invite-placeholder',
|
||||
'members:change-ownership',
|
||||
'members:make-placeholder',
|
||||
'members:merge-into',
|
||||
'members:update',
|
||||
'members:delete',
|
||||
'billing',
|
||||
'reports:view',
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
'invoices:download',
|
||||
'invoices:delete',
|
||||
'invoice-settings:view',
|
||||
'invoice-settings:update',
|
||||
],
|
||||
'description' => 'Owner users can perform any action. There is only one owner per organization.',
|
||||
],
|
||||
'admin' => [
|
||||
'name' => 'Administrator',
|
||||
'permissions' => [
|
||||
'charts:view:own',
|
||||
'charts:view:all',
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
'projects:create',
|
||||
'projects:update',
|
||||
'projects:delete',
|
||||
'project-members:view',
|
||||
'project-members:create',
|
||||
'project-members:update',
|
||||
'project-members:delete',
|
||||
'tasks:view',
|
||||
'tasks:view:all',
|
||||
'tasks:create',
|
||||
'tasks:create:all',
|
||||
'tasks:update',
|
||||
'tasks:update:all',
|
||||
'tasks:delete',
|
||||
'tasks:delete:all',
|
||||
'time-entries:view:all',
|
||||
'time-entries:create:all',
|
||||
'time-entries:update:all',
|
||||
'time-entries:delete:all',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'tags:view',
|
||||
'tags:create',
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
'organizations:view',
|
||||
'organizations:update',
|
||||
'import',
|
||||
'export',
|
||||
'invitations:view',
|
||||
'invitations:create',
|
||||
'invitations:resend',
|
||||
'invitations:remove',
|
||||
'members:view',
|
||||
'members:invite-placeholder',
|
||||
'members:make-placeholder',
|
||||
'members:merge-into',
|
||||
'members:delete',
|
||||
'members:update',
|
||||
'reports:view',
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
'invoices:download',
|
||||
'invoices:delete',
|
||||
'invoice-settings:view',
|
||||
'invoice-settings:update',
|
||||
],
|
||||
'description' => 'Administrator users can perform any action, except accessing the billing dashboard.',
|
||||
],
|
||||
'manager' => [
|
||||
'name' => 'Manager',
|
||||
'permissions' => [
|
||||
'charts:view:own',
|
||||
'charts:view:all',
|
||||
'projects:view',
|
||||
'projects:view:all',
|
||||
'projects:create',
|
||||
'projects:update',
|
||||
'projects:delete',
|
||||
'project-members:view',
|
||||
'project-members:create',
|
||||
'project-members:update',
|
||||
'project-members:delete',
|
||||
'tasks:view',
|
||||
'tasks:view:all',
|
||||
'tasks:create',
|
||||
'tasks:create:all',
|
||||
'tasks:update',
|
||||
'tasks:update:all',
|
||||
'tasks:delete',
|
||||
'tasks:delete:all',
|
||||
'time-entries:view:all',
|
||||
'time-entries:create:all',
|
||||
'time-entries:update:all',
|
||||
'time-entries:delete:all',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'tags:view',
|
||||
'tags:create',
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
'organizations:view',
|
||||
'invitations:view',
|
||||
'members:view',
|
||||
'reports:view',
|
||||
'reports:create',
|
||||
'reports:update',
|
||||
'reports:delete',
|
||||
'invoices:view',
|
||||
'invoices:create',
|
||||
'invoices:update',
|
||||
'invoices:download',
|
||||
'invoices:delete',
|
||||
'invoice-settings:view',
|
||||
'invoice-settings:update',
|
||||
],
|
||||
'description' => 'Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).',
|
||||
],
|
||||
'employee' => [
|
||||
'name' => 'Employee',
|
||||
'permissions' => [
|
||||
'charts:view:own',
|
||||
'projects:view',
|
||||
'tags:view',
|
||||
'tasks:view',
|
||||
'clients:view',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'organizations:view',
|
||||
],
|
||||
'description' => 'Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.',
|
||||
],
|
||||
'placeholder' => [
|
||||
'name' => 'Placeholder',
|
||||
'permissions' => [],
|
||||
'description' => 'Placeholders are used for importing data. They cannot log in and have no permissions.',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, array<string>>
|
||||
*/
|
||||
private static array $customRolePermissions = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array<string>>
|
||||
*/
|
||||
@@ -22,6 +246,37 @@ class PermissionStore
|
||||
$this->permissionCache = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{name: string, permissions: array<string>, description: string}>
|
||||
*/
|
||||
public static function roleDefinitions(): array
|
||||
{
|
||||
return self::ROLE_DEFINITIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $permissions
|
||||
*/
|
||||
public static function registerCustomRole(string $role, array $permissions): void
|
||||
{
|
||||
self::$customRolePermissions[$role] = $permissions;
|
||||
}
|
||||
|
||||
public static function resetCustomRoles(): void
|
||||
{
|
||||
self::$customRolePermissions = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public static function permissionsForRole(string $role): array
|
||||
{
|
||||
return self::$customRolePermissions[$role]
|
||||
?? self::ROLE_DEFINITIONS[$role]['permissions']
|
||||
?? [];
|
||||
}
|
||||
|
||||
public function has(Organization $organization, string $permission): bool
|
||||
{
|
||||
/** @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',
|
||||
|
||||
45
app/Support/Base64File.php
Normal file
45
app/Support/Base64File.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Symfony\Component\Mime\MimeTypes;
|
||||
|
||||
class Base64File
|
||||
{
|
||||
/**
|
||||
* @return array{data: string, mime_type: string}|null
|
||||
*/
|
||||
public static function decode(string $value): ?array
|
||||
{
|
||||
if (str_contains($value, ',')) {
|
||||
[, $value] = explode(',', $value, 2);
|
||||
}
|
||||
|
||||
$value = preg_replace('/\s+/', '', $value);
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = base64_decode($value, true);
|
||||
if ($decoded === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($decoded);
|
||||
if ($mimeType === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $decoded,
|
||||
'mime_type' => $mimeType,
|
||||
];
|
||||
}
|
||||
|
||||
public static function extension(string $mimeType): ?string
|
||||
{
|
||||
return MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->string('pending_email')->nullable()->after('email');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->dropColumn('pending_email');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$duplicateEmails = DB::table('users')
|
||||
->selectRaw('LOWER(email) as normalized_email')
|
||||
->selectRaw('COUNT(*) as user_count')
|
||||
->selectRaw("STRING_AGG(id::text || ' <' || email || '>', ', ' ORDER BY email) as users")
|
||||
->where('is_placeholder', false)
|
||||
->groupByRaw('LOWER(email)')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->orderBy('normalized_email')
|
||||
->get();
|
||||
|
||||
if ($duplicateEmails->isNotEmpty()) {
|
||||
$duplicateEmailMessage = $duplicateEmails
|
||||
->take(20)
|
||||
->map(fn (\stdClass $duplicateEmail): string => sprintf(
|
||||
'%s (%d users: %s)',
|
||||
$duplicateEmail->normalized_email,
|
||||
$duplicateEmail->user_count,
|
||||
$duplicateEmail->users,
|
||||
))
|
||||
->implode('; ');
|
||||
|
||||
$remainingDuplicateCount = $duplicateEmails->count() - 20;
|
||||
$remainingDuplicateMessage = $remainingDuplicateCount > 0
|
||||
? sprintf('; and %d more duplicate normalized emails', $remainingDuplicateCount)
|
||||
: '';
|
||||
|
||||
throw new RuntimeException(
|
||||
'Cannot lowercase users.email because doing so would create duplicate non-placeholder user emails and violate the unique index on users.email for non-placeholder users. Resolve these case-insensitive duplicates first: '.
|
||||
$duplicateEmailMessage.
|
||||
$remainingDuplicateMessage
|
||||
);
|
||||
}
|
||||
|
||||
DB::table('users')
|
||||
->whereRaw('email <> LOWER(email)')
|
||||
->update([
|
||||
'email' => DB::raw('LOWER(email)'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -7413,7 +7413,7 @@
|
||||
},
|
||||
"resources/js/packages/ui": {
|
||||
"name": "@solidtime/ui",
|
||||
"version": "0.0.17",
|
||||
"version": "0.0.21",
|
||||
"license": "AGPL-3.0",
|
||||
"devDependencies": {
|
||||
"@types/chroma-js": "^3.1.0",
|
||||
|
||||
BIN
resources/testfiles/test.png
Normal file
BIN
resources/testfiles/test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
9
resources/views/emails/verify-updated-email.blade.php
Normal file
9
resources/views/emails/verify-updated-email.blade.php
Normal file
@@ -0,0 +1,9 @@
|
||||
@component('mail::message')
|
||||
{{ __('Please verify your new email address for your solidtime account.') }}
|
||||
|
||||
@component('mail::button', ['url' => $verificationUrl])
|
||||
{{ __('Verify Email Address') }}
|
||||
@endcomponent
|
||||
|
||||
{{ __('If you did not request this change, you may discard this email.') }}
|
||||
@endcomponent
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
53
tests/Unit/Mail/VerifyUpdatedEmailMailTest.php
Normal file
53
tests/Unit/Mail/VerifyUpdatedEmailMailTest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Mail;
|
||||
|
||||
use App\Mail\VerifyUpdatedEmailMail;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Tests\TestCaseWithDatabase;
|
||||
|
||||
#[CoversClass(VerifyUpdatedEmailMail::class)]
|
||||
class VerifyUpdatedEmailMailTest extends TestCaseWithDatabase
|
||||
{
|
||||
public function test_mail_renders_content_correctly(): void
|
||||
{
|
||||
// Arrange
|
||||
$user = User::factory()->create();
|
||||
$mail = new VerifyUpdatedEmailMail($user, 'New.Email@Example.com');
|
||||
|
||||
// Act
|
||||
$rendered = $mail->render();
|
||||
|
||||
// Assert
|
||||
$this->assertEquals('new.email@example.com', $mail->email);
|
||||
$this->assertStringContainsString('Please verify your new email address', $rendered);
|
||||
}
|
||||
|
||||
public function test_mail_uses_relative_signed_verification_url(): void
|
||||
{
|
||||
// Arrange
|
||||
Carbon::setTestNow('2026-05-21 12:00:00');
|
||||
$user = User::factory()->create();
|
||||
$mail = new VerifyUpdatedEmailMail($user, 'new.email@example.com');
|
||||
|
||||
// Act
|
||||
$rendered = $mail->render();
|
||||
$expectedPath = URL::temporarySignedRoute(
|
||||
'users.verify-email-change',
|
||||
now()->addMinutes((int) config('auth.verification.expire', 60)),
|
||||
[
|
||||
'user' => $user->getKey(),
|
||||
'email' => 'new.email@example.com',
|
||||
],
|
||||
false
|
||||
);
|
||||
|
||||
// Assert
|
||||
$this->assertStringContainsString(e(URL::to($expectedPath)), $rendered);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\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
|
||||
|
||||
Reference in New Issue
Block a user