Enhanced admin panel

This commit is contained in:
Constantin Graf
2024-04-26 14:39:36 +02:00
committed by Constantin Graf
parent 42ad5e004a
commit 0bf8a25d64
15 changed files with 258 additions and 77 deletions
+2 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Enums\Role;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\User;
@@ -81,7 +82,7 @@ class CreateNewUser implements CreatesNewUsers
$organization->users()->attach(
$user, [
'role' => 'owner',
'role' => Role::Owner->value,
]
);
+2 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
@@ -42,7 +43,7 @@ class CreateOrganization implements CreatesTeams
$organization->users()->attach(
$user, [
'role' => 'owner',
'role' => Role::Owner->value,
]
);
+15
View File
@@ -8,6 +8,7 @@ use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager;
use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager;
use App\Models\User;
use Exception;
use Filament\Forms;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
@@ -15,6 +16,7 @@ use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Hash;
use STS\FilamentImpersonate\Tables\Actions\Impersonate;
class UserResource extends Resource
{
@@ -70,6 +72,19 @@ class UserResource extends Resource
//
])
->actions([
Impersonate::make()->before(function (User $record): void {
if ($record->currentTeam === null) {
$organization = $record->organizations()->where('personal_team', '=', true)->first();
if ($organization === null) {
$organization = $record->organizations()->first();
}
if ($organization === null) {
throw new Exception('User has no organization');
}
$record->currentTeam()->associate($organization);
$record->save();
}
}),
Tables\Actions\EditAction::make(),
])
->bulkActions([
@@ -7,6 +7,7 @@ namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
class EditUser extends EditRecord
{
@@ -15,6 +16,7 @@ class EditUser extends EditRecord
protected function getHeaderActions(): array
{
return [
Impersonate::make()->record($this->getRecord()),
Actions\DeleteAction::make(),
];
}
+5
View File
@@ -126,6 +126,11 @@ class User extends Authenticatable implements FilamentUser, MustVerifyEmail
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
}
public function canBeImpersonated(): bool
{
return $this->is_placeholder === false;
}
/**
* @return BelongsToMany<Organization>
*/
+5 -1
View File
@@ -59,10 +59,14 @@ class PermissionStore
?->membership
?->role;
if ($role === null) {
return [];
}
/** @var Role|null $roleObj */
$roleObj = Jetstream::findRole($role);
return $role !== null ? ($roleObj?->permissions ?? []) : [];
return $roleObj?->permissions ?? [];
}
/**
+1
View File
@@ -23,6 +23,7 @@
"nwidart/laravel-modules": "dev-feature/fixed_path",
"pxlrbt/filament-environment-indicator": "^2.0",
"spatie/temporary-directory": "^2.2",
"stechstudio/filament-impersonate": "^3.8",
"tightenco/ziggy": "^2.1.0",
"tpetry/laravel-postgresql-enhanced": "^0.38.0",
"wikimedia/composer-merge-plugin": "^2.1.0"
Generated
+132 -23
View File
@@ -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": "ad100a87fbe76efc70708726bc921412",
"content-hash": "5779fb7e87efa88e5db2b37e64fe89e3",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -74,28 +74,28 @@
},
{
"name": "bacon/bacon-qr-code",
"version": "2.0.8",
"version": "v3.0.0",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22"
"reference": "510de6eca6248d77d31b339d62437cc995e2fb41"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22",
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/510de6eca6248d77d31b339d62437cc995e2fb41",
"reference": "510de6eca6248d77d31b339d62437cc995e2fb41",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^7.1 || ^8.0"
"php": "^8.1"
},
"require-dev": {
"phly/keep-a-changelog": "^2.1",
"phpunit/phpunit": "^7 | ^8 | ^9",
"spatie/phpunit-snapshot-assertions": "^4.2.9",
"squizlabs/php_codesniffer": "^3.4"
"phly/keep-a-changelog": "^2.12",
"phpunit/phpunit": "^10.5.11 || 11.0.4",
"spatie/phpunit-snapshot-assertions": "^5.1.5",
"squizlabs/php_codesniffer": "^3.9"
},
"suggest": {
"ext-imagick": "to generate QR code images"
@@ -122,9 +122,9 @@
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8"
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.0"
},
"time": "2022-12-07T17:46:57+00:00"
"time": "2024-04-18T11:16:25+00:00"
},
{
"name": "blade-ui-kit/blade-heroicons",
@@ -3496,6 +3496,73 @@
},
"time": "2024-02-28T15:07:15+00:00"
},
{
"name": "lab404/laravel-impersonate",
"version": "1.7.5",
"source": {
"type": "git",
"url": "https://github.com/404labfr/laravel-impersonate.git",
"reference": "82cad73700a8699d63de169bb41abd5ae283e9a8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/404labfr/laravel-impersonate/zipball/82cad73700a8699d63de169bb41abd5ae283e9a8",
"reference": "82cad73700a8699d63de169bb41abd5ae283e9a8",
"shasum": ""
},
"require": {
"laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0",
"php": "^7.2 | ^8.0"
},
"require-dev": {
"mockery/mockery": "^1.3.3",
"orchestra/testbench": "^4.0 | ^5.0 | ^6.0 | ^7.0 | ^8.0 | ^9.0",
"phpunit/phpunit": "^7.5 | ^8.0 | ^9.0 | ^10.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Lab404\\Impersonate\\ImpersonateServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Lab404\\Impersonate\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Marceau Casals",
"email": "marceau@casals.fr"
}
],
"description": "Laravel Impersonate is a plugin that allows to you to authenticate as your users.",
"keywords": [
"auth",
"impersonate",
"impersonation",
"laravel",
"laravel-package",
"laravel-plugin",
"package",
"plugin",
"user"
],
"support": {
"issues": "https://github.com/404labfr/laravel-impersonate/issues",
"source": "https://github.com/404labfr/laravel-impersonate/tree/1.7.5"
},
"time": "2024-03-11T14:26:14+00:00"
},
{
"name": "laminas/laminas-diactoros",
"version": "3.3.1",
@@ -3583,20 +3650,20 @@
},
{
"name": "laravel/fortify",
"version": "v1.21.1",
"version": "v1.21.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/fortify.git",
"reference": "405388fd399264715573e23ed2f368fbce426da3"
"reference": "cb122ceec7f8d0231985c1dde8161b3c561bfe90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/fortify/zipball/405388fd399264715573e23ed2f368fbce426da3",
"reference": "405388fd399264715573e23ed2f368fbce426da3",
"url": "https://api.github.com/repos/laravel/fortify/zipball/cb122ceec7f8d0231985c1dde8161b3c561bfe90",
"reference": "cb122ceec7f8d0231985c1dde8161b3c561bfe90",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^2.0",
"bacon/bacon-qr-code": "^3.0",
"ext-json": "*",
"illuminate/support": "^10.0|^11.0",
"php": "^8.1",
@@ -3644,7 +3711,7 @@
"issues": "https://github.com/laravel/fortify/issues",
"source": "https://github.com/laravel/fortify"
},
"time": "2024-03-19T20:08:25+00:00"
"time": "2024-04-25T14:17:43+00:00"
},
{
"name": "laravel/framework",
@@ -8038,6 +8105,48 @@
],
"time": "2023-12-25T11:46:58+00:00"
},
{
"name": "stechstudio/filament-impersonate",
"version": "3.8",
"source": {
"type": "git",
"url": "https://github.com/stechstudio/filament-impersonate.git",
"reference": "d24a9ffc1ef2f87940d151ca1cb2c2d2e5e524a8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stechstudio/filament-impersonate/zipball/d24a9ffc1ef2f87940d151ca1cb2c2d2e5e524a8",
"reference": "d24a9ffc1ef2f87940d151ca1cb2c2d2e5e524a8",
"shasum": ""
},
"require": {
"filament/filament": "^3.0",
"lab404/laravel-impersonate": "^1.7"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"STS\\FilamentImpersonate\\FilamentImpersonateServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"STS\\FilamentImpersonate\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A Filament package to impersonate your users.",
"support": {
"issues": "https://github.com/stechstudio/filament-impersonate/issues",
"source": "https://github.com/stechstudio/filament-impersonate/tree/3.8"
},
"time": "2024-03-25T03:05:55+00:00"
},
{
"name": "symfony/clock",
"version": "v7.0.5",
@@ -13908,16 +14017,16 @@
},
{
"name": "spatie/ignition",
"version": "1.13.2",
"version": "1.14.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/ignition.git",
"reference": "952798e239d9969e4e694b124c2cc222798dbb28"
"reference": "80385994caed328f6f9c9952926932e65b9b774c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/ignition/zipball/952798e239d9969e4e694b124c2cc222798dbb28",
"reference": "952798e239d9969e4e694b124c2cc222798dbb28",
"url": "https://api.github.com/repos/spatie/ignition/zipball/80385994caed328f6f9c9952926932e65b9b774c",
"reference": "80385994caed328f6f9c9952926932e65b9b774c",
"shasum": ""
},
"require": {
@@ -13987,7 +14096,7 @@
"type": "github"
}
],
"time": "2024-04-16T08:49:17+00:00"
"time": "2024-04-26T08:45:51+00:00"
},
{
"name": "spatie/laravel-ignition",
+65 -37
View File
@@ -44,8 +44,8 @@ return [
],
'replacements' => [
'routes/web' => ['LOWER_NAME', 'STUDLY_NAME', 'MODULE_NAMESPACE', 'CONTROLLER_NAMESPACE'],
'routes/api' => ['LOWER_NAME', 'STUDLY_NAME'],
'vite' => ['LOWER_NAME'],
'routes/api' => ['LOWER_NAME', 'STUDLY_NAME', 'MODULE_NAMESPACE', 'CONTROLLER_NAMESPACE'],
'vite' => ['LOWER_NAME', 'STUDLY_NAME'],
'json' => ['LOWER_NAME', 'STUDLY_NAME', 'MODULE_NAMESPACE', 'PROVIDER_NAMESPACE'],
'views/index' => ['LOWER_NAME'],
'views/master' => ['LOWER_NAME', 'STUDLY_NAME'],
@@ -58,6 +58,7 @@ return [
'AUTHOR_EMAIL',
'MODULE_NAMESPACE',
'PROVIDER_NAMESPACE',
'APP_FOLDER_NAME',
],
],
'gitkeep' => true,
@@ -93,8 +94,18 @@ return [
| the migration files?
|
*/
'migration' => base_path('database/migrations'),
/*
|--------------------------------------------------------------------------
| The app path
|--------------------------------------------------------------------------
|
| app folder name
| for example can change it to 'src' or 'App'
*/
'app_folder' => 'app/',
/*
|--------------------------------------------------------------------------
| Generator path
@@ -103,35 +114,52 @@ return [
| Setting the generate key to false will not generate that folder
*/
'generator' => [
// app/
'channels' => ['path' => 'app/Broadcasting', 'generate' => false],
'command' => ['path' => 'app/Console', 'generate' => false],
'emails' => ['path' => 'app/Emails', 'generate' => false],
'event' => ['path' => 'app/Events', 'generate' => false],
'jobs' => ['path' => 'app/Jobs', 'generate' => false],
'listener' => ['path' => 'app/Listeners', 'generate' => false],
'model' => ['path' => 'app/Models', 'generate' => false],
'notifications' => ['path' => 'app/Notifications', 'generate' => false],
'observer' => ['path' => 'app/Observers', 'generate' => false],
'policies' => ['path' => 'app/Policies', 'generate' => false],
'provider' => ['path' => 'app/Providers', 'generate' => true],
'route-provider' => ['path' => 'app/Providers', 'generate' => true],
'repository' => ['path' => 'app/Repositories', 'generate' => false],
'resource' => ['path' => 'app/Transformers', 'generate' => false],
'rules' => ['path' => 'app/Rules', 'generate' => false],
'component-class' => ['path' => 'app/View/Components', 'generate' => false],
'service' => ['path' => 'app/Services', 'generate' => false],
// app/Http/
'controller' => ['path' => 'app/Http/Controllers', 'generate' => true],
'filter' => ['path' => 'app/Http/Middleware', 'generate' => false],
'request' => ['path' => 'app/Http/Requests', 'generate' => false],
// config/
'config' => ['path' => 'config', 'generate' => true],
'command' => ['path' => 'App/Console', 'generate' => false],
'channels' => ['path' => 'App/Broadcasting', 'generate' => false],
'migration' => ['path' => 'Database/migrations', 'generate' => false],
'seeder' => ['path' => 'Database/Seeders', 'generate' => true],
'factory' => ['path' => 'Database/Factories', 'generate' => false],
'model' => ['path' => 'App/Models', 'generate' => false],
'observer' => ['path' => 'App/Observers', 'generate' => false],
'routes' => ['path' => 'routes', 'generate' => true],
'controller' => ['path' => 'App/Http/Controllers', 'generate' => true],
'filter' => ['path' => 'App/Http/Middleware', 'generate' => false],
'request' => ['path' => 'App/Http/Requests', 'generate' => false],
'provider' => ['path' => 'App/Providers', 'generate' => true],
'assets' => ['path' => 'resources/assets', 'generate' => false],
// database/
'migration' => ['path' => 'database/migrations', 'generate' => true],
'seeder' => ['path' => 'database/seeders', 'namespace' => 'Database\Seeders', 'generate' => true],
'factory' => ['path' => 'database/factories', 'namespace' => 'Database\Factories', 'generate' => true],
// lang/
'lang' => ['path' => 'lang', 'generate' => false],
// resource/
'assets' => ['path' => 'resources/assets', 'generate' => true],
'views' => ['path' => 'resources/views', 'generate' => true],
'test' => ['path' => 'tests/Unit', 'generate' => false],
'test-feature' => ['path' => 'tests/Feature', 'generate' => false],
'repository' => ['path' => 'App/Repositories', 'generate' => false],
'event' => ['path' => 'App/Events', 'generate' => false],
'listener' => ['path' => 'App/Listeners', 'generate' => false],
'policies' => ['path' => 'App/Policies', 'generate' => false],
'rules' => ['path' => 'App/Rules', 'generate' => false],
'jobs' => ['path' => 'App/Jobs', 'generate' => false],
'emails' => ['path' => 'App/Emails', 'generate' => false],
'notifications' => ['path' => 'App/Notifications', 'generate' => false],
'resource' => ['path' => 'App/resources', 'generate' => false],
'component-view' => ['path' => 'resources/views/components', 'generate' => false],
'component-class' => ['path' => 'App/View/Components', 'generate' => false],
// routes/
'routes' => ['path' => 'routes', 'generate' => true],
// tests/
'test-unit' => ['path' => 'tests/Unit', 'generate' => true],
'test-feature' => ['path' => 'tests/Feature', 'generate' => true],
],
],
@@ -158,13 +186,13 @@ return [
| directory. This is useful if you host the package in packagist website.
|
*/
'scan' => [
'enabled' => false,
'paths' => [
base_path('vendor/*/*'),
],
],
/*
|--------------------------------------------------------------------------
| Composer File Template
@@ -173,12 +201,11 @@ return [
| Here is the config for the composer.json file, generated by this package
|
*/
'composer' => [
'vendor' => 'nwidart',
'vendor' => env('MODULE_VENDOR', 'solidtime-io'),
'author' => [
'name' => 'Nicolas Widart',
'email' => 'n.widart@gmail.com',
'name' => env('MODULE_AUTHOR_NAME', 'Nicolas Widart'),
'email' => env('MODULE_AUTHOR_EMAIL', 'n.widart@gmail.com'),
],
'composer-output' => false,
],
@@ -192,11 +219,12 @@ return [
|
*/
'cache' => [
'enabled' => false,
'driver' => 'file',
'key' => 'laravel-modules',
'lifetime' => 60,
'enabled' => env('MODULES_CACHE_ENABLED', false),
'driver' => env('MODULES_CACHE_DRIVER', 'file'),
'key' => env('MODULES_CACHE_KEY', 'laravel-modules'),
'lifetime' => env('MODULES_CACHE_LIFETIME', 60),
],
/*
|--------------------------------------------------------------------------
| Choose what laravel-modules will register as custom namespaces.
+4 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Database\Factories;
use App\Enums\Role;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\User;
@@ -100,7 +101,9 @@ class UserFactory extends Factory
->when(is_callable($callback), $callback)
->create();
$organization->users()->attach($user, ['role' => 'owner']);
$organization->users()->attach($user, ['role' => Role::Owner->value]);
$user->currentTeam()->associate($organization);
$user->save();
});
}
}
+16 -8
View File
@@ -25,12 +25,14 @@ class DatabaseSeeder extends Seeder
public function run(): void
{
$this->deleteAll();
$userAcmeOwner = User::factory()->create([
$userAcmeOwner = User::factory()->withPersonalOrganization()->create([
'name' => 'Acme Owner',
'email' => 'owner@acme.test',
]);
$organizationAcme = Organization::factory()->withOwner($userAcmeOwner)->create([
'name' => 'ACME Corp',
'personal_team' => false,
'currency' => 'EUR',
]);
$userAcmeManager = User::factory()->withPersonalOrganization()->create([
'name' => 'Acme Manager',
@@ -80,29 +82,35 @@ class DatabaseSeeder extends Seeder
->forUser($userAcmeEmployee)
->forOrganization($organizationAcme)
->create();
$client = Client::factory()->create([
$client = Client::factory()->forOrganization($organizationAcme)->create([
'name' => 'Big Company',
]);
$bigCompanyProject = Project::factory()->forClient($client)->create([
$bigCompanyProject = Project::factory()->forOrganization($organizationAcme)->forClient($client)->create([
'name' => 'Big Company Project',
]);
Task::factory()->forProject($bigCompanyProject)->create();
Task::factory()->forOrganization($organizationAcme)->forProject($bigCompanyProject)->create();
$internalProject = Project::factory()->create([
$internalProject = Project::factory()->forOrganization($organizationAcme)->create([
'name' => 'Internal Project',
]);
$organization2 = Organization::factory()->create([
$organization2Owner = User::factory()->create([
'name' => 'Other Owner',
'email' => 'owner@rival-company.test',
]);
$organization2 = Organization::factory()->withOwner($organization2Owner)->create([
'name' => 'Rival Corp',
'personal_team' => true,
'currency' => 'USD',
]);
$userAcmeManager = User::factory()->withPersonalOrganization()->create([
'name' => 'Other User',
'email' => 'test@rival-company.test',
]);
$userAcmeManager->organizations()->attach($organization2, [
'role' => 'admin',
'role' => Role::Admin->value,
]);
$otherCompanyProject = Project::factory()->forClient($client)->create([
$otherCompanyProject = Project::factory()->forOrganization($organization2)->forClient($client)->create([
'name' => 'Scale Company',
]);
+2 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Enums\Role;
use App\Models\Membership;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -29,6 +30,6 @@ class CreateTeamTest extends TestCase
$this->assertCount(2, $user->fresh()->ownedTeams);
$this->assertEquals('Test Organization', $newOrganization->name);
$member = Membership::query()->whereBelongsTo($user, 'user')->whereBelongsTo($newOrganization, 'organization')->firstOrFail();
$this->assertSame('owner', $member->role);
$this->assertSame(Role::Owner->value, $member->role);
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Enums\Role;
use App\Models\Membership;
use App\Models\User;
use App\Providers\RouteServiceProvider;
@@ -58,7 +59,7 @@ class RegistrationTest extends TestCase
$organization = $user->organizations()->firstOrFail();
$this->assertSame(true, $organization->personal_team);
$member = Membership::query()->whereBelongsTo($user, 'user')->whereBelongsTo($organization, 'organization')->firstOrFail();
$this->assertSame('owner', $member->role);
$this->assertSame(Role::Owner->value, $member->role);
}
public function test_new_users_can_register_and_frontend_can_send_timezone_for_user(): void
+3 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Enums\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -64,12 +65,12 @@ class UpdateTeamMemberRoleTest extends TestCase
// Act
$response = $this->withoutExceptionHandling()->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->getKey(), [
'role' => 'owner',
'role' => Role::Owner->value,
]);
// Assert
$this->assertTrue($otherUser->fresh()->hasTeamRole(
$user->currentTeam->fresh(), 'owner'
$user->currentTeam->fresh(), Role::Owner->value
));
$this->assertSame($user->currentTeam->fresh()->user_id, $otherUser->getKey());
}
+2 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Enums\Role;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\Project;
@@ -267,7 +268,7 @@ class DashboardServiceTest extends TestCase
]);
$organization = Organization::factory()->withOwner($user)->create();
$organization->users()->attach($user, [
'role' => 'owner',
'role' => Role::Owner->value,
]);
$project1 = Project::factory()->forOrganization($organization)->create();
$project2 = Project::factory()->forOrganization($organization)->create();