General Edit User Improvements (#1779)

Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
Co-authored-by: Boy132 <mail@boy132.de>
This commit is contained in:
Charles 2025-10-08 05:04:52 -04:00 committed by GitHub
parent f6710dbbe4
commit dbe4bdd62d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 414 additions and 61 deletions

View File

@ -2,7 +2,9 @@
namespace App\Extensions\OAuth; namespace App\Extensions\OAuth;
use App\Models\User;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Laravel\Socialite\Contracts\User as OAuthUser;
use SocialiteProviders\Manager\SocialiteWasCalled; use SocialiteProviders\Manager\SocialiteWasCalled;
class OAuthService class OAuthService
@ -43,4 +45,27 @@ class OAuthService
$this->schemas[$schema->getId()] = $schema; $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();
}
} }

View File

@ -48,8 +48,7 @@ class EditUser extends EditRecord
if (!$record instanceof User) { if (!$record instanceof User) {
return $record; return $record;
} }
unset($data['roles'], $data['avatar']);
unset($data['roles']);
return $this->service->handle($record, $data); return $this->service->handle($record, $data);
} }

View File

@ -3,33 +3,56 @@
namespace App\Filament\Admin\Resources\Users; namespace App\Filament\Admin\Resources\Users;
use App\Enums\CustomizationKey; 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\CreateUser;
use App\Filament\Admin\Resources\Users\Pages\EditUser; use App\Filament\Admin\Resources\Users\Pages\EditUser;
use App\Filament\Admin\Resources\Users\Pages\ListUsers; use App\Filament\Admin\Resources\Users\Pages\ListUsers;
use App\Filament\Admin\Resources\Users\Pages\ViewUser; use App\Filament\Admin\Resources\Users\Pages\ViewUser;
use App\Filament\Admin\Resources\Users\RelationManagers\ServersRelationManager; use App\Filament\Admin\Resources\Users\RelationManagers\ServersRelationManager;
use App\Models\ActivityLog;
use App\Models\ApiKey;
use App\Models\Role; use App\Models\Role;
use App\Models\User; use App\Models\User;
use App\Models\UserSSHKey;
use App\Services\Helpers\LanguageService;
use App\Traits\Filament\CanCustomizePages; use App\Traits\Filament\CanCustomizePages;
use App\Traits\Filament\CanCustomizeRelations; use App\Traits\Filament\CanCustomizeRelations;
use App\Traits\Filament\CanModifyForm; use App\Traits\Filament\CanModifyForm;
use App\Traits\Filament\CanModifyTable; use App\Traits\Filament\CanModifyTable;
use DateTimeZone;
use Exception; use Exception;
use Filament\Actions\Action;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Actions\ViewAction; use Filament\Actions\ViewAction;
use Filament\Auth\Notifications\ResetPassword;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList; 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\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\PageRegistration; use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Resources\Resource; 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\Schemas\Schema;
use Filament\Support\Colors\Color;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Auth\Events\PasswordResetLinkSent;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\HtmlString;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class UserResource extends Resource class UserResource extends Resource
{ {
@ -119,44 +142,326 @@ class UserResource extends Resource
public static function defaultForm(Schema $schema): Schema public static function defaultForm(Schema $schema): Schema
{ {
return $schema return $schema
->columns(['default' => 1, 'lg' => 3]) ->columns(['default' => 1, 'lg' => 3, 'md' => 2])
->components([ ->components([
TextInput::make('username') Tabs::make()
->label(trans('admin/user.username')) ->schema([
->required() Tab::make('account')
->unique() ->label(trans('profile.tabs.account'))
->maxLength(255), ->icon('tabler-user-cog')
TextInput::make('email') ->columns([
->label(trans('admin/user.email')) 'default' => 1,
->email() 'md' => 3,
->required() 'lg' => 3,
->unique() ])
->maxLength(255), ->schema([
TextInput::make('password') TextInput::make('username')
->label(trans('admin/user.password')) ->label(trans('admin/user.username'))
->hintIcon(fn ($operation) => $operation === 'create' ? 'tabler-question-mark' : null, fn ($operation) => $operation === 'create' ? trans('admin/user.password_help') : null) ->columnSpan([
->password(), 'default' => 1,
CheckboxList::make('roles') 'md' => 1,
->hidden(fn (?User $user) => $user && $user->isRootAdmin()) 'lg' => 1,
->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)))) ->required()
->dehydrated() ->unique()
->label(trans('admin/user.admin_roles')) ->maxLength(255),
->columnSpanFull() TextInput::make('email')
->bulkToggleable(false), ->label(trans('admin/user.email'))
CheckboxList::make('root_admin_role') ->columnSpan([
->visible(fn (?User $user) => $user && $user->isRootAdmin()) 'default' => 1,
->disabled() 'md' => 1,
->options([ 'lg' => 1,
'root_admin' => Role::ROOT_ADMIN, ])
]) ->email()
->descriptions([ ->required()
'root_admin' => trans('admin/role.root_admin', ['role' => Role::ROOT_ADMIN]), ->unique()
]) ->maxLength(255),
->formatStateUsing(fn () => ['root_admin']) TextInput::make('password')
->dehydrated(false) ->label(trans('admin/user.password'))
->label(trans('admin/user.admin_roles')) ->columnSpan([
->columnSpanFull(), '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(),
]); ]);
} }

View File

@ -48,6 +48,7 @@ use Illuminate\Support\Facades\Storage;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
/** /**
* @method User getUser() * @method User getUser()
@ -128,7 +129,7 @@ class EditProfile extends BaseEditProfile
->label(trans('profile.timezone')) ->label(trans('profile.timezone'))
->required() ->required()
->prefixIcon('tabler-clock-pin') ->prefixIcon('tabler-clock-pin')
->default('UTC') ->default(config('app.timezone', 'UTC'))
->selectablePlaceholder(false) ->selectablePlaceholder(false)
->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz])) ->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz]))
->searchable() ->searchable()
@ -151,14 +152,20 @@ class EditProfile extends BaseEditProfile
->directory('avatars') ->directory('avatars')
->disk('public') ->disk('public')
->getUploadedFileNameForStorageUsing(fn () => $this->getUser()->id . '.png') ->getUploadedFileNameForStorageUsing(fn () => $this->getUser()->id . '.png')
->hintAction(function (FileUpload $fileUpload) { ->formatStateUsing(function (FileUpload $fileUpload) {
$path = $fileUpload->getDirectory() . '/' . $this->getUser()->id . '.png'; $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') if ($fileUpload->getDisk()->exists($file)) {
->icon('tabler-photo-minus') return $fileUpload->getDisk()->delete($file);
->iconButton() }
->hidden(fn () => !$fileUpload->getDisk()->exists($path))
->action(fn () => $fileUpload->getDisk()->delete($path));
}), }),
]), ]),
Tab::make('oauth') Tab::make('oauth')
@ -292,7 +299,13 @@ class EditProfile extends BaseEditProfile
TextEntry::make('memo') TextEntry::make('memo')
->hiddenLabel() ->hiddenLabel()
->state(fn (ApiKey $key) => $key->memo), ->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') TextEntry::make('fingerprint')
->hiddenLabel() ->hiddenLabel()
->state(fn (UserSSHKey $key) => "SHA256:{$key->fingerprint}"), ->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()),
]), ]),
]), ]),
]), ]),

View File

@ -56,7 +56,7 @@ class OAuthController extends Controller
$oauthUser = Socialite::driver($driver->getId())->user(); $oauthUser = Socialite::driver($driver->getId())->user();
if ($request->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')); return redirect(EditProfile::getUrl(['tab' => 'oauth::data::tab'], panel: 'app'));
} }
@ -69,16 +69,6 @@ class OAuthController extends Controller
return $this->handleMissingUser($driver, $oauthUser); 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 private function handleMissingUser(OAuthSchemaInterface $driver, OAuthUser $oauthUser): RedirectResponse
{ {
$email = $oauthUser->getEmail(); $email = $oauthUser->getEmail();
@ -93,7 +83,7 @@ class OAuthController extends Controller
return $this->errorRedirect(); return $this->errorRedirect();
} }
$user = $this->linkUser($user, $driver, $oauthUser); $user = $this->oauthService->linkUser($user, $driver, $oauthUser);
} else { } else {
if (!$driver->shouldCreateMissingUsers()) { if (!$driver->shouldCreateMissingUsers()) {
return $this->errorRedirect(); return $this->errorRedirect();

View File

@ -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); 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 public function htmlable(): string
{ {
$user = $this->actor; $user = $this->actor;
@ -175,6 +180,8 @@ class ActivityLog extends Model implements HasIcon, HasLabel
$avatarUrl = Filament::getUserAvatarUrl($user); $avatarUrl = Filament::getUserAvatarUrl($user);
$username = str($user->username)->stripTags(); $username = str($user->username)->stripTags();
$ip = $this->getIp();
$ip = $ip ? $ip . ' — ' : '';
return " return "
<div style='display: flex; align-items: center;'> <div style='display: flex; align-items: center;'>
@ -183,7 +190,7 @@ class ActivityLog extends Model implements HasIcon, HasLabel
<div> <div>
<p>$username $this->event</p> <p>$username $this->event</p>
<p>{$this->getLabel()}</p> <p>{$this->getLabel()}</p>
<p>$this->ip <span title='{$this->timestamp->format('M j, Y g:ia')}'>{$this->timestamp->diffForHumans()}</span></p> <p>$ip<span title='{$this->timestamp->format('M j, Y g:ia')}'>{$this->timestamp->diffForHumans()}</span></p>
</div> </div>
</div> </div>
"; ";

View File

@ -9,10 +9,14 @@ return [
'email' => 'Email', 'email' => 'Email',
'username' => 'Username', 'username' => 'Username',
'password' => 'Password', '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.', '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', 'admin_roles' => 'Admin Roles',
'roles' => 'Roles', 'roles' => 'Roles',
'no_roles' => 'No Roles', 'no_roles' => 'No Roles',
'servers' => 'Servers', 'servers' => 'Servers',
'subusers' => 'Subusers', 'subusers' => 'Subusers',
'password_reset' => 'Reset Password',
'password_reset_sent' => 'Password Reset E-Mail Sent',
'password_reset_failed' => 'Failed to Send Password Reset E-Mail',
]; ];

View File

@ -8,6 +8,7 @@ return [
'activity' => 'Activity', 'activity' => 'Activity',
'api_keys' => 'API Keys', 'api_keys' => 'API Keys',
'ssh_keys' => 'SSH Keys', 'ssh_keys' => 'SSH Keys',
'keys' => 'Keys',
'2fa' => '2FA', '2fa' => '2FA',
'customization' => 'Customization', 'customization' => 'Customization',
], ],
@ -62,4 +63,7 @@ return [
'navigation' => 'Navigation Type', 'navigation' => 'Navigation Type',
'top' => 'Topbar', 'top' => 'Topbar',
'side' => 'Sidebar', 'side' => 'Sidebar',
'no_oauth' => 'No Accounts Linked',
'no_api_keys' => 'No API Keys',
'no_ssh_keys' => 'No SSH Keys',
]; ];