From 9d1e7f510f3f954bdf9533c4a07a5973b904eec8 Mon Sep 17 00:00:00 2001 From: Boy132 Date: Wed, 17 Dec 2025 20:09:17 +0100 Subject: [PATCH] Add toggle for externally managed users (#1825) --- .../Admin/Resources/Users/UserResource.php | 99 ++++++++++--------- app/Filament/Pages/Auth/EditProfile.php | 4 +- .../Application/Users/StoreUserRequest.php | 2 + .../Api/Client/Account/UpdateEmailRequest.php | 2 +- .../Client/Account/UpdatePasswordRequest.php | 2 +- .../Client/Account/UpdateUsernameRequest.php | 2 +- app/Models/User.php | 5 + .../Api/Application/UserTransformer.php | 1 + database/Factories/UserFactory.php | 1 + ...209_add_is_managed_externally_to_users.php | 28 ++++++ lang/en/admin/user.php | 2 + .../Users/ExternalUserControllerTest.php | 3 +- .../Application/Users/UserControllerTest.php | 15 +-- 13 files changed, 109 insertions(+), 57 deletions(-) create mode 100644 database/migrations/2025_10_23_073209_add_is_managed_externally_to_users.php diff --git a/app/Filament/Admin/Resources/Users/UserResource.php b/app/Filament/Admin/Resources/Users/UserResource.php index 1aad76c1b..8a530f62d 100644 --- a/app/Filament/Admin/Resources/Users/UserResource.php +++ b/app/Filament/Admin/Resources/Users/UserResource.php @@ -33,6 +33,7 @@ use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Infolists\Components\TextEntry; use Filament\Notifications\Notification; use Filament\Resources\Pages\PageRegistration; @@ -224,65 +225,71 @@ class UserResource extends Resource 'md' => 1, 'lg' => 1, ]), - Select::make('timezone') - ->label(trans('profile.timezone')) + Toggle::make('is_managed_externally') + ->label(trans('admin/user.is_managed_externally')) + ->hintIcon('tabler-question-mark', trans('admin/user.is_managed_externally_helper')) + ->inline(false) ->columnSpan([ 'default' => 1, 'md' => 1, 'lg' => 1, - ]) - ->required() - ->prefixIcon('tabler-clock-pin') - ->default(fn () => config('app.timezone', 'UTC')) - ->selectablePlaceholder(false) - ->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz])) - ->searchable(), - Select::make('language') - ->label(trans('profile.language')) - ->columnSpan([ - 'default' => 1, - 'md' => 1, - 'lg' => 1, - ]) - ->required() - ->prefixIcon('tabler-flag') - ->live() - ->default('en') - ->searchable() - ->selectablePlaceholder(false) - ->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()), - FileUpload::make('avatar') - ->visible(fn (?User $user, FileUpload $fileUpload) => $user ? $fileUpload->getDisk()->exists($fileUpload->getDirectory() . '/' . $user->id . '.png') : false) - ->avatar() - ->directory('avatars') - ->disk('public') - ->formatStateUsing(function (FileUpload $fileUpload, ?User $user) { - if (!$user) { - return null; - } - $path = $fileUpload->getDirectory() . '/' . $user->id . '.png'; - if ($fileUpload->getDisk()->exists($path)) { - return $path; - } - }) - ->deleteUploadedFileUsing(function (FileUpload $fileUpload, $file) { - if ($file instanceof TemporaryUploadedFile) { - return $file->delete(); - } + ]), + Section::make(trans('profile.tabs.customization')) + ->collapsible() + ->columnSpanFull() + ->columns(2) + ->schema([ + Select::make('timezone') + ->label(trans('profile.timezone')) + ->required() + ->prefixIcon('tabler-clock-pin') + ->default(fn () => config('app.timezone', 'UTC')) + ->selectablePlaceholder(false) + ->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz])) + ->searchable(), + Select::make('language') + ->label(trans('profile.language')) + ->required() + ->prefixIcon('tabler-flag') + ->live() + ->default('en') + ->searchable() + ->selectablePlaceholder(false) + ->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()), + FileUpload::make('avatar') + ->visible(fn (?User $user, FileUpload $fileUpload) => $user ? $fileUpload->getDisk()->exists($fileUpload->getDirectory() . '/' . $user->id . '.png') : false) + ->columnSpanFull() + ->avatar() + ->directory('avatars') + ->disk('public') + ->formatStateUsing(function (FileUpload $fileUpload, ?User $user) { + if (!$user) { + return null; + } + $path = $fileUpload->getDirectory() . '/' . $user->id . '.png'; + if ($fileUpload->getDisk()->exists($path)) { + return $path; + } + }) + ->deleteUploadedFileUsing(function (FileUpload $fileUpload, $file) { + if ($file instanceof TemporaryUploadedFile) { + return $file->delete(); + } - if ($fileUpload->getDisk()->exists($file)) { - return $fileUpload->getDisk()->delete($file); - } - }), + if ($fileUpload->getDisk()->exists($file)) { + return $fileUpload->getDisk()->delete($file); + } + }), + ]), Section::make(trans('profile.tabs.oauth')) ->visible(fn (?User $user) => $user) ->collapsible() ->columnSpanFull() ->schema(function (OAuthService $oauthService, ?User $user) { - if (!$user) { return; } + $actions = []; foreach ($user->oauth ?? [] as $schema => $_) { $schema = $oauthService->get($schema); diff --git a/app/Filament/Pages/Auth/EditProfile.php b/app/Filament/Pages/Auth/EditProfile.php index fbffc4752..97e9549f3 100644 --- a/app/Filament/Pages/Auth/EditProfile.php +++ b/app/Filament/Pages/Auth/EditProfile.php @@ -93,12 +93,14 @@ class EditProfile extends BaseEditProfile ->icon('tabler-user-cog') ->schema([ TextInput::make('username') + ->disabled(fn (User $user) => $user->is_managed_externally) ->prefixIcon('tabler-user') ->label(trans('profile.username')) ->required() ->maxLength(255) ->unique(), TextInput::make('email') + ->disabled(fn (User $user) => $user->is_managed_externally) ->prefixIcon('tabler-mail') ->label(trans('profile.email')) ->email() @@ -106,6 +108,7 @@ class EditProfile extends BaseEditProfile ->maxLength(255) ->unique(), TextInput::make('password') + ->hidden(fn (User $user) => $user->is_managed_externally) ->label(trans('profile.password')) ->password() ->prefixIcon('tabler-password') @@ -535,7 +538,6 @@ class EditProfile extends BaseEditProfile ]), ]), ]), - ]) ->operation('edit') ->model($this->getUser()) diff --git a/app/Http/Requests/Api/Application/Users/StoreUserRequest.php b/app/Http/Requests/Api/Application/Users/StoreUserRequest.php index cf3f4c7de..520e36c6b 100644 --- a/app/Http/Requests/Api/Application/Users/StoreUserRequest.php +++ b/app/Http/Requests/Api/Application/Users/StoreUserRequest.php @@ -22,6 +22,7 @@ class StoreUserRequest extends ApplicationApiRequest return collect($rules)->only([ 'external_id', + 'is_managed_externally', 'email', 'username', 'password', @@ -39,6 +40,7 @@ class StoreUserRequest extends ApplicationApiRequest { return [ 'external_id' => 'Third Party Identifier', + 'is_managed_externally' => 'Is managed by Third Party?', ]; } } diff --git a/app/Http/Requests/Api/Client/Account/UpdateEmailRequest.php b/app/Http/Requests/Api/Client/Account/UpdateEmailRequest.php index 4d58055c7..68bdb31d7 100644 --- a/app/Http/Requests/Api/Client/Account/UpdateEmailRequest.php +++ b/app/Http/Requests/Api/Client/Account/UpdateEmailRequest.php @@ -26,7 +26,7 @@ class UpdateEmailRequest extends ClientApiRequest throw new InvalidPasswordProvidedException(trans('validation.internal.invalid_password')); } - return true; + return !$this->user()->is_managed_externally; } public function rules(): array diff --git a/app/Http/Requests/Api/Client/Account/UpdatePasswordRequest.php b/app/Http/Requests/Api/Client/Account/UpdatePasswordRequest.php index ad305e4d6..afbbc1f2f 100644 --- a/app/Http/Requests/Api/Client/Account/UpdatePasswordRequest.php +++ b/app/Http/Requests/Api/Client/Account/UpdatePasswordRequest.php @@ -25,7 +25,7 @@ class UpdatePasswordRequest extends ClientApiRequest throw new InvalidPasswordProvidedException(trans('validation.internal.invalid_password')); } - return true; + return !$this->user()->is_managed_externally; } public function rules(): array diff --git a/app/Http/Requests/Api/Client/Account/UpdateUsernameRequest.php b/app/Http/Requests/Api/Client/Account/UpdateUsernameRequest.php index 430fa3c71..ac2ba0b59 100644 --- a/app/Http/Requests/Api/Client/Account/UpdateUsernameRequest.php +++ b/app/Http/Requests/Api/Client/Account/UpdateUsernameRequest.php @@ -26,7 +26,7 @@ class UpdateUsernameRequest extends ClientApiRequest throw new InvalidPasswordProvidedException(trans('validation.internal.invalid_password')); } - return true; + return !$this->user()->is_managed_externally; } public function rules(): array diff --git a/app/Models/User.php b/app/Models/User.php index 7a969ec91..e389c8371 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -49,6 +49,7 @@ use Spatie\Permission\Traits\HasRoles; * * @property int $id * @property string|null $external_id + * @property bool $is_managed_externally * @property string $uuid * @property string $username * @property string $email @@ -118,6 +119,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac */ protected $fillable = [ 'external_id', + 'is_managed_externally', 'username', 'email', 'password', @@ -140,6 +142,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac */ protected $attributes = [ 'external_id' => null, + 'is_managed_externally' => false, 'language' => 'en', 'timezone' => 'UTC', 'mfa_app_secret' => null, @@ -154,6 +157,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac 'uuid' => ['nullable', 'string', 'size:36', 'unique:users,uuid'], 'email' => ['required', 'email', 'between:1,255', 'unique:users,email'], 'external_id' => ['sometimes', 'nullable', 'string', 'max:255', 'unique:users,external_id'], + 'is_managed_externally' => ['boolean'], 'username' => ['required', 'between:1,255', 'unique:users,username'], 'password' => ['sometimes', 'nullable', 'string'], 'language' => ['string'], @@ -175,6 +179,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac protected function casts(): array { return [ + 'is_managed_externally' => 'boolean', 'mfa_app_secret' => 'encrypted', 'mfa_app_recovery_codes' => 'encrypted:array', 'oauth' => 'array', diff --git a/app/Transformers/Api/Application/UserTransformer.php b/app/Transformers/Api/Application/UserTransformer.php index da45df2ae..f3da7cc49 100644 --- a/app/Transformers/Api/Application/UserTransformer.php +++ b/app/Transformers/Api/Application/UserTransformer.php @@ -31,6 +31,7 @@ class UserTransformer extends BaseTransformer return [ 'id' => $user->id, 'external_id' => $user->external_id, + 'is_managed_externally' => $user->is_managed_externally, 'uuid' => $user->uuid, 'username' => $user->username, 'email' => $user->email, diff --git a/database/Factories/UserFactory.php b/database/Factories/UserFactory.php index 2ac70cc1d..0d43631c6 100644 --- a/database/Factories/UserFactory.php +++ b/database/Factories/UserFactory.php @@ -26,6 +26,7 @@ class UserFactory extends Factory return [ 'external_id' => null, + 'is_managed_externally' => false, 'uuid' => Uuid::uuid4()->toString(), 'username' => $this->faker->userName() . '_' . Str::random(10), 'email' => Str::random(32) . '@example.com', diff --git a/database/migrations/2025_10_23_073209_add_is_managed_externally_to_users.php b/database/migrations/2025_10_23_073209_add_is_managed_externally_to_users.php new file mode 100644 index 000000000..24f171fb7 --- /dev/null +++ b/database/migrations/2025_10_23_073209_add_is_managed_externally_to_users.php @@ -0,0 +1,28 @@ +boolean('is_managed_externally')->default(false)->after('external_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('is_managed_externally'); + }); + } +}; diff --git a/lang/en/admin/user.php b/lang/en/admin/user.php index 5059f238f..62081d28d 100644 --- a/lang/en/admin/user.php +++ b/lang/en/admin/user.php @@ -10,6 +10,8 @@ return [ 'username' => 'Username', 'password' => 'Password', 'external_id' => 'External ID', + 'is_managed_externally' => 'Is managed externally?', + 'is_managed_externally_helper' => 'If your users are managed by external software (e.g. a billing software) you may enable this to prevent users from changing their username, e-mail and password from within the panel.', 'password_help' => 'Providing a user password is optional. New user email will prompt users to create a password the first time they login.', 'admin_roles' => 'Admin Roles', 'roles' => 'Roles', diff --git a/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php b/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php index 11df853d7..33d14ce9b 100644 --- a/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php +++ b/tests/Integration/Api/Application/Users/ExternalUserControllerTest.php @@ -23,7 +23,7 @@ class ExternalUserControllerTest extends ApplicationApiIntegrationTestCase $response->assertJsonStructure([ 'object', 'attributes' => [ - 'id', 'external_id', 'uuid', 'username', 'email', + 'id', 'external_id', 'is_managed_externally', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa', 'created_at', 'updated_at', ], ]); @@ -33,6 +33,7 @@ class ExternalUserControllerTest extends ApplicationApiIntegrationTestCase 'attributes' => [ 'id' => $user->id, 'external_id' => $user->external_id, + 'is_managed_externally' => $user->is_managed_externally, 'uuid' => $user->uuid, 'username' => $user->username, 'email' => $user->email, diff --git a/tests/Integration/Api/Application/Users/UserControllerTest.php b/tests/Integration/Api/Application/Users/UserControllerTest.php index d096645c4..d9eb6a231 100644 --- a/tests/Integration/Api/Application/Users/UserControllerTest.php +++ b/tests/Integration/Api/Application/Users/UserControllerTest.php @@ -26,8 +26,8 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase $response->assertJsonStructure([ 'object', 'data' => [ - ['object', 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa_enabled', '2fa', 'created_at', 'updated_at']], - ['object', 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa_enabled', '2fa', 'created_at', 'updated_at']], + ['object', 'attributes' => ['id', 'external_id', 'is_managed_externally', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa_enabled', '2fa', 'created_at', 'updated_at']], + ['object', 'attributes' => ['id', 'external_id', 'is_managed_externally', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa_enabled', '2fa', 'created_at', 'updated_at']], ], 'meta' => ['pagination' => ['total', 'count', 'per_page', 'current_page', 'total_pages']], ]); @@ -51,6 +51,7 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase 'attributes' => [ 'id' => $this->getApiUser()->id, 'external_id' => $this->getApiUser()->external_id, + 'is_managed_externally' => $this->getApiUser()->is_managed_externally, 'uuid' => $this->getApiUser()->uuid, 'username' => $this->getApiUser()->username, 'email' => $this->getApiUser()->email, @@ -67,6 +68,7 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase 'attributes' => [ 'id' => $user->id, 'external_id' => $user->external_id, + 'is_managed_externally' => $user->is_managed_externally, 'uuid' => $user->uuid, 'username' => $user->username, 'email' => $user->email, @@ -92,7 +94,7 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase $response->assertJsonCount(2); $response->assertJsonStructure([ 'object', - 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa', 'created_at', 'updated_at'], + 'attributes' => ['id', 'external_id', 'is_managed_externally', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa', 'created_at', 'updated_at'], ]); $response->assertJson([ @@ -100,6 +102,7 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase 'attributes' => [ 'id' => $user->id, 'external_id' => $user->external_id, + 'is_managed_externally' => $user->is_managed_externally, 'uuid' => $user->uuid, 'username' => $user->username, 'email' => $user->email, @@ -126,7 +129,7 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase $response->assertJsonStructure([ 'object', 'attributes' => [ - 'id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa', 'created_at', 'updated_at', + 'id', 'external_id', 'is_managed_externally', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa', 'created_at', 'updated_at', 'relationships' => ['servers' => ['object', 'data' => [['object', 'attributes' => []]]]], ], ]); @@ -213,7 +216,7 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase $response->assertJsonCount(3); $response->assertJsonStructure([ 'object', - 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa', 'created_at', 'updated_at'], + 'attributes' => ['id', 'external_id', 'is_managed_externally', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa', 'created_at', 'updated_at'], 'meta' => ['resource'], ]); @@ -244,7 +247,7 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase $response->assertJsonCount(2); $response->assertJsonStructure([ 'object', - 'attributes' => ['id', 'external_id', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa', 'created_at', 'updated_at'], + 'attributes' => ['id', 'external_id', 'is_managed_externally', 'uuid', 'username', 'email', 'language', 'root_admin', '2fa', 'created_at', 'updated_at'], ]); $this->assertDatabaseHas('users', ['username' => 'new.test.name', 'email' => 'new@emailtest.com']);