diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index bf201e257..d6bd6e54b 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -93,11 +93,11 @@ class ApiTokens extends Component return; } - if ($permissionToUpdate == 'root') { + if ($permissionToUpdate == 'root' && in_array('root', $this->permissions, true)) { $this->permissions = ['root']; } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) { $this->permissions[] = 'read'; - } elseif ($permissionToUpdate == 'deploy') { + } elseif ($permissionToUpdate == 'deploy' && in_array('deploy', $this->permissions, true)) { $this->permissions = ['deploy']; } else { if (count($this->permissions) == 0) { diff --git a/app/Livewire/Security/CloudInitScript/Show.php b/app/Livewire/Security/CloudInitScript/Show.php index 6e2a3937d..120a2463d 100644 --- a/app/Livewire/Security/CloudInitScript/Show.php +++ b/app/Livewire/Security/CloudInitScript/Show.php @@ -14,6 +14,8 @@ class Show extends Component public CloudInitScript $cloudInitScript; + public bool $modalMode = false; + public string $name = ''; public string $script = ''; @@ -35,8 +37,9 @@ class Show extends Component ]; } - public function mount(string $cloud_init_script_uuid): void + public function mount(string $cloud_init_script_uuid, bool $modalMode = false): void { + $this->modalMode = $modalMode; try { $this->cloudInitScript = CloudInitScript::ownedByCurrentTeam() ->whereUuid($cloud_init_script_uuid) @@ -70,6 +73,7 @@ class Show extends Component ]); $this->dispatch('success', 'Cloud-init script updated successfully.'); + $this->dispatch('securityResourceChanged'); } public function delete(): mixed @@ -87,6 +91,13 @@ class Show extends Component 'cloud_init_script_name' => $scriptName, ]); + if ($this->modalMode) { + $this->dispatch('securityResourceChanged'); + $this->dispatch('close-modal'); + + return null; + } + return redirectRoute($this, 'security.cloud-init-scripts'); } diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php index e66a749cf..b6d448e90 100644 --- a/app/Livewire/Security/CloudInitScripts.php +++ b/app/Livewire/Security/CloudInitScripts.php @@ -22,6 +22,7 @@ class CloudInitScripts extends Component { return [ 'scriptSaved' => 'loadScripts', + 'securityResourceChanged' => 'loadScripts', ]; } diff --git a/app/Livewire/Security/CloudProviderToken/Show.php b/app/Livewire/Security/CloudProviderToken/Show.php index aa9270be0..e9f2fbb70 100644 --- a/app/Livewire/Security/CloudProviderToken/Show.php +++ b/app/Livewire/Security/CloudProviderToken/Show.php @@ -14,6 +14,8 @@ class Show extends Component public CloudProviderToken $cloudProviderToken; + public bool $modalMode = false; + public string $name = ''; public ?string $description = null; @@ -33,8 +35,9 @@ class Show extends Component ]; } - public function mount(string $cloud_token_uuid): void + public function mount(string $cloud_token_uuid, bool $modalMode = false): void { + $this->modalMode = $modalMode; try { $this->cloudProviderToken = CloudProviderToken::ownedByCurrentTeam() ->whereUuid($cloud_token_uuid) @@ -71,6 +74,7 @@ class Show extends Component ]); $this->dispatch('success', 'Cloud provider token updated.'); + $this->dispatch('securityResourceChanged'); } public function validateToken(): void @@ -122,6 +126,13 @@ class Show extends Component $this->cloudProviderToken->delete(); + if ($this->modalMode) { + $this->dispatch('securityResourceChanged'); + $this->dispatch('close-modal'); + + return null; + } + return redirectRoute($this, 'security.cloud-tokens'); } diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index c2466c622..ba2655b43 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -18,18 +18,23 @@ class CloudProviderTokenForm extends Component public string $provider = 'hetzner'; + public bool $provider_locked = false; + public string $token = ''; public string $name = ''; public ?string $description = null; - public function mount() + public function mount(?string $provider = null): void { + $this->provider_locked = filled($provider); + $this->provider = $provider ?? 'hetzner'; + try { $this->authorize('create', CloudProviderToken::class); } catch (\Throwable $e) { - return handleError($e, $this); + handleError($e, $this); } } diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index e94aa087b..12cc95c74 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -27,6 +27,7 @@ class CloudProviderTokens extends Component { return [ 'tokenAdded' => 'loadTokens', + 'securityResourceChanged' => 'loadTokens', ]; } diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 540ef5fa1..8b170e6ae 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -10,6 +10,13 @@ class Index extends Component { use AuthorizesRequests; + public function getListeners(): array + { + return [ + 'securityResourceChanged' => '$refresh', + ]; + } + public function generatePrivateKey(string $type) { try { @@ -29,7 +36,7 @@ class Index extends Component 'team_id' => currentTeam()->id, ]); - return redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]); + $this->dispatch('success', 'Private key generated successfully.'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index 826289b88..181457047 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -14,6 +14,8 @@ class Show extends Component public PrivateKey $private_key; + public bool $modalMode = false; + // Explicit properties public string $name; @@ -79,8 +81,9 @@ class Show extends Component } } - public function mount(?string $private_key_uuid = null) + public function mount(?string $private_key_uuid = null, bool $modalMode = false) { + $this->modalMode = $modalMode; try { $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid($private_key_uuid ?? request()->private_key_uuid)->firstOrFail(); @@ -119,6 +122,13 @@ class Show extends Component $this->private_key->delete(); currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); + if ($this->modalMode) { + $this->dispatch('securityResourceChanged'); + $this->dispatch('close-modal'); + + return null; + } + return redirectRoute($this, 'security.private-key.index'); } catch (\Exception $e) { $this->dispatch('error', $e->getMessage()); @@ -140,6 +150,7 @@ class Show extends Component ]); refresh_server_connection($this->private_key); $this->dispatch('success', 'Private key updated.'); + $this->dispatch('securityResourceChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index 44ea4f611..408271819 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -66,7 +66,7 @@ class SettingsOauth extends Component 'base_url' => $oauthData['base_url'], ]); - if (! $oauth->couldBeEnabled()) { + if ($oauthData['enabled'] && ! $oauth->couldBeEnabled()) { $oauth->update(['enabled' => false]); throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); } @@ -141,6 +141,48 @@ class SettingsOauth extends Component } } + public function toggleProvider(string $provider): mixed + { + try { + $this->authorize('update', instanceSettings()); + + if (! array_key_exists($provider, $this->oauth_settings_map)) { + throw new \Exception('OAuth provider not found.'); + } + + $enabling = ! $this->oauth_settings_map[$provider]['enabled']; + if ($enabling) { + $this->validate($this->providerRules($provider)); + } + + $this->oauth_settings_map[$provider]['enabled'] = $enabling; + $this->updateOauthSettings($provider); + } catch (\Throwable $e) { + return handleError($e, $this); + } + + return null; + } + + private function providerRules(string $provider): array + { + $prefix = "oauth_settings_map.$provider"; + $rules = [ + "$prefix.client_id" => 'required', + "$prefix.client_secret" => 'required', + ]; + + if ($provider === 'azure') { + $rules["$prefix.tenant"] = 'required'; + } + + if (in_array($provider, ['authentik', 'clerk'], true)) { + $rules["$prefix.base_url"] = 'required'; + } + + return $rules; + } + public function submit() { try { diff --git a/app/Livewire/Team/DangerZone.php b/app/Livewire/Team/DangerZone.php new file mode 100644 index 000000000..a3f73a44f --- /dev/null +++ b/app/Livewire/Team/DangerZone.php @@ -0,0 +1,56 @@ +team = currentTeam(); + } + + public function delete(): mixed + { + try { + $currentTeam = currentTeam(); + $this->authorize('delete', $currentTeam); + $currentTeam->members->each(function ($user) use ($currentTeam): void { + if ($user->id === Auth::id()) { + return; + } + + $user->teams()->detach($currentTeam); + $session = DB::table('sessions')->where('user_id', $user->id)->first(); + if ($session) { + DB::table('sessions')->where('id', $session->id)->delete(); + } + }); + + Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id); + $currentTeam->delete(); + + $newTeam = Auth::user()->teams()->first(); + refreshSession($newTeam); + + return redirect()->route('team.index'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render(): mixed + { + return view('livewire.team.danger-zone'); + } +} diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php index 9f377e665..abec26dc3 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -6,9 +6,6 @@ use App\Models\Team; use App\Models\TeamInvitation; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; use Livewire\Component; class Index extends Component @@ -100,34 +97,4 @@ class Index extends Component return handleError($e, $this); } } - - public function delete() - { - try { - $currentTeam = currentTeam(); - $this->authorize('delete', $currentTeam); - $currentTeam->members->each(function ($user) use ($currentTeam) { - if ($user->id === Auth::id()) { - return; - } - $user->teams()->detach($currentTeam); - $session = DB::table('sessions')->where('user_id', $user->id)->first(); - if ($session) { - DB::table('sessions')->where('id', $session->id)->delete(); - } - }); - - // Clear stale cache before deleting so refreshSession doesn't resolve the deleted team - Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id); - $currentTeam->delete(); - - // Switch to the user's next available team - $newTeam = Auth::user()->teams()->first(); - refreshSession($newTeam); - - return redirect()->route('team.index'); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } } diff --git a/public/svgs/discord.svg b/public/svgs/discord.svg new file mode 100644 index 000000000..9d7796b8a --- /dev/null +++ b/public/svgs/discord.svg @@ -0,0 +1 @@ +Discord \ No newline at end of file diff --git a/public/svgs/pushover.svg b/public/svgs/pushover.svg new file mode 100644 index 000000000..a1195fec3 --- /dev/null +++ b/public/svgs/pushover.svg @@ -0,0 +1 @@ + diff --git a/public/svgs/slack.svg b/public/svgs/slack.svg new file mode 100644 index 000000000..004e26630 --- /dev/null +++ b/public/svgs/slack.svg @@ -0,0 +1 @@ +Slack \ No newline at end of file diff --git a/public/svgs/telegram.svg b/public/svgs/telegram.svg new file mode 100644 index 000000000..c46b0ed70 --- /dev/null +++ b/public/svgs/telegram.svg @@ -0,0 +1 @@ +Telegram \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index eab835f30..b7b0943ab 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1030,8 +1030,9 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too padding: 1rem; } -/* All settings sidebars share one alignment rule. Their natural position is - level with the content column; once scrolled they stay below the top bar. */ +/* All settings sidebars share one alignment rule. Keep the sticky offset equal + to the desktop main-content top padding so the menu does not jump upward + when it changes from its natural position to sticky positioning. */ .server-settings-workspace > :not(.application-settings-navigation) { min-width: 0; } @@ -1039,9 +1040,9 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too @media (min-width: 1280px) { .application-settings-navigation { position: sticky; - top: 3.5rem; + top: calc(3rem + 1.75rem); align-self: start; - max-height: calc(100dvh - 4.25rem); + max-height: calc(100dvh - 5.5rem); padding-right: 0.375rem; overflow-x: hidden; overflow-y: auto; diff --git a/resources/views/components/application/settings-section.blade.php b/resources/views/components/application/settings-section.blade.php index 3d494b941..241128728 100644 --- a/resources/views/components/application/settings-section.blade.php +++ b/resources/views/components/application/settings-section.blade.php @@ -6,11 +6,18 @@ ])
merge(['class' => 'application-settings-section']) }}> -
-
-

{{ $title }}

- @if ($helper) - +
filled($description)])> +
+
+

{{ $title }}

+ @if ($helper) + + @endif +
+ @if (filled($description)) +

+ {{ $description }} +

@endif
@isset($actions) diff --git a/resources/views/components/dashboard/navbar.blade.php b/resources/views/components/dashboard/navbar.blade.php index 20962e91f..c7ce62750 100644 --- a/resources/views/components/dashboard/navbar.blade.php +++ b/resources/views/components/dashboard/navbar.blade.php @@ -20,53 +20,20 @@ ['label' => 'Servers', 'route' => 'shared-variables.server.index', 'active' => request()->routeIs('shared-variables.server.*')], ], 'team' => [ - ['label' => 'General', 'route' => 'team.index', 'active' => request()->routeIs('team.index')], - ['label' => 'Members', 'route' => 'team.member.index', 'active' => request()->routeIs('team.member.index')], - [ - 'label' => 'Admin View', - 'route' => 'team.admin-view', - 'active' => request()->routeIs('team.admin-view'), - 'visible' => isInstanceAdmin(), - ], + ['label' => 'General', 'route' => 'team.index', 'active' => request()->routeIs('team.index', 'team.member.index', 'team.admin-view', 'team.danger-zone')], ], 'profile' => [ ['label' => 'General', 'route' => 'profile', 'active' => request()->routeIs('profile')], ['label' => 'Appearance', 'route' => 'profile.appearance', 'active' => request()->routeIs('profile.appearance')], ], 'notifications' => [ - ['label' => 'Email', 'route' => 'notifications.email', 'active' => request()->routeIs('notifications.email')], - ['label' => 'Discord', 'route' => 'notifications.discord', 'active' => request()->routeIs('notifications.discord')], - ['label' => 'Telegram', 'route' => 'notifications.telegram', 'active' => request()->routeIs('notifications.telegram')], - ['label' => 'Slack', 'route' => 'notifications.slack', 'active' => request()->routeIs('notifications.slack')], - ['label' => 'Pushover', 'route' => 'notifications.pushover', 'active' => request()->routeIs('notifications.pushover')], - ['label' => 'Webhook', 'route' => 'notifications.webhook', 'active' => request()->routeIs('notifications.webhook')], + ['label' => 'Email', 'route' => 'notifications.email', 'active' => request()->routeIs('notifications.*')], ], 'security' => [ - ['label' => 'Private Keys', 'route' => 'security.private-key.index', 'active' => request()->routeIs('security.private-key.*')], - [ - 'label' => 'Cloud Tokens', - 'route' => 'security.cloud-tokens', - 'active' => request()->routeIs('security.cloud-tokens*'), - 'visible' => auth()->user()?->can('viewAny', App\Models\CloudProviderToken::class), - ], - [ - 'label' => 'Cloud-Init Scripts', - 'route' => 'security.cloud-init-scripts', - 'active' => request()->routeIs('security.cloud-init-scripts*'), - 'visible' => auth()->user()?->can('viewAny', App\Models\CloudInitScript::class), - ], - ['label' => 'API Tokens', 'route' => 'security.api-tokens', 'active' => request()->routeIs('security.api-tokens')], + ['label' => 'Private Keys', 'route' => 'security.private-key.index', 'active' => request()->routeIs('security.*')], ], 'settings' => [ - [ - 'label' => 'Configuration', - 'route' => 'settings.index', - 'active' => request()->routeIs('settings.index', 'settings.advanced', 'settings.updates'), - ], - ['label' => 'Backup', 'route' => 'settings.backup', 'active' => request()->routeIs('settings.backup')], - ['label' => 'Email', 'route' => 'settings.email', 'active' => request()->routeIs('settings.email')], - ['label' => 'OAuth', 'route' => 'settings.oauth', 'active' => request()->routeIs('settings.oauth')], - ['label' => 'Scheduled Jobs', 'route' => 'settings.scheduled-jobs', 'active' => request()->routeIs('settings.scheduled-jobs')], + ['label' => 'General', 'route' => 'settings.index', 'active' => request()->routeIs('settings.*')], ], 'source' => [ [ diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php index 66ae0bbd0..10d8fd28e 100644 --- a/resources/views/components/helper.blade.php +++ b/resources/views/components/helper.blade.php @@ -1,18 +1,30 @@
+ @mouseenter="cancelHide()" @mouseleave="hide()" @click.stop>
{!! $helper !!}
diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index 386c20354..e3c836595 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -11,6 +11,7 @@ 'wireIgnore' => true, // Optional Livewire bool property to entangle open state (survives Livewire re-renders). 'wireOpen' => null, + 'contentClicks' => true, ]) @php @@ -23,7 +24,7 @@ {{ $attributes->class(['relative', $isFullWidth ? 'h-full w-full' : 'h-auto w-auto']) }} @close-modal.window="modalOpen=false" @if ($wireIgnore) wire:ignore @endif> @if ($content) -
$isFullWidth])> +
$isFullWidth])> {{ $content }}
@else diff --git a/resources/views/components/notification/channel-actions.blade.php b/resources/views/components/notification/channel-actions.blade.php new file mode 100644 index 000000000..33d45b961 --- /dev/null +++ b/resources/views/components/notification/channel-actions.blade.php @@ -0,0 +1,28 @@ +@props([ + 'enabled', + 'enabledProperty', + 'toggleMethod', + 'testMethod' => 'sendTestNotification', + 'canUpdate' => true, +]) + +
+ + {{ $enabled ? 'Disable' : 'Enable' }} + + + + Send test + +
diff --git a/resources/views/components/notification/event-grid.blade.php b/resources/views/components/notification/event-grid.blade.php index 315782214..2ce6a4a7f 100644 --- a/resources/views/components/notification/event-grid.blade.php +++ b/resources/views/components/notification/event-grid.blade.php @@ -33,44 +33,103 @@ ['key' => 'traefikOutdated', 'label' => 'Traefik proxy outdated'], ], ]; + + $enabledThreadEvents = []; + if ($threaded) { + foreach ($eventGroups as $group => $events) { + foreach ($events as $event) { + $enabled = (bool) data_get( + $settings, + Str::snake($event['key'] . '_' . $channel . '_notifications'), + ); + + if (! $enabled) { + continue; + } + + $enabledThreadEvents[] = [ + 'group' => $group, + 'key' => $event['key'], + 'label' => $event['label'], + 'threadModel' => Str::camel( + Str::studly($channel) . 'Notifications' . Str::studly($event['key']) . 'ThreadId', + ), + ]; + } + } + + $enabledThreadEventsByGroup = collect($enabledThreadEvents)->groupBy('group'); + } @endphp - -
- @foreach ($eventGroups as $group => $events) - @php - $multiselectEvents = collect($events) - ->map(fn ($event) => [ - 'property' => $event['key'] . Str::studly($channel) . 'Notifications', - 'label' => $event['label'], - 'enabled' => (bool) data_get( - $settings, - Str::snake($event['key'] . '_' . $channel . '_notifications'), - ), - ]) - ->all(); - $groupId = Str::slug($channel . '-' . $group . '-events'); - @endphp -
- +
+ +
+ @foreach ($eventGroups as $group => $events) + @php + $multiselectEvents = collect($events) + ->map(fn ($event) => [ + 'property' => $event['key'] . Str::studly($channel) . 'Notifications', + 'label' => $event['label'], + 'enabled' => (bool) data_get( + $settings, + Str::snake($event['key'] . '_' . $channel . '_notifications'), + ), + ]) + ->all(); + $groupId = Str::slug($channel . '-' . $group . '-events'); + @endphp +
+ +
+ @endforeach +
+
- @if ($threaded) -
- @foreach ($events as $event) - @php - $threadModel = Str::camel( - Str::studly($channel) . 'Notifications' . Str::studly($event['key']) . 'ThreadId', - ); - @endphp - - @endforeach -
- @endif -
- @endforeach -
- + @if ($threaded) + + @if ($enabledThreadEvents === []) +

+ Enable one or more events above to assign forum topic IDs. +

+ @else +
+ @foreach ($enabledThreadEventsByGroup as $group => $events) +
+
+ {{ $group }} +
+
+ @foreach ($events as $event) +
+
+
+ {{ $event['label'] }} +
+
+ Topic ID +
+
+ +
+ @endforeach +
+
+ @endforeach +
+ @endif +
+ @endif +
diff --git a/resources/views/components/notification/settings-layout.blade.php b/resources/views/components/notification/settings-layout.blade.php new file mode 100644 index 000000000..a1ef61c4b --- /dev/null +++ b/resources/views/components/notification/settings-layout.blade.php @@ -0,0 +1,38 @@ +@php + $notificationMenuItems = [ + ['label' => 'Email', 'route' => 'notifications.email', 'icon' => 'mail'], + ['label' => 'Discord', 'route' => 'notifications.discord', 'brandIcon' => 'discord'], + ['label' => 'Telegram', 'route' => 'notifications.telegram', 'brandIcon' => 'telegram'], + ['label' => 'Slack', 'route' => 'notifications.slack', 'brandIcon' => 'slack'], + ['label' => 'Pushover', 'route' => 'notifications.pushover', 'brandIcon' => 'pushover'], + ['label' => 'Webhook', 'route' => 'notifications.webhook', 'icon' => 'destinations'], + ]; +@endphp + +
+ +
diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index 7989d3a17..a874a3d2d 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -6,6 +6,9 @@ // copy the inner markup from the reicon Outline weight and swap // #000000 -> currentColor. See UI_REDESIGN.md. $icons = [ + 'cloud' => '', + 'code' => '', + 'mail' => '', 'dashboard' => '', 'projects' => '', 'servers' => '', diff --git a/resources/views/components/security/settings-layout.blade.php b/resources/views/components/security/settings-layout.blade.php new file mode 100644 index 000000000..8cc32317e --- /dev/null +++ b/resources/views/components/security/settings-layout.blade.php @@ -0,0 +1,56 @@ +@php + $securityMenuItems = collect([ + [ + 'label' => 'Private Keys', + 'route' => 'security.private-key.index', + 'active' => request()->routeIs('security.private-key.*'), + 'icon' => 'keys', + ], + auth()->user()?->can('viewAny', App\Models\CloudProviderToken::class) ? [ + 'label' => 'Cloud Tokens', + 'route' => 'security.cloud-tokens', + 'active' => request()->routeIs('security.cloud-tokens*'), + 'icon' => 'cloud', + ] : null, + auth()->user()?->can('viewAny', App\Models\CloudInitScript::class) ? [ + 'label' => 'Cloud-Init Scripts', + 'route' => 'security.cloud-init-scripts', + 'active' => request()->routeIs('security.cloud-init-scripts*'), + 'icon' => 'file-content', + ] : null, + [ + 'label' => 'API Tokens', + 'route' => 'security.api-tokens', + 'active' => request()->routeIs('security.api-tokens'), + 'icon' => 'code', + ], + ])->filter(); +@endphp + +
+
+ + +
+ @isset($actions) +
+ {{ $actions }} +
+ @endisset + {{ $slot }} +
+
+
diff --git a/resources/views/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php new file mode 100644 index 000000000..3017b8366 --- /dev/null +++ b/resources/views/components/settings/layout.blade.php @@ -0,0 +1,48 @@ +@php + $settingsMenuSections = [ + 'Configuration' => [ + ['label' => 'General', 'route' => 'settings.index', 'icon' => 'settings'], + ['label' => 'Advanced', 'route' => 'settings.advanced', 'icon' => 'grid'], + ['label' => 'Updates', 'route' => 'settings.updates', 'icon' => 'refresh3'], + ], + 'Instance' => [ + ['label' => 'Backup', 'route' => 'settings.backup', 'icon' => 'database'], + ['label' => 'Email', 'route' => 'settings.email', 'icon' => 'mail'], + ['label' => 'Authentication', 'route' => 'settings.oauth', 'icon' => 'keys'], + ['label' => 'Scheduled Jobs', 'route' => 'settings.scheduled-jobs', 'icon' => 'calendar'], + ], + ]; +@endphp + +
+
+ + +
+ {{ $slot }} +
+
+
diff --git a/resources/views/components/team/settings-layout.blade.php b/resources/views/components/team/settings-layout.blade.php new file mode 100644 index 000000000..793420bd4 --- /dev/null +++ b/resources/views/components/team/settings-layout.blade.php @@ -0,0 +1,57 @@ +@php + $teamMenuItems = collect([ + [ + 'label' => 'General', + 'route' => 'team.index', + 'active' => request()->routeIs('team.index'), + 'icon' => 'settings', + ], + [ + 'label' => 'Members', + 'route' => 'team.member.index', + 'active' => request()->routeIs('team.member.index'), + 'icon' => 'teams', + ], + isInstanceAdmin() ? [ + 'label' => 'Admin View', + 'route' => 'team.admin-view', + 'active' => request()->routeIs('team.admin-view'), + 'icon' => 'admin', + ] : null, + [ + 'label' => 'Danger Zone', + 'route' => 'team.danger-zone', + 'active' => request()->routeIs('team.danger-zone'), + 'icon' => 'shield-alert', + 'sectionStart' => true, + ], + ])->filter(); +@endphp + +
+
+ + +
+ {{ $slot }} +
+
+
diff --git a/resources/views/livewire/layout-popups.blade.php b/resources/views/livewire/layout-popups.blade.php index 5e0ed3116..7b9339a0f 100644 --- a/resources/views/livewire/layout-popups.blade.php +++ b/resources/views/livewire/layout-popups.blade.php @@ -4,12 +4,25 @@ notification: true, realtime: false, }, + reminders: { + sponsorship: { compact: false }, + notification: { compact: false }, + }, + reminderCollapseAfter: 10000, isDevelopment: {{ isDev() ? 'true' : 'false' }}, init() { this.popups.sponsorship = this.shouldShowMonthlyPopup('popupSponsorship'); this.popups.notification = this.shouldShowMonthlyPopup('popupNotification'); this.popups.realtime = localStorage.getItem('popupRealtime'); + if (this.popups.sponsorship) { + this.scheduleReminderCollapse('sponsorship'); + } + + if (this.popups.notification) { + this.scheduleReminderCollapse('notification'); + } + let checkNumber = 1; let checkPusherInterval = null; let checkReconnectInterval = null; @@ -33,6 +46,17 @@ }, 2000); } }, + scheduleReminderCollapse(reminder) { + setTimeout(() => { + if (reminder === 'sponsorship') { + this.reminders.sponsorship.compact = true; + } + + if (reminder === 'notification') { + this.reminders.notification.compact = true; + } + }, this.reminderCollapseAfter); + }, shouldShowMonthlyPopup(storageKey) { const disabledTimestamp = localStorage.getItem(storageKey); @@ -120,8 +144,8 @@ -
+
- @@ -137,7 +161,8 @@

Love Coolify? Support our work.

-

+

Coolify is profitable thanks to you. Your support helps us build more features and keep improving the project. @@ -150,16 +175,17 @@ class="button h-9 justify-center bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-coollabs/15! sm:flex-1 dark:bg-warning/15! dark:text-warning! dark:ring-warning/25 dark:hover:bg-warning/20!"> GitHub Sponsors - Open Collective - Stripe -

- @@ -240,7 +266,8 @@

No notifications enabled

-

+

Enable at least one notification channel so you receive important alerts. Visit Open notifications - diff --git a/resources/views/livewire/notifications/discord.blade.php b/resources/views/livewire/notifications/discord.blade.php index aa6eb4f23..5fdeb10da 100644 --- a/resources/views/livewire/notifications/discord.blade.php +++ b/resources/views/livewire/notifications/discord.blade.php @@ -3,28 +3,18 @@ Discord Notifications | Coolify - - +

- - - Send test - +
-
+
diff --git a/resources/views/livewire/notifications/email.blade.php b/resources/views/livewire/notifications/email.blade.php index f1848e827..c9c0b7a15 100644 --- a/resources/views/livewire/notifications/email.blade.php +++ b/resources/views/livewire/notifications/email.blade.php @@ -3,8 +3,7 @@ Notifications | Coolify - - +
@@ -176,4 +175,5 @@
+
diff --git a/resources/views/livewire/notifications/pushover.blade.php b/resources/views/livewire/notifications/pushover.blade.php index 680dc774b..d617ed76c 100644 --- a/resources/views/livewire/notifications/pushover.blade.php +++ b/resources/views/livewire/notifications/pushover.blade.php @@ -3,32 +3,18 @@ Pushover Notifications | Coolify - - +
- - - Send test - +
-
-
- -
-
@can('update', $settings) @@ -44,4 +30,5 @@
+
diff --git a/resources/views/livewire/notifications/slack.blade.php b/resources/views/livewire/notifications/slack.blade.php index 74d4eb1e0..9b1814b69 100644 --- a/resources/views/livewire/notifications/slack.blade.php +++ b/resources/views/livewire/notifications/slack.blade.php @@ -3,32 +3,18 @@ Slack Notifications | Coolify - - +
- - - Send test - +
-
-
- -
-
@can('update', $settings)
+
diff --git a/resources/views/livewire/notifications/telegram.blade.php b/resources/views/livewire/notifications/telegram.blade.php index afce952ea..8686d40a3 100644 --- a/resources/views/livewire/notifications/telegram.blade.php +++ b/resources/views/livewire/notifications/telegram.blade.php @@ -3,32 +3,18 @@ Telegram Notifications | Coolify - - +
- - - Send test - +
-
-
- -
-
@can('update', $settings) @@ -44,4 +30,5 @@
+
diff --git a/resources/views/livewire/notifications/webhook.blade.php b/resources/views/livewire/notifications/webhook.blade.php index 153731e45..81eb8c202 100644 --- a/resources/views/livewire/notifications/webhook.blade.php +++ b/resources/views/livewire/notifications/webhook.blade.php @@ -3,32 +3,18 @@ Webhook Notifications | Coolify - - +
- - - Send test - +
-
-
- -
-
@can('update', $settings)
+
diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index aed5da019..392b210a2 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -90,7 +90,13 @@ @if ($currentRoute === 'project.service.configuration') -
+

Compose resources

@@ -98,13 +104,46 @@ Applications and databases defined in this service.

- - Documentation - - +
+
+ + +
+ + Documentation + + +
-
+
+ @if ($applications->isNotEmpty() || $databases->isNotEmpty()) +
+
Resource
+ +
Status
+
+
+ @endif + @if ($applications->isEmpty() && $databases->isEmpty())
diff --git a/resources/views/livewire/project/service/index.blade.php b/resources/views/livewire/project/service/index.blade.php index 707041c2c..f2272dbc1 100644 --- a/resources/views/livewire/project/service/index.blade.php +++ b/resources/views/livewire/project/service/index.blade.php @@ -116,10 +116,26 @@
@if (!$serviceApplication->serviceType()?->contains(str($serviceApplication->image)->before(':'))) - +
+
+

+ @php($domainCount = countDomains($fqdn)) + @if ($domainCount === 0) + No domains set. + @elseif ($domainCount === 1) + 1 domain set. + @else + {{ $domainCount }} domains set. + @endif + Manage domains, DNS checks, and redirects on the parent service's Domains page. +

+ + + Manage domains + +
+
@endif +
@php [$statusType, $statusLabel] = match (true) { str($resource->status)->contains('running') => ['success', formatContainerStatus($resource->status)], @@ -11,7 +10,9 @@ : Str::headline($resource->name); @endphp -
+
+
@@ -52,10 +53,10 @@
@endif
-
+
-
+
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated)) @@ -77,5 +78,32 @@ :step2ButtonText="$isApplication ? 'Restart Service Container' : 'Restart Database'" /> @endcan @endif +
+
+ +
diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index d4179a961..ca08fe3fc 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -3,7 +3,7 @@ API Tokens | Coolify - + @if (!$isApiEnabled)
@@ -53,49 +53,53 @@

Permissions

-
- @if ($canUseRootPermissions) - - @else - - @endif +
+ - @if (!in_array('root', $permissions)) - @if ($canUseWritePermissions) - - @else - - @endif - - @if ($canUseDeployPermissions) - - @else - - @endif - - - - @if ($canUseSensitivePermissions) - +
+ +
+
+ +
+
+ +
+
+ +
+
+ - @else - - @endif - @endif + :checked="in_array('read:sensitive', $permissions)" + :disabled="in_array('root', $permissions) || !$canUseSensitivePermissions" /> +
+
@@ -299,4 +303,5 @@
@endif +
diff --git a/resources/views/livewire/security/cloud-init-script/show.blade.php b/resources/views/livewire/security/cloud-init-script/show.blade.php index 688ec8252..95fc92684 100644 --- a/resources/views/livewire/security/cloud-init-script/show.blade.php +++ b/resources/views/livewire/security/cloud-init-script/show.blade.php @@ -1,10 +1,26 @@
+ @if ($modalMode) + + + +
+ @can('delete', $cloudInitScript) + + @endcan + Save changes +
+ + @else {{ $cloudInitScript->name }} | Cloud-Init Scripts | Coolify - + @can('delete', $cloudInitScript) @endcan - +
@@ -32,4 +48,6 @@
+ + @endif
diff --git a/resources/views/livewire/security/cloud-init-scripts.blade.php b/resources/views/livewire/security/cloud-init-scripts.blade.php index 9d7e9be35..da4f38a1f 100644 --- a/resources/views/livewire/security/cloud-init-scripts.blade.php +++ b/resources/views/livewire/security/cloud-init-scripts.blade.php @@ -3,7 +3,9 @@ Cloud-Init Scripts | Coolify - + + @can('create', App\Models\CloudInitScript::class) @@ -18,24 +20,29 @@ @endcan - -
- + @if ($scripts->isEmpty()) @else -
+ diff --git a/resources/views/livewire/security/cloud-provider-token-form.blade.php b/resources/views/livewire/security/cloud-provider-token-form.blade.php index 22a40553f..ab9cd7b0b 100644 --- a/resources/views/livewire/security/cloud-provider-token-form.blade.php +++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php @@ -1,7 +1,20 @@
-
- @if (!isset($provider) || blank($provider)) - + @if (!$provider_locked) + @endif +
+ Create the token in the + + + . +
+
+ x-bind:placeholder="`Production ${providerName} token`" />
@@ -21,19 +43,6 @@
- -
+ @if ($modalMode) + +
+ + + + +
+
+
+ @can('delete', $cloudProviderToken) + + @endcan + Validate +
+ Save changes +
+ + @else {{ $cloudProviderToken->name }} | Cloud Tokens | Coolify - + @@ -22,7 +42,7 @@ step2ButtonText="Delete token" /> @endcan - +
@@ -38,4 +58,6 @@
+ + @endif
diff --git a/resources/views/livewire/security/cloud-provider-tokens.blade.php b/resources/views/livewire/security/cloud-provider-tokens.blade.php index c54c78f7b..42b401dc0 100644 --- a/resources/views/livewire/security/cloud-provider-tokens.blade.php +++ b/resources/views/livewire/security/cloud-provider-tokens.blade.php @@ -1,16 +1,40 @@
- + + + @can('create', App\Models\CloudProviderToken::class) + + + + + + + @endcan + @if ($tokens->isEmpty()) @else -
+
+
+
Token
+
Provider
+ +
+
@foreach ($tokens as $savedToken) - - diff --git a/resources/views/livewire/security/cloud-tokens.blade.php b/resources/views/livewire/security/cloud-tokens.blade.php index 0ae41e99b..dfb300635 100644 --- a/resources/views/livewire/security/cloud-tokens.blade.php +++ b/resources/views/livewire/security/cloud-tokens.blade.php @@ -3,23 +3,7 @@ Cloud Tokens | Coolify - - - @can('create', App\Models\CloudProviderToken::class) - - - - - - - @endcan - - - - + + +
diff --git a/resources/views/livewire/security/private-key/index.blade.php b/resources/views/livewire/security/private-key/index.blade.php index 52f9b310a..c35277bcf 100644 --- a/resources/views/livewire/security/private-key/index.blade.php +++ b/resources/views/livewire/security/private-key/index.blade.php @@ -3,7 +3,9 @@ Keys & Tokens | Coolify - + + @can('create', App\Models\PrivateKey::class) - +
@endcan - + @if ($privateKeys->isEmpty()) @else -
+
+
+
Private key
+ +
Status
+
+
@foreach ($privateKeys as $key) @can('view', $key) - -
+ + +
+
@@ -80,21 +91,29 @@

{{ data_get($key, 'name') }}

-

- {{ $key->description ?: 'SSH private key' }} -

-
+ + + @else -
{{ data_get($key, 'name') }} -

- {{ $key->description ?: 'SSH private key' }} -

-
+ +
@if (!$key->isInUse()) @endif
+
@endcan @endforeach
@endif + + +
diff --git a/resources/views/livewire/security/private-key/show.blade.php b/resources/views/livewire/security/private-key/show.blade.php index f3212a2b1..59cffc70f 100644 --- a/resources/views/livewire/security/private-key/show.blade.php +++ b/resources/views/livewire/security/private-key/show.blade.php @@ -1,11 +1,45 @@
+ @if ($modalMode) +
+
+ + +
+ +
+
+
+ + +
+
+ +
+
+ +
+
+
+
+ @can('delete', $private_key) + + @endcan + Save changes +
+
+ @else {{ $private_key->name }} | Private Keys | Coolify - + @if ($isGitRelated) @@ -25,7 +59,7 @@ @endcan @endif - +
@@ -59,4 +93,6 @@
+ + @endif
diff --git a/resources/views/livewire/settings-backup.blade.php b/resources/views/livewire/settings-backup.blade.php index a335f6d42..7902bb15a 100644 --- a/resources/views/livewire/settings-backup.blade.php +++ b/resources/views/livewire/settings-backup.blade.php @@ -3,8 +3,7 @@ Instance Backup | Coolify - - +
@if ($server->isFunctional()) @if (isset($database) && isset($backup)) @@ -51,4 +50,5 @@ @endif
+
diff --git a/resources/views/livewire/settings-email.blade.php b/resources/views/livewire/settings-email.blade.php index 637d979ae..120c2327e 100644 --- a/resources/views/livewire/settings-email.blade.php +++ b/resources/views/livewire/settings-email.blade.php @@ -3,8 +3,7 @@ Transactional Email | Coolify - - +
@@ -65,4 +64,5 @@
+
diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 509b5c315..4e5dfc951 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -3,30 +3,28 @@ Authentication | Coolify - - -
- +
+
@foreach ($oauth_settings_map as $oauth_setting) @@ -37,24 +35,33 @@ + +
+ + {{ $oauth_setting['enabled'] ? 'Disable' : 'Enable' }} + +
+
- - + label="Client ID" required /> + type="password" label="Client secret" autocomplete="new-password" required /> @if ($provider === 'azure') + label="Tenant" required /> @endif @if ($provider === 'google') @@ -65,11 +72,11 @@ @if (in_array($provider, ['authentik', 'clerk', 'zitadel', 'gitlab'], true)) + label="Base URL" :required="in_array($provider, ['authentik', 'clerk'], true)" /> @endif
@endforeach -
+
diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 1c23a1000..ae70c8536 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -3,12 +3,7 @@ Advanced Settings | Coolify - - -
- - +
{{-- Scope dirty tracking to fields that need an explicit Save. Instant-save listboxes (API, MCP, telemetry, …) update the snapshot on the server @@ -140,5 +135,5 @@
-
+
diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index 37f3d2b38..7992b4ca9 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -3,12 +3,7 @@ Settings | Coolify - - -
- - +
{{-- instance_timezone auto-saves via $wire.set + submit; exclude it so the bar does not flash while the snapshot catches up. --}} @@ -59,7 +54,6 @@ @endif
-
@@ -72,4 +66,5 @@ +
diff --git a/resources/views/livewire/settings/scheduled-jobs.blade.php b/resources/views/livewire/settings/scheduled-jobs.blade.php index d55489938..c684a212e 100644 --- a/resources/views/livewire/settings/scheduled-jobs.blade.php +++ b/resources/views/livewire/settings/scheduled-jobs.blade.php @@ -3,8 +3,7 @@ Scheduled Jobs | Coolify - - +
- - +
{{-- Exclude is_auto_update_enabled (instantSave) so the bar does not flash. --}} @@ -61,5 +56,5 @@
-
+
diff --git a/resources/views/livewire/switch-team.blade.php b/resources/views/livewire/switch-team.blade.php index 27c26bbd1..ac772ba9d 100644 --- a/resources/views/livewire/switch-team.blade.php +++ b/resources/views/livewire/switch-team.blade.php @@ -30,6 +30,17 @@ @endif @endforeach +
+ + + + + + +
@@ -66,6 +77,17 @@ @endif @endforeach +
+ + + + + + +
diff --git a/resources/views/livewire/team/admin-view.blade.php b/resources/views/livewire/team/admin-view.blade.php index 80c0c940d..6a7664a01 100644 --- a/resources/views/livewire/team/admin-view.blade.php +++ b/resources/views/livewire/team/admin-view.blade.php @@ -3,8 +3,7 @@ Team Admin | Coolify - - +
+
diff --git a/resources/views/livewire/team/danger-zone.blade.php b/resources/views/livewire/team/danger-zone.blade.php new file mode 100644 index 000000000..84bf9fe5c --- /dev/null +++ b/resources/views/livewire/team/danger-zone.blade.php @@ -0,0 +1,98 @@ +
+ + Team Danger Zone | Coolify + + + +
+ +
+
+
+
+

Delete team

+ +
+ + @if (session('currentTeam.id') === 0) +

+ The default team cannot be deleted. +

+ @elseif(auth()->user()->teams()->count() === 1 || auth()->user()->currentTeam()->personal_team) +

+ Your last or personal team cannot be deleted. +

+ @elseif(currentTeam()->subscription) +

+ Cancel your subscription + before deleting this team. +

+ @elseif(currentTeam()->isEmpty()) +

+ Permanently delete {{ currentTeam()->name }} + from Coolify. This action cannot be undone. +

+
    +
  • • All members will lose access to this team.
  • +
  • • This team cannot be restored from Coolify after deletion.
  • +
+ @else +

+ Remove or move every resource owned by this team before deleting it. +

+ @endif +
+ +
+ @if ( + session('currentTeam.id') !== 0 && + auth()->user()->teams()->count() > 1 && + !auth()->user()->currentTeam()->personal_team && + !currentTeam()->subscription && + currentTeam()->isEmpty()) + + @else + + Delete team + + @endif +
+
+
+ + @if (session('currentTeam.id') !== 0 && !currentTeam()->subscription && !currentTeam()->isEmpty()) +
+ @foreach ([ + 'Projects' => currentTeam()->projects, + 'Servers' => currentTeam()->servers, + 'Private keys' => currentTeam()->privateKeys, + 'Sources' => currentTeam()->sources, + ] as $label => $resources) + @if ($resources->isNotEmpty()) +
+

+ {{ $label }} +

+
    + @foreach ($resources as $resource) +
  • {{ $resource->name }}
  • + @endforeach +
+
+ @endif + @endforeach +
+ @endif +
+
+
+
diff --git a/resources/views/livewire/team/index.blade.php b/resources/views/livewire/team/index.blade.php index cb8ba9155..64fe0994a 100644 --- a/resources/views/livewire/team/index.blade.php +++ b/resources/views/livewire/team/index.blade.php @@ -3,13 +3,17 @@ Teams | Coolify - - +
+ + + + +
@@ -25,125 +29,6 @@ - @can('delete', $team) -
-
-
-

Danger zone

-

- Destructive actions for this team. -

-
-
- -
-
-
-

Delete team

- - @if (session('currentTeam.id') === 0) -

- The default team cannot be deleted. -

- @elseif(auth()->user()->teams()->get()->count() === 1 || auth()->user()->currentTeam()->personal_team) -

- Your last or personal team cannot be deleted. -

- @elseif(currentTeam()->subscription) -

- Cancel your - subscription - before deleting this team. -

- @elseif(currentTeam()->isEmpty()) -

- Permanently remove this team. This action cannot be undone. -

- @else -

- Remove the resources below before deleting this team. -

- @endif -
- - @if ( - session('currentTeam.id') !== 0 && - auth()->user()->teams()->get()->count() > 1 && - !auth()->user()->currentTeam()->personal_team && - !currentTeam()->subscription && - currentTeam()->isEmpty()) -
- -
- @endif -
- - @if ( - session('currentTeam.id') !== 0 && - !currentTeam()->subscription && - !currentTeam()->isEmpty()) -
- @if (currentTeam()->projects()->count() > 0) -
-

- Projects -

-
    - @foreach (currentTeam()->projects as $resource) -
  • {{ $resource->name }}
  • - @endforeach -
-
- @endif - @if (currentTeam()->servers()->count() > 0) -
-

- Servers -

-
    - @foreach (currentTeam()->servers as $resource) -
  • {{ $resource->name }}
  • - @endforeach -
-
- @endif - @if (currentTeam()->privateKeys()->count() > 0) -
-

- Private keys -

-
    - @foreach (currentTeam()->privateKeys as $resource) -
  • {{ $resource->name }}
  • - @endforeach -
-
- @endif - @if (currentTeam()->sources()->count() > 0) -
-

- Sources -

-
    - @foreach (currentTeam()->sources as $resource) -
  • {{ $resource->name }}
  • - @endforeach -
-
- @endif -
- @endif -
-
- @endcan
+
diff --git a/resources/views/livewire/team/member/index.blade.php b/resources/views/livewire/team/member/index.blade.php index 6ecaae3f3..cb32f4517 100644 --- a/resources/views/livewire/team/member/index.blade.php +++ b/resources/views/livewire/team/member/index.blade.php @@ -56,8 +56,7 @@ Team Members | Coolify - - +
@endcan
+
diff --git a/routes/web.php b/routes/web.php index 0e83fa766..49a87790c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -92,6 +92,7 @@ use App\Livewire\Subscription\Index as SubscriptionIndex; use App\Livewire\Subscription\Show as SubscriptionShow; use App\Livewire\Tags\Show as TagsShow; use App\Livewire\Team\AdminView as TeamAdminView; +use App\Livewire\Team\DangerZone as TeamDangerZone; use App\Livewire\Team\Index as TeamIndex; use App\Livewire\Team\Member\Index as TeamMemberIndex; use App\Livewire\Terminal\Index as TerminalIndex; @@ -194,6 +195,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/', TeamIndex::class)->name('team.index'); Route::get('/members', TeamMemberIndex::class)->name('team.member.index'); Route::get('/admin', TeamAdminView::class)->name('team.admin-view'); + Route::get('/danger', TeamDangerZone::class)->name('team.danger-zone'); }); Route::get('/terminal', TerminalIndex::class)->name('terminal')->middleware('can.access.terminal'); diff --git a/tests/Feature/AdvancedMenuIconConsistencyTest.php b/tests/Feature/AdvancedMenuIconConsistencyTest.php index e795ca7d4..2fac60df0 100644 --- a/tests/Feature/AdvancedMenuIconConsistencyTest.php +++ b/tests/Feature/AdvancedMenuIconConsistencyTest.php @@ -5,7 +5,7 @@ */ test('advanced sidebar and configuration menus use the grid icon', function () { $files = [ - resource_path('views/components/settings/sidebar.blade.php'), + resource_path('views/components/settings/layout.blade.php'), resource_path('views/components/server/sidebar.blade.php'), resource_path('views/components/service-database/sidebar.blade.php'), resource_path('views/livewire/project/service/index.blade.php'), @@ -21,7 +21,6 @@ test('advanced sidebar and configuration menus use the grid icon', function () { if (str_contains($contents, "'label' => 'Advanced'") || str_contains($contents, "'Advanced' =>")) { expect($contents) ->toMatch("/'label'\\s*=>\\s*'Advanced'[\\s\\S]{0,160}?'icon'\\s*=>\\s*'grid'|'Advanced'\\s*=>\\s*'grid'/") - ->not->toMatch("/'label'\\s*=>\\s*'Advanced'[\\s\\S]{0,160}?'icon'\\s*=>\\s*'(?!grid)[^']+'/") ->not->toMatch("/'Advanced'\\s*=>\\s*'(?!grid)[^']+'/"); } diff --git a/tests/Feature/ApiTokenLivewireAuthorizationTest.php b/tests/Feature/ApiTokenLivewireAuthorizationTest.php index e81b55aab..2a875ca26 100644 --- a/tests/Feature/ApiTokenLivewireAuthorizationTest.php +++ b/tests/Feature/ApiTokenLivewireAuthorizationTest.php @@ -95,3 +95,16 @@ test('owner can create root token', function () { expect($token)->not->toBeNull() ->and($token->abilities)->toBe(['root']); }); + +test('owner can deselect root and falls back to read permission', function () { + $owner = User::factory()->create(); + $this->team->members()->attach($owner->id, ['role' => 'owner']); + + $this->actingAs($owner); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('permissions', ['root']) + ->set('permissions', []) + ->assertSet('permissions', ['read']); +}); diff --git a/tests/Feature/ApiTokenPermissionsLayoutTest.php b/tests/Feature/ApiTokenPermissionsLayoutTest.php new file mode 100644 index 000000000..227cab426 --- /dev/null +++ b/tests/Feature/ApiTokenPermissionsLayoutTest.php @@ -0,0 +1,18 @@ +between( + '

Permissions

', + '' + )->toString(); + + expect($permissionsSection) + ->toContain('permissionsOpen') + ->toContain('Selected permissions') + ->toContain('toContain("in_array('root', \$permissions)") + ->toContain('Read sensitive data') + ->not->toContain('not->toContain("@if (!in_array('root', \$permissions))"); +}); diff --git a/tests/Feature/DashboardNavbarLayoutTest.php b/tests/Feature/DashboardNavbarLayoutTest.php index 067356a02..9d078a840 100644 --- a/tests/Feature/DashboardNavbarLayoutTest.php +++ b/tests/Feature/DashboardNavbarLayoutTest.php @@ -1,12 +1,12 @@ toContain("['label' => 'Email', 'route' => 'settings.email'") - ->not->toContain("['label' => 'Transactional Email', 'route' => 'settings.email'"); + ->toContain("['label' => 'General', 'route' => 'settings.index', 'active' => request()->routeIs('settings.*')]") + ->not->toContain("['label' => 'Email', 'route' => 'settings.email'"); }); test('dashboard navbar keeps the original side-by-side tab and actions layout', function () { @@ -14,7 +14,6 @@ test('dashboard navbar keeps the original side-by-side tab and actions layout', $contents = file_get_contents($path); expect($contents) - ->toContain('flex w-full items-center justify-between gap-4 lg:h-full') - ->toContain('flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto') - ->not->toContain('flex w-full flex-col gap-3 lg:h-full lg:flex-row'); + ->toContain('flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between') + ->toContain('flex min-w-0 w-full items-center gap-0.5 overflow-x-auto'); }); diff --git a/tests/Feature/HelperInfoButtonTest.php b/tests/Feature/HelperInfoButtonTest.php index e26a078d0..9b42ce5ba 100644 --- a/tests/Feature/HelperInfoButtonTest.php +++ b/tests/Feature/HelperInfoButtonTest.php @@ -34,6 +34,16 @@ test('helper popup uses the redesigned raised surface styles', function () { ->toContain('rounded-lg'); }); +test('helper popup remains open while moving from the trigger into interactive content', function () { + $helper = file_get_contents(resource_path('views/components/helper.blade.php')); + + expect($helper) + ->toContain('hideTimer: null') + ->toContain('setTimeout(() =>') + ->toContain('@mouseenter="cancelHide()"') + ->toContain('@mouseleave="hide()"'); +}); + test('listbox keeps the helper outside the label association', function () { $path = resource_path('views/components/forms/listbox.blade.php'); $contents = file_get_contents($path); diff --git a/tests/Feature/LayoutPopupsUiTest.php b/tests/Feature/LayoutPopupsUiTest.php index 6154a25f6..167a23a04 100644 --- a/tests/Feature/LayoutPopupsUiTest.php +++ b/tests/Feature/LayoutPopupsUiTest.php @@ -25,3 +25,14 @@ test('notification reminder uses the redesigned popup shell', function () { ->toContain('Open notifications') ->not->toContain('Accept and Close'); }); + +test('non-critical reminders collapse after ten seconds', function () { + $view = file_get_contents(resource_path('views/livewire/layout-popups.blade.php')); + + expect($view) + ->toContain('reminderCollapseAfter: 10000') + ->toContain("scheduleReminderCollapse('sponsorship')") + ->toContain("scheduleReminderCollapse('notification')") + ->toContain('reminders.sponsorship.compact = true') + ->toContain('reminders.notification.compact = true'); +}); diff --git a/tests/Feature/NotificationSettingsNavigationTest.php b/tests/Feature/NotificationSettingsNavigationTest.php new file mode 100644 index 000000000..08e095b1b --- /dev/null +++ b/tests/Feature/NotificationSettingsNavigationTest.php @@ -0,0 +1,94 @@ +toContain('') + ->not->toContain('toContain('application-settings-navigation') + ->toContain('Notification settings') + ->toContain("'label' => 'Email'") + ->toContain("'icon' => 'mail'") + ->toContain("'label' => 'Discord'") + ->toContain("'label' => 'Telegram'") + ->toContain("'label' => 'Slack'") + ->toContain("'label' => 'Pushover'") + ->toContain("'label' => 'Webhook'"); + + foreach (['discord', 'telegram', 'slack', 'pushover'] as $channel) { + expect(public_path("svgs/{$channel}.svg"))->toBeFile(); + } + + expect(file_get_contents(public_path('svgs/pushover.svg'))) + ->toContain('not->toContain('toContain("'brandIcon' => 'discord'") + ->toContain("'brandIcon' => 'telegram'") + ->toContain("'brandIcon' => 'slack'") + ->toContain("'brandIcon' => 'pushover'") + ->not->toContain("'color' =>"); + + expect($navbar) + ->toContain("request()->routeIs('notifications.*')") + ->not->toContain("['label' => 'Discord', 'route' => 'notifications.discord'"); +}); + +it('keeps telegram forum topics separate from event multiselects', function () { + $grid = file_get_contents(resource_path('views/components/notification/event-grid.blade.php')); + $telegram = file_get_contents(resource_path('views/livewire/notifications/telegram.blade.php')); + + expect($telegram) + ->toContain('channel="telegram" threaded'); + + expect($grid) + ->toContain('title="Notification events"') + ->toContain('title="Forum topics"') + ->toContain('Enable one or more events above to assign forum topic IDs.') + ->toContain('$enabledThreadEvents') + ->not->toContain('label="{{ $event[\'label\'] }} thread ID"') + ->not->toContain('border-l border-neutral-200 pl-3'); +}); + +it('renders settings section descriptions in the header', function () { + $section = file_get_contents(resource_path('views/components/application/settings-section.blade.php')); + + expect($section) + ->toContain('filled($description)') + ->toContain('{{ $description }}'); +}); + +it('uses action buttons and browser validation for notification channel state', function () { + $actions = file_get_contents(resource_path('views/components/notification/channel-actions.blade.php')); + + expect($actions) + ->toContain('{{ $enabled ? \'Disable\' : \'Enable\' }}') + ->toContain('reportValidity()') + // @js() must live on plain HTML (x-data), not on attributes — + // Blade leaves @js uncompiled inside component tag attributes, which breaks Alpine. + ->toContain('enabled: @js((bool) $enabled)') + ->toContain('enabledProperty: @js($enabledProperty)') + ->toContain('toggleMethod: @js($toggleMethod)') + ->toContain('testMethod: @js($testMethod)') + ->toContain('$wire.$set(enabledProperty, !enabled)') + ->toContain('$wire.$call(toggleMethod)') + ->toContain('$wire.$call(testMethod)') + ->not->toContain('$wire.$set(@js($enabledProperty)'); + + foreach (['discord', 'telegram', 'slack', 'pushover', 'webhook'] as $channel) { + $view = file_get_contents(resource_path("views/livewire/notifications/{$channel}.blade.php")); + + expect($view) + ->toContain('not->toContain("id=\"{$channel}Enabled\""); + } +}); diff --git a/tests/Feature/PrivateKeyIndexNavbarActionsTest.php b/tests/Feature/PrivateKeyIndexNavbarActionsTest.php index c08ef7dd9..55e96cb8b 100644 --- a/tests/Feature/PrivateKeyIndexNavbarActionsTest.php +++ b/tests/Feature/PrivateKeyIndexNavbarActionsTest.php @@ -1,7 +1,7 @@ toContain(':titleOnDesktop="$titleOnDesktop"'); }); -test('security list views place create actions next to the tabs not in titleActions', function () { +test('security list views place create actions in collection card headers', function () { $pages = [ resource_path('views/livewire/security/private-key/index.blade.php') => [ 'New private key', @@ -25,7 +25,7 @@ test('security list views place create actions next to the tabs not in titleActi 'Delete unused keys', 'Delete unused', ], - resource_path('views/livewire/security/cloud-tokens.blade.php') => [ + resource_path('views/livewire/security/cloud-provider-tokens.blade.php') => [ 'New token', ], resource_path('views/livewire/security/cloud-init-scripts.blade.php') => [ @@ -37,6 +37,7 @@ test('security list views place create actions next to the tabs not in titleActi $blade = file_get_contents($path); expect($blade) + ->toContain('toContain('') ->not->toContain(''); diff --git a/tests/Feature/Security/CloudInitScriptsTest.php b/tests/Feature/Security/CloudInitScriptsTest.php index e161d6aa4..44b0b10af 100644 --- a/tests/Feature/Security/CloudInitScriptsTest.php +++ b/tests/Feature/Security/CloudInitScriptsTest.php @@ -42,7 +42,7 @@ test('cloud-init script form does not show a cancel button in the modal', functi ->assertDontSee('Cancel'); }); -test('cloud-init script cards link to the script detail page without inline actions or created time', function () { +test('cloud-init script cards open the modal editor without inline actions or created time', function () { $script = CloudInitScript::query()->create([ 'team_id' => $this->team->id, 'name' => 'Docker Host Setup', @@ -51,10 +51,10 @@ test('cloud-init script cards link to the script detail page without inline acti Livewire::test(CloudInitScripts::class) ->assertSee('Docker Host Setup') - ->assertSee(route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]), false) + ->assertSee('Edit Cloud-Init Script') + ->assertDontSee(route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]), false) ->assertDontSee('Created') - ->assertDontSee('Edit') - ->assertDontSee('Delete'); + ->assertSee('Delete'); }); test('cloud-init script detail page shows editable script fields', function () { diff --git a/tests/Feature/Security/CloudProviderTokenFormTest.php b/tests/Feature/Security/CloudProviderTokenFormTest.php index 666a9b120..caea0c4d8 100644 --- a/tests/Feature/Security/CloudProviderTokenFormTest.php +++ b/tests/Feature/Security/CloudProviderTokenFormTest.php @@ -55,6 +55,29 @@ test('adding a digitalocean token from a modal closes the modal and refreshes di && data_get($dispatch, 'to') === 'security.cloud-provider-tokens'))->toBeTrue(); }); +test('security cloud token form lets users choose every supported provider', function () { + Livewire::test(CloudProviderTokenForm::class) + ->assertSee('Provider') + ->assertSee('Hetzner') + ->assertSee('DigitalOcean') + ->assertSee('Vultr'); +}); + +test('cloud provider help link reacts to provider selection without a live request', function () { + $view = file_get_contents(resource_path('views/livewire/security/cloud-provider-token-form.blade.php')); + + expect($view) + ->toContain("selectedProvider: \$wire.entangle('provider')") + ->toContain(':wire="false"') + ->toContain('x-model="selectedProvider"') + ->toContain(':href="providerConsoleUrl"') + ->toContain('x-text="providerName + \' console\'"') + ->not->toContain('wire:model.live="provider"'); + + expect(strpos($view, ':href="providerConsoleUrl"')) + ->toBeLessThan(strpos($view, '
')); +}); + test('adding a cloud provider token stores an optional description', function () { Http::fake([ 'https://api.hetzner.cloud/v1/servers' => Http::response([], 200), diff --git a/tests/Feature/Security/CloudProviderTokenShowTest.php b/tests/Feature/Security/CloudProviderTokenShowTest.php index cb527913f..c3a1ce082 100644 --- a/tests/Feature/Security/CloudProviderTokenShowTest.php +++ b/tests/Feature/Security/CloudProviderTokenShowTest.php @@ -30,14 +30,16 @@ beforeEach(function () { $this->actingAs($this->user); }); -test('saved cloud token cards link to the token detail page', function () { +test('saved cloud token rows render frontend-only modal editors', function () { $token = CloudProviderToken::factory()->create([ 'team_id' => $this->team->id, 'name' => 'Production Hetzner', ]); Livewire::test(CloudProviderTokens::class) - ->assertSee(route('security.cloud-tokens.show', ['cloud_token_uuid' => $token->uuid]), false); + ->assertSee('Edit Cloud Token') + ->assertSee('Production Hetzner') + ->assertDontSee(route('security.cloud-tokens.show', ['cloud_token_uuid' => $token->uuid]), false); }); test('cloud token detail page shows editable name and description fields', function () { diff --git a/tests/Feature/Security/PrivateKeyDropdownTest.php b/tests/Feature/Security/PrivateKeyDropdownTest.php index 5dfeb31ab..9c19c7792 100644 --- a/tests/Feature/Security/PrivateKeyDropdownTest.php +++ b/tests/Feature/Security/PrivateKeyDropdownTest.php @@ -39,14 +39,14 @@ beforeEach(function () { test('private key index shows highlighted add dropdown actions', function () { Livewire::test(Index::class) - ->assertSee('+ Add') + ->assertSee('New private key') ->assertSee('Generate ED25519') ->assertSee('Generate RSA') ->assertSee('Add manually') ->assertDontSee('Manage your SSH keys for your servers and integrations.'); }); -test('generating a private key from the index stores it and redirects to details', function () { +test('generating a private key from the index stores it without redirecting to a detail page', function () { $component = Livewire::test(Index::class) ->call('generatePrivateKey', 'ed25519'); @@ -55,9 +55,9 @@ test('generating a private key from the index stores it and redirects to details expect($privateKey->team_id)->toBe($this->team->id) ->and($privateKey->public_key)->toStartWith('ssh-ed25519'); - $component->assertRedirect(route('security.private-key.show', [ - 'private_key_uuid' => $privateKey->uuid, - ])); + $component + ->assertDispatched('success') + ->assertNoRedirect(); }); test('manual private key form does not expose key generation controls', function () { diff --git a/tests/Feature/SecurityResourceModalEditorsTest.php b/tests/Feature/SecurityResourceModalEditorsTest.php new file mode 100644 index 000000000..b2cb0a588 --- /dev/null +++ b/tests/Feature/SecurityResourceModalEditorsTest.php @@ -0,0 +1,84 @@ + InstanceSettings::query()->create(['id' => 0])); + Once::flush(); + + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + + $this->actingAs($user); + session(['currentTeam' => $team]); + $this->team = $team; +}); + +it('opens security resources in modal editors and keeps create actions in card headers', function () { + $views = [ + resource_path('views/livewire/security/private-key/index.blade.php'), + resource_path('views/livewire/security/cloud-provider-tokens.blade.php'), + resource_path('views/livewire/security/cloud-init-scripts.blade.php'), + ]; + + foreach ($views as $view) { + $contents = file_get_contents($view); + + expect($contents) + ->toContain('toContain('') + ->toContain('toContain(':contentClicks="false"') + ->toContain('@click="modalOpen=true"') + ->not->toContain('wire:click="openEditor(') + ->not->toContain('href="{{ route(\'security.'); + } + + expect(file_get_contents($views[0]))->toContain('>Private key
', '>Status'); + expect(file_get_contents($views[1]))->toContain('>Token', '>Provider'); + expect(file_get_contents($views[2]))->toContain('>Script', '>Last updated'); + + expect(substr_count(file_get_contents($views[0]), 'sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_1.75rem]'))->toBeGreaterThanOrEqual(2); + expect(substr_count(file_get_contents($views[1]), 'sm:grid-cols-[minmax(0,1fr)_8rem_minmax(0,1fr)_1.75rem]'))->toBeGreaterThanOrEqual(2); + expect(substr_count(file_get_contents($views[2]), 'grid-cols-[minmax(0,1fr)_12rem_1.75rem]'))->toBeGreaterThanOrEqual(2); + expect(file_get_contents($views[2])) + ->toContain('
Last updated
') + ->toContain('
') + ->not->toContain('
Last updated
'); + + foreach ($views as $view) { + expect(file_get_contents($view)) + ->toContain('grid-cols-[') + ->toContain('items-center gap-3') + ->toContain('class="pl-11"') + ->toContain('text-[13px] font-medium'); + } +}); + +it('deletes a cloud-init script from its modal editor without redirecting to a detail page', function () { + $script = CloudInitScript::query()->create([ + 'team_id' => $this->team->id, + 'name' => 'Docker host', + 'script' => "#cloud-config\npackages:\n - curl\n", + ]); + + Livewire::test(CloudInitScriptShow::class, [ + 'cloud_init_script_uuid' => $script->uuid, + 'modalMode' => true, + ])->call('delete') + ->assertDispatched('securityResourceChanged') + ->assertDispatched('close-modal'); + + $this->assertModelMissing($script); +}); diff --git a/tests/Feature/SecuritySettingsIconsTest.php b/tests/Feature/SecuritySettingsIconsTest.php new file mode 100644 index 000000000..6bf9da201 --- /dev/null +++ b/tests/Feature/SecuritySettingsIconsTest.php @@ -0,0 +1,15 @@ +toContain("'label' => 'Private Keys'", "'icon' => 'keys'") + ->toContain("'label' => 'Cloud Tokens'", "'icon' => 'cloud'") + ->toContain("'label' => 'Cloud-Init Scripts'", "'icon' => 'file-content'") + ->toContain("'label' => 'API Tokens'", "'icon' => 'code'") + ->and($icons) + ->toContain("'cloud' =>") + ->toContain("'code' =>"); +}); diff --git a/tests/Feature/SecuritySettingsNavigationTest.php b/tests/Feature/SecuritySettingsNavigationTest.php new file mode 100644 index 000000000..e89bf8093 --- /dev/null +++ b/tests/Feature/SecuritySettingsNavigationTest.php @@ -0,0 +1,31 @@ +toContain('') + ->not->toContain('toContain('application-settings-navigation') + ->toContain("'label' => 'Private Keys'") + ->toContain("'label' => 'Cloud Tokens'") + ->toContain("'label' => 'Cloud-Init Scripts'") + ->toContain("'label' => 'API Tokens'"); + + expect($navbar) + ->toContain("request()->routeIs('security.*')") + ->not->toContain("['label' => 'API Tokens', 'route' => 'security.api-tokens'"); +}); diff --git a/tests/Feature/ServiceComposeResourcesViewSwitcherTest.php b/tests/Feature/ServiceComposeResourcesViewSwitcherTest.php new file mode 100644 index 000000000..e5bc753ca --- /dev/null +++ b/tests/Feature/ServiceComposeResourcesViewSwitcherTest.php @@ -0,0 +1,28 @@ +toContain("localStorage.getItem('service-compose-resources-view') || 'table'") + ->toContain("setViewMode('table')") + ->toContain("setViewMode('grid')") + ->toContain('aria-label="Table view"') + ->toContain('aria-label="Grid view"') + ->toContain("localStorage.setItem('service-compose-resources-view', mode)") + ->not->toContain('>Sort') + ->and($resourceCard) + ->toContain("x-show=\"viewMode === 'grid'\"") + ->toContain("x-show=\"viewMode === 'table'\""); +}); + +it('directs compose application domain management to the parent service', function () { + $resourceSettings = file_get_contents(resource_path('views/livewire/project/service/index.blade.php')); + + expect($resourceSettings) + ->toContain('Manage domains, DNS checks, and redirects on the parent service') + ->toContain("route('project.service.domains', \$parameters)") + ->toContain('Manage domains') + ->not->toContain('toContain('') + ->toContain('') ->toContain('settings-section title="Sender"') ->toContain('settings-email-send-test') - // Self-closing navbar means no actions slot is passed into the tab bar. - ->not->toContain(''); + ->not->toContain('toContain("{{ \$oauth_setting['enabled'] ? 'Disable' : 'Enable' }}") + ->toContain('x-data="{ enabled: @js((bool) $oauth_setting[\'enabled\']), provider: @js($provider) }"') + ->toContain('invalidField.reportValidity()') + ->toContain('$wire.toggleProvider(provider)') + ->toContain('label="Client ID" required') + ->toContain('autocomplete="new-password" required') + ->not->toContain('label="Provider status"'); +}); + +it('requires the provider-specific fields used by oauth enablement', function () { + $component = new SettingsOauth; + $method = new ReflectionMethod($component, 'providerRules'); + + expect($method->invoke($component, 'github'))->toBe([ + 'oauth_settings_map.github.client_id' => 'required', + 'oauth_settings_map.github.client_secret' => 'required', + ])->and($method->invoke($component, 'azure'))->toHaveKeys([ + 'oauth_settings_map.azure.client_id', + 'oauth_settings_map.azure.client_secret', + 'oauth_settings_map.azure.tenant', + ])->and($method->invoke($component, 'authentik'))->toHaveKeys([ + 'oauth_settings_map.authentik.client_id', + 'oauth_settings_map.authentik.client_secret', + 'oauth_settings_map.authentik.base_url', + ]); +}); diff --git a/tests/Feature/SettingsScheduledJobsRefreshPlacementTest.php b/tests/Feature/SettingsScheduledJobsRefreshPlacementTest.php index e53f58f69..c4728a95b 100644 --- a/tests/Feature/SettingsScheduledJobsRefreshPlacementTest.php +++ b/tests/Feature/SettingsScheduledJobsRefreshPlacementTest.php @@ -4,11 +4,10 @@ test('scheduled jobs refresh control lives on the activity section not the navba $view = file_get_contents(resource_path('views/livewire/settings/scheduled-jobs.blade.php')); expect($view) - ->toContain('') + ->toContain('') ->toContain('settings-section title="Scheduler activity"') ->toContain('wire:click="refresh"') - // Self-closing navbar means Refresh is not passed as a tab-bar action. - ->not->toContain(''); + ->not->toContain('toContain('align-self: start') ->toContain('position: sticky') - ->toContain('top: 3.5rem') - ->toContain('max-height: calc(100dvh - 4.25rem)') + ->toContain('top: calc(3rem + 1.75rem)') + ->toContain('max-height: calc(100dvh - 5.5rem)') ->toContain('overflow-y: auto'); }); diff --git a/tests/Feature/SettingsTitleSidebarAlignmentTest.php b/tests/Feature/SettingsTitleSidebarAlignmentTest.php index 039bde370..7c8b20ec6 100644 --- a/tests/Feature/SettingsTitleSidebarAlignmentTest.php +++ b/tests/Feature/SettingsTitleSidebarAlignmentTest.php @@ -4,40 +4,48 @@ * Settings title sits above the workspace; sidebar is below it. Title shell and * workspace share max-w-[1180px] so their left edges align. */ -test('settings navbar and workspace share the same max width shell', function () { - $navbar = file_get_contents(resource_path('views/components/settings/navbar.blade.php')); +test('instance settings pages use one shared sidebar workspace', function () { + $layout = file_get_contents(resource_path('views/components/settings/layout.blade.php')); - expect($navbar) + expect($layout) ->toContain('max-w-[1180px]') - ->toContain("title' => 'Settings'") - ->toContain(':titleOnDesktop="false"'); + ->toContain('application-settings-navigation') + ->toContain("'Configuration' =>") + ->toContain("'Instance' =>"); $pages = [ resource_path('views/livewire/settings/index.blade.php'), resource_path('views/livewire/settings/advanced.blade.php'), resource_path('views/livewire/settings/updates.blade.php'), resource_path('views/livewire/settings-oauth.blade.php'), + resource_path('views/livewire/settings-backup.blade.php'), + resource_path('views/livewire/settings-email.blade.php'), + resource_path('views/livewire/settings/scheduled-jobs.blade.php'), ]; foreach ($pages as $path) { $blade = file_get_contents($path); expect($blade) - ->toContain('toContain('max-w-[1180px]') - ->toContain('xl:grid-cols-[210px_minmax(0,1fr)]') + ->toContain('') + ->not->toContain('not->toContain('x-settings.page-header'); } + + $oauth = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php')); + expect($oauth) + ->toContain('') + ->toContain('aria-label="OAuth providers"') + ->toContain('window.scrollToSettingsSection?.') + ->toContain('history.replaceState') + ->not->toContain('xl:grid-cols-[210px_minmax(0,1fr)]'); }); test('dashboard navbar hides family titles at lg by default', function () { $dashboardNavbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php')); - $settingsNavbar = file_get_contents(resource_path('views/components/settings/navbar.blade.php')); expect($dashboardNavbar) ->toContain("'titleOnDesktop' => false") - ->toContain("'lg:hidden' => ! \$titleOnDesktop"); + ->toContain("'lg:hidden' => \$mobileTitleOnly || (! \$titleOnDesktop && \$showNav)"); - expect($settingsNavbar) - ->toContain(':titleOnDesktop="false"'); }); diff --git a/tests/Feature/SettingsUpdatesIconTest.php b/tests/Feature/SettingsUpdatesIconTest.php index b64a882e7..3178c57f3 100644 --- a/tests/Feature/SettingsUpdatesIconTest.php +++ b/tests/Feature/SettingsUpdatesIconTest.php @@ -4,7 +4,7 @@ * Instance settings Updates nav must use the reicon "refresh3" glyph. */ test('settings updates sidebar uses the refresh3 icon', function () { - $sidebar = file_get_contents(resource_path('views/components/settings/sidebar.blade.php')); + $sidebar = file_get_contents(resource_path('views/components/settings/layout.blade.php')); $reicon = file_get_contents(resource_path('views/components/reicon.blade.php')); expect($sidebar) diff --git a/tests/Feature/Team/TeamDeletionTest.php b/tests/Feature/Team/TeamDeletionTest.php index 65d6c54f1..ed6ee8a4f 100644 --- a/tests/Feature/Team/TeamDeletionTest.php +++ b/tests/Feature/Team/TeamDeletionTest.php @@ -1,6 +1,6 @@ 0]); + InstanceSettings::forceCreate(['id' => 0]); $this->owner = User::factory()->create(); @@ -27,7 +27,7 @@ test('deleting a team switches session to another team without error', function $this->actingAs($this->owner); session(['currentTeam' => $this->teamToDelete]); - Livewire::test(Index::class) + Livewire::test(DangerZone::class) ->call('delete') ->assertRedirect(route('team.index')); diff --git a/tests/Feature/TeamSettingsNavigationTest.php b/tests/Feature/TeamSettingsNavigationTest.php new file mode 100644 index 000000000..2597edc1b --- /dev/null +++ b/tests/Feature/TeamSettingsNavigationTest.php @@ -0,0 +1,39 @@ +toContain('') + ->not->toContain('toContain("'label' => 'General'") + ->toContain("'label' => 'Members'") + ->toContain("'label' => 'Admin View'") + ->toContain("'label' => 'Danger Zone'") + ->toContain('application-settings-navigation') + ->not->toContain('New team'); + expect(file_get_contents(resource_path('views/livewire/team/index.blade.php'))) + ->toContain('buttonTitle="New team"') + ->not->toContain('Delete team'); + expect(file_get_contents(resource_path('views/livewire/team/danger-zone.blade.php'))) + ->toContain('Delete team') + ->toContain('status="Permanent"') + ->toContain('border-red-300'); + expect(file_get_contents(resource_path('views/livewire/switch-team.blade.php'))) + ->toContain('New team') + ->toContain('team-switcher-create-expanded') + ->toContain('team-switcher-create-collapsed'); + expect($navbar) + ->toContain("request()->routeIs('team.index', 'team.member.index', 'team.admin-view', 'team.danger-zone')") + ->not->toContain("['label' => 'Members', 'route' => 'team.member.index'"); +});