diff --git a/app/Extensions/OAuth/OAuthService.php b/app/Extensions/OAuth/OAuthService.php index cedd4dc1c..8ac26d99d 100644 --- a/app/Extensions/OAuth/OAuthService.php +++ b/app/Extensions/OAuth/OAuthService.php @@ -2,7 +2,9 @@ namespace App\Extensions\OAuth; +use App\Models\User; use Illuminate\Support\Facades\Event; +use Laravel\Socialite\Contracts\User as OAuthUser; use SocialiteProviders\Manager\SocialiteWasCalled; class OAuthService @@ -43,4 +45,27 @@ class OAuthService $this->schemas[$schema->getId()] = $schema; } + + public function linkUser(User $user, OAuthSchemaInterface $schema, OAuthUser $oauthUser): User + { + $oauth = $user->oauth ?? []; + $oauth[$schema->getId()] = $oauthUser->getId(); + + $user->update(['oauth' => $oauth]); + + return $user->refresh(); + } + + public function unlinkUser(User $user, OAuthSchemaInterface $schema): User + { + $oauth = $user->oauth ?? []; + if (!isset($oauth[$schema->getId()])) { + return $user; + } + + unset($oauth[$schema->getId()]); + $user->update(['oauth' => $oauth]); + + return $user->refresh(); + } } diff --git a/app/Filament/Admin/Resources/Users/Pages/EditUser.php b/app/Filament/Admin/Resources/Users/Pages/EditUser.php index 4941c56ad..be1beffe4 100644 --- a/app/Filament/Admin/Resources/Users/Pages/EditUser.php +++ b/app/Filament/Admin/Resources/Users/Pages/EditUser.php @@ -48,8 +48,7 @@ class EditUser extends EditRecord if (!$record instanceof User) { return $record; } - - unset($data['roles']); + unset($data['roles'], $data['avatar']); return $this->service->handle($record, $data); } diff --git a/app/Filament/Admin/Resources/Users/UserResource.php b/app/Filament/Admin/Resources/Users/UserResource.php index 7fe225fc1..38824b53a 100644 --- a/app/Filament/Admin/Resources/Users/UserResource.php +++ b/app/Filament/Admin/Resources/Users/UserResource.php @@ -3,33 +3,56 @@ namespace App\Filament\Admin\Resources\Users; use App\Enums\CustomizationKey; +use App\Extensions\OAuth\OAuthService; +use App\Facades\Activity; use App\Filament\Admin\Resources\Users\Pages\CreateUser; use App\Filament\Admin\Resources\Users\Pages\EditUser; use App\Filament\Admin\Resources\Users\Pages\ListUsers; use App\Filament\Admin\Resources\Users\Pages\ViewUser; use App\Filament\Admin\Resources\Users\RelationManagers\ServersRelationManager; +use App\Models\ActivityLog; +use App\Models\ApiKey; use App\Models\Role; use App\Models\User; +use App\Models\UserSSHKey; +use App\Services\Helpers\LanguageService; use App\Traits\Filament\CanCustomizePages; use App\Traits\Filament\CanCustomizeRelations; use App\Traits\Filament\CanModifyForm; use App\Traits\Filament\CanModifyTable; +use DateTimeZone; use Exception; +use Filament\Actions\Action; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; +use Filament\Auth\Notifications\ResetPassword; use Filament\Facades\Filament; use Filament\Forms\Components\CheckboxList; +use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\Repeater; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; +use Filament\Infolists\Components\TextEntry; +use Filament\Notifications\Notification; use Filament\Resources\Pages\PageRegistration; use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\Resource; +use Filament\Schemas\Components\Actions; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs; +use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Schema; +use Filament\Support\Colors\Color; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Auth\Events\PasswordResetLinkSent; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Password; +use Illuminate\Support\HtmlString; +use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; class UserResource extends Resource { @@ -119,44 +142,326 @@ class UserResource extends Resource public static function defaultForm(Schema $schema): Schema { return $schema - ->columns(['default' => 1, 'lg' => 3]) + ->columns(['default' => 1, 'lg' => 3, 'md' => 2]) ->components([ - TextInput::make('username') - ->label(trans('admin/user.username')) - ->required() - ->unique() - ->maxLength(255), - TextInput::make('email') - ->label(trans('admin/user.email')) - ->email() - ->required() - ->unique() - ->maxLength(255), - TextInput::make('password') - ->label(trans('admin/user.password')) - ->hintIcon(fn ($operation) => $operation === 'create' ? 'tabler-question-mark' : null, fn ($operation) => $operation === 'create' ? trans('admin/user.password_help') : null) - ->password(), - CheckboxList::make('roles') - ->hidden(fn (?User $user) => $user && $user->isRootAdmin()) - ->relationship('roles', 'name', fn (Builder $query) => $query->whereNot('id', Role::getRootAdmin()->id)) - ->saveRelationshipsUsing(fn (User $user, array $state) => $user->syncRoles(collect($state)->map(fn ($role) => Role::findById($role)))) - ->dehydrated() - ->label(trans('admin/user.admin_roles')) - ->columnSpanFull() - ->bulkToggleable(false), - CheckboxList::make('root_admin_role') - ->visible(fn (?User $user) => $user && $user->isRootAdmin()) - ->disabled() - ->options([ - 'root_admin' => Role::ROOT_ADMIN, - ]) - ->descriptions([ - 'root_admin' => trans('admin/role.root_admin', ['role' => Role::ROOT_ADMIN]), - ]) - ->formatStateUsing(fn () => ['root_admin']) - ->dehydrated(false) - ->label(trans('admin/user.admin_roles')) - ->columnSpanFull(), + Tabs::make() + ->schema([ + Tab::make('account') + ->label(trans('profile.tabs.account')) + ->icon('tabler-user-cog') + ->columns([ + 'default' => 1, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ + TextInput::make('username') + ->label(trans('admin/user.username')) + ->columnSpan([ + 'default' => 1, + 'md' => 1, + 'lg' => 1, + ]) + ->required() + ->unique() + ->maxLength(255), + TextInput::make('email') + ->label(trans('admin/user.email')) + ->columnSpan([ + 'default' => 1, + 'md' => 1, + 'lg' => 1, + ]) + ->email() + ->required() + ->unique() + ->maxLength(255), + TextInput::make('password') + ->label(trans('admin/user.password')) + ->columnSpan([ + 'default' => 1, + 'md' => 1, + 'lg' => 1, + ]) + ->hintIcon(fn ($operation) => $operation === 'create' ? 'tabler-question-mark' : null, fn ($operation) => $operation === 'create' ? trans('admin/user.password_help') : null) + ->password() + ->hintAction( + Action::make('password_reset') + ->label(trans('admin/user.password_reset')) + ->hidden(fn () => config('mail.default', 'log') === 'log') + ->icon('tabler-send') + ->action(function (User $user) { + $status = Password::broker(Filament::getPanel('app')->getAuthPasswordBroker())->sendResetLink([ + 'email' => $user->email, + ], + function (User $user, string $token) { + $notification = new ResetPassword($token); + $notification->url = Filament::getPanel('app')->getResetPasswordUrl($token, $user); + + $user->notify($notification); + + event(new PasswordResetLinkSent($user)); + }, + ); + + if ($status === Password::RESET_LINK_SENT) { + Notification::make() + ->title(trans('admin/user.password_reset_sent')) + ->success() + ->send(); + } else { + Notification::make() + ->title(trans('admin/user.password_reset_failed')) + ->body($status) + ->danger() + ->send(); + } + })), + TextInput::make('external_id') + ->label(trans('admin/user.external_id')) + ->columnSpan([ + 'default' => 1, + 'md' => 1, + 'lg' => 1, + ]), + Select::make('timezone') + ->label(trans('profile.timezone')) + ->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() + ->native(false), + 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()) + ->native(false), + 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(); + } + + 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); + if (!$schema) { + return; + } + + $id = $schema->getId(); + $name = $schema->getName(); + $actions[] = Action::make("oauth_$id") + ->label(trans('profile.unlink', ['name' => $name])) + ->icon('tabler-unlink') + ->requiresConfirmation() + ->color(Color::hex($schema->getHexColor())) + ->action(function ($livewire) use ($oauthService, $user, $name, $schema) { + $oauthService->unlinkUser($user, $schema); + $livewire->form->fill($user->attributesToArray()); + Notification::make() + ->title(trans('profile.unlinked', ['name' => $name])) + ->success() + ->send(); + }); + } + + if (!$actions) { + return [ + TextEntry::make('no_oauth') + ->state(trans('profile.no_oauth')) + ->hiddenLabel(), + ]; + } + + return [Actions::make($actions)]; + }), + ]), + Tab::make('roles') + ->label(trans('admin/user.roles')) + ->icon('tabler-users-group') + ->components([ + CheckboxList::make('roles') + ->hidden(fn (?User $user) => $user && $user->isRootAdmin()) + ->relationship('roles', 'name', fn (Builder $query) => $query->whereNot('id', Role::getRootAdmin()->id)) + ->saveRelationshipsUsing(fn (User $user, array $state) => $user->syncRoles(collect($state)->map(fn ($role) => Role::findById($role)))) + ->dehydrated() + ->label(trans('admin/user.admin_roles')) + ->columnSpanFull() + ->bulkToggleable(false), + CheckboxList::make('root_admin_role') + ->visible(fn (?User $user) => $user && $user->isRootAdmin()) + ->disabled() + ->options([ + 'root_admin' => Role::ROOT_ADMIN, + ]) + ->descriptions([ + 'root_admin' => trans('admin/role.root_admin', ['role' => Role::ROOT_ADMIN]), + ]) + ->formatStateUsing(fn () => ['root_admin']) + ->dehydrated(false) + ->label(trans('admin/user.admin_roles')) + ->columnSpanFull(), + ]), + Tab::make('keys') + ->visible(fn (?User $user) => $user) + ->label(trans('profile.tabs.keys')) + ->icon('tabler-key') + ->schema([ + Section::make(trans('profile.api_keys')) + ->columnSpan(2) + ->schema([ + Repeater::make('api_keys') + ->hiddenLabel() + ->inlineLabel(false) + ->relationship('apiKeys') + ->addable(false) + ->itemLabel(fn ($state) => $state['identifier']) + ->deleteAction(function (Action $action) { + $action->requiresConfirmation()->action(function (array $arguments, Repeater $component, ?User $user) { + $items = $component->getState(); + $key = $items[$arguments['item']] ?? null; + + if ($key) { + $apiKey = ApiKey::find($key['id']); + if ($apiKey?->exists()) { + $apiKey->delete(); + + Activity::event('user:api-key.delete') + ->actor(user()) + ->subject($user) + ->subject($apiKey) + ->property('identifier', $apiKey->identifier) + ->log(); + } + + unset($items[$arguments['item']]); + $component->state($items); + $component->callAfterStateUpdated(); + } + }); + }) + ->schema([ + TextEntry::make('memo') + ->hiddenLabel() + ->state(fn (ApiKey $key) => $key->memo), + ]) + ->visible(fn (User $user) => $user->apiKeys()->exists()), + + TextEntry::make('no_api_keys') + ->state(trans('profile.no_api_keys')) + ->hiddenLabel() + ->visible(fn (User $user) => !$user->apiKeys()->exists()), + ]), + Section::make(trans('profile.ssh_keys'))->columnSpan(2) + ->schema([ + Repeater::make('ssh_keys') + ->hiddenLabel() + ->inlineLabel(false) + ->relationship('sshKeys') + ->addable(false) + ->itemLabel(fn ($state) => $state['name']) + ->deleteAction(function (Action $action) { + $action->requiresConfirmation()->action(function (array $arguments, Repeater $component, User $user) { + $items = $component->getState(); + $key = $items[$arguments['item']]; + + $sshKey = UserSSHKey::find($key['id'] ?? null); + if ($sshKey->exists()) { + $sshKey->delete(); + + Activity::event('user:ssh-key.delete') + ->actor(auth()->user()) + ->subject($user) + ->subject($sshKey) + ->property('fingerprint', $sshKey->fingerprint) + ->log(); + } + + unset($items[$arguments['item']]); + + $component->state($items); + + $component->callAfterStateUpdated(); + }); + }) + ->schema(fn () => [ + TextEntry::make('fingerprint') + ->hiddenLabel() + ->state(fn (UserSSHKey $key) => "SHA256:{$key->fingerprint}"), + ]) + ->visible(fn (User $user) => $user->sshKeys()->exists()), + + TextEntry::make('no_ssh_keys') + ->state(trans('profile.no_ssh_keys')) + ->hiddenLabel() + ->visible(fn (User $user) => !$user->sshKeys()->exists()), + ]), + ]), + Tab::make('activity') + ->visible(fn (?User $user) => $user) + ->disabledOn('create') + ->label(trans('profile.tabs.activity')) + ->icon('tabler-history') + ->schema([ + Repeater::make('activity') + ->hiddenLabel() + ->inlineLabel(false) + ->deletable(false) + ->addable(false) + ->relationship(null, function (Builder $query) { + $query->orderBy('timestamp', 'desc'); + }) + ->schema([ + TextEntry::make('log') + ->hiddenLabel() + ->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())), + ]), + ]), + ])->columnSpanFull(), ]); } diff --git a/app/Filament/Pages/Auth/EditProfile.php b/app/Filament/Pages/Auth/EditProfile.php index 7b6a81aea..1e6f56893 100644 --- a/app/Filament/Pages/Auth/EditProfile.php +++ b/app/Filament/Pages/Auth/EditProfile.php @@ -48,6 +48,7 @@ use Illuminate\Support\Facades\Storage; use Illuminate\Support\HtmlString; use Illuminate\Validation\Rules\Password; use Laravel\Socialite\Facades\Socialite; +use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; /** * @method User getUser() @@ -128,7 +129,7 @@ class EditProfile extends BaseEditProfile ->label(trans('profile.timezone')) ->required() ->prefixIcon('tabler-clock-pin') - ->default('UTC') + ->default(config('app.timezone', 'UTC')) ->selectablePlaceholder(false) ->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz])) ->searchable() @@ -151,14 +152,20 @@ class EditProfile extends BaseEditProfile ->directory('avatars') ->disk('public') ->getUploadedFileNameForStorageUsing(fn () => $this->getUser()->id . '.png') - ->hintAction(function (FileUpload $fileUpload) { + ->formatStateUsing(function (FileUpload $fileUpload) { $path = $fileUpload->getDirectory() . '/' . $this->getUser()->id . '.png'; + if ($fileUpload->getDisk()->exists($path)) { + return $path; + } + }) + ->deleteUploadedFileUsing(function (FileUpload $fileUpload, $file) { + if ($file instanceof TemporaryUploadedFile) { + return $file->delete(); + } - return Action::make('remove_avatar') - ->icon('tabler-photo-minus') - ->iconButton() - ->hidden(fn () => !$fileUpload->getDisk()->exists($path)) - ->action(fn () => $fileUpload->getDisk()->delete($path)); + if ($fileUpload->getDisk()->exists($file)) { + return $fileUpload->getDisk()->delete($file); + } }), ]), Tab::make('oauth') @@ -292,7 +299,13 @@ class EditProfile extends BaseEditProfile TextEntry::make('memo') ->hiddenLabel() ->state(fn (ApiKey $key) => $key->memo), - ]), + ]) + ->visible(fn (User $user) => $user->apiKeys()->exists()), + + TextEntry::make('no_api_keys') + ->state(trans('profile.no_api_keys')) + ->hiddenLabel() + ->visible(fn (User $user) => !$user->apiKeys()->exists()), ]), ]), ]), @@ -381,7 +394,13 @@ class EditProfile extends BaseEditProfile TextEntry::make('fingerprint') ->hiddenLabel() ->state(fn (UserSSHKey $key) => "SHA256:{$key->fingerprint}"), - ]), + ]) + ->visible(fn (User $user) => $user->sshKeys()->exists()), + + TextEntry::make('no_ssh_keys') + ->state(trans('profile.no_ssh_keys')) + ->hiddenLabel() + ->visible(fn (User $user) => !$user->sshKeys()->exists()), ]), ]), ]), diff --git a/app/Http/Controllers/Auth/OAuthController.php b/app/Http/Controllers/Auth/OAuthController.php index aefc5ba1a..e75798cc1 100644 --- a/app/Http/Controllers/Auth/OAuthController.php +++ b/app/Http/Controllers/Auth/OAuthController.php @@ -56,7 +56,7 @@ class OAuthController extends Controller $oauthUser = Socialite::driver($driver->getId())->user(); if ($request->user()) { - $this->linkUser($request->user(), $driver, $oauthUser); + $this->oauthService->linkUser($request->user(), $driver, $oauthUser); return redirect(EditProfile::getUrl(['tab' => 'oauth::data::tab'], panel: 'app')); } @@ -69,16 +69,6 @@ class OAuthController extends Controller return $this->handleMissingUser($driver, $oauthUser); } - private function linkUser(User $user, OAuthSchemaInterface $driver, OAuthUser $oauthUser): User - { - $oauth = $user->oauth; - $oauth[$driver->getId()] = $oauthUser->getId(); - - $user->update(['oauth' => $oauth]); - - return $user->refresh(); - } - private function handleMissingUser(OAuthSchemaInterface $driver, OAuthUser $oauthUser): RedirectResponse { $email = $oauthUser->getEmail(); @@ -93,7 +83,7 @@ class OAuthController extends Controller return $this->errorRedirect(); } - $user = $this->linkUser($user, $driver, $oauthUser); + $user = $this->oauthService->linkUser($user, $driver, $oauthUser); } else { if (!$driver->shouldCreateMissingUsers()) { return $this->errorRedirect(); diff --git a/app/Models/ActivityLog.php b/app/Models/ActivityLog.php index 3cf5e4b8f..d0fe61169 100644 --- a/app/Models/ActivityLog.php +++ b/app/Models/ActivityLog.php @@ -163,6 +163,11 @@ class ActivityLog extends Model implements HasIcon, HasLabel return trans_choice('activity.'.str($this->event)->replace(':', '.'), array_key_exists('count', $properties) ? $properties['count'] : 1, $properties); } + public function getIp(): ?string + { + return auth()->user()->can('seeIps activityLog') ? $this->ip : null; + } + public function htmlable(): string { $user = $this->actor; @@ -175,6 +180,8 @@ class ActivityLog extends Model implements HasIcon, HasLabel $avatarUrl = Filament::getUserAvatarUrl($user); $username = str($user->username)->stripTags(); + $ip = $this->getIp(); + $ip = $ip ? $ip . ' — ' : ''; return "
@@ -183,7 +190,7 @@ class ActivityLog extends Model implements HasIcon, HasLabel

$username — $this->event

{$this->getLabel()}

-

$this->ip — {$this->timestamp->diffForHumans()}

+

$ip{$this->timestamp->diffForHumans()}

"; diff --git a/lang/en/admin/user.php b/lang/en/admin/user.php index ebee18099..5059f238f 100644 --- a/lang/en/admin/user.php +++ b/lang/en/admin/user.php @@ -9,10 +9,14 @@ return [ 'email' => 'Email', 'username' => 'Username', 'password' => 'Password', + 'external_id' => 'External ID', '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', 'no_roles' => 'No Roles', 'servers' => 'Servers', 'subusers' => 'Subusers', + 'password_reset' => 'Reset Password', + 'password_reset_sent' => 'Password Reset E-Mail Sent', + 'password_reset_failed' => 'Failed to Send Password Reset E-Mail', ]; diff --git a/lang/en/profile.php b/lang/en/profile.php index 208e2845e..04d8bb7aa 100644 --- a/lang/en/profile.php +++ b/lang/en/profile.php @@ -8,6 +8,7 @@ return [ 'activity' => 'Activity', 'api_keys' => 'API Keys', 'ssh_keys' => 'SSH Keys', + 'keys' => 'Keys', '2fa' => '2FA', 'customization' => 'Customization', ], @@ -62,4 +63,7 @@ return [ 'navigation' => 'Navigation Type', 'top' => 'Topbar', 'side' => 'Sidebar', + 'no_oauth' => 'No Accounts Linked', + 'no_api_keys' => 'No API Keys', + 'no_ssh_keys' => 'No SSH Keys', ];