feat(ui): unify team, security, and notification settings layouts

Extract shared settings-layout components and a dedicated team
danger-zone page. Enable modal editing for security resources,
polish OAuth/API-token controls, and align notification and
service configuration UI with the new navigation patterns.
This commit is contained in:
Andras Bacsai 2026-08-05 16:24:31 +02:00
parent 5842d77b81
commit 2291c3fa1c
83 changed files with 1585 additions and 597 deletions

View file

@ -93,11 +93,11 @@ public function updatedPermissions($permissionToUpdate)
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) {

View file

@ -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 @@ protected function messages(): array
];
}
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 @@ public function save(): void
]);
$this->dispatch('success', 'Cloud-init script updated successfully.');
$this->dispatch('securityResourceChanged');
}
public function delete(): mixed
@ -87,6 +91,13 @@ public function delete(): mixed
'cloud_init_script_name' => $scriptName,
]);
if ($this->modalMode) {
$this->dispatch('securityResourceChanged');
$this->dispatch('close-modal');
return null;
}
return redirectRoute($this, 'security.cloud-init-scripts');
}

View file

@ -22,6 +22,7 @@ public function getListeners()
{
return [
'scriptSaved' => 'loadScripts',
'securityResourceChanged' => 'loadScripts',
];
}

View file

@ -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 @@ protected function messages(): array
];
}
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 @@ public function save(): void
]);
$this->dispatch('success', 'Cloud provider token updated.');
$this->dispatch('securityResourceChanged');
}
public function validateToken(): void
@ -122,6 +126,13 @@ public function delete(): mixed
$this->cloudProviderToken->delete();
if ($this->modalMode) {
$this->dispatch('securityResourceChanged');
$this->dispatch('close-modal');
return null;
}
return redirectRoute($this, 'security.cloud-tokens');
}

View file

@ -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);
}
}

View file

@ -27,6 +27,7 @@ public function getListeners()
{
return [
'tokenAdded' => 'loadTokens',
'securityResourceChanged' => 'loadTokens',
];
}

View file

@ -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 @@ public function generatePrivateKey(string $type)
'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);
}

View file

@ -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 @@ private function syncData(bool $toModel = false): void
}
}
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 @@ public function delete()
$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 @@ public function changePrivateKey()
]);
refresh_server_connection($this->private_key);
$this->dispatch('success', 'Private key updated.');
$this->dispatch('securityResourceChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -66,7 +66,7 @@ private function updateOauthSettings(?string $provider = null)
'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.'.<br/>Please fill in all required fields.');
}
@ -141,6 +141,48 @@ public function instantSave(string $provider)
}
}
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 {

View file

@ -0,0 +1,56 @@
<?php
namespace App\Livewire\Team;
use App\Models\Team;
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 DangerZone extends Component
{
use AuthorizesRequests;
public Team $team;
public function mount(): void
{
$this->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');
}
}

View file

@ -6,9 +6,6 @@
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 @@ public function submit()
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);
}
}
}

1
public/svgs/discord.svg Normal file
View file

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/svgs/pushover.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600"><path d="M 280.949 172.514 L 355.429 162.714 L 282.909 326.374 L 282.909 326.374 C 295.649 325.394 308.142 321.067 320.389 313.394 L 320.389 313.394 L 320.389 313.394 C 332.642 305.714 343.916 296.077 354.209 284.484 L 354.209 284.484 L 354.209 284.484 C 364.496 272.884 373.396 259.981 380.909 245.774 L 380.909 245.774 L 380.909 245.774 C 388.422 231.561 393.812 217.594 397.079 203.874 L 397.079 203.874 L 397.079 203.874 C 399.039 195.381 399.939 187.214 399.779 179.374 L 399.779 179.374 L 399.779 179.374 C 399.612 171.534 397.569 164.674 393.649 158.794 L 393.649 158.794 L 393.649 158.794 C 389.729 152.914 383.766 148.177 375.759 144.584 L 375.759 144.584 L 375.759 144.584 C 367.759 140.991 356.899 139.194 343.179 139.194 L 343.179 139.194 L 343.179 139.194 C 327.172 139.194 311.409 141.807 295.889 147.034 L 295.889 147.034 L 295.889 147.034 C 280.376 152.261 266.002 159.857 252.769 169.824 L 252.769 169.824 L 252.769 169.824 C 239.542 179.784 228.029 192.197 218.229 207.064 L 218.229 207.064 L 218.229 207.064 C 208.429 221.924 201.406 238.827 197.159 257.774 L 197.159 257.774 L 197.159 257.774 C 195.526 263.981 194.546 268.961 194.219 272.714 L 194.219 272.714 L 194.219 272.714 C 193.892 276.474 193.812 279.577 193.979 282.024 L 193.979 282.024 L 193.979 282.024 C 194.139 284.477 194.462 286.357 194.949 287.664 L 194.949 287.664 L 194.949 287.664 C 195.442 288.971 195.852 290.277 196.179 291.584 L 196.179 291.584 L 196.179 291.584 C 179.519 291.584 167.349 288.234 159.669 281.534 L 159.669 281.534 L 159.669 281.534 C 151.996 274.841 150.119 263.164 154.039 246.504 L 154.039 246.504 L 154.039 246.504 C 157.959 229.191 166.862 212.694 180.749 197.014 L 180.749 197.014 L 180.749 197.014 C 194.629 181.334 211.122 167.531 230.229 155.604 L 230.229 155.604 L 230.229 155.604 C 249.342 143.684 270.249 134.214 292.949 127.194 L 292.949 127.194 L 292.949 127.194 C 315.656 120.167 337.789 116.654 359.349 116.654 L 359.349 116.654 L 359.349 116.654 C 378.296 116.654 394.219 119.347 407.119 124.734 L 407.119 124.734 L 407.119 124.734 C 420.026 130.127 430.072 137.234 437.259 146.054 L 437.259 146.054 L 437.259 146.054 C 444.446 154.874 448.936 165.164 450.729 176.924 L 450.729 176.924 L 450.729 176.924 C 452.529 188.684 451.959 200.934 449.019 213.674 L 449.019 213.674 L 449.019 213.674 C 445.426 229.027 438.646 244.464 428.679 259.984 L 428.679 259.984 L 428.679 259.984 C 418.719 275.497 406.226 289.544 391.199 302.124 L 391.199 302.124 L 391.199 302.124 C 376.172 314.697 358.939 324.904 339.499 332.744 L 339.499 332.744 L 339.499 332.744 C 320.066 340.584 299.406 344.504 277.519 344.504 L 277.519 344.504 L 275.069 344.504 L 212.839 484.154 L 142.279 484.154 L 280.949 172.514 Z"/></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

1
public/svgs/slack.svg Normal file
View file

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Slack</title><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
public/svgs/telegram.svg Normal file
View file

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Telegram</title><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>

After

Width:  |  Height:  |  Size: 742 B

View file

@ -1030,8 +1030,9 @@ .application-settings-section-body {
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 @@ .server-settings-workspace > :not(.application-settings-navigation) {
@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;

View file

@ -6,13 +6,20 @@
])
<section {{ $attributes->merge(['class' => 'application-settings-section']) }}>
<header>
<header @class(['items-start!' => filled($description)])>
<div class="min-w-0 py-0.5">
<div class="flex items-center gap-2">
<h3>{{ $title }}</h3>
@if ($helper)
<x-helper :helper="$helper" />
@endif
</div>
@if (filled($description))
<p class="mt-0.5 text-xs leading-4 text-neutral-500 dark:text-[var(--coollabs-subtle)]">
{{ $description }}
</p>
@endif
</div>
@isset($actions)
<div class="flex min-w-0 max-w-full flex-wrap items-center gap-2">
{{ $actions }}

View file

@ -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' => [
[

View file

@ -1,18 +1,30 @@
<div x-data="{
open: false,
pinned: false,
hideTimer: null,
style: '',
show(pinned = false) {
this.cancelHide();
this.pinned = pinned;
this.open = true;
this.$nextTick(() => this.position());
},
hide() {
this.cancelHide();
this.hideTimer = setTimeout(() => {
if (!this.pinned) {
this.open = false;
}
}, 150);
},
cancelHide() {
if (this.hideTimer) {
clearTimeout(this.hideTimer);
this.hideTimer = null;
}
},
close() {
this.cancelHide();
this.pinned = false;
this.open = false;
},
@ -64,7 +76,7 @@ class="size-3.5 text-neutral-400 transition-colors hover:text-neutral-600 dark:t
x-transition:leave-end="opacity-0"
:style="style"
class="info-helper-popup fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))]"
@click.stop>
@mouseenter="cancelHide()" @mouseleave="hide()" @click.stop>
<div class="px-3 py-2.5 text-[13px] leading-5">
{!! $helper !!}
</div>

View file

@ -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)
<div @click="modalOpen=true" @class(['h-full w-full' => $isFullWidth])>
<div @if ($contentClicks) @click="modalOpen=true" @endif @class(['h-full w-full' => $isFullWidth])>
{{ $content }}
</div>
@else

View file

@ -0,0 +1,28 @@
@props([
'enabled',
'enabledProperty',
'toggleMethod',
'testMethod' => 'sendTestNotification',
'canUpdate' => true,
])
<div class="flex items-center gap-2"
x-data="{
enabled: @js((bool) $enabled),
enabledProperty: @js($enabledProperty),
toggleMethod: @js($toggleMethod),
testMethod: @js($testMethod),
}">
<x-forms.button type="button" :disabled="!$canUpdate"
x-on:click="
if (!enabled && !$el.closest('form').reportValidity()) return;
$wire.$set(enabledProperty, !enabled).then(() => $wire.$call(toggleMethod));
">
{{ $enabled ? 'Disable' : 'Enable' }}
</x-forms.button>
<x-forms.button type="button" :disabled="!$enabled"
x-on:click="if ($el.closest('form').reportValidity()) $wire.$call(testMethod)">
<x-reicon name="notifications" class="size-3.5" />
Send test
</x-forms.button>
</div>

View file

@ -33,9 +33,38 @@
['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
<x-application.settings-section title="Notification events">
<div class="flex flex-col gap-6">
<x-application.settings-section title="Notification events"
description="Choose which events send a notification on this channel.">
<div class="grid gap-4 lg:grid-cols-2">
@foreach ($eventGroups as $group => $events)
@php
@ -54,23 +83,53 @@
<div class="min-w-0">
<x-notification.event-multiselect :settings="$settings" :id="$groupId"
:label="$group" :events="$multiselectEvents" />
</div>
@endforeach
</div>
</x-application.settings-section>
@if ($threaded)
<x-application.settings-section title="Forum topics"
description="Optional. Route enabled events to a Telegram forum topic using its message thread ID. Leave blank to post in the main chat.">
@if ($enabledThreadEvents === [])
<p class="text-[13px] leading-relaxed text-neutral-500 dark:text-fg-dim">
Enable one or more events above to assign forum topic IDs.
</p>
@else
<div class="flex flex-col gap-5">
@foreach ($enabledThreadEventsByGroup as $group => $events)
<div class="min-w-0">
<div
class="mt-3 grid gap-3 border-l border-neutral-200 pl-3 sm:grid-cols-2 dark:border-white/[0.08]">
class="mb-2 text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">
{{ $group }}
</div>
<div
class="divide-y divide-neutral-200 overflow-hidden rounded-xl border border-neutral-200 dark:divide-white/[0.06] dark:border-white/[0.08]">
@foreach ($events as $event)
@php
$threadModel = Str::camel(
Str::studly($channel) . 'Notifications' . Str::studly($event['key']) . 'ThreadId',
);
@endphp
<div
class="grid gap-2 px-3.5 py-3 sm:grid-cols-[minmax(0,1fr)_minmax(10rem,14rem)] sm:items-center sm:gap-4"
wire:key="{{ $channel }}-thread-row-{{ $event['key'] }}">
<div class="min-w-0">
<div
class="truncate text-[13px] font-medium text-black dark:text-fg">
{{ $event['label'] }}
</div>
<div class="text-[11px] text-neutral-500 dark:text-fg-dim">
Topic ID
</div>
</div>
<x-forms.input wire:key="{{ $channel }}-thread-{{ $event['key'] }}"
canGate="update" :canResource="$settings" type="password" :id="$threadModel"
label="{{ $event['label'] }} thread ID" placeholder="Optional" />
canGate="update" :canResource="$settings" type="password"
:id="$event['threadModel']" :label="null"
:placeholder="'Optional'"
:aria-label="$event['label'] . ' topic ID'" />
</div>
@endforeach
</div>
</div>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
</x-application.settings-section>
</x-application.settings-section>
@endif
</div>

View file

@ -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
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Notification settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Notifications</div>
@foreach ($notificationMenuItems as $menuItem)
<a wire:key="notification-settings-{{ str($menuItem['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => request()->routeIs($menuItem['route'])])
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
@if (isset($menuItem['brandIcon']))
<span class="menu-item-icon bg-current"
style="mask: url('{{ asset('svgs/' . $menuItem['brandIcon'] . '.svg') }}') center / contain no-repeat; -webkit-mask: url('{{ asset('svgs/' . $menuItem['brandIcon'] . '.svg') }}') center / contain no-repeat;"></span>
@else
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
@endif
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</nav>
</aside>
<div class="min-w-0">
{{ $slot }}
</div>
</div>
</section>

View file

@ -6,6 +6,9 @@
// copy the inner markup from the reicon Outline weight and swap
// #000000 -> currentColor. See UI_REDESIGN.md.
$icons = [
'cloud' => '<path d="M7.5 19.25h9.75a4.5 4.5 0 0 0 .62-8.96A6.25 6.25 0 0 0 5.8 8.65 5.25 5.25 0 0 0 7.5 19.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
'code' => '<path d="m8.5 7-5 5 5 5M15.5 7l5 5-5 5M13.5 4l-3 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
'mail' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M5.5 3.25h13A4.25 4.25 0 0 1 22.75 7.5v9a4.25 4.25 0 0 1-4.25 4.25h-13A4.25 4.25 0 0 1 1.25 16.5v-9A4.25 4.25 0 0 1 5.5 3.25Zm-2.75 4.5v8.75a2.75 2.75 0 0 0 2.75 2.75h13a2.75 2.75 0 0 0 2.75-2.75V7.75l-6.6 5.13a4.25 4.25 0 0 1-5.3 0l-6.6-5.13Zm18.4-1.3A2.75 2.75 0 0 0 18.5 4.75h-13a2.75 2.75 0 0 0-2.65 1.7l7.42 5.77a2.75 2.75 0 0 0 3.46 0l7.42-5.77Z" fill="currentColor"/>',
'dashboard' => '<path d="M9 17.25C8.58579 17.25 8.25 17.5858 8.25 18C8.25 18.4142 8.58579 18.75 9 18.75H15C15.4142 18.75 15.75 18.4142 15.75 18C15.75 17.5858 15.4142 17.25 15 17.25H9Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M12 1.25C11.2919 1.25 10.6485 1.45282 9.95055 1.79224C9.27585 2.12035 8.49642 2.60409 7.52286 3.20832L5.45628 4.4909C4.53509 5.06261 3.79744 5.5204 3.2289 5.95581C2.64015 6.40669 2.18795 6.86589 1.86131 7.46263C1.53535 8.05812 1.38857 8.69174 1.31819 9.4407C1.24999 10.1665 1.24999 11.0541 1.25 12.1672V13.7799C1.24999 15.6837 1.24998 17.1866 1.4027 18.3616C1.55937 19.567 1.88856 20.5401 2.63236 21.3094C3.37958 22.0824 4.33046 22.4277 5.50761 22.5914C6.64849 22.75 8.10556 22.75 9.94185 22.75H14.0581C15.8944 22.75 17.3515 22.75 18.4924 22.5914C19.6695 22.4277 20.6204 22.0824 21.3676 21.3094C22.1114 20.5401 22.4406 19.567 22.5973 18.3616C22.75 17.1866 22.75 15.6838 22.75 13.7799V12.1672C22.75 11.0541 22.75 10.1665 22.6818 9.4407C22.6114 8.69174 22.4646 8.05812 22.1387 7.46263C21.8121 6.86589 21.3599 6.40669 20.7711 5.95581C20.2026 5.5204 19.4649 5.06262 18.5437 4.49091L16.4771 3.20831C15.5036 2.60409 14.7241 2.12034 14.0494 1.79224C13.3515 1.45282 12.7081 1.25 12 1.25ZM8.27953 4.50412C9.29529 3.87371 10.0095 3.43153 10.6065 3.1412C11.1882 2.85833 11.6002 2.75 12 2.75C12.3998 2.75 12.8118 2.85833 13.3935 3.14119C13.9905 3.43153 14.7047 3.87371 15.7205 4.50412L17.7205 5.74537C18.6813 6.34169 19.3559 6.76135 19.8591 7.1467C20.3487 7.52164 20.6303 7.83106 20.8229 8.18285C21.0162 8.53589 21.129 8.94865 21.1884 9.58104C21.2492 10.2286 21.25 11.0458 21.25 12.2039V13.725C21.25 15.6959 21.2485 17.1012 21.1098 18.1683C20.9736 19.2163 20.717 19.8244 20.2892 20.2669C19.8649 20.7058 19.2871 20.9664 18.2858 21.1057C17.2602 21.2483 15.9075 21.25 14 21.25H10C8.09247 21.25 6.73983 21.2483 5.71422 21.1057C4.71286 20.9664 4.13514 20.7058 3.71079 20.2669C3.28301 19.8244 3.02642 19.2163 2.89019 18.1683C2.75149 17.1012 2.75 15.6959 2.75 13.725V12.2039C2.75 11.0458 2.75076 10.2286 2.81161 9.58104C2.87103 8.94865 2.98385 8.53589 3.17709 8.18285C3.36965 7.83106 3.65133 7.52164 4.14092 7.1467C4.6441 6.76135 5.31869 6.34169 6.27953 5.74537L8.27953 4.50412Z" fill="currentColor"/>',
'projects' => '<path d="M5 10H7C9 10 10 9 10 7V5C10 3 9 2 7 2H5C3 2 2 3 2 5V7C2 9 3 10 5 10Z" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M17 10H19C21 10 22 9 22 7V5C22 3 21 2 19 2H17C15 2 14 3 14 5V7C14 9 15 10 17 10Z" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M17 22H19C21 22 22 21 22 19V17C22 15 21 14 19 14H17C15 14 14 15 14 17V19C14 21 15 22 17 22Z" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M5 22H7C9 22 10 21 10 19V17C10 15 9 14 7 14H5C3 14 2 15 2 17V19C2 21 3 22 5 22Z" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>',
'servers' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M5.948 1.5H18.052C18.9505 1.49997 19.6997 1.49995 20.2945 1.57991C20.9223 1.66432 21.4891 1.84999 21.9445 2.30546C22.4 2.76093 22.5857 3.32773 22.6701 3.95552C22.7501 4.5503 22.75 5.29953 22.75 6.19801V6.302C22.75 7.20048 22.7501 7.9497 22.6701 8.54448C22.5857 9.17228 22.4 9.73907 21.9445 10.1945C21.4891 10.65 20.9223 10.8357 20.2945 10.9201C19.6997 11.0001 18.9505 11 18.052 11H5.94801C5.04953 11 4.3003 11.0001 3.70552 10.9201C3.07773 10.8357 2.51093 10.65 2.05546 10.1945C1.59999 9.73907 1.41432 9.17228 1.32991 8.54448C1.24995 7.9497 1.24997 7.20048 1.25 6.302V6.198C1.24997 5.29952 1.24995 4.5503 1.32991 3.95552C1.41432 3.32773 1.59999 2.76093 2.05546 2.30546C2.51093 1.84999 3.07773 1.66432 3.70552 1.57991C4.3003 1.49995 5.04952 1.49997 5.948 1.5ZM3.90539 3.06654C3.44393 3.12858 3.24644 3.2358 3.11612 3.36612C2.9858 3.49643 2.87858 3.69393 2.81654 4.15539C2.7516 4.63843 2.75 5.28599 2.75 6.25C2.75 7.21401 2.7516 7.86157 2.81654 8.34461C2.87858 8.80607 2.9858 9.00357 3.11612 9.13388C3.24644 9.2642 3.44393 9.37142 3.90539 9.43347C4.38843 9.49841 5.03599 9.5 6 9.5H18C18.964 9.5 19.6116 9.49841 20.0946 9.43347C20.5561 9.37142 20.7536 9.2642 20.8839 9.13388C21.0142 9.00357 21.1214 8.80607 21.1835 8.34461C21.2484 7.86157 21.25 7.21401 21.25 6.25C21.25 5.28599 21.2484 4.63843 21.1835 4.15539C21.1214 3.69393 21.0142 3.49643 20.8839 3.36612C20.7536 3.2358 20.5561 3.12858 20.0946 3.06654C19.6116 3.00159 18.964 3 18 3H6C5.03599 3 4.38843 3.00159 3.90539 3.06654ZM5.25 6.25C5.25 5.83579 5.58579 5.5 6 5.5H8C8.41422 5.5 8.75 5.83579 8.75 6.25C8.75 6.66422 8.41422 7 8 7H6C5.58579 7 5.25 6.66422 5.25 6.25ZM10.25 6.25C10.25 5.83579 10.5858 5.5 11 5.5H18C18.4142 5.5 18.75 5.83579 18.75 6.25C18.75 6.66422 18.4142 7 18 7H11C10.5858 7 10.25 6.66422 10.25 6.25ZM5.94801 12.5H18.052C18.9505 12.5 19.6997 12.4999 20.2945 12.5799C20.9223 12.6643 21.4891 12.85 21.9445 13.3055C22.4 13.7609 22.5857 14.3277 22.6701 14.9555C22.7501 15.5503 22.75 16.2995 22.75 17.198V17.302C22.75 18.2005 22.7501 18.9497 22.6701 19.5445C22.5857 20.1723 22.4 20.7391 21.9445 21.1945C21.4891 21.65 20.9223 21.8357 20.2945 21.9201C19.6997 22.0001 18.9505 22 18.052 22H5.94801C5.04953 22 4.30031 22.0001 3.70552 21.9201C3.07773 21.8357 2.51093 21.65 2.05546 21.1945C1.59999 20.7391 1.41432 20.1723 1.32991 19.5445C1.24995 18.9497 1.24997 18.2005 1.25 17.302V17.198C1.24997 16.2995 1.24995 15.5503 1.32991 14.9555C1.41432 14.3277 1.59999 13.7609 2.05546 13.3055C2.51093 12.85 3.07773 12.6643 3.70552 12.5799C4.30031 12.4999 5.04953 12.5 5.94801 12.5ZM3.90539 14.0665C3.44393 14.1286 3.24644 14.2358 3.11612 14.3661C2.9858 14.4964 2.87858 14.6939 2.81654 15.1554C2.7516 15.6384 2.75 16.286 2.75 17.25C2.75 18.214 2.7516 18.8616 2.81654 19.3446C2.87858 19.8061 2.9858 20.0036 3.11612 20.1339C3.24644 20.2642 3.44393 20.3714 3.90539 20.4335C4.38843 20.4984 5.03599 20.5 6 20.5H18C18.964 20.5 19.6116 20.4984 20.0946 20.4335C20.5561 20.3714 20.7536 20.2642 20.8839 20.1339C21.0142 20.0036 21.1214 19.8061 21.1835 19.3446C21.2484 18.8616 21.25 18.214 21.25 17.25C21.25 16.286 21.2484 15.6384 21.1835 15.1554C21.1214 14.6939 21.0142 14.4964 20.8839 14.3661C20.7536 14.2358 20.5561 14.1286 20.0946 14.0665C19.6116 14.0016 18.964 14 18 14H6C5.03599 14 4.38843 14.0016 3.90539 14.0665ZM5.25 17.25C5.25 16.8358 5.58579 16.5 6 16.5H8C8.41422 16.5 8.75 16.8358 8.75 17.25C8.75 17.6642 8.41422 18 8 18H6C5.58579 18 5.25 17.6642 5.25 17.25ZM10.25 17.25C10.25 16.8358 10.5858 16.5 11 16.5H18C18.4142 16.5 18.75 16.8358 18.75 17.25C18.75 17.6642 18.4142 18 18 18H11C10.5858 18 10.25 17.6642 10.25 17.25Z" fill="currentColor"/>',

View file

@ -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
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Keys and tokens"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Keys & Tokens</div>
@foreach ($securityMenuItems as $menuItem)
<a wire:key="security-settings-{{ str($menuItem['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => $menuItem['active']])
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</nav>
</aside>
<div class="min-w-0">
@isset($actions)
<div class="mb-3 flex min-h-8 flex-wrap items-center justify-end gap-2">
{{ $actions }}
</div>
@endisset
{{ $slot }}
</div>
</div>
</section>

View file

@ -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
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Instance settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($settingsMenuSections as $section => $menuItems)
<div @class([
'nav-section col-span-full hidden xl:block',
'mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.06]' => !$loop->first,
])>{{ $section }}</div>
@foreach ($menuItems as $menuItem)
<a wire:key="instance-settings-{{ str($menuItem['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => request()->routeIs($menuItem['route'])])
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@if ($menuItem['route'] === 'settings.oauth' && isset($submenu))
<div class="col-span-full ml-5 border-l border-neutral-200 pl-2 dark:border-white/[0.08]">
{{ $submenu }}
</div>
@endif
@endforeach
@endforeach
</nav>
</aside>
<div class="min-w-0">
{{ $slot }}
</div>
</div>
</section>

View file

@ -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
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Team settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Team</div>
@foreach ($teamMenuItems as $menuItem)
@if ($menuItem['sectionStart'] ?? false)
<div class="col-span-full my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
aria-hidden="true"></div>
@endif
<a wire:key="team-settings-{{ str($menuItem['label'])->slug() }}"
@class(['menu-item', 'menu-item-active' => $menuItem['active']])
{{ wireNavigate() }} href="{{ route($menuItem['route']) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
</nav>
</aside>
<div class="min-w-0">
{{ $slot }}
</div>
</div>
</section>

View file

@ -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 @@ class="button h-9 justify-center bg-red-600! text-white! ring-1 ring-red-600/25
<span x-show="popups.sponsorship">
<x-popup>
<x-slot:customActions>
<div
class="relative mx-auto flex w-full max-w-2xl flex-col gap-5 overflow-hidden rounded-2xl border border-neutral-200 bg-white p-5 shadow-modal sm:p-6 dark:border-white/[0.1] dark:bg-surface">
<div class="relative mx-auto flex w-full flex-col overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-modal transition-all duration-300 dark:border-white/[0.1] dark:bg-surface"
:class="reminders.sponsorship.compact ? 'max-w-sm gap-3 p-4' : 'max-w-2xl gap-5 p-5 sm:p-6'">
<button type="button" aria-label="Dismiss sponsorship reminder"
class="absolute top-3 right-3 flex size-7 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
@click="bannerVisible=false;disableSponsorship()">
@ -129,7 +153,7 @@ class="absolute top-3 right-3 flex size-7 items-center justify-center rounded-fu
</button>
<div class="flex items-start gap-4 pr-8">
<div
<div x-show="!reminders.sponsorship.compact" x-transition.opacity
class="hidden size-12 shrink-0 items-center justify-center rounded-xl border border-neutral-200 bg-neutral-50 sm:flex dark:border-white/[0.08] dark:bg-white/[0.04]">
<img src="{{ asset('heart.png') }}" alt="" class="size-9">
</div>
@ -137,7 +161,8 @@ class="hidden size-12 shrink-0 items-center justify-center rounded-xl border bor
<h2 class="text-[15px]! leading-5! font-semibold! text-black dark:text-fg">
Love Coolify? Support our work.
</h2>
<p class="mt-1 text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
<p x-show="!reminders.sponsorship.compact" x-transition.opacity
class="mt-1 text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
Coolify is profitable thanks to <span
class="font-semibold text-coollabs dark:text-warning">you</span>. Your support
helps us build more features and keep improving the project.
@ -150,16 +175,17 @@ class="font-semibold text-coollabs dark:text-warning">you</span>. Your support
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
</a>
<a target="_blank"
<a x-show="!reminders.sponsorship.compact" x-transition.opacity target="_blank"
href="https://opencollective.com/coollabsio/donate?interval=month&amount=10&name=&legalName=&email="
class="button h-9 justify-center sm:flex-1">
Open Collective
</a>
<a href="https://donate.stripe.com/8x2bJ104ifmB9kB45u38402" target="_blank"
<a x-show="!reminders.sponsorship.compact" x-transition.opacity
href="https://donate.stripe.com/8x2bJ104ifmB9kB45u38402" target="_blank"
class="button h-9 justify-center sm:flex-1">
Stripe
</a>
<button type="button"
<button x-show="!reminders.sponsorship.compact" x-transition.opacity type="button"
class="h-9 cursor-pointer px-2 text-[12px] font-medium text-neutral-500 transition-colors hover:text-black sm:shrink-0 dark:text-fg-dim dark:hover:text-fg"
@click="bannerVisible=false;disableSponsorship()">
Maybe next time
@ -223,8 +249,8 @@ class="underline dark:text-white">/subscription</a> to update your subscription
<span x-show="popups.notification">
<x-popup>
<x-slot:customActions>
<div
class="relative mx-auto flex w-full max-w-2xl flex-col gap-5 overflow-hidden rounded-2xl border border-neutral-200 bg-white p-5 shadow-modal sm:p-6 dark:border-white/[0.1] dark:bg-surface">
<div class="relative mx-auto flex w-full flex-col overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-modal transition-all duration-300 dark:border-white/[0.1] dark:bg-surface"
:class="reminders.notification.compact ? 'max-w-sm gap-3 p-4' : 'max-w-2xl gap-5 p-5 sm:p-6'">
<button type="button" aria-label="Dismiss notifications reminder"
class="absolute top-3 right-3 flex size-7 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
@click="bannerVisible=false;disableNotification()">
@ -232,7 +258,7 @@ class="absolute top-3 right-3 flex size-7 items-center justify-center rounded-fu
</button>
<div class="flex items-start gap-4 pr-8">
<div
<div x-show="!reminders.notification.compact" x-transition.opacity
class="hidden size-12 shrink-0 items-center justify-center rounded-xl border border-amber-200 bg-amber-50 text-amber-700 sm:flex dark:border-warning/20 dark:bg-warning/10 dark:text-warning">
<x-reicon name="alert-triangle" class="size-6" />
</div>
@ -240,7 +266,8 @@ class="hidden size-12 shrink-0 items-center justify-center rounded-xl border bor
<h2 class="text-[15px]! leading-5! font-semibold! text-black dark:text-fg">
No notifications enabled
</h2>
<p class="mt-1 text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
<p x-show="!reminders.notification.compact" x-transition.opacity
class="mt-1 text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
Enable at least one notification channel so you receive important alerts.
Visit
<a href="{{ route('notifications.email') }}" {{ wireNavigate() }}
@ -255,7 +282,8 @@ class="font-medium text-coollabs underline decoration-coollabs/30 underline-offs
class="button h-9 justify-center sm:min-w-28">
Open notifications
</a>
<button type="button" class="button h-9 justify-center sm:min-w-32"
<button x-show="!reminders.notification.compact" x-transition.opacity type="button"
class="button h-9 justify-center sm:min-w-32"
@click="bannerVisible=false;disableNotification()">
Accept and close
</button>

View file

@ -3,28 +3,18 @@
Discord Notifications | Coolify
</x-slot>
<x-notification.navbar />
<x-notification.settings-layout>
<div class="application-settings-form flex flex-col gap-6">
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<x-application.settings-section title="Discord"
description="Send team notifications to a Discord channel through an incoming webhook.">
<x-slot:actions>
<x-forms.button type="button" wire:click="sendTestNotification"
:disabled="!$discordEnabled">
<x-reicon name="notifications" class="size-3.5" />
Send test
</x-forms.button>
<x-notification.channel-actions :enabled="$discordEnabled" enabledProperty="discordEnabled"
toggleMethod="instantSaveDiscordEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="discordEnabled" label="Discord delivery"
onChange="instantSaveDiscordEnabled"
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
<x-forms.listbox id="discordPingEnabled" label="Critical event mention"
helper="Mention @here when a critical event occurs."
onChange="instantSaveDiscordPingEnabled"
@ -46,4 +36,5 @@
<x-notification.event-grid :settings="$settings" channel="discord" />
</div>
</x-notification.settings-layout>
</div>

View file

@ -3,8 +3,7 @@
Notifications | Coolify
</x-slot>
<x-notification.navbar />
<x-notification.settings-layout>
<div class="flex flex-col gap-6">
<form wire:submit="submit" class="application-settings-form">
<x-unsaved-bar action="submit" />
@ -176,4 +175,5 @@ class="button bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-co
</x-application.settings-section>
</div>
</div>
</x-notification.settings-layout>
</div>

View file

@ -3,32 +3,18 @@
Pushover Notifications | Coolify
</x-slot>
<x-notification.navbar />
<x-notification.settings-layout>
<div class="application-settings-form flex flex-col gap-6">
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<x-application.settings-section title="Pushover"
description="Deliver team alerts through your Pushover application.">
<x-slot:actions>
<x-forms.button type="button" wire:click="sendTestNotification"
:disabled="!$pushoverEnabled">
<x-reicon name="notifications" class="size-3.5" />
Send test
</x-forms.button>
<x-notification.channel-actions :enabled="$pushoverEnabled" enabledProperty="pushoverEnabled"
toggleMethod="instantSavePushoverEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<div class="lg:col-span-2">
<div class="w-full sm:w-72">
<x-forms.listbox id="pushoverEnabled" label="Pushover delivery"
onChange="instantSavePushoverEnabled"
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
</div>
</div>
@can('update', $settings)
<x-forms.input type="password" required id="pushoverUserKey" label="User key"
helper="Find this in the Pushover dashboard." />
@ -44,4 +30,5 @@
<x-notification.event-grid :settings="$settings" channel="pushover" />
</div>
</x-notification.settings-layout>
</div>

View file

@ -3,32 +3,18 @@
Slack Notifications | Coolify
</x-slot>
<x-notification.navbar />
<x-notification.settings-layout>
<div class="application-settings-form flex flex-col gap-6">
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<x-application.settings-section title="Slack"
description="Send team notifications to Slack through an incoming webhook.">
<x-slot:actions>
<x-forms.button type="button" wire:click="sendTestNotification"
:disabled="!$slackEnabled">
<x-reicon name="notifications" class="size-3.5" />
Send test
</x-forms.button>
<x-notification.channel-actions :enabled="$slackEnabled" enabledProperty="slackEnabled"
toggleMethod="instantSaveSlackEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<div class="lg:col-span-2">
<div class="w-full sm:w-72">
<x-forms.listbox id="slackEnabled" label="Slack delivery"
onChange="instantSaveSlackEnabled"
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
</div>
</div>
<div class="lg:col-span-2">
@can('update', $settings)
<x-forms.input type="password" required id="slackWebhookUrl" label="Webhook URL"
@ -43,4 +29,5 @@
<x-notification.event-grid :settings="$settings" channel="slack" />
</div>
</x-notification.settings-layout>
</div>

View file

@ -3,32 +3,18 @@
Telegram Notifications | Coolify
</x-slot>
<x-notification.navbar />
<x-notification.settings-layout>
<div class="application-settings-form flex flex-col gap-6">
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<x-application.settings-section title="Telegram"
description="Deliver team notifications through a Telegram bot and chat.">
<x-slot:actions>
<x-forms.button type="button" wire:click="sendTestNotification"
:disabled="!$telegramEnabled">
<x-reicon name="notifications" class="size-3.5" />
Send test
</x-forms.button>
<x-notification.channel-actions :enabled="$telegramEnabled" enabledProperty="telegramEnabled"
toggleMethod="instantSaveTelegramEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<div class="lg:col-span-2">
<div class="w-full sm:w-72">
<x-forms.listbox id="telegramEnabled" label="Telegram delivery"
onChange="instantSaveTelegramEnabled"
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
</div>
</div>
@can('update', $settings)
<x-forms.input type="password" autocomplete="new-password" required id="telegramToken"
label="Bot API token" helper="Create a bot with BotFather to obtain this token." />
@ -44,4 +30,5 @@
<x-notification.event-grid :settings="$settings" channel="telegram" threaded />
</div>
</x-notification.settings-layout>
</div>

View file

@ -3,32 +3,18 @@
Webhook Notifications | Coolify
</x-slot>
<x-notification.navbar />
<x-notification.settings-layout>
<div class="application-settings-form flex flex-col gap-6">
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
<x-application.settings-section title="Webhook"
description="Send JSON event payloads to your own HTTP endpoint.">
<x-slot:actions>
<x-forms.button type="button" wire:click="sendTestNotification"
:disabled="!$webhookEnabled">
<x-reicon name="notifications" class="size-3.5" />
Send test
</x-forms.button>
<x-notification.channel-actions :enabled="$webhookEnabled" enabledProperty="webhookEnabled"
toggleMethod="instantSaveWebhookEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<div class="lg:col-span-2">
<div class="w-full sm:w-72">
<x-forms.listbox id="webhookEnabled" label="Webhook delivery"
onChange="instantSaveWebhookEnabled"
:disabled="!auth()->user()->can('update', $settings)" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
</div>
</div>
<div class="lg:col-span-2">
@can('update', $settings)
<x-forms.input type="password" required id="webhookUrl" label="Webhook URL"
@ -43,4 +29,5 @@
<x-notification.event-grid :settings="$settings" channel="webhook" />
</div>
</x-notification.settings-layout>
</div>

View file

@ -90,7 +90,13 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
@if ($currentRoute === 'project.service.configuration')
<livewire:project.service.stack-form :service="$service" />
<div class="mt-8">
<div class="mt-8" x-data="{
viewMode: localStorage.getItem('service-compose-resources-view') || 'table',
setViewMode(mode) {
this.viewMode = mode;
localStorage.setItem('service-compose-resources-view', mode);
}
}">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<h2 class="text-base font-semibold text-black dark:text-fg">Compose resources</h2>
@ -98,13 +104,46 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
Applications and databases defined in this service.
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<div
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
? 'bg-coollabs/10 text-coollabs dark:bg-warning/15 dark:text-warning'
: 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
aria-label="Table view" title="Table view">
<x-reicon name="unordered-list" class="size-3.5" />
</button>
<button type="button" x-on:click="setViewMode('grid')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'grid'
? 'bg-coollabs/10 text-coollabs dark:bg-warning/15 dark:text-warning'
: 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
aria-label="Grid view" title="Grid view">
<x-reicon name="grid" class="size-3.5" />
</button>
</div>
<a class="button" target="_blank" href="{{ $service->documentation() }}">
Documentation
<x-reicon name="external-link" class="size-4" />
</a>
</div>
</div>
<div :class="viewMode === 'grid'
? 'grid grid-cols-1 gap-3 sm:grid-cols-2'
: 'overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]'">
@if ($applications->isNotEmpty() || $databases->isNotEmpty())
<div x-cloak x-show="viewMode === 'table'"
class="grid grid-cols-[minmax(0,1fr)_auto] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
<div>Resource</div>
<div class="hidden sm:block">Image</div>
<div>Status</div>
<div class="w-20"></div>
</div>
@endif
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
@if ($applications->isEmpty() && $databases->isEmpty())
<div
class="application-settings-section overflow-hidden sm:col-span-2">

View file

@ -116,10 +116,26 @@ class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3
</div>
<div class="grid gap-4 sm:grid-cols-2">
@if (!$serviceApplication->serviceType()?->contains(str($serviceApplication->image)->before(':')))
<x-forms.domain-chips model="fqdn" label="Domains"
:required="(bool) $serviceApplication->required_fqdn"
:can-update="auth()->user()->can('update', $serviceApplication)"
:disabled="! auth()->user()->can('update', $serviceApplication)" />
<div class="rounded-lg border border-neutral-200 p-4 dark:border-white/[0.08]">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm text-neutral-500 dark:text-fg-dim">
@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.
</p>
<a class="button shrink-0" href="{{ route('project.service.domains', $parameters) }}"
{{ wireNavigate() }}>
<x-reicon name="globe" class="size-4" />
Manage domains
</a>
</div>
</div>
@endif
<x-forms.input canGate="update" :canResource="$serviceApplication"
helper="You can change the image you would like to deploy.<br><br><span class='dark:text-warning'>WARNING. You could corrupt your data. Only do it if you know what you are doing.</span>"

View file

@ -1,5 +1,4 @@
<div
class="group flex min-w-0 flex-col overflow-hidden rounded-[10px] border border-neutral-200 bg-white transition-[border-color,background-color,box-shadow] hover:border-neutral-300 hover:shadow-sm dark:border-white/[0.07] dark:bg-surface dark:hover:border-white/[0.12] dark:hover:bg-white/[0.035]">
<div>
@php
[$statusType, $statusLabel] = match (true) {
str($resource->status)->contains('running') => ['success', formatContainerStatus($resource->status)],
@ -11,6 +10,8 @@ class="group flex min-w-0 flex-col overflow-hidden rounded-[10px] border border-
: Str::headline($resource->name);
@endphp
<div x-cloak x-show="viewMode === 'grid'"
class="group flex min-w-0 flex-col overflow-hidden rounded-[10px] border border-neutral-200 bg-white transition-[border-color,background-color,box-shadow] hover:border-neutral-300 hover:shadow-sm dark:border-white/[0.07] dark:bg-surface dark:hover:border-white/[0.12] dark:hover:bg-white/[0.035]">
<div class="flex min-w-0 flex-1 items-start gap-3 p-4">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-500 dark:bg-white/[0.06] dark:text-fg-dim">
@ -78,4 +79,31 @@ class="flex items-center justify-end gap-1 border-t border-neutral-200 bg-neutra
@endcan
@endif
</div>
</div>
<div x-cloak x-show="viewMode === 'table'"
class="grid min-h-14 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-neutral-200 px-4 py-2.5 last:border-b-0 hover:bg-neutral-50 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<div class="flex min-w-0 items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-500 dark:bg-white/[0.06] dark:text-fg-dim">
<x-reicon :name="$isDatabase ? 'database' : 'grid'" class="size-4" />
</div>
<div class="min-w-0">
<div class="truncate text-[13px] font-semibold text-black dark:text-fg">{{ $resourceName }}</div>
@if ($isApplication && $resource->fqdn)
<div class="truncate text-[11px] text-neutral-500 sm:hidden dark:text-fg-faint">{{ $resource->fqdn }}</div>
@endif
</div>
</div>
<div class="hidden truncate font-mono text-xs text-neutral-500 sm:block dark:text-fg-faint">
{{ $resource->image }}
</div>
<x-status-badge :status="$statusLabel" :type="$statusType" />
<div class="flex w-20 justify-end">
<a class="button" {{ wireNavigate() }}
href="{{ route('project.service.index', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
Settings
</a>
</div>
</div>
</div>

View file

@ -3,7 +3,7 @@
API Tokens | Coolify
</x-slot>
<x-security.navbar />
<x-security.settings-layout>
@if (!$isApiEnabled)
<div class="application-settings-form">
@ -53,49 +53,53 @@ class="button bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-co
<h4 class="text-[12px] font-semibold text-black dark:text-fg">Permissions</h4>
<x-helper helper="Only grant the abilities this token needs." />
</div>
<div class="grid gap-3 lg:grid-cols-2">
@if ($canUseRootPermissions)
<x-forms.checkbox label="Root" wire:model.live="permissions" domValue="root"
helper="Full access to every API operation." :checked="in_array('root', $permissions)" />
@else
<x-forms.checkbox label="Root" disabled domValue="root"
helper="Requires an admin or owner role." :checked="false" />
@endif
<div class="relative" x-data="{ permissionsOpen: false }"
@click.outside="permissionsOpen = false" @keydown.escape.window="permissionsOpen = false">
<button type="button" class="listbox-trigger" @click="permissionsOpen = !permissionsOpen"
aria-haspopup="listbox" :aria-expanded="permissionsOpen">
<span class="truncate">
Selected permissions: {{ collect($permissions)->map(fn ($permission) => str($permission)->replace(':', ' ')->headline())->join(', ') }}
</span>
<x-reicon name="chevron-down" class="size-3.5 shrink-0 opacity-60" />
</button>
@if (!in_array('root', $permissions))
@if ($canUseWritePermissions)
<x-forms.checkbox label="Write" wire:model.live="permissions"
domValue="write" helper="Create and update resources."
:checked="in_array('write', $permissions)" />
@else
<x-forms.checkbox label="Write" disabled domValue="write"
helper="Requires an admin or owner role." :checked="false" />
@endif
@if ($canUseDeployPermissions)
<x-forms.checkbox label="Deploy" wire:model.live="permissions"
domValue="deploy" helper="Trigger deployments through webhooks."
:checked="in_array('deploy', $permissions)" />
@else
<x-forms.checkbox label="Deploy" disabled domValue="deploy"
helper="Requires an admin or owner role." :checked="false" />
@endif
<x-forms.checkbox label="Read" wire:model.live="permissions" domValue="read"
<div x-cloak x-show="permissionsOpen" x-transition.origin.top
class="listbox-panel top-full! mt-1! w-full!" role="listbox">
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-root" label="Root" fullWidth
wire:model.live="permissions" domValue="root"
helper="Full access to every API operation."
:checked="in_array('root', $permissions)" :disabled="!$canUseRootPermissions" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-write" label="Write" fullWidth
wire:model.live="permissions" domValue="write"
helper="Create and update resources."
:checked="in_array('write', $permissions)"
:disabled="in_array('root', $permissions) || !$canUseWritePermissions" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-deploy" label="Deploy" fullWidth
wire:model.live="permissions" domValue="deploy"
helper="Trigger deployments through webhooks."
:checked="in_array('deploy', $permissions)"
:disabled="in_array('root', $permissions) || !$canUseDeployPermissions" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-read" label="Read" fullWidth
wire:model.live="permissions" domValue="read"
helper="Read non-sensitive resource data."
:checked="in_array('read', $permissions)" />
@if ($canUseSensitivePermissions)
<x-forms.checkbox label="Read sensitive data" wire:model.live="permissions"
domValue="read:sensitive"
:checked="in_array('read', $permissions)"
:disabled="in_array('root', $permissions)" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-read-sensitive" label="Read sensitive data"
fullWidth wire:model.live="permissions" domValue="read:sensitive"
helper="Include secrets, logs, passwords, and Compose content."
:checked="in_array('read:sensitive', $permissions)" />
@else
<x-forms.checkbox label="Read sensitive data" disabled
domValue="read:sensitive" helper="Requires an admin or owner role."
:checked="false" />
@endif
@endif
:checked="in_array('read:sensitive', $permissions)"
:disabled="in_array('root', $permissions) || !$canUseSensitivePermissions" />
</div>
</div>
</div>
</div>
</x-application.settings-section>
@ -299,4 +303,5 @@ class="flex w-10 items-center justify-center text-neutral-500 transition-colors
</x-application.settings-section>
</div>
@endif
</x-security.settings-layout>
</div>

View file

@ -1,10 +1,26 @@
<div>
@if ($modalMode)
<form wire:submit="save" class="flex flex-col gap-4">
<x-forms.input canGate="update" :canResource="$cloudInitScript" id="name" label="Script name" required />
<x-forms.textarea canGate="update" :canResource="$cloudInitScript" id="script"
label="Script content" rows="16" monospace
helper="Cloud-config YAML or another script accepted by your provider." required />
<div class="flex items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
@can('delete', $cloudInitScript)
<x-modal-confirmation title="Confirm Script Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['This cloud-init script will be permanently deleted.']"
confirmationText="{{ $cloudInitScript->name }}" :confirmWithPassword="false"
step2ButtonText="Delete script" />
@endcan
<x-forms.button type="submit" isHighlighted>Save changes</x-forms.button>
</div>
</form>
@else
<x-slot:title>
{{ $cloudInitScript->name }} | Cloud-Init Scripts | Coolify
</x-slot>
<x-security.navbar :title="$cloudInitScript->name"
subtitle="Reusable cloud-init script for server provisioning" :titleOnDesktop="true">
<x-security.settings-layout>
<x-slot:actions>
@can('delete', $cloudInitScript)
<x-modal-confirmation title="Confirm Script Deletion?" isErrorButton buttonTitle="Delete"
@ -17,7 +33,7 @@
step2ButtonText="Delete script" />
@endcan
</x-slot:actions>
</x-security.navbar>
<form wire:submit="save" class="application-settings-form">
<x-unsaved-bar action="save" />
@ -32,4 +48,6 @@
</div>
</x-application.settings-section>
</form>
</x-security.settings-layout>
@endif
</div>

View file

@ -3,7 +3,9 @@
Cloud-Init Scripts | Coolify
</x-slot>
<x-security.navbar>
<x-security.settings-layout>
<x-application.settings-section title="Cloud-init scripts"
description="Reusable initialization scripts for cloud servers." flush>
<x-slot:actions>
@can('create', App\Models\CloudInitScript::class)
<x-modal-input title="New Cloud-Init Script">
@ -18,24 +20,29 @@ class="button bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-co
</x-modal-input>
@endcan
</x-slot:actions>
</x-security.navbar>
<div class="application-settings-form">
<x-application.settings-section title="Cloud-init scripts"
description="Reusable initialization scripts for cloud servers." flush>
@if ($scripts->isEmpty())
<x-empty title="No cloud-init scripts"
description="Create a script to reuse it during server provisioning."
icon-name="file-content" size="sm" />
@else
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4">
<div>
<div class="grid grid-cols-[minmax(0,1fr)_12rem_1.75rem] items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[13px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
<div class="pl-11">Script</div>
<div>Last updated</div>
<div class="w-7"></div>
</div>
@foreach ($scripts as $script)
@can('view', $script)
<a wire:key="cloud-init-script-{{ $script->id }}"
class="group flex min-h-28 min-w-0 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
href="{{ route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]) }}"
{{ wireNavigate() }}>
<div class="flex min-w-0 items-start gap-3">
<x-modal-input title="Edit Cloud-Init Script" isFullWidth :wireIgnore="false" :contentClicks="false"
wire:key="cloud-init-script-{{ $script->id }}"
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<x-slot:content>
<div
class="grid min-h-14 w-full grid-cols-[minmax(0,1fr)_12rem_1.75rem] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.025]"
>
<div class="flex min-w-0 items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<x-reicon name="file-content" class="size-4" />
@ -45,21 +52,27 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg border border
class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
{{ $script->name }}
</h3>
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint">
Cloud-init script
</p>
</div>
</div>
<div class="mt-auto flex items-center pt-4">
<div class="flex items-center">
<span class="text-[10px] text-neutral-400 dark:text-fg-faint">
Updated {{ $script->updated_at->diffForHumans() }}
</span>
</div>
</a>
<button type="button" class="icon-button" title="Edit cloud-init script"
aria-label="Edit {{ $script->name }}" @click="modalOpen=true">
<x-reicon name="settings" class="size-3.5" />
</button>
</div>
</x-slot:content>
<livewire:security.cloud-init-script.show :cloud_init_script_uuid="$script->uuid"
:modalMode="true" :key="'cloud-init-editor-'.$script->uuid" />
</x-modal-input>
@endcan
@endforeach
</div>
@endif
</x-application.settings-section>
</div>
</x-security.settings-layout>
</div>

View file

@ -1,7 +1,20 @@
<div class="w-full">
<form class="application-settings-form flex w-full flex-col gap-4" wire:submit="addToken">
@if (!isset($provider) || blank($provider))
<x-forms.listbox required id="provider" label="Provider" wire:model.live="provider" :options="[
<form class="application-settings-form flex w-full flex-col gap-4" wire:submit="addToken"
x-data="{
selectedProvider: $wire.entangle('provider'),
get providerName() {
return this.selectedProvider === 'digitalocean' ? 'DigitalOcean' :
this.selectedProvider.charAt(0).toUpperCase() + this.selectedProvider.slice(1);
},
get providerConsoleUrl() {
if (this.selectedProvider === 'hetzner') return 'https://console.hetzner.com/projects';
if (this.selectedProvider === 'vultr') return 'https://console.vultr.com/user/apiaccess/';
return 'https://cloud.digitalocean.com/account/api/tokens';
}
}">
@if (!$provider_locked)
<x-forms.listbox required id="provider" label="Provider" :wire="false" :value="$provider"
x-model="selectedProvider" :options="[
['value' => 'hetzner', 'label' => 'Hetzner'],
['value' => 'digitalocean', 'label' => 'DigitalOcean'],
['value' => 'vultr', 'label' => 'Vultr'],
@ -10,9 +23,18 @@
<input type="hidden" wire:model="provider" />
@endif
<div
class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
Create the token in the
<a :href="providerConsoleUrl"
target="_blank" class="font-medium text-coollabs hover:underline dark:text-warning">
<span x-text="providerName + ' console'"></span>
</a>.
</div>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input required id="name" label="Token name"
placeholder="Production {{ $provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($provider) }} token" />
x-bind:placeholder="`Production ${providerName} token`" />
<x-forms.input required type="password" id="token" label="API token"
placeholder="Paste the provider token" />
<div class="lg:col-span-2">
@ -21,19 +43,6 @@
</div>
</div>
<div
class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
Create the token in the
<a href="{{ $provider === 'hetzner'
? 'https://console.hetzner.com/projects'
: ($provider === 'vultr'
? 'https://console.vultr.com/user/apiaccess/'
: 'https://cloud.digitalocean.com/account/api/tokens') }}"
target="_blank" class="font-medium text-coollabs hover:underline dark:text-warning">
{{ $provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($provider) }} console
</a>.
</div>
<div class="flex justify-end border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<x-forms.button type="submit"
class="bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-coollabs/15! dark:bg-warning/15! dark:text-warning! dark:ring-warning/25 dark:hover:bg-warning/20!"

View file

@ -1,11 +1,31 @@
<div>
@if ($modalMode)
<form wire:submit="save" class="flex flex-col gap-4">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input canGate="update" :canResource="$cloudProviderToken" id="name" label="Name" required />
<x-forms.input canGate="update" :canResource="$cloudProviderToken" id="description" label="Description" />
<x-forms.input readonly label="Provider" :value="$this->providerName()" />
<x-forms.input readonly label="Created" :value="$cloudProviderToken->created_at->format('Y-m-d H:i')" />
</div>
<div class="flex items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<div class="flex items-center gap-2">
@can('delete', $cloudProviderToken)
<x-modal-confirmation title="Confirm Token Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['This cloud provider token will be permanently deleted.']"
confirmationText="{{ $cloudProviderToken->name }}" :confirmWithPassword="false"
step2ButtonText="Delete token" />
@endcan
<x-forms.button type="button" wire:click="validateToken">Validate</x-forms.button>
</div>
<x-forms.button type="submit" isHighlighted>Save changes</x-forms.button>
</div>
</form>
@else
<x-slot:title>
{{ $cloudProviderToken->name }} | Cloud Tokens | Coolify
</x-slot>
<x-security.navbar :title="$cloudProviderToken->name"
:subtitle="filled($cloudProviderToken->description) ? $cloudProviderToken->description : 'Cloud provider API credential'"
:titleOnDesktop="true">
<x-security.settings-layout>
<x-slot:actions>
<x-forms.button type="button" wire:click="validateToken">
<x-reicon name="check-circle" class="size-3.5" />
@ -22,7 +42,7 @@
step2ButtonText="Delete token" />
@endcan
</x-slot:actions>
</x-security.navbar>
<form wire:submit="save" class="application-settings-form">
<x-unsaved-bar action="save" />
@ -38,4 +58,6 @@
</div>
</x-application.settings-section>
</form>
</x-security.settings-layout>
@endif
</div>

View file

@ -1,16 +1,40 @@
<div class="application-settings-form">
<x-application.settings-section title="Cloud tokens" flush>
<x-application.settings-section title="Cloud tokens"
description="Provider credentials used to provision cloud servers." flush>
<x-slot:actions>
@can('create', App\Models\CloudProviderToken::class)
<x-modal-input title="New Cloud Token">
<x-slot:content>
<button type="button"
class="button bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-coollabs/15! dark:bg-warning/15! dark:text-warning! dark:ring-warning/25 dark:hover:bg-warning/20!">
<x-reicon name="plus" class="size-3.5" />
New token
</button>
</x-slot:content>
<livewire:security.cloud-provider-token-form :modal_mode="true" wire:key="new-cloud-provider-token" />
</x-modal-input>
@endcan
</x-slot:actions>
@if ($tokens->isEmpty())
<x-empty title="No cloud tokens"
description="Add a provider token to provision new cloud servers." icon-name="keys" size="sm" />
@else
<div class="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 lg:grid-cols-4">
<div>
<div class="grid grid-cols-[minmax(0,1fr)_8rem_1.75rem] items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[13px] font-medium text-neutral-500 sm:grid-cols-[minmax(0,1fr)_8rem_minmax(0,1fr)_1.75rem] dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
<div class="pl-11">Token</div>
<div class="text-center">Provider</div>
<div class="hidden sm:block">Description</div>
<div class="w-7"></div>
</div>
@foreach ($tokens as $savedToken)
<a wire:key="cloud-token-{{ $savedToken->id }}"
class="group flex min-h-28 min-w-0 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
href="{{ route('security.cloud-tokens.show', ['cloud_token_uuid' => $savedToken->uuid]) }}"
{{ wireNavigate() }}>
<div class="flex min-w-0 items-start gap-3">
<x-modal-input title="Edit Cloud Token" isFullWidth :wireIgnore="false" :contentClicks="false"
wire:key="cloud-token-{{ $savedToken->id }}"
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<x-slot:content>
<div
class="grid min-h-14 w-full grid-cols-[minmax(0,1fr)_8rem_1.75rem] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-neutral-50 sm:grid-cols-[minmax(0,1fr)_8rem_minmax(0,1fr)_1.75rem] dark:hover:bg-white/[0.025]"
>
<div class="flex min-w-0 items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<x-reicon name="keys" class="size-4" />
@ -19,20 +43,27 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg border border
<h3 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
{{ $savedToken->name }}
</h3>
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $savedToken->description ?: 'No description' }}
</p>
</div>
</div>
<div class="mt-auto pt-4">
<div class="flex justify-center">
<span
class="inline-flex rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim">
{{ $savedToken->provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($savedToken->provider) }}
</span>
</div>
</a>
<p class="hidden truncate text-[12px] text-neutral-500 sm:block dark:text-fg-dim">{{ $savedToken->description ?: '-' }}</p>
<button type="button" class="icon-button" title="Edit cloud token"
aria-label="Edit {{ $savedToken->name }}" @click="modalOpen=true">
<x-reicon name="settings" class="size-3.5" />
</button>
</div>
</x-slot:content>
<livewire:security.cloud-provider-token.show :cloud_token_uuid="$savedToken->uuid"
:modalMode="true" :key="'cloud-token-editor-'.$savedToken->uuid" />
</x-modal-input>
@endforeach
</div>
@endif
</x-application.settings-section>
</div>

View file

@ -3,23 +3,7 @@
Cloud Tokens | Coolify
</x-slot>
<x-security.navbar>
<x-slot:actions>
@can('create', App\Models\CloudProviderToken::class)
<x-modal-input title="New Cloud Token">
<x-slot:content>
<button type="button"
class="button bg-coollabs/10! text-coollabs! ring-1 ring-coollabs/25 hover:bg-coollabs/15! dark:bg-warning/15! dark:text-warning! dark:ring-warning/25 dark:hover:bg-warning/20!">
<x-reicon name="plus" class="size-3.5" />
New token
</button>
</x-slot:content>
<livewire:security.cloud-provider-token-form :modal_mode="true"
wire:key="new-cloud-provider-token" />
</x-modal-input>
@endcan
</x-slot:actions>
</x-security.navbar>
<x-security.settings-layout>
<livewire:security.cloud-provider-tokens />
</x-security.settings-layout>
</div>

View file

@ -3,7 +3,9 @@
Keys & Tokens | Coolify
</x-slot>
<x-security.navbar>
<x-security.settings-layout>
<x-application.settings-section title="Private keys"
description="SSH keys used to connect servers and private repositories." flush>
<x-slot:actions>
@can('create', App\Models\PrivateKey::class)
<x-modal-confirmation title="Confirm unused SSH Key Deletion?"
@ -52,26 +54,35 @@ class="listbox-option justify-start! gap-2.5!" role="menuitem">
Add manually
</button>
</x-slot:content>
<livewire:security.private-key.create />
<livewire:security.private-key.create :modal_mode="true" />
</x-modal-input>
</div>
</div>
@endcan
</x-slot:actions>
</x-security.navbar>
@if ($privateKeys->isEmpty())
<x-empty title="No private keys yet"
description="Generate or add an SSH key to connect Coolify to servers and private repositories."
icon-name="keys" />
@else
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div>
<div class="grid grid-cols-[minmax(0,1fr)_7rem_1.75rem] items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[13px] font-medium text-neutral-500 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_1.75rem] dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
<div class="pl-11">Private key</div>
<div class="hidden sm:block">Description</div>
<div class="text-center">Status</div>
<div class="w-7"></div>
</div>
@foreach ($privateKeys as $key)
@can('view', $key)
<a class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]"
href="{{ route('security.private-key.show', ['private_key_uuid' => data_get($key, 'uuid')]) }}"
{{ wireNavigate() }}>
<div class="flex items-start gap-3">
<x-modal-input title="Edit Private Key" isFullWidth :wireIgnore="false" :contentClicks="false"
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<x-slot:content>
<div
class="grid min-h-14 w-full grid-cols-[minmax(0,1fr)_7rem_1.75rem] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-neutral-50 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_1.75rem] dark:hover:bg-white/[0.025]"
>
<div class="flex min-w-0 items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<x-reicon name="keys" class="size-4" />
@ -80,21 +91,29 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg border border
<h3 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
{{ data_get($key, 'name') }}
</h3>
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $key->description ?: 'SSH private key' }}
</div>
</div>
<p class="hidden truncate text-[12px] text-neutral-500 sm:block dark:text-fg-dim">
{{ $key->description ?: '-' }}
</p>
</div>
</div>
<div class="mt-auto pt-4">
<div class="flex justify-center">
@if ($key->isInUse())
<x-status-badge label="In use" type="success" />
@else
<x-status-badge label="Unused" type="warning" />
@endif
</div>
</a>
<button type="button" class="icon-button" title="Edit private key"
aria-label="Edit {{ $key->name }}" @click="modalOpen=true">
<x-reicon name="settings" class="size-3.5" />
</button>
</div>
</x-slot:content>
<livewire:security.private-key.show :private_key_uuid="$key->uuid" :modalMode="true"
:key="'private-key-editor-'.$key->uuid" />
</x-modal-input>
@else
<div class="flex min-h-28 cursor-not-allowed flex-col rounded-xl border border-neutral-200 bg-neutral-50 p-3 opacity-65 dark:border-white/[0.08] dark:bg-white/[0.015]"
<div class="grid min-h-14 cursor-not-allowed grid-cols-[minmax(0,1fr)_7rem_1.75rem] items-center gap-3 border-b border-neutral-200 px-4 py-2.5 opacity-65 last:border-b-0 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_7rem_1.75rem] dark:border-white/[0.07]"
title="You do not have permission to view this private key">
<div class="flex items-start gap-3">
<div
@ -105,20 +124,22 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg border border
<h3 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
{{ data_get($key, 'name') }}
</h3>
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $key->description ?: 'SSH private key' }}
</p>
</div>
</div>
<div class="mt-auto flex flex-wrap gap-2 pt-4">
<p class="hidden truncate text-[12px] text-neutral-500 sm:block dark:text-fg-dim">{{ $key->description ?: '-' }}</p>
<div class="flex flex-wrap justify-center gap-2">
<x-status-badge label="View only" type="neutral" />
@if (!$key->isInUse())
<x-status-badge label="Unused" type="warning" />
@endif
</div>
<span class="size-7"></span>
</div>
@endcan
@endforeach
</div>
@endif
</x-application.settings-section>
</x-security.settings-layout>
</div>

View file

@ -1,11 +1,45 @@
<div x-init="$wire.loadPublicKey()">
@if ($modalMode)
<form wire:submit="changePrivateKey" class="flex flex-col gap-4" x-data="{ showPrivateKey: false }">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input canGate="update" :canResource="$private_key" id="name" label="Name" required />
<x-forms.input canGate="update" :canResource="$private_key" id="description" label="Description" />
<div class="lg:col-span-2">
<x-forms.input canGate="update" :canResource="$private_key" readonly id="public_key"
label="Public key" helper="Copy this value to ~/.ssh/authorized_keys on the target server." />
</div>
<div class="lg:col-span-2">
<div class="mb-1.5 flex items-center justify-between gap-3">
<label class="text-[13px] font-medium">Private key <span class="text-helper">*</span></label>
<button type="button" class="text-[11px] font-medium text-coollabs hover:underline dark:text-warning"
x-on:click="showPrivateKey = !showPrivateKey" x-text="showPrivateKey ? 'Hide editor' : 'Edit key'"></button>
</div>
<div x-show="!showPrivateKey">
<x-forms.input canGate="update" :canResource="$private_key" allowToPeak="false"
type="password" id="privateKeyValue" required disabled />
</div>
<div x-cloak x-show="showPrivateKey">
<x-forms.textarea canGate="update" :canResource="$private_key" rows="10"
id="privateKeyValue" required monospace />
</div>
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
@can('delete', $private_key)
<x-modal-confirmation title="Confirm Private Key Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :disabled="$isInUse" :disabledTooltip="$deleteDisabledReason"
:actions="['This private key will be permanently deleted.']" confirmationText="{{ $private_key->name }}"
:confirmWithPassword="false" step2ButtonText="Delete private key" />
@endcan
<x-forms.button type="submit" isHighlighted>Save changes</x-forms.button>
</div>
</form>
@else
<x-slot:title>
{{ $private_key->name }} | Private Keys | Coolify
</x-slot>
<x-security.navbar :title="$private_key->name"
:subtitle="filled($private_key->description) ? $private_key->description : 'SSH private key'"
:titleOnDesktop="true">
<x-security.settings-layout>
<x-slot:actions>
@if ($isGitRelated)
<x-status-badge label="Used by GitHub App" type="neutral" />
@ -25,7 +59,7 @@
@endcan
@endif
</x-slot:actions>
</x-security.navbar>
<form wire:submit="changePrivateKey" class="application-settings-form" x-data="{ showPrivateKey: false }">
<x-unsaved-bar action="changePrivateKey" />
@ -59,4 +93,6 @@ class="text-[11px] font-medium text-coollabs hover:underline dark:text-warning"
</div>
</x-application.settings-section>
</form>
</x-security.settings-layout>
@endif
</div>

View file

@ -3,8 +3,7 @@
Instance Backup | Coolify
</x-slot>
<x-settings.navbar />
<x-settings.layout>
<div class="application-settings-form mx-auto flex w-full max-w-[1180px] min-w-0 flex-col gap-6">
@if ($server->isFunctional())
@if (isset($database) && isset($backup))
@ -51,4 +50,5 @@
</x-application.settings-section>
@endif
</div>
</x-settings.layout>
</div>

View file

@ -3,8 +3,7 @@
Transactional Email | Coolify
</x-slot>
<x-settings.navbar />
<x-settings.layout>
<div class="application-settings-form mx-auto flex w-full max-w-[1180px] min-w-0 flex-col gap-6">
<form wire:submit="submit">
<x-unsaved-bar action="submit" />
@ -65,4 +64,5 @@
</x-application.settings-section>
</form>
</div>
</x-settings.layout>
</div>

View file

@ -3,30 +3,28 @@
Authentication | Coolify
</x-slot>
<x-settings.navbar />
<x-settings.layout>
<x-slot:submenu>
<div
class="application-settings-workspace mx-auto grid w-full max-w-[1180px] min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="application-settings-navigation min-w-0 xl:self-start"
x-data="{ activeProvider: location.hash.slice(1).replace('-oauth-section', '') || '{{ $oauth_settings_map[0]['provider'] ?? '' }}' }"
@hashchange.window="activeProvider = location.hash.slice(1).replace('-oauth-section', '')">
<nav aria-label="OAuth providers"
class="grid max-h-[calc(100vh-8rem)] grid-cols-2 gap-0.5 overflow-y-auto border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Providers</div>
class="grid gap-0.5 py-1">
@foreach ($oauth_settings_map as $oauth_setting)
@php
$provider = $oauth_setting['provider'];
$providerLabel = str($provider)->headline();
@endphp
<a href="#{{ $provider }}-oauth-section" class="menu-item"
<a href="#{{ $provider }}-oauth-section" class="menu-item min-h-8! py-1! text-[12px]!"
:class="{ 'menu-item-active': activeProvider === '{{ $provider }}' }"
@click="activeProvider = '{{ $provider }}'">
@click.prevent="activeProvider = '{{ $provider }}'; history.replaceState(null, '', '#{{ $provider }}-oauth-section'); window.scrollToSettingsSection?.('{{ $provider }}-oauth-section')">
<x-reicon name="keys" class="menu-item-icon" />
<span class="menu-item-label">{{ $providerLabel }}</span>
</a>
@endforeach
</nav>
</aside>
</div>
</x-slot:submenu>
<form wire:submit="submit" class="application-settings-form flex w-full min-w-0 flex-col gap-6">
<x-unsaved-bar action="submit" />
@foreach ($oauth_settings_map as $oauth_setting)
@ -37,24 +35,33 @@ class="grid max-h-[calc(100vh-8rem)] grid-cols-2 gap-0.5 overflow-y-auto border-
<x-application.settings-section id="{{ $provider }}-oauth-section" class="scroll-mt-28"
title="{{ $providerLabel }}">
<x-slot:actions>
<div x-data="{ enabled: @js((bool) $oauth_setting['enabled']), provider: @js($provider) }">
<x-forms.button type="button"
x-on:click="
if (!enabled) {
const invalidField = [...$el.closest('section').querySelectorAll('[required]')]
.find(field => !field.checkValidity());
if (invalidField) { invalidField.reportValidity(); return; }
}
$wire.toggleProvider(provider);
">
{{ $oauth_setting['enabled'] ? 'Disable' : 'Enable' }}
</x-forms.button>
</div>
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="oauth_settings_map.{{ $provider }}.enabled"
label="Provider status" :options="[
['value' => true, 'label' => 'Enabled'],
['value' => false, 'label' => 'Disabled'],
]" />
<x-forms.input id="oauth_settings_map.{{ $provider }}.redirect_uri"
placeholder="{{ route('auth.callback', $provider) }}" label="Redirect URI" />
<x-forms.input id="oauth_settings_map.{{ $provider }}.client_id"
label="Client ID" />
label="Client ID" required />
<x-forms.input id="oauth_settings_map.{{ $provider }}.client_secret"
type="password" label="Client secret" autocomplete="new-password" />
type="password" label="Client secret" autocomplete="new-password" required />
@if ($provider === 'azure')
<x-forms.input id="oauth_settings_map.{{ $provider }}.tenant"
label="Tenant" />
label="Tenant" required />
@endif
@if ($provider === 'google')
@ -65,11 +72,11 @@ class="grid max-h-[calc(100vh-8rem)] grid-cols-2 gap-0.5 overflow-y-auto border-
@if (in_array($provider, ['authentik', 'clerk', 'zitadel', 'gitlab'], true))
<x-forms.input id="oauth_settings_map.{{ $provider }}.base_url"
label="Base URL" />
label="Base URL" :required="in_array($provider, ['authentik', 'clerk'], true)" />
@endif
</div>
</x-application.settings-section>
@endforeach
</form>
</div>
</x-settings.layout>
</div>

View file

@ -3,12 +3,7 @@
Advanced Settings | Coolify
</x-slot>
<x-settings.navbar />
<div
class="application-settings-workspace mx-auto grid w-full max-w-[1180px] min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-settings.sidebar activeMenu="advanced" />
<x-settings.layout>
<form wire:submit="submit" class="application-settings-form flex min-w-0 flex-col gap-6">
{{-- 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 @@ class="application-settings-workspace mx-auto grid w-full max-w-[1180px] min-w-0
</div>
</x-application.settings-section>
</form>
</div>
</x-settings.layout>
</div>

View file

@ -3,12 +3,7 @@
Settings | Coolify
</x-slot>
<x-settings.navbar />
<div
class="application-settings-workspace mx-auto grid w-full max-w-[1180px] min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-settings.sidebar activeMenu="general" />
<x-settings.layout>
<form wire:submit="submit" class="application-settings-form flex w-full min-w-0 flex-col gap-6">
{{-- 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 @@ class="application-settings-workspace mx-auto grid w-full max-w-[1180px] min-w-0
</x-application.settings-section>
@endif
</form>
</div>
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
confirmAction="confirmDomainUsage">
@ -72,4 +66,5 @@ class="application-settings-workspace mx-auto grid w-full max-w-[1180px] min-w-0
</ul>
</x-slot:consequences>
</x-domain-conflict-modal>
</x-settings.layout>
</div>

View file

@ -3,8 +3,7 @@
Scheduled Jobs | Coolify
</x-slot>
<x-settings.navbar />
<x-settings.layout>
<div class="application-settings-form mx-auto w-full max-w-[1180px] min-w-0" x-data="{
activeTab: ['executions', 'scheduler-runs', 'skipped-jobs'].includes(location.hash.slice(1))
? location.hash.slice(1)
@ -363,4 +362,5 @@ class="flex min-h-12 items-center justify-between gap-3 border-t border-neutral-
</div>
</x-application.settings-section>
</div>
</x-settings.layout>
</div>

View file

@ -3,12 +3,7 @@
Update Settings | Coolify
</x-slot>
<x-settings.navbar />
<div
class="application-settings-workspace mx-auto grid w-full max-w-[1180px] min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-settings.sidebar activeMenu="updates" />
<x-settings.layout>
<form wire:submit="submit" class="application-settings-form flex min-w-0 flex-col gap-6">
{{-- Exclude is_auto_update_enabled (instantSave) so the bar does not flash. --}}
<x-unsaved-bar action="submit" targets="update_check_frequency,auto_update_frequency" />
@ -61,5 +56,5 @@ class="application-settings-workspace mx-auto grid w-full max-w-[1180px] min-w-0
</div>
</x-application.settings-section>
</form>
</div>
</x-settings.layout>
</div>

View file

@ -30,6 +30,17 @@ class="listbox-option {{ $team->id === $currentTeam->id ? 'bg-neutral-100 font-m
@endif
</button>
@endforeach
<div class="mt-1 border-t border-neutral-200 pt-1 dark:border-white/[0.08]">
<x-modal-input title="New Team">
<x-slot:content>
<button type="button" class="listbox-option w-full" @click="open = false">
<x-reicon name="plus" class="size-3.5 shrink-0" />
<span class="min-w-0 flex-1 text-left">New team</span>
</button>
</x-slot:content>
<livewire:team.create :key="'team-switcher-create-expanded'" />
</x-modal-input>
</div>
</div>
</div>
@ -66,6 +77,17 @@ class="listbox-option {{ $team->id === $currentTeam->id ? 'bg-neutral-100 font-m
@endif
</button>
@endforeach
<div class="mt-1 border-t border-neutral-200 pt-1 dark:border-white/[0.08]">
<x-modal-input title="New Team">
<x-slot:content>
<button type="button" class="listbox-option w-full" @click="teamOpen = false">
<x-reicon name="plus" class="size-3.5 shrink-0" />
<span class="min-w-0 flex-1 text-left">New team</span>
</button>
</x-slot:content>
<livewire:team.create :key="'team-switcher-create-collapsed'" />
</x-modal-input>
</div>
</div>
</div>
</div>

View file

@ -3,8 +3,7 @@
Team Admin | Coolify
</x-slot>
<x-team.navbar />
<x-team.settings-layout>
<div class="application-settings-form">
<x-application.settings-section title="Instance users" flush>
<form wire:submit.prevent="submitSearch"
@ -97,4 +96,5 @@ class="text-[12px] font-medium text-red-600 transition-colors hover:text-red-700
@endif
</x-application.settings-section>
</div>
</x-team.settings-layout>
</div>

View file

@ -0,0 +1,98 @@
<div>
<x-slot:title>
Team Danger Zone | Coolify
</x-slot>
<x-team.settings-layout>
<div class="application-settings-form">
<x-application.settings-section id="team-danger-zone" title="Danger zone"
helper="Destructive actions for this team cannot be undone.">
<div
class="rounded-lg border border-red-300 bg-red-50 p-4 ring-1 ring-inset ring-red-200/60 dark:border-error/30 dark:bg-error/[0.08] dark:ring-error/10">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-red-700 dark:text-error">Delete team</h4>
<x-status-badge status="Permanent" type="error" />
</div>
@if (session('currentTeam.id') === 0)
<p class="mt-2 text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
The default team cannot be deleted.
</p>
@elseif(auth()->user()->teams()->count() === 1 || auth()->user()->currentTeam()->personal_team)
<p class="mt-2 text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
Your last or personal team cannot be deleted.
</p>
@elseif(currentTeam()->subscription)
<p class="mt-2 text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
Cancel your <a class="font-medium text-coollabs hover:underline dark:text-warning"
{{ wireNavigate() }} href="{{ route('subscription.show') }}">subscription</a>
before deleting this team.
</p>
@elseif(currentTeam()->isEmpty())
<p class="mt-2 max-w-2xl text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
Permanently delete <strong class="font-semibold text-black dark:text-fg">{{ currentTeam()->name }}</strong>
from Coolify. This action cannot be undone.
</p>
<ul class="mt-3 space-y-1 text-xs text-neutral-500 dark:text-fg-dim">
<li> All members will lose access to this team.</li>
<li> This team cannot be restored from Coolify after deletion.</li>
</ul>
@else
<p class="mt-2 text-[13px] leading-5 text-neutral-600 dark:text-fg-dim">
Remove or move every resource owned by this team before deleting it.
</p>
@endif
</div>
<div class="shrink-0">
@if (
session('currentTeam.id') !== 0 &&
auth()->user()->teams()->count() > 1 &&
!auth()->user()->currentTeam()->personal_team &&
!currentTeam()->subscription &&
currentTeam()->isEmpty())
<x-modal-confirmation title="Confirm Team Deletion?" buttonTitle="Delete team"
isErrorButton submitAction="delete"
:actions="['The current team will be permanently deleted from Coolify and the database.']"
confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter the team name to confirm permanent deletion"
shortConfirmationLabel="Team name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@else
<x-forms.button disabled tooltip="Resolve the requirements shown before deleting this team.">
Delete team
</x-forms.button>
@endif
</div>
</div>
</div>
@if (session('currentTeam.id') !== 0 && !currentTeam()->subscription && !currentTeam()->isEmpty())
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
@foreach ([
'Projects' => currentTeam()->projects,
'Servers' => currentTeam()->servers,
'Private keys' => currentTeam()->privateKeys,
'Sources' => currentTeam()->sources,
] as $label => $resources)
@if ($resources->isNotEmpty())
<div class="rounded-lg border border-neutral-200 p-3 dark:border-white/[0.08]">
<p class="text-[11px] font-semibold uppercase tracking-wide text-neutral-500 dark:text-fg-faint">
{{ $label }}
</p>
<ul class="mt-2 space-y-1 text-[12px] text-neutral-600 dark:text-fg-dim">
@foreach ($resources as $resource)
<li class="truncate">{{ $resource->name }}</li>
@endforeach
</ul>
</div>
@endif
@endforeach
</div>
@endif
</x-application.settings-section>
</div>
</x-team.settings-layout>
</div>

View file

@ -3,13 +3,17 @@
Teams | Coolify
</x-slot>
<x-team.navbar />
<x-team.settings-layout>
<div class="flex flex-col gap-6">
<form wire:submit="submit" class="application-settings-form">
<x-unsaved-bar action="submit" />
<x-application.settings-section title="General"
description="Manage this team's identity and shared API access.">
<x-slot:actions>
<x-modal-input buttonTitle="New team" title="New Team">
<livewire:team.create />
</x-modal-input>
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input id="name" label="Name" required canGate="update" :canResource="$team" />
<x-forms.input id="description" label="Description" canGate="update" :canResource="$team" />
@ -25,125 +29,6 @@
</x-application.settings-section>
</form>
@can('delete', $team)
<section
class="overflow-hidden rounded-xl border border-red-200 bg-red-50/60 shadow-sm dark:border-red-500/20 dark:bg-red-500/[0.04]">
<header
class="flex min-h-12 items-center justify-between border-b border-red-200 px-4 dark:border-red-500/15">
<div>
<h3 class="text-[13px]! font-semibold! text-red-700 dark:text-red-300">Danger zone</h3>
<p class="mt-0.5 text-[11px] text-red-600/75 dark:text-red-300/60">
Destructive actions for this team.
</p>
</div>
</header>
<div class="bg-white/70 p-4 dark:bg-black/10">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<h4 class="text-[13px] font-semibold text-black dark:text-fg">Delete team</h4>
@if (session('currentTeam.id') === 0)
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
The default team cannot be deleted.
</p>
@elseif(auth()->user()->teams()->get()->count() === 1 || auth()->user()->currentTeam()->personal_team)
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
Your last or personal team cannot be deleted.
</p>
@elseif(currentTeam()->subscription)
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
Cancel your
<a class="font-medium text-coollabs hover:underline dark:text-warning"
{{ wireNavigate() }} href="{{ route('subscription.show') }}">subscription</a>
before deleting this team.
</p>
@elseif(currentTeam()->isEmpty())
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
Permanently remove this team. This action cannot be undone.
</p>
@else
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
Remove the resources below before deleting this team.
</p>
@endif
</div>
@if (
session('currentTeam.id') !== 0 &&
auth()->user()->teams()->get()->count() > 1 &&
!auth()->user()->currentTeam()->personal_team &&
!currentTeam()->subscription &&
currentTeam()->isEmpty())
<div class="shrink-0">
<x-modal-confirmation title="Confirm Team Deletion?" buttonTitle="Delete team"
isErrorButton submitAction="delete({{ currentTeam()->id }})"
:actions="['The current team will be permanently deleted from Coolify and the database.']"
confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the Team Name below"
shortConfirmationLabel="Team Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
</div>
@endif
</div>
@if (
session('currentTeam.id') !== 0 &&
!currentTeam()->subscription &&
!currentTeam()->isEmpty())
<div class="mt-4 grid gap-3 border-t border-red-200 pt-4 sm:grid-cols-2 lg:grid-cols-4 dark:border-red-500/15">
@if (currentTeam()->projects()->count() > 0)
<div class="rounded-lg border border-red-200/80 bg-white p-3 dark:border-red-500/15 dark:bg-white/[0.025]">
<p class="text-[11px] font-semibold uppercase tracking-wide text-red-600 dark:text-red-300">
Projects
</p>
<ul class="mt-2 space-y-1 text-[12px] text-neutral-600 dark:text-fg-dim">
@foreach (currentTeam()->projects as $resource)
<li class="truncate">{{ $resource->name }}</li>
@endforeach
</ul>
</div>
@endif
@if (currentTeam()->servers()->count() > 0)
<div class="rounded-lg border border-red-200/80 bg-white p-3 dark:border-red-500/15 dark:bg-white/[0.025]">
<p class="text-[11px] font-semibold uppercase tracking-wide text-red-600 dark:text-red-300">
Servers
</p>
<ul class="mt-2 space-y-1 text-[12px] text-neutral-600 dark:text-fg-dim">
@foreach (currentTeam()->servers as $resource)
<li class="truncate">{{ $resource->name }}</li>
@endforeach
</ul>
</div>
@endif
@if (currentTeam()->privateKeys()->count() > 0)
<div class="rounded-lg border border-red-200/80 bg-white p-3 dark:border-red-500/15 dark:bg-white/[0.025]">
<p class="text-[11px] font-semibold uppercase tracking-wide text-red-600 dark:text-red-300">
Private keys
</p>
<ul class="mt-2 space-y-1 text-[12px] text-neutral-600 dark:text-fg-dim">
@foreach (currentTeam()->privateKeys as $resource)
<li class="truncate">{{ $resource->name }}</li>
@endforeach
</ul>
</div>
@endif
@if (currentTeam()->sources()->count() > 0)
<div class="rounded-lg border border-red-200/80 bg-white p-3 dark:border-red-500/15 dark:bg-white/[0.025]">
<p class="text-[11px] font-semibold uppercase tracking-wide text-red-600 dark:text-red-300">
Sources
</p>
<ul class="mt-2 space-y-1 text-[12px] text-neutral-600 dark:text-fg-dim">
@foreach (currentTeam()->sources as $resource)
<li class="truncate">{{ $resource->name }}</li>
@endforeach
</ul>
</div>
@endif
</div>
@endif
</div>
</section>
@endcan
</div>
</x-team.settings-layout>
</div>

View file

@ -56,8 +56,7 @@
Team Members | Coolify
</x-slot>
<x-team.navbar />
<x-team.settings-layout>
<div class="application-settings-form flex flex-col gap-6">
<x-application.settings-section title="Members" flush>
<div
@ -172,4 +171,5 @@ class="flex w-10 items-center justify-center text-neutral-500 transition-colors
<livewire:team.invitations :invitations="$invitations" />
@endcan
</div>
</x-team.settings-layout>
</div>

View file

@ -92,6 +92,7 @@
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::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');

View file

@ -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 @@
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)[^']+'/");
}

View file

@ -95,3 +95,16 @@
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']);
});

View file

@ -0,0 +1,18 @@
<?php
it('shows API token permissions in a dropdown and keeps non-root options rendered', function () {
$view = file_get_contents(resource_path('views/livewire/security/api-tokens.blade.php'));
$permissionsSection = str($view)->between(
'<h4 class="text-[12px] font-semibold text-black dark:text-fg">Permissions</h4>',
'</x-application.settings-section>'
)->toString();
expect($permissionsSection)
->toContain('permissionsOpen')
->toContain('Selected permissions')
->toContain('<x-forms.checkbox')
->toContain("in_array('root', \$permissions)")
->toContain('Read sensitive data')
->not->toContain('<input type="checkbox" value="root"')
->not->toContain("@if (!in_array('root', \$permissions))");
});

View file

@ -1,12 +1,12 @@
<?php
test('settings navbar uses a short email tab label', function () {
test('settings header navigation is replaced by the settings sidebar', function () {
$path = resource_path('views/components/dashboard/navbar.blade.php');
$contents = file_get_contents($path);
expect($contents)
->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 @@
$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');
});

View file

@ -34,6 +34,16 @@
->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);

View file

@ -25,3 +25,14 @@
->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');
});

View file

@ -0,0 +1,94 @@
<?php
it('uses shared sidebar navigation for every notification channel', function () {
$sidebar = file_get_contents(resource_path('views/components/notification/settings-layout.blade.php'));
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
foreach (['email', 'discord', 'telegram', 'slack', 'pushover', 'webhook'] as $channel) {
$view = file_get_contents(resource_path("views/livewire/notifications/{$channel}.blade.php"));
expect($view)
->toContain('<x-notification.settings-layout>')
->not->toContain('<x-notification.navbar');
}
expect($sidebar)
->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('<path')
->not->toContain('<ellipse');
expect($sidebar)
->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 <x-forms.button> 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('<x-notification.channel-actions')
->not->toContain("id=\"{$channel}Enabled\"");
}
});

View file

@ -1,7 +1,7 @@
<?php
/**
* Security / notifications family: mobile-only H1 on list views; actions in layer-2 nav.
* Security / notifications family: mobile-only H1 on list views; list actions live in card headers.
* Detail views keep the resource title on desktop.
*/
test('security navbar defaults to hiding the title on desktop', function () {
@ -17,7 +17,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 @@
'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 @@
$blade = file_get_contents($path);
expect($blade)
->toContain('<x-application.settings-section')
->toContain('<x-slot:actions>')
->not->toContain('<x-slot:titleActions>');

View file

@ -42,7 +42,7 @@
->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 @@
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 () {

View file

@ -55,6 +55,29 @@
&& 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, '<div class="grid gap-4 lg:grid-cols-2">'));
});
test('adding a cloud provider token stores an optional description', function () {
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),

View file

@ -30,14 +30,16 @@
$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 () {

View file

@ -39,14 +39,14 @@
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 @@
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 () {

View file

@ -0,0 +1,84 @@
<?php
use App\Livewire\Security\CloudInitScript\Show as CloudInitScriptShow;
use App\Models\CloudInitScript;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => 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('<x-application.settings-section')
->toContain('<x-slot:actions>')
->toContain('<x-modal-input title="Edit')
->toContain('<x-reicon name="settings"')
->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</div>', '>Status</div>');
expect(file_get_contents($views[1]))->toContain('>Token</div>', '>Provider</div>');
expect(file_get_contents($views[2]))->toContain('>Script</div>', '>Last updated</div>');
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('<div>Last updated</div>')
->toContain('<div class="flex items-center">')
->not->toContain('<div class="text-right">Last updated</div>');
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);
});

View file

@ -0,0 +1,15 @@
<?php
it('uses distinct icons for each keys and tokens navigation item', function () {
$layout = file_get_contents(resource_path('views/components/security/settings-layout.blade.php'));
$icons = file_get_contents(resource_path('views/components/reicon.blade.php'));
expect($layout)
->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' =>");
});

View file

@ -0,0 +1,31 @@
<?php
it('uses shared sidebar navigation for keys and tokens pages', function () {
$sidebar = file_get_contents(resource_path('views/components/security/settings-layout.blade.php'));
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
foreach ([
'security/private-key/index.blade.php',
'security/private-key/show.blade.php',
'security/cloud-tokens.blade.php',
'security/cloud-provider-token/show.blade.php',
'security/cloud-init-scripts.blade.php',
'security/cloud-init-script/show.blade.php',
'security/api-tokens.blade.php',
] as $viewPath) {
expect(file_get_contents(resource_path("views/livewire/{$viewPath}")))
->toContain('<x-security.settings-layout>')
->not->toContain('<x-security.navbar');
}
expect($sidebar)
->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'");
});

View file

@ -0,0 +1,28 @@
<?php
it('provides grid and table views for compose resources without sorting controls', function () {
$configuration = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
expect($configuration)
->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</button>')
->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('<x-forms.domain-chips model="fqdn" label="Domains"');
});

View file

@ -44,11 +44,10 @@ function setupInstanceAdminWithTransactionalEmail(): User
$view = file_get_contents(resource_path('views/livewire/settings-email.blade.php'));
expect($view)
->toContain('<x-settings.navbar />')
->toContain('<x-settings.layout>')
->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('<x-settings.navbar>');
->not->toContain('<x-settings.navbar');
});
test('send test is not rendered when transactional email is disabled', function () {

View file

@ -0,0 +1,34 @@
<?php
use App\Livewire\SettingsOauth;
it('uses per-provider enable buttons with browser validation', function () {
$view = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php'));
expect($view)
->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',
]);
});

View file

@ -4,11 +4,10 @@
$view = file_get_contents(resource_path('views/livewire/settings/scheduled-jobs.blade.php'));
expect($view)
->toContain('<x-settings.navbar />')
->toContain('<x-settings.layout>')
->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('<x-settings.navbar>');
->not->toContain('<x-settings.navbar');
// Refresh should appear after the activity section opens, not before.
$activityPos = strpos($view, 'settings-section title="Scheduler activity"');

View file

@ -10,8 +10,8 @@
expect($navigationCss)
->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');
});

View file

@ -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('<x-settings.navbar')
->toContain('max-w-[1180px]')
->toContain('xl:grid-cols-[210px_minmax(0,1fr)]')
->toContain('<x-settings.layout>')
->not->toContain('<x-settings.navbar')
->not->toContain('x-settings.page-header');
}
$oauth = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php'));
expect($oauth)
->toContain('<x-slot:submenu>')
->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"');
});

View file

@ -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)

View file

@ -1,6 +1,6 @@
<?php
use App\Livewire\Team\Index;
use App\Livewire\Team\DangerZone;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
@ -10,7 +10,7 @@
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::forceCreate(['id' => 0]);
$this->owner = User::factory()->create();
@ -27,7 +27,7 @@
$this->actingAs($this->owner);
session(['currentTeam' => $this->teamToDelete]);
Livewire::test(Index::class)
Livewire::test(DangerZone::class)
->call('delete')
->assertRedirect(route('team.index'));

View file

@ -0,0 +1,39 @@
<?php
it('uses shared sidebar navigation for every team settings page', function () {
$sidebar = file_get_contents(resource_path('views/components/team/settings-layout.blade.php'));
$navbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
foreach ([
resource_path('views/livewire/team/index.blade.php'),
resource_path('views/livewire/team/member/index.blade.php'),
resource_path('views/livewire/team/admin-view.blade.php'),
resource_path('views/livewire/team/danger-zone.blade.php'),
] as $view) {
expect(file_get_contents($view))
->toContain('<x-team.settings-layout>')
->not->toContain('<x-team.navbar');
}
expect($sidebar)
->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'");
});