diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index a66c0b4a..3f1ae30f 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -16,7 +16,6 @@ use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Laravel\Fortify\Contracts\CreatesNewUsers; -use Laravel\Jetstream\Jetstream; use Log; class CreateNewUser implements CreatesNewUsers @@ -55,7 +54,7 @@ class CreateNewUser implements CreatesNewUsers }), ], 'password' => $this->passwordRules(), - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '', + 'terms' => ['accepted', 'required'], 'newsletter_consent' => [ 'boolean', ], diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index 4bd09c0d..eb3f53f3 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -4,16 +4,9 @@ declare(strict_types=1); namespace App\Actions\Fortify; -use App\Enums\Weekday; -use App\Mail\VerifyUpdatedEmailMail; +use App\Exceptions\MovedToApiException; 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; use Laravel\Fortify\Contracts\UpdatesUserProfileInformation; class UpdateUserProfileInformation implements UpdatesUserProfileInformation @@ -27,61 +20,6 @@ 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', - 'string', - 'max:255', - ], - 'email' => [ - 'required', - 'email', - 'max:255', - UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) { - /** @var Builder $query */ - return $query->where('is_placeholder', '=', false); - }), - ], - 'photo' => [ - 'nullable', - 'mimes:jpg,jpeg,png', - 'max:1024', - ], - 'timezone' => [ - 'required', - 'timezone:all', - ], - 'week_start' => [ - 'required', - Rule::enum(Weekday::class), - ], - ])->validateWithBag('updateProfileInformation'); - - if (isset($input['photo'])) { - $user->updateProfilePhoto($input['photo']); - } - - $email = Str::lower((string) $input['email']); - - if ($email !== Str::lower($user->email)) { - $user->forceFill([ - 'name' => $input['name'], - 'pending_email' => $email, - 'timezone' => $input['timezone'], - 'week_start' => $input['week_start'], - ])->save(); - - Mail::to($email)->send(new VerifyUpdatedEmailMail($user, $email)); - } else { - $user->forceFill([ - 'name' => $input['name'], - 'timezone' => $input['timezone'], - 'week_start' => $input['week_start'], - ])->save(); - } + throw new MovedToApiException; } } diff --git a/app/Actions/Jetstream/AddOrganizationMember.php b/app/Actions/Jetstream/AddOrganizationMember.php deleted file mode 100644 index d831799e..00000000 --- a/app/Actions/Jetstream/AddOrganizationMember.php +++ /dev/null @@ -1,21 +0,0 @@ - $input - * - * @throws AuthorizationException - * @throws ValidationException - * - * @deprecated Use REST endpoint instead - */ - public function create(User $user, array $input): Organization - { - Gate::forUser($user)->authorize('create', Jetstream::newTeamModel()); - - Validator::make($input, [ - 'name' => ['required', 'string', 'max:255'], - ])->validateWithBag('createTeam'); - - $ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip()); - - $currency = null; - if ($ipLookupResponse !== null) { - $currency = $ipLookupResponse->currency; - } - - $organization = app(OrganizationService::class)->createOrganization( - $input['name'], - $user, - false, - $currency - ); - - app(UserService::class)->switchCurrentOrganization($user, $organization); - - AfterCreateOrganization::dispatch($organization); - - return $organization; - } -} diff --git a/app/Actions/Jetstream/DeleteOrganization.php b/app/Actions/Jetstream/DeleteOrganization.php deleted file mode 100644 index ec48e5ee..00000000 --- a/app/Actions/Jetstream/DeleteOrganization.php +++ /dev/null @@ -1,23 +0,0 @@ -deleteOrganization($organization); - } -} diff --git a/app/Actions/Jetstream/DeleteUser.php b/app/Actions/Jetstream/DeleteUser.php deleted file mode 100644 index 062bf82d..00000000 --- a/app/Actions/Jetstream/DeleteUser.php +++ /dev/null @@ -1,32 +0,0 @@ -deleteUser($user); - } catch (ApiException $exception) { - throw ValidationException::withMessages([ - 'password' => $exception->getTranslatedMessage(), - ]); - } - } -} diff --git a/app/Actions/Jetstream/InviteOrganizationMember.php b/app/Actions/Jetstream/InviteOrganizationMember.php deleted file mode 100644 index 1fb7288e..00000000 --- a/app/Actions/Jetstream/InviteOrganizationMember.php +++ /dev/null @@ -1,24 +0,0 @@ - $input - * - * @throws AuthorizationException - * @throws ValidationException - */ - public function update(User $user, Organization $organization, array $input): void - { - Gate::forUser($user)->authorize('update', $organization); - - Validator::make($input, [ - 'name' => [ - 'required', - 'string', - 'max:255', - ], - 'currency' => [ - 'required', - 'string', - new CurrencyRule, - ], - ])->validateWithBag('updateTeamName'); - - $organization->forceFill([ - 'name' => $input['name'], - 'currency' => $input['currency'], - ])->save(); - } -} diff --git a/app/Actions/Jetstream/ValidateOrganizationDeletion.php b/app/Actions/Jetstream/ValidateOrganizationDeletion.php deleted file mode 100644 index 7a9d36eb..00000000 --- a/app/Actions/Jetstream/ValidateOrganizationDeletion.php +++ /dev/null @@ -1,30 +0,0 @@ -userHas($organization, $user, 'organizations:delete')) { - throw new AuthorizationException; - } - } -} diff --git a/app/Enums/Role.php b/app/Enums/Role.php index e9ed774d..d37e97c9 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -4,8 +4,12 @@ declare(strict_types=1); namespace App\Enums; +use Datomatic\LaravelEnumHelper\LaravelEnumHelper; + enum Role: string { + use LaravelEnumHelper; + case Owner = 'owner'; case Admin = 'admin'; case Manager = 'manager'; diff --git a/app/Events/OrganizationInvitationAdding.php b/app/Events/OrganizationInvitationAdding.php new file mode 100644 index 00000000..52b8d540 --- /dev/null +++ b/app/Events/OrganizationInvitationAdding.php @@ -0,0 +1,35 @@ +role = $role; + $this->email = $email; + $this->organization = $organization; + $this->inviter = $inviter; + } +} diff --git a/app/Filament/Resources/OrganizationResource/RelationManagers/InvitationsRelationManager.php b/app/Filament/Resources/OrganizationResource/RelationManagers/InvitationsRelationManager.php index 79108b55..a4cdaf47 100644 --- a/app/Filament/Resources/OrganizationResource/RelationManagers/InvitationsRelationManager.php +++ b/app/Filament/Resources/OrganizationResource/RelationManagers/InvitationsRelationManager.php @@ -64,7 +64,7 @@ class InvitationsRelationManager extends RelationManager $ownerRecord = $this->getOwnerRecord(); return app(InvitationService::class) - ->inviteUser($ownerRecord, $data['email'], Role::from($data['role'])); + ->inviteUser($ownerRecord, $data['email'], Role::from($data['role']), auth()->user()); }), ]) ->actions([ diff --git a/app/Http/Controllers/Api/V1/InvitationController.php b/app/Http/Controllers/Api/V1/InvitationController.php index 8ef32854..fe7d6b90 100644 --- a/app/Http/Controllers/Api/V1/InvitationController.php +++ b/app/Http/Controllers/Api/V1/InvitationController.php @@ -63,7 +63,7 @@ class InvitationController extends Controller $email = $request->getEmail(); $role = $request->getRole(); - $invitationService->inviteUser($organization, $email, $role); + $invitationService->inviteUser($organization, $email, $role, $this->user()); return response()->json(null, 204); } diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index 6dde1b6e..cd699cfd 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -192,7 +192,7 @@ class MemberController extends Controller throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException; } - $invitationService->inviteUser($organization, $user->email, Role::Employee); + $invitationService->inviteUser($organization, $user->email, Role::Employee, $this->user()); return response()->json(null, 204); } diff --git a/app/Http/Controllers/Api/V1/TimeZoneController.php b/app/Http/Controllers/Api/V1/TimeZoneController.php new file mode 100644 index 00000000..d921e269 --- /dev/null +++ b/app/Http/Controllers/Api/V1/TimeZoneController.php @@ -0,0 +1,33 @@ +getTimezones(); + + $response = []; + + foreach ($timezones as $timezone) { + $response[] = (object) [ + 'key' => $timezone, + ]; + } + + return response()->json($response); + } +} diff --git a/app/Http/Controllers/Api/V1/UserController.php b/app/Http/Controllers/Api/V1/UserController.php index a3deee36..10e7cd3d 100644 --- a/app/Http/Controllers/Api/V1/UserController.php +++ b/app/Http/Controllers/Api/V1/UserController.php @@ -50,7 +50,7 @@ class UserController extends Controller } if ($request->hasPhotoKey()) { - $photoDisk = (string) config('jetstream.profile_photo_disk', 'public'); + $photoDisk = (string) config('filesystems.public'); $previousPhotoPath = $user->profile_photo_path; $newPhoto = $request->getPhoto(); diff --git a/app/Http/Controllers/Web/Controller.php b/app/Http/Controllers/Web/Controller.php index 03c28725..111996cc 100644 --- a/app/Http/Controllers/Web/Controller.php +++ b/app/Http/Controllers/Web/Controller.php @@ -4,4 +4,21 @@ declare(strict_types=1); namespace App\Http\Controllers\Web; -abstract class Controller extends \App\Http\Controllers\Controller {} +use App\Models\Organization; +use App\Service\PermissionStore; +use Illuminate\Auth\Access\AuthorizationException; + +abstract class Controller extends \App\Http\Controllers\Controller +{ + public function __construct( + protected PermissionStore $permissionStore, + ) {} + + /** + * @throws AuthorizationException + */ + protected function hasPermission(Organization $organization, string $permission): bool + { + return $this->permissionStore->has($organization, $permission); + } +} diff --git a/app/Http/Controllers/Web/OrganizationController.php b/app/Http/Controllers/Web/OrganizationController.php new file mode 100644 index 00000000..fa4e3a6b --- /dev/null +++ b/app/Http/Controllers/Web/OrganizationController.php @@ -0,0 +1,69 @@ +route('dashboard'); + } + if (! $this->hasPermission($organization, 'organizations:view')) { + return redirect()->route('dashboard'); + } + + $owner = $organization->owner; + + return Inertia::render('Teams/Show', [ + 'team' => [ + 'id' => $organization->getKey(), + 'name' => $organization->name, + 'currency' => $organization->currency, + 'owner' => [ + 'id' => $owner->getKey(), + 'name' => $owner->name, + 'profile_photo_url' => $owner->profile_photo_url, + ], + ], + 'currencies' => array_map(function (Currency $currency): string { + return $currency->getName(); + }, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()), + 'availableRoles' => [], + 'availablePermissions' => [], + 'defaultPermissions' => [], + 'permissions' => [ + 'canAddTeamMembers' => true, + 'canDeleteTeam' => true, + 'canRemoveTeamMembers' => true, + 'canUpdateTeam' => true, + 'canUpdateTeamMembers' => true, + ], + ]); + } +} diff --git a/app/Http/Controllers/Web/UserProfileController.php b/app/Http/Controllers/Web/UserProfileController.php new file mode 100644 index 00000000..a247b842 --- /dev/null +++ b/app/Http/Controllers/Web/UserProfileController.php @@ -0,0 +1,142 @@ +twoFactorAuthenticationDisabled($request)) { + $request->session()->put('two_factor_empty_at', $currentTime); + } + + // If was previously totally disabled this session but is now confirming, notate time... + if ($this->hasJustBegunConfirmingTwoFactorAuthentication($request)) { + $request->session()->put('two_factor_confirming_at', $currentTime); + } + + // If the profile is reloaded and is not confirmed but was previously in confirming state, disable... + if ($this->neverFinishedConfirmingTwoFactorAuthentication($request, $currentTime)) { + app(DisableTwoFactorAuthentication::class)(Auth::user()); + + $request->session()->put('two_factor_empty_at', $currentTime); + $request->session()->remove('two_factor_confirming_at'); + } + } + + /** + * Determine if two-factor authentication is totally disabled. + * + * @return bool + */ + protected function twoFactorAuthenticationDisabled(Request $request) + { + return is_null($request->user()->two_factor_secret) && + is_null($request->user()->two_factor_confirmed_at); + } + + /** + * Determine if two-factor authentication is just now being confirmed within the last request cycle. + * + * @return bool + */ + protected function hasJustBegunConfirmingTwoFactorAuthentication(Request $request) + { + return ! is_null($request->user()->two_factor_secret) && + is_null($request->user()->two_factor_confirmed_at) && + $request->session()->has('two_factor_empty_at') && + is_null($request->session()->get('two_factor_confirming_at')); + } + + /** + * Determine if two-factor authentication was never totally confirmed once confirmation started. + * + * @return bool + */ + protected function neverFinishedConfirmingTwoFactorAuthentication(Request $request, int $currentTime) + { + return ! array_key_exists('code', $request->session()->getOldInput()) && + is_null($request->user()->two_factor_confirmed_at) && + $request->session()->get('two_factor_confirming_at', 0) !== $currentTime; + } + + /** + * Show the general profile settings screen. + */ + public function show(Request $request): Response + { + $this->validateTwoFactorAuthenticationState($request); + + return Inertia::render('Profile/Show', [ + 'timezones' => app(TimezoneService::class)->getSelectOptions(), + 'weekdays' => Weekday::toSelectArray(), + 'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'), + 'sessions' => $this->sessions($request), + ]); + } + + /** + * Get the current sessions. + * + * @return array + */ + public function sessions(Request $request): array + { + if (config('session.driver') !== 'database') { + return []; + } + + return collect( + DB::connection(config('session.connection'))->table(config('session.table', 'sessions')) + ->where('user_id', $request->user()->getAuthIdentifier()) + ->orderBy('last_activity', 'desc') + ->get() + )->map(function (object $session) use ($request): object { + $agent = $this->createAgent(is_string($session->user_agent) ? $session->user_agent : ''); + + return (object) [ + 'agent' => [ + 'is_desktop' => $agent->isDesktop(), + 'platform' => $agent->platform(), + 'browser' => $agent->browser(), + ], + 'ip_address' => is_string($session->ip_address) ? $session->ip_address : '', + 'is_current_device' => $session->id === $request->session()->getId(), + 'last_active' => Carbon::createFromTimestamp($session->last_activity)->diffForHumans(), + ]; + })->all(); + } + + /** + * Create a new agent instance from the given session. + */ + protected function createAgent(string $userAgent): UserAgentDto + { + return tap(new UserAgentDto, fn ($agent) => $agent->setUserAgent($userAgent)); + } +} diff --git a/app/Http/Middleware/ShareInertiaData.php b/app/Http/Middleware/ShareInertiaData.php index 539c1506..cdf8c223 100644 --- a/app/Http/Middleware/ShareInertiaData.php +++ b/app/Http/Middleware/ShareInertiaData.php @@ -26,7 +26,7 @@ class ShareInertiaData $permissions = app(PermissionStore::class); Inertia::share([ 'auth' => [ - 'permissions' => $request->user() !== null && $request->user()->currentTeam !== null ? $permissions->getPermissions($request->user()->currentTeam) : [], + 'permissions' => $request->user() !== null && $request->user()->currentOrganization !== null ? $permissions->getPermissions($request->user()->currentOrganization) : [], 'user' => function () use ($request): array { /** @var User|null $user */ $user = $request->user(); @@ -35,6 +35,8 @@ class ShareInertiaData return []; } + $currentOrganization = $user->currentOrganization; + return array_merge([ 'id' => $user->id, 'name' => $user->name, @@ -47,12 +49,12 @@ class ShareInertiaData 'profile_photo_url' => $user->profile_photo_url, 'two_factor_enabled' => Features::enabled(Features::twoFactorAuthentication()) && ! is_null($user->two_factor_secret), - 'current_team' => $user->currentTeam !== null ? [ - 'id' => $user->currentTeam->id, - 'user_id' => $user->currentTeam->user_id, - 'name' => $user->currentTeam->name, - 'personal_team' => $user->currentTeam->personal_team, - 'currency' => $user->currentTeam->currency, + 'current_team' => $currentOrganization !== null ? [ + 'id' => $currentOrganization->id, + 'user_id' => $currentOrganization->user_id, + 'name' => $currentOrganization->name, + 'personal_team' => $currentOrganization->personal_team, + 'currency' => $currentOrganization->currency, ] : null, ], array_filter([ 'all_teams' => $user->organizations->map(function (Organization $organization): array { diff --git a/app/Models/Member.php b/app/Models/Member.php index 4bca1125..e8af33e4 100644 --- a/app/Models/Member.php +++ b/app/Models/Member.php @@ -9,10 +9,11 @@ use App\Models\Concerns\HasUuids; use Database\Factories\MemberFactory; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Support\Carbon; -use Laravel\Jetstream\Membership as JetstreamMembership; use OwenIt\Auditing\Contracts\Auditable as AuditableContract; /** @@ -30,7 +31,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * * @method static MemberFactory factory() */ -class Member extends JetstreamMembership implements AuditableContract +class Member extends Pivot implements AuditableContract { use CustomAuditable; diff --git a/app/Models/Organization.php b/app/Models/Organization.php index e0a67fb7..c5250e90 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -14,6 +14,7 @@ use App\Models\Concerns\HasUuids; use Database\Factories\OrganizationFactory; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -21,11 +22,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Support\Carbon; use Illuminate\Support\Str; -use Laravel\Jetstream\Events\TeamCreated; -use Laravel\Jetstream\Events\TeamDeleted; -use Laravel\Jetstream\Events\TeamUpdated; -use Laravel\Jetstream\Team; -use Laravel\Jetstream\Team as JetstreamTeam; use OwenIt\Auditing\Contracts\Auditable as AuditableContract; /** @@ -53,7 +49,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * * @method static OrganizationFactory factory() */ -class Organization extends JetstreamTeam implements AuditableContract +class Organization extends Model implements AuditableContract { use CustomAuditable; @@ -91,17 +87,6 @@ class Organization extends JetstreamTeam implements AuditableContract 'personal_team', ]; - /** - * The event map for the model. - * - * @var array - */ - protected $dispatchesEvents = [ - 'created' => TeamCreated::class, - 'updated' => TeamUpdated::class, - 'deleted' => TeamDeleted::class, - ]; - /** * The model's default values for attributes. * @@ -163,12 +148,13 @@ class Organization extends JetstreamTeam implements AuditableContract } /** - * This method prevents an unhandled exception when the ID is not a UUID. - * Normally this can be fixed with a route pattern, but Jetstream does not use route model binding. + * Find a model by its primary key or throw an exception. * - * @param array $columns + * @param array $columns + * + * @throws ModelNotFoundException */ - public function findOrFail(string $id, array $columns = ['*']): Team + public static function findOrFail(string $id, array $columns = ['*']): Model { if (! Str::isUuid($id)) { throw (new ModelNotFoundException)->setModel( diff --git a/app/Models/OrganizationInvitation.php b/app/Models/OrganizationInvitation.php index 63e8f21d..2b72882e 100644 --- a/app/Models/OrganizationInvitation.php +++ b/app/Models/OrganizationInvitation.php @@ -8,9 +8,9 @@ use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\HasUuids; use Database\Factories\OrganizationInvitationFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Carbon; -use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation; use OwenIt\Auditing\Contracts\Auditable as AuditableContract; /** @@ -25,7 +25,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * * @method static OrganizationInvitationFactory factory() */ -class OrganizationInvitation extends JetstreamTeamInvitation implements AuditableContract +class OrganizationInvitation extends Model implements AuditableContract { use CustomAuditable; diff --git a/app/Models/User.php b/app/Models/User.php index 9704db93..9fcea6ff 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -26,8 +26,6 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Storage; use Laravel\Fortify\TwoFactorAuthenticatable; -use Laravel\Jetstream\HasProfilePhoto; -use Laravel\Jetstream\HasTeams; use Laravel\Passport\AuthCode; use Laravel\Passport\Contracts\OAuthenticatable; use Laravel\Passport\HasApiTokens; @@ -46,7 +44,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @property Weekday $week_start * @property string|null $profile_photo_path * @property-read Organization|null $currentOrganization - * @property-read Organization|null $currentTeam * @property-read string $profile_photo_url * @property-read Collection $tokens * @property Carbon|null $created_at @@ -71,8 +68,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M /** @use HasFactory */ use HasFactory; - use HasProfilePhoto; - use HasTeams; use HasUuids; use Notifiable; use TwoFactorAuthenticatable; diff --git a/app/Policies/OrganizationPolicy.php b/app/Policies/OrganizationPolicy.php deleted file mode 100644 index 5395d1f7..00000000 --- a/app/Policies/OrganizationPolicy.php +++ /dev/null @@ -1,102 +0,0 @@ -isMemberOfOrganization($organization); - } - - /** - * Determine whether the user can create models. - */ - public function create(User $user): bool - { - if (Filament::isServing()) { - return true; - } - - return true; - } - - /** - * Determine whether the user can update the model. - */ - public function update(User $user, Organization $organization): bool - { - if (Filament::isServing()) { - return true; - } - - return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update'); - } - - /** - * Determine whether the user can update team member permissions. - */ - public function updateTeamMember(User $user, Organization $organization): bool - { - if (Filament::isServing()) { - return true; - } - - // Note: since this policy is only used for jetstream endpoints, we can return false here - return false; - } - - /** - * Determine whether the user can remove team members. - */ - public function removeTeamMember(User $user, Organization $organization): bool - { - if (Filament::isServing()) { - return true; - } - - // Note: since this policy is only used for jetstream endpoints that are no longer in use, we can return false here - return false; - } - - /** - * Determine whether the user can delete the model. - */ - public function delete(User $user, Organization $organization): bool - { - if (Filament::isServing()) { - return true; - } - - return app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete'); - } -} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index f524f92d..cb15494c 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -4,14 +4,11 @@ declare(strict_types=1); namespace App\Providers; -use App\Models\Organization; use App\Models\Passport\AuthCode; use App\Models\Passport\Client; use App\Models\Passport\RefreshToken; use App\Models\Passport\Token; -use App\Policies\OrganizationPolicy; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; -use Laravel\Jetstream\Jetstream; use Laravel\Passport\Passport; class AuthServiceProvider extends ServiceProvider @@ -22,7 +19,6 @@ class AuthServiceProvider extends ServiceProvider * @var array */ protected $policies = [ - Organization::class => OrganizationPolicy::class, ]; /** @@ -56,11 +52,5 @@ class AuthServiceProvider extends ServiceProvider // Passport::tokensExpireIn(now()->addDays(15)); // Passport::refreshTokensExpireIn(now()->addDays(30)); Passport::personalAccessTokensExpireIn(now()->addMonths(12)); - - // same as passport default above - Jetstream::defaultApiTokenPermissions(['read']); - - // use passport scopes for jetstream token permissions - Jetstream::permissions(Passport::scopeIds()); } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index a10d375b..270a1571 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -15,12 +15,13 @@ use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; use Inertia\Inertia; +use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract; use Laravel\Fortify\Contracts\TwoFactorLoginResponse; use Laravel\Fortify\Fortify; -use Laravel\Fortify\Http\Responses\LoginResponse; class FortifyServiceProvider extends ServiceProvider { @@ -50,6 +51,40 @@ class FortifyServiceProvider extends ServiceProvider ]); }); + Fortify::loginView(function () { + return Inertia::render('Auth/Login', [ + 'canResetPassword' => Route::has('password.request'), + 'status' => session('status'), + ]); + }); + + Fortify::requestPasswordResetLinkView(function () { + return Inertia::render('Auth/ForgotPassword', [ + 'status' => session('status'), + ]); + }); + + Fortify::resetPasswordView(function (Request $request) { + return Inertia::render('Auth/ResetPassword', [ + 'email' => $request->input('email'), + 'token' => $request->route('token'), + ]); + }); + + Fortify::verifyEmailView(function () { + return Inertia::render('Auth/VerifyEmail', [ + 'status' => session('status'), + ]); + }); + + Fortify::twoFactorChallengeView(function () { + return Inertia::render('Auth/TwoFactorChallenge'); + }); + + Fortify::confirmPasswordView(function () { + return Inertia::render('Auth/ConfirmPassword'); + }); + Fortify::authenticateUsing(function (Request $request): ?User { /** @var User|null $user */ $user = User::query() @@ -74,7 +109,7 @@ class FortifyServiceProvider extends ServiceProvider return Limit::perMinute(5)->by($request->session()->get('login.id')); }); - $this->app->instance(LoginResponse::class, new CustomLoginResponse); + $this->app->instance(LoginResponseContract::class, new CustomLoginResponse); $this->app->instance(TwoFactorLoginResponse::class, new CustomTwoFactorLoginResponse); } } diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php deleted file mode 100644 index 5f51f00f..00000000 --- a/app/Providers/JetstreamServiceProvider.php +++ /dev/null @@ -1,113 +0,0 @@ -configurePermissions(); - - Jetstream::createTeamsUsing(CreateOrganization::class); - Jetstream::updateTeamNamesUsing(UpdateOrganization::class); - Jetstream::addTeamMembersUsing(AddOrganizationMember::class); - Jetstream::inviteTeamMembersUsing(InviteOrganizationMember::class); - Jetstream::removeTeamMembersUsing(RemoveOrganizationMember::class); - Jetstream::deleteTeamsUsing(DeleteOrganization::class); - Jetstream::deleteUsersUsing(DeleteUser::class); - Jetstream::useTeamModel(Organization::class); - Jetstream::useMembershipModel(Member::class); - Jetstream::useTeamInvitationModel(OrganizationInvitation::class); - app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class); - app()->singleton(ValidateTeamDeletion::class, ValidateOrganizationDeletion::class); - Gate::define('removeTeamMember', function (User $user, Organization $team) { - return false; - }); - } - - /** - * Configure the roles and permissions that are available within the application. - */ - protected function configurePermissions(): void - { - Jetstream::defaultApiTokenPermissions([]); - - foreach (PermissionStore::roleDefinitions() as $role => $definition) { - Jetstream::role($role, $definition['name'], $definition['permissions']) - ->description($definition['description']); - } - - Jetstream::inertia() - ->whenRendering( - 'Profile/Show', - function (Request $request, array $data): array { - return array_merge($data, [ - 'timezones' => $this->app->get(TimezoneService::class)->getSelectOptions(), - 'weekdays' => Weekday::toSelectArray(), - ]); - } - ) - ->whenRendering( - 'Teams/Show', - function (Request $request, array $data): array { - /** @var Organization $teamModel */ - $teamModel = $data['team']; - $owner = $teamModel->owner; - - return array_merge($data, [ - 'team' => [ - 'id' => $teamModel->getKey(), - 'name' => $teamModel->name, - 'currency' => $teamModel->currency, - 'owner' => [ - 'id' => $owner->getKey(), - 'name' => $owner->name, - 'profile_photo_url' => $owner->profile_photo_url, - ], - ], - 'currencies' => array_map(function (Currency $currency): string { - return $currency->getName(); - }, ISOCurrencyProvider::getInstance()->getAvailableCurrencies()), - ]); - } - ); - } -} diff --git a/app/Service/Dto/UserAgentDto.php b/app/Service/Dto/UserAgentDto.php new file mode 100644 index 00000000..66156b13 --- /dev/null +++ b/app/Service/Dto/UserAgentDto.php @@ -0,0 +1,179 @@ + + */ + protected static array $additionalOperatingSystems = [ + 'Windows' => 'Windows', + 'Windows NT' => 'Windows NT', + 'OS X' => 'Mac OS X', + 'Debian' => 'Debian', + 'Ubuntu' => 'Ubuntu', + 'Macintosh' => 'PPC', + 'OpenBSD' => 'OpenBSD', + 'Linux' => 'Linux', + 'ChromeOS' => 'CrOS', + ]; + + /** + * List of additional browsers. + * + * @var array + */ + protected static array $additionalBrowsers = [ + 'Opera Mini' => 'Opera Mini', + 'Opera' => 'Opera|OPR', + 'Edge' => 'Edge|Edg', + 'Coc Coc' => 'coc_coc_browser', + 'UCBrowser' => 'UCBrowser', + 'Vivaldi' => 'Vivaldi', + 'Chrome' => 'Chrome', + 'Firefox' => 'Firefox', + 'Safari' => 'Safari', + 'IE' => 'MSIE|IEMobile|MSIEMobile|Trident/[.0-9]+', + 'Netscape' => 'Netscape', + 'Mozilla' => 'Mozilla', + 'WeChat' => 'MicroMessenger', + ]; + + /** + * Key value store for resolved strings. + * + * @var array + */ + protected array $store = []; + + /** + * Get the platform name from the User Agent. + */ + public function platform(): ?string + { + return $this->retrieveUsingCacheOrResolve('platform', function () { + return $this->findDetectionRulesAgainstUserAgent( + $this->mergeRules(MobileDetect::getOperatingSystems(), static::$additionalOperatingSystems) + ); + }); + } + + /** + * Get the browser name from the User Agent. + */ + public function browser(): ?string + { + return $this->retrieveUsingCacheOrResolve('browser', function (): ?string { + return $this->findDetectionRulesAgainstUserAgent( + $this->mergeRules(static::$additionalBrowsers, MobileDetect::getBrowsers()) + ); + }); + } + + /** + * Determine if the device is a desktop computer. + */ + public function isDesktop(): bool + { + return $this->retrieveUsingCacheOrResolve('desktop', function (): bool { + // Check specifically for cloudfront headers if the useragent === 'Amazon CloudFront' + if ( + $this->getUserAgent() === static::$cloudFrontUA + && $this->getHttpHeader('HTTP_CLOUDFRONT_IS_DESKTOP_VIEWER') === 'true' + ) { + return true; + } + + return ! $this->isMobile() && ! $this->isTablet(); + }); + } + + /** + * Match a detection rule and return the matched key. + * + * @param array> $rules + */ + protected function findDetectionRulesAgainstUserAgent(array $rules): ?string + { + $userAgent = $this->getUserAgent(); + + foreach ($rules as $key => $regex) { + if (is_array($regex)) { + $regex = implode('|', $regex); + } + + if (empty($regex)) { + continue; + } + + if ($this->match($regex, $userAgent)) { + if ($key !== '') { + return $key; + } + + $match = reset($this->matchesArray); + + return is_string($match) ? $match : null; + } + } + + return null; + } + + /** + * Retrieve from the given key from the cache or resolve the value. + * + * @template TReturn of string|bool|null + * + * @param Closure():TReturn $callback + * @return TReturn + */ + protected function retrieveUsingCacheOrResolve(string $key, Closure $callback): string|bool|null + { + $cacheKey = $this->createCacheKey($key); + + if (! is_null($cacheItem = $this->store[$cacheKey] ?? null)) { + return $cacheItem; + } + + return tap(call_user_func($callback), function ($result) use ($cacheKey): void { + $this->store[$cacheKey] = $result; + }); + } + + /** + * Merge multiple rules into one array. + * + * @param array> ...$all + * @return array + */ + protected function mergeRules(array ...$all): array + { + $merged = []; + + foreach ($all as $rules) { + foreach ($rules as $key => $value) { + $value = is_array($value) ? implode('|', $value) : $value; + + if (empty($merged[$key])) { + $merged[$key] = $value; + } else { + $merged[$key] .= '|'.$value; + } + } + } + + return $merged; + } +} diff --git a/app/Service/InvitationService.php b/app/Service/InvitationService.php index 06b106c4..345933a7 100644 --- a/app/Service/InvitationService.php +++ b/app/Service/InvitationService.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Service; use App\Enums\Role; +use App\Events\OrganizationInvitationAdding; use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Mail\OrganizationInvitationMail; @@ -14,14 +15,13 @@ use App\Models\User; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; -use Laravel\Jetstream\Events\InvitingTeamMember; class InvitationService { /** * @throws UserIsAlreadyMemberOfOrganizationApiException|InvitationForTheEmailAlreadyExistsApiException */ - public function inviteUser(Organization $organization, string $email, Role $role): OrganizationInvitation + public function inviteUser(Organization $organization, string $email, Role $role, User $inviter): OrganizationInvitation { if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) { throw new UserIsAlreadyMemberOfOrganizationApiException; @@ -34,7 +34,7 @@ class InvitationService throw new InvitationForTheEmailAlreadyExistsApiException; } - InvitingTeamMember::dispatch($organization, $email, $role->value); + OrganizationInvitationAdding::dispatch($organization, $email, $role, $inviter); $invitation = new OrganizationInvitation; $invitation->email = $email; diff --git a/app/Service/MemberService.php b/app/Service/MemberService.php index a3fb079f..68e2a516 100644 --- a/app/Service/MemberService.php +++ b/app/Service/MemberService.php @@ -23,8 +23,6 @@ use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; use InvalidArgumentException; -use Laravel\Jetstream\Events\AddingTeamMember; -use Laravel\Jetstream\Events\TeamMemberAdded; class MemberService { @@ -39,7 +37,6 @@ class MemberService { if (! $asSuperAdmin) { MemberAdding::dispatch($user, $organization, $role); - AddingTeamMember::dispatch($organization, $user); // Legacy event } $member = new Member; @@ -56,7 +53,6 @@ class MemberService if (! $asSuperAdmin) { MemberAdded::dispatch($member, $organization, $user); - TeamMemberAdded::dispatch($organization, $user); // Legacy event } return $member; diff --git a/composer.json b/composer.json index 53c40842..b904de68 100644 --- a/composer.json +++ b/composer.json @@ -18,8 +18,8 @@ "korridor/laravel-computed-attributes": "^3.1", "korridor/laravel-has-many-sync": "^3.1", "korridor/laravel-model-validation-rules": "^3.0", + "laravel/fortify": "^1.37", "laravel/framework": "^12.19.3", - "laravel/jetstream": "^5.0", "laravel/octane": "^2.3", "laravel/passport": "^13.0.5", "laravel/tinker": "^2.8", @@ -27,6 +27,7 @@ "league/flysystem-aws-s3-v3": "^3.0", "league/iso3166": "^4.3", "maatwebsite/excel": "^3.1", + "mobiledetect/mobiledetectlib": "^4.11", "novadaemon/filament-pretty-json": "^2.2", "nwidart/laravel-modules": "^12.0.4", "owen-it/laravel-auditing": "^14.0.0", diff --git a/composer.lock b/composer.lock index 163b41ae..60efe66e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4c728f01d2beb426b2d157143618fdae", + "content-hash": "897ca7bc13f827db641f7affa54a8523", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -4413,72 +4413,6 @@ }, "time": "2026-05-20T11:48:19+00:00" }, - { - "name": "laravel/jetstream", - "version": "v5.5.3", - "source": { - "type": "git", - "url": "https://github.com/laravel/jetstream.git", - "reference": "61cac5cde455311890f6981fb2da47acd298e4e2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/jetstream/zipball/61cac5cde455311890f6981fb2da47acd298e4e2", - "reference": "61cac5cde455311890f6981fb2da47acd298e4e2", - "shasum": "" - }, - "require": { - "ext-json": "*", - "illuminate/console": "^11.0|^12.0|^13.0", - "illuminate/support": "^11.0|^12.0|^13.0", - "laravel/fortify": "^1.20", - "mobiledetect/mobiledetectlib": "^4.8.08", - "php": "^8.2.0", - "symfony/console": "^7.0|^8.0" - }, - "require-dev": { - "inertiajs/inertia-laravel": "^2.0", - "laravel/sanctum": "^4.0", - "livewire/livewire": "^3.3", - "mockery/mockery": "^1.0", - "orchestra/testbench": "^9.15|^10.8|^11.0", - "phpstan/phpstan": "^1.10" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Jetstream\\JetstreamServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Jetstream\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Tailwind scaffolding for the Laravel framework.", - "keywords": [ - "auth", - "laravel", - "tailwind" - ], - "support": { - "issues": "https://github.com/laravel/jetstream/issues", - "source": "https://github.com/laravel/jetstream" - }, - "time": "2026-05-19T01:30:03+00:00" - }, { "name": "laravel/octane", "version": "v2.17.4", @@ -6445,16 +6379,16 @@ }, { "name": "mobiledetect/mobiledetectlib", - "version": "4.10.0", + "version": "4.11.0", "source": { "type": "git", "url": "https://github.com/serbanghita/Mobile-Detect.git", - "reference": "1473bd9d6aa40158f75f1e05116e6dd081148b2c" + "reference": "ab39168b7556f44c11c80be1222b44b239f5c2e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/1473bd9d6aa40158f75f1e05116e6dd081148b2c", - "reference": "1473bd9d6aa40158f75f1e05116e6dd081148b2c", + "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/ab39168b7556f44c11c80be1222b44b239f5c2e4", + "reference": "ab39168b7556f44c11c80be1222b44b239f5c2e4", "shasum": "" }, "require": { @@ -6497,7 +6431,7 @@ ], "support": { "issues": "https://github.com/serbanghita/Mobile-Detect/issues", - "source": "https://github.com/serbanghita/Mobile-Detect/tree/4.10.0" + "source": "https://github.com/serbanghita/Mobile-Detect/tree/4.11.0" }, "funding": [ { @@ -6505,7 +6439,7 @@ "type": "github" } ], - "time": "2026-04-23T13:05:57+00:00" + "time": "2026-05-24T12:32:40+00:00" }, { "name": "monolog/monolog", @@ -13803,16 +13737,16 @@ }, { "name": "web-auth/webauthn-lib", - "version": "5.3.3", + "version": "5.3.5", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df" + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e6f656d6c6b29fa305382fe6a0a3be8177d177df", - "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", "shasum": "" }, "require": { @@ -13873,7 +13807,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.3" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" }, "funding": [ { @@ -13885,7 +13819,7 @@ "type": "patreon" } ], - "time": "2026-05-17T19:04:30+00:00" + "time": "2026-05-31T15:00:08+00:00" }, { "name": "webmozart/assert", diff --git a/config/app.php b/config/app.php index 2374875e..b6acb40d 100644 --- a/config/app.php +++ b/config/app.php @@ -12,7 +12,6 @@ use App\Providers\AuthServiceProvider; use App\Providers\EventServiceProvider; use App\Providers\Filament\AdminPanelProvider; use App\Providers\FortifyServiceProvider; -use App\Providers\JetstreamServiceProvider; use App\Providers\RouteServiceProvider; use Illuminate\Support\Facades\Facade; use Illuminate\Support\ServiceProvider; @@ -203,7 +202,6 @@ return [ AdminPanelProvider::class, RouteServiceProvider::class, FortifyServiceProvider::class, - JetstreamServiceProvider::class, // Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider LaravelModulesServiceProvider::class, ])->toArray(), diff --git a/config/jetstream.php b/config/jetstream.php deleted file mode 100644 index 755a1e67..00000000 --- a/config/jetstream.php +++ /dev/null @@ -1,82 +0,0 @@ - 'inertia', - - /* - |-------------------------------------------------------------------------- - | Jetstream Route Middleware - |-------------------------------------------------------------------------- - | - | Here you may specify which middleware Jetstream will assign to the routes - | that it registers with the application. When necessary, you may modify - | these middleware; however, this default value is usually sufficient. - | - */ - - 'middleware' => ['web'], - - 'auth_session' => AuthenticateSession::class, - - /* - |-------------------------------------------------------------------------- - | Jetstream Guard - |-------------------------------------------------------------------------- - | - | Here you may specify the authentication guard Jetstream will use while - | authenticating users. This value should correspond with one of your - | guards that is already present in your "auth" configuration file. - | - */ - - 'guard' => 'web', - - /* - |-------------------------------------------------------------------------- - | Features - |-------------------------------------------------------------------------- - | - | Some of Jetstream's features are optional. You may disable the features - | by removing them from this array. You're free to only remove some of - | these features or you can even remove all of these if you need to. - | - */ - - 'features' => [ - Features::termsAndPrivacyPolicy(), - Features::profilePhotos(), - Features::teams(['invitations' => true]), - Features::accountDeletion(), - ], - - /* - |-------------------------------------------------------------------------- - | Profile Photo Disk - |-------------------------------------------------------------------------- - | - | This configuration value determines the default disk that will be used - | when storing profile photos for your application's users. Typically - | this will be the "public" disk but you may adjust this if needed. - | - */ - - 'profile_photo_disk' => env('PROFILE_PHOTO_DISK', env('PUBLIC_FILESYSTEM_DISK', 'public')), - -]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index e7b9b38c..15b3393e 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -94,7 +94,7 @@ class UserFactory extends Factory $profilePhoto = $this->faker->image(null, 500, 500); /** @see FileHelpers::hashName */ $path = 'profile-photos/'.Str::random(40).'.png'; - Storage::disk(config('jetstream.profile_photo_disk', 'public'))->put($path, $profilePhoto); + Storage::disk(config('filesystems.public'))->put($path, $profilePhoto); return $this->state(function (array $attributes) use ($path): array { return [ diff --git a/routes/api.php b/routes/api.php index 7a275f89..2738aa03 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,6 +18,7 @@ use App\Http\Controllers\Api\V1\ReportController; use App\Http\Controllers\Api\V1\TagController; use App\Http\Controllers\Api\V1\TaskController; use App\Http\Controllers\Api\V1\TimeEntryController; +use App\Http\Controllers\Api\V1\TimeZoneController; use App\Http\Controllers\Api\V1\UserController; use App\Http\Controllers\Api\V1\UserMembershipController; use App\Http\Controllers\Api\V1\UserTimeEntryController; @@ -179,10 +180,15 @@ Route::prefix('v1')->name('v1.')->group(static function (): void { Route::name('export.')->prefix('/organizations/{organization}')->group(static function (): void { Route::post('/export', [ExportController::class, 'export'])->name('export'); }); + }); + // Currency routes Route::get('/currencies', [CurrencyController::class, 'index'])->name('currencies.index'); + // Timezone routes + Route::get('/time-zones', [TimeZoneController::class, 'index'])->name('time-zones.index'); + // Public routes Route::name('public.')->prefix('/public')->group(static function (): void { Route::get('/reports', [PublicReportController::class, 'show'])->name('reports.show'); diff --git a/routes/web.php b/routes/web.php index 2107b3bd..7a8c3497 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,13 +2,16 @@ declare(strict_types=1); +use App\Enums\Role; use App\Http\Controllers\Web\DashboardController; use App\Http\Controllers\Web\HomeController; +use App\Http\Controllers\Web\OrganizationController; use App\Http\Controllers\Web\OrganizationInvitationController; use App\Http\Controllers\Web\UserController; +use App\Http\Controllers\Web\UserProfileController; +use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Route; use Inertia\Inertia; -use Laravel\Jetstream\Jetstream; /* |-------------------------------------------------------------------------- @@ -29,7 +32,6 @@ Route::get('/shared-report', function () { Route::middleware([ 'auth:web', - config('jetstream.auth_session'), 'verified', ])->group(function (): void { Route::get('/dashboard', [DashboardController::class, 'dashboard'])->name('dashboard'); @@ -72,7 +74,7 @@ Route::middleware([ Route::get('/members', function () { return Inertia::render('Members', [ - 'availableRoles' => array_values(Jetstream::$roles), + 'availableRoles' => Role::values(), ]); })->name('members'); @@ -84,6 +86,15 @@ Route::middleware([ return Inertia::render('Import'); })->name('import'); + Route::get('/organizations/create', [OrganizationController::class, 'create'])->name('organizations.create'); + Route::get('/organizations/{organizationId}', [OrganizationController::class, 'show'])->name('organizations.show'); + Route::get('/teams/create', function (): RedirectResponse { + return to_route('organizations.create'); + })->name('teams.create'); + Route::get('/teams/{organizationId}', function (string $organizationId): RedirectResponse { + return to_route('organizations.show', [$organizationId]); + })->name('teams.show'); + Route::get('/user/profile', [UserProfileController::class, 'show'])->name('profile.show'); }); Route::get('/team-invitations/{invitation}', [OrganizationInvitationController::class, 'accept']) @@ -94,5 +105,5 @@ Route::get('/organization-invitations/{invitation}', [OrganizationInvitationCont ->name('organization-invitations.accept'); Route::get('/users/{user}/verify-email-change', [UserController::class, 'verifyEmailChange']) - ->middleware(['auth:web', config('jetstream.auth_session'), 'signed:relative']) + ->middleware(['auth:web', 'signed:relative']) ->name('users.verify-email-change'); diff --git a/tests/Feature/CreateOrganizationTest.php b/tests/Feature/CreateOrganizationTest.php deleted file mode 100644 index ac50f0eb..00000000 --- a/tests/Feature/CreateOrganizationTest.php +++ /dev/null @@ -1,48 +0,0 @@ -withPersonalOrganization()->create(); - $this->actingAs($user); - Event::fake([ - AfterCreateOrganization::class, - ]); - - // Act - $response = $this->post('/teams', [ - 'name' => 'Test Organization', - ]); - - // Assert - $response->assertStatus(302); - /** @var Organization|null $newOrganization */ - $ownedOrganizations = $user->fresh()->ownedOrganizations; - $this->assertCount(2, $ownedOrganizations); - $this->assertTrue($ownedOrganizations->contains('name', 'Test Organization')); - $newOrganization = $ownedOrganizations->firstWhere('name', 'Test Organization'); - /** @var Member $member */ - $member = Member::query()->whereBelongsTo($user, 'user')->whereBelongsTo($newOrganization, 'organization')->firstOrFail(); - $this->assertSame(Role::Owner->value, $member->role); - Event::assertDispatched(AfterCreateOrganization::class, function (AfterCreateOrganization $event) use ($newOrganization): bool { - return $event->organization->is($newOrganization); - }); - } -} diff --git a/tests/Feature/DeleteAccountTest.php b/tests/Feature/DeleteAccountTest.php deleted file mode 100644 index ff1bdd44..00000000 --- a/tests/Feature/DeleteAccountTest.php +++ /dev/null @@ -1,68 +0,0 @@ -create(); - $this->actingAs($user); - - // Act - $response = $this->delete('/user', [ - 'password' => 'password', - ]); - - // Assert - $response->assertStatus(302); - $this->assertNull($user->fresh()); - } - - public function test_correct_password_must_be_provided_before_account_can_be_deleted(): void - { - // Arrange - $user = User::factory()->create(); - $this->actingAs($user); - - // Act - $response = $this->delete('/user', [ - 'password' => 'wrong-password', - ]); - - // Assert - $this->assertNotNull($user->fresh()); - } - - public function test_user_account_can_not_be_deleted_if_attached_to_a_organization_with_multiple_users(): void - { - // Arrange - $user = User::factory()->create(); - $organization = Organization::factory()->withOwner($user)->create(); - $userMember = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Owner)->create(); - $otherUser = User::factory()->create(); - $otherMember = Member::factory()->forOrganization($organization)->forUser($otherUser)->role(Role::Admin)->create(); - $this->actingAs($user); - - // Act - $response = $this->delete('/user', [ - 'password' => 'password', - ]); - - // Assert - $response->assertInvalid(['password']); - $this->assertNotNull($user->fresh()); - } -} diff --git a/tests/Feature/DeleteOrganizationTest.php b/tests/Feature/DeleteOrganizationTest.php deleted file mode 100644 index 10a5f4e1..00000000 --- a/tests/Feature/DeleteOrganizationTest.php +++ /dev/null @@ -1,84 +0,0 @@ -withPersonalOrganization()->create(); - $this->actingAs($user); - - $organization = Organization::factory()->withOwner($user)->create([ - 'personal_team' => false, - ]); - Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Owner)->create(); - - $otherUser = User::factory()->create(); - $organization->users()->attach( - $otherUser, ['role' => 'test-role'] - ); - - // Act - $response = $this->delete('/teams/'.$organization->getKey()); - - // Assert - $this->assertNull($organization->fresh()); - $this->assertCount(1, $otherUser->fresh()->organizations); - $this->assertFalse($otherUser->fresh()->organizations->first()->is($organization)); - } - - public function test_personal_organizations_can_be_deleted_but_user_gets_an_new_one_if_this_is_the_only_one_left(): void - { - // Arrange - $user = User::factory()->withPersonalOrganization()->create(); - $organization = $user->currentOrganization; - $this->actingAs($user); - - // Act - $response = $this->delete('/teams/'.$organization->getKey()); - - // Assert - $user->refresh(); - $this->assertDatabaseMissing(Organization::class, [ - 'id' => $organization->getKey(), - ]); - $this->assertTrue($user->currentOrganization->isNot($organization)); - } - - public function test_organization_can_not_be_deleted_if_user_is_not_owner(): void - { - // Arrange - $user = User::factory()->withPersonalOrganization()->create(); - $organization = Organization::factory()->withOwner($user)->create([ - 'personal_team' => false, - ]); - $this->actingAs($user); - - $otherUser = User::factory()->create(); - $organization->users()->attach( - $otherUser, ['role' => Role::Admin->value] - ); - - // Act - $response = $this->delete('/teams/'.$organization->getKey()); - - // Assert - $response->assertForbidden(); - $this->assertDatabaseHas(Organization::class, [ - 'id' => $organization->getKey(), - ]); - } -} diff --git a/tests/Feature/InviteTeamMemberTest.php b/tests/Feature/InviteTeamMemberTest.php index 24c37fb5..f43b4629 100644 --- a/tests/Feature/InviteTeamMemberTest.php +++ b/tests/Feature/InviteTeamMemberTest.php @@ -17,44 +17,6 @@ class InviteTeamMemberTest extends TestCase { use RefreshDatabase; - public function test_team_members_can_no_longer_be_invited_to_team_over_jetstream(): void - { - // Arrange - Mail::fake(); - $this->actingAs($user = User::factory()->withPersonalOrganization()->create()); - - // Act - $response = $this->post('/teams/'.$user->currentOrganization->id.'/members', [ - 'email' => 'test@example.com', - 'role' => 'admin', - ]); - - // Assert - $response->assertStatus(403); - $response->assertSee('Moved to API'); - Mail::assertNothingSent(); - } - - public function test_team_member_invitations_can_no_longer_be_cancelled_over_jetstream(): void - { - // Arrange - Mail::fake(); - - $this->actingAs($user = User::factory()->withPersonalOrganization()->create()); - - $invitation = $user->currentOrganization->organizationInvitations()->create([ - 'email' => 'test@example.com', - 'role' => 'admin', - ]); - - // Act - $response = $this->delete('/team-invitations/'.$invitation->id); - - // Assert - $response->assertStatus(403); - $this->assertCount(1, $user->currentOrganization->fresh()->organizationInvitations); - } - public function test_team_member_invitations_can_be_accepted(): void { // Arrange diff --git a/tests/Feature/LeaveTeamTest.php b/tests/Feature/LeaveTeamTest.php deleted file mode 100644 index b7c7134d..00000000 --- a/tests/Feature/LeaveTeamTest.php +++ /dev/null @@ -1,33 +0,0 @@ -withPersonalOrganization()->create(); - - $user->currentOrganization->users()->attach( - $otherUser = User::factory()->create(), ['role' => 'admin'] - ); - - $this->actingAs($otherUser); - - // Act - $response = $this->delete('/teams/'.$user->currentOrganization->id.'/members/'.$otherUser->id); - - // Assert - $response->assertStatus(403); - $this->assertCount(2, $user->currentOrganization->fresh()->users); - } -} diff --git a/tests/Feature/ProfileInformationTest.php b/tests/Feature/ProfileInformationTest.php index 31ed18ca..f63ab911 100644 --- a/tests/Feature/ProfileInformationTest.php +++ b/tests/Feature/ProfileInformationTest.php @@ -17,20 +17,7 @@ class ProfileInformationTest extends TestCase { use RefreshDatabase; - public function test_show_profile_information_succeeds(): void - { - // Arrange - $user = User::factory()->withPersonalOrganization()->create(); - $this->actingAs($user); - - // Act - $response = $this->get('/user/profile'); - - // Assert - $response->assertSuccessful(); - } - - public function test_profile_information_can_be_updated(): void + public function test_profile_information_can_no_longer_be_updated_via_inertia(): void { // Arrange $user = User::factory()->create([ @@ -48,99 +35,9 @@ class ProfileInformationTest extends TestCase ]); // Assert - $response->assertValid(errorBag: 'updateProfileInformation'); + $response->assertStatus(403); $user = $user->fresh(); - $this->assertEquals('Test Name', $user->name); - $this->assertEquals('test@example.com', $user->email); - $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')); - $response->assertSessionHas('bannerStyle', 'success'); - $response->assertSessionHas('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); + $this->assertEquals($user->name, $user->name); } public function test_pending_email_verification_redirects_with_danger_banner_when_email_already_in_use(): void diff --git a/tests/Feature/RegistrationTest.php b/tests/Feature/RegistrationTest.php index 01cce7e4..28caaf6e 100644 --- a/tests/Feature/RegistrationTest.php +++ b/tests/Feature/RegistrationTest.php @@ -17,7 +17,6 @@ use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Laravel\Fortify\Features; -use Laravel\Jetstream\Jetstream; use Tests\TestCaseWithDatabase; use TiMacDonald\Log\LogEntry; @@ -47,7 +46,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, ]); // Assert @@ -78,7 +77,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, ]); // Assert @@ -97,7 +96,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'peter.test@gmail', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, ]); // Assert @@ -112,7 +111,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'PETER.test@gmail.com ', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, ]); // Assert @@ -132,7 +131,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, 'newsletter_consent' => true, ]); @@ -154,7 +153,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, 'timezone' => 'Europe/Berlin', ]); @@ -182,7 +181,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, 'timezone' => 'Europe/Berlin', ]); @@ -213,7 +212,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, 'timezone' => null, ]); @@ -244,7 +243,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, 'timezone' => 'Unknown timezone', ]); @@ -275,7 +274,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, 'timezone' => 'Asia/Calcutta', ]); @@ -296,7 +295,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, 'timezone' => 'Unknown timezone', ]); @@ -319,7 +318,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, ]); $this->assertFalse($this->isAuthenticated(), 'The user is authenticated'); @@ -340,7 +339,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, ]); $this->assertAuthenticated(); @@ -365,7 +364,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, ]); $this->assertAuthenticated(); @@ -398,7 +397,7 @@ class RegistrationTest extends TestCaseWithDatabase 'email' => 'test@example.com', 'password' => 'password', 'password_confirmation' => 'password', - 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + 'terms' => true, ]); // Assert diff --git a/tests/Feature/RemoveTeamMemberTest.php b/tests/Feature/RemoveTeamMemberTest.php deleted file mode 100644 index ae716391..00000000 --- a/tests/Feature/RemoveTeamMemberTest.php +++ /dev/null @@ -1,31 +0,0 @@ -actingAs($user = User::factory()->withPersonalOrganization()->create()); - - $user->currentOrganization->users()->attach( - $otherUser = User::factory()->create(), ['role' => 'admin'] - ); - - // Act - $response = $this->delete('/teams/'.$user->currentOrganization->id.'/members/'.$otherUser->id); - - // Assert - $response->assertStatus(403); - $response->assertSee('Moved to API'); - } -} diff --git a/tests/Feature/UpdateTeamMemberRoleTest.php b/tests/Feature/UpdateTeamMemberRoleTest.php deleted file mode 100644 index 3cf5ce58..00000000 --- a/tests/Feature/UpdateTeamMemberRoleTest.php +++ /dev/null @@ -1,35 +0,0 @@ -withPersonalOrganization()->create(); - $this->actingAs($user); - - $user->currentOrganization->users()->attach( - $otherUser = User::factory()->create(), ['role' => 'admin'] - ); - - // Act - $response = $this->put('/teams/'.$user->currentOrganization->id.'/members/'.$otherUser->id, [ - 'role' => Role::Employee->value, - ]); - - // Assert - $response->assertStatus(403); - $response->assertSee('Moved to API'); - } -} diff --git a/tests/Feature/UpdateTeamTest.php b/tests/Feature/UpdateTeamTest.php deleted file mode 100644 index 66a17ffc..00000000 --- a/tests/Feature/UpdateTeamTest.php +++ /dev/null @@ -1,47 +0,0 @@ -withPersonalOrganization()->create(); - $this->actingAs($user); - - // Act - $response = $this->get('/teams/1'); - - // Assert - $response->assertStatus(404); - } - - public function test_team_names_can_be_updated(): void - { - // Arrange - $user = User::factory()->withPersonalOrganization()->create(); - $this->actingAs($user); - - // Act - $response = $this->put('/teams/'.$user->currentOrganization->id, [ - 'name' => 'Test Organization', - 'currency' => 'USD', - ]); - - // Assert - $response->assertValid(errorBag: 'updateTeamName'); - $this->assertCount(1, $user->fresh()->ownedOrganizations); - $organization = $user->currentOrganization->fresh(); - $this->assertEquals('Test Organization', $organization->name); - $this->assertEquals('USD', $organization->currency); - } -} diff --git a/tests/TestCaseWithDatabase.php b/tests/TestCaseWithDatabase.php index 7dcb7f9e..81a44a4b 100644 --- a/tests/TestCaseWithDatabase.php +++ b/tests/TestCaseWithDatabase.php @@ -12,7 +12,6 @@ use App\Service\PermissionStore; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; -use Laravel\Jetstream\Jetstream; abstract class TestCaseWithDatabase extends TestCase { @@ -25,8 +24,6 @@ abstract class TestCaseWithDatabase extends TestCase protected function createUserWithPermission(array $permissions = [], bool $isOwner = false): object { $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) { diff --git a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php index d1752a14..753c61fa 100644 --- a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php @@ -193,7 +193,7 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract Passport::actingAs($data->user); // Act - $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [ + $response = $this->withoutExceptionHandling()->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [ 'email' => $user->email, 'role' => Role::Employee->value, ]); diff --git a/tests/Unit/Endpoint/Api/V1/TimeZoneEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeZoneEndpointTest.php new file mode 100644 index 00000000..bf0fc962 --- /dev/null +++ b/tests/Unit/Endpoint/Api/V1/TimeZoneEndpointTest.php @@ -0,0 +1,44 @@ +getTimezones(); + + // Act + $response = $this->getJson(route('api.v1.time-zones.index')); + + // Assert + $response->assertOk(); + $response->assertJsonCount(count($timezones)); + $response->assertJsonStructure([ + [ + 'key', + ], + ]); + + $responseObj = collect($response->json()); + $this->assertSame([ + 'key' => $timezones[0], + ], $responseObj->first()); + $this->assertSame([ + 'key' => 'Europe/Vienna', + ], $responseObj->firstWhere('key', '=', 'Europe/Vienna')); + $this->assertSame([ + 'key' => 'America/New_York', + ], $responseObj->firstWhere('key', '=', 'America/New_York')); + } +} diff --git a/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php b/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php index 4f9184e6..2d4f4765 100644 --- a/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php @@ -310,7 +310,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract { // Arrange $data = $this->createUserWithPermission(); - $photoDisk = (string) config('jetstream.profile_photo_disk', 'public'); + $photoDisk = (string) config('filesystems.public', 'public'); $previousPhotoPath = 'profile-photos/previous.png'; $photo = file_get_contents(resource_path('testfiles/test.png')); $this->assertIsString($photo); @@ -491,7 +491,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract { // Arrange $data = $this->createUserWithPermission(); - $photoDisk = (string) config('jetstream.profile_photo_disk', 'public'); + $photoDisk = (string) config('filesystems.public', 'public'); $photoPath = 'profile-photos/existing.png'; Storage::fake($photoDisk); Storage::disk($photoDisk)->put($photoPath, 'photo contents'); @@ -515,7 +515,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract { // Arrange $data = $this->createUserWithPermission(); - $photoDisk = (string) config('jetstream.profile_photo_disk', 'public'); + $photoDisk = (string) config('filesystems.public', 'public'); Storage::fake($photoDisk); $data->user->profile_photo_path = null; $data->user->save(); @@ -536,7 +536,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract { // Arrange $data = $this->createUserWithPermission(); - $photoDisk = (string) config('jetstream.profile_photo_disk', 'public'); + $photoDisk = (string) config('filesystems.public', 'public'); $photoPath = 'profile-photos/existing.png'; Storage::fake($photoDisk); Storage::disk($photoDisk)->put($photoPath, 'photo contents'); diff --git a/tests/Unit/Endpoint/Web/OrganizationEndpointTest.php b/tests/Unit/Endpoint/Web/OrganizationEndpointTest.php new file mode 100644 index 00000000..fd934d62 --- /dev/null +++ b/tests/Unit/Endpoint/Web/OrganizationEndpointTest.php @@ -0,0 +1,174 @@ +withPersonalOrganization()->create(); + $this->actingAs($user); + + // Act + $response = $this->get(route('organizations.create')); + + // Assert + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('Teams/Create') + ); + } + + public function test_legacy_teams_create_redirects_to_new_organization_create(): void + { + // Arrange + $user = User::factory()->withPersonalOrganization()->create(); + $this->actingAs($user); + + // Act + $response = $this->get(route('teams.create')); + + // Assert + $response->assertRedirect(route('organizations.create')); + } + + public function test_organization_show_succeeds(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'organizations:view', + ]); + $this->actingAs($data->user); + + // Act + $response = $this->get(route('organizations.show', [$data->organization->getKey()])); + + // Assert + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('Teams/Show') + ->where('team.id', $data->organization->getKey()) + ->where('team.name', $data->organization->name) + ->where('team.currency', $data->organization->currency) + ->where('team.owner.id', $data->owner->getKey()) + ->where('team.owner.name', $data->owner->name) + ->has('team.owner.profile_photo_url') + ->has('currencies') + ->where('availableRoles', []) + ->where('availablePermissions', []) + ->where('defaultPermissions', []) + ->where('permissions.canAddTeamMembers', true) + ->where('permissions.canDeleteTeam', true) + ->where('permissions.canRemoveTeamMembers', true) + ->where('permissions.canUpdateTeam', true) + ->where('permissions.canUpdateTeamMembers', true) + ); + } + + public function test_legacy_team_show_redirects_to_organization_show(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'organizations:view', + ]); + $this->actingAs($data->user); + + // Act + $response = $this->get(route('teams.show', [$data->organization->getKey()])); + + // Assert + $response->assertRedirect(route('organizations.show', [$data->organization->getKey()])); + } + + public function test_team_show_redirects_to_dashboard_for_invalid_organization_id(): void + { + // Arrange + $user = User::factory()->withPersonalOrganization()->create(); + $this->actingAs($user); + + // Act + $response = $this->get(route('organizations.show', ['not-a-uuid'])); + + // Assert + $response->assertRedirect(route('dashboard')); + } + + public function test_organization_show_redirects_to_dashboard_for_unknown_organization_id(): void + { + // Arrange + $user = User::factory()->withPersonalOrganization()->create(); + $this->actingAs($user); + + // Act + $response = $this->get(route('organizations.show', ['00000000-0000-4000-8000-000000000000'])); + + // Assert + $response->assertRedirect(route('dashboard')); + } + + public function test_organization_show_redirects_to_dashboard_without_organization_view_permission(): void + { + // Arrange + $data = $this->createUserWithPermission(); + $this->actingAs($data->user); + + // Act + $response = $this->get(route('organizations.show', [$data->organization->getKey()])); + + // Assert + $response->assertRedirect(route('dashboard')); + } + + public function test_organization_show_redirects_to_dashboard_for_organization_outside_user_memberships(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'organizations:view', + ]); + $otherOrganization = Organization::factory()->create(); + $this->actingAs($data->user); + + // Act + $response = $this->get(route('organizations.show', [$otherOrganization->getKey()])); + + // Assert + $response->assertRedirect(route('dashboard')); + } + + public function test_organization_show_does_not_expose_member_roster_invitations_or_owner_email(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'organizations:view', + ]); + OrganizationInvitation::factory()->forOrganization($data->organization)->create([ + 'email' => 'pending@example.com', + ]); + $this->actingAs($data->user); + + // Act + $response = $this->get(route('organizations.show', [$data->organization->getKey()])); + + // Assert + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->missing('team.users') + ->missing('team.team_invitations') + ->missing('team.owner.email') + ->has('team.owner.id') + ->has('team.owner.name') + ->has('team.owner.profile_photo_url') + ); + } +} diff --git a/tests/Unit/Endpoint/Web/TeamShowEndpointTest.php b/tests/Unit/Endpoint/Web/TeamShowEndpointTest.php deleted file mode 100644 index 3a2b892f..00000000 --- a/tests/Unit/Endpoint/Web/TeamShowEndpointTest.php +++ /dev/null @@ -1,45 +0,0 @@ -createUserWithPermission([]); - OrganizationInvitation::factory()->forOrganization($data->organization)->create([ - 'email' => 'pending@example.com', - ]); - $this->actingAs($data->user); - - // Act - $response = $this->get('/teams/'.$data->organization->getKey()); - - // Assert - $response->assertOk(); - $response->assertInertia(fn (Assert $page) => $page - ->missing('team.users') - ->missing('team.team_invitations') - ->missing('team.owner.email') - ->has('team.owner.id') - ->has('team.owner.name') - ->has('team.owner.profile_photo_url') - ); - } -} diff --git a/tests/Unit/Endpoint/Web/UserProfileEndpointTest.php b/tests/Unit/Endpoint/Web/UserProfileEndpointTest.php new file mode 100644 index 00000000..b659b3b3 --- /dev/null +++ b/tests/Unit/Endpoint/Web/UserProfileEndpointTest.php @@ -0,0 +1,138 @@ + 'array']); + $user = User::factory()->withPersonalOrganization()->create(); + $this->actingAs($user); + + // Act + $response = $this->get('/user/profile'); + + // Assert + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('Profile/Show') + ->has('timezones') + ->where('weekdays', Weekday::toSelectArray()) + ->where('confirmsTwoFactorAuthentication', true) + ->where('sessions', []) + ); + } + + public function test_showing_profile_exposes_database_sessions_for_current_user(): void + { + // Arrange + config(['session.driver' => 'database']); + $this->travelTo(Carbon::parse('2024-01-02 12:00:00', 'UTC')); + $user = User::factory()->withPersonalOrganization()->create(); + $otherUser = User::factory()->create(); + $this->actingAs($user); + + DB::table('sessions')->insert([ + [ + 'id' => 'older-session', + 'user_id' => $user->getKey(), + 'ip_address' => '192.0.2.10', + 'user_agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'payload' => '', + 'last_activity' => now()->subMinutes(5)->timestamp, + ], + [ + 'id' => 'newer-session', + 'user_id' => $user->getKey(), + 'ip_address' => '192.0.2.20', + 'user_agent' => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + 'payload' => '', + 'last_activity' => now()->subMinute()->timestamp, + ], + [ + 'id' => 'other-user-session', + 'user_id' => $otherUser->getKey(), + 'ip_address' => '192.0.2.30', + 'user_agent' => '', + 'payload' => '', + 'last_activity' => now()->timestamp, + ], + ]); + + // Act + $response = $this->get('/user/profile'); + + // Assert + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('Profile/Show') + ->has('sessions', 2) + ->where('sessions.0.agent.is_desktop', true) + ->where('sessions.0.agent.platform', 'Linux') + ->where('sessions.0.agent.browser', 'Chrome') + ->where('sessions.0.ip_address', '192.0.2.20') + ->where('sessions.0.is_current_device', false) + ->where('sessions.0.last_active', '1 minute ago') + ->where('sessions.1.agent.is_desktop', true) + ->where('sessions.1.agent.platform', 'OS X') + ->where('sessions.1.agent.browser', 'Chrome') + ->where('sessions.1.ip_address', '192.0.2.10') + ->where('sessions.1.is_current_device', false) + ->where('sessions.1.last_active', '5 minutes ago') + ); + } + + public function test_showing_profile_marks_two_factor_authentication_as_empty_when_disabled(): void + { + // Arrange + config(['session.driver' => 'array']); + $user = User::factory()->withPersonalOrganization()->create([ + 'two_factor_secret' => null, + 'two_factor_confirmed_at' => null, + ]); + $this->actingAs($user); + + // Act + $response = $this->get('/user/profile'); + + // Assert + $response->assertOk(); + $response->assertSessionHas('two_factor_empty_at'); + } + + public function test_showing_profile_disables_unconfirmed_two_factor_authentication_after_confirmation_was_abandoned(): void + { + // Arrange + config(['session.driver' => 'array']); + $user = User::factory()->withPersonalOrganization()->create([ + 'two_factor_secret' => 'secret', + 'two_factor_recovery_codes' => '[]', + 'two_factor_confirmed_at' => null, + ]); + $this->actingAs($user); + $this->withSession(['two_factor_confirming_at' => time() - 1]); + + // Act + $response = $this->get('/user/profile'); + + // Assert + $response->assertOk(); + $response->assertSessionHas('two_factor_empty_at'); + $response->assertSessionMissing('two_factor_confirming_at'); + $this->assertNull($user->fresh()->two_factor_secret); + $this->assertNull($user->fresh()->two_factor_confirmed_at); + } +}