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:
parent
5842d77b81
commit
2291c3fa1c
83 changed files with 1585 additions and 597 deletions
|
|
@ -93,11 +93,11 @@ public function updatedPermissions($permissionToUpdate)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($permissionToUpdate == 'root') {
|
if ($permissionToUpdate == 'root' && in_array('root', $this->permissions, true)) {
|
||||||
$this->permissions = ['root'];
|
$this->permissions = ['root'];
|
||||||
} elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) {
|
} elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) {
|
||||||
$this->permissions[] = 'read';
|
$this->permissions[] = 'read';
|
||||||
} elseif ($permissionToUpdate == 'deploy') {
|
} elseif ($permissionToUpdate == 'deploy' && in_array('deploy', $this->permissions, true)) {
|
||||||
$this->permissions = ['deploy'];
|
$this->permissions = ['deploy'];
|
||||||
} else {
|
} else {
|
||||||
if (count($this->permissions) == 0) {
|
if (count($this->permissions) == 0) {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ class Show extends Component
|
||||||
|
|
||||||
public CloudInitScript $cloudInitScript;
|
public CloudInitScript $cloudInitScript;
|
||||||
|
|
||||||
|
public bool $modalMode = false;
|
||||||
|
|
||||||
public string $name = '';
|
public string $name = '';
|
||||||
|
|
||||||
public string $script = '';
|
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 {
|
try {
|
||||||
$this->cloudInitScript = CloudInitScript::ownedByCurrentTeam()
|
$this->cloudInitScript = CloudInitScript::ownedByCurrentTeam()
|
||||||
->whereUuid($cloud_init_script_uuid)
|
->whereUuid($cloud_init_script_uuid)
|
||||||
|
|
@ -70,6 +73,7 @@ public function save(): void
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->dispatch('success', 'Cloud-init script updated successfully.');
|
$this->dispatch('success', 'Cloud-init script updated successfully.');
|
||||||
|
$this->dispatch('securityResourceChanged');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(): mixed
|
public function delete(): mixed
|
||||||
|
|
@ -87,6 +91,13 @@ public function delete(): mixed
|
||||||
'cloud_init_script_name' => $scriptName,
|
'cloud_init_script_name' => $scriptName,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($this->modalMode) {
|
||||||
|
$this->dispatch('securityResourceChanged');
|
||||||
|
$this->dispatch('close-modal');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return redirectRoute($this, 'security.cloud-init-scripts');
|
return redirectRoute($this, 'security.cloud-init-scripts');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ public function getListeners()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'scriptSaved' => 'loadScripts',
|
'scriptSaved' => 'loadScripts',
|
||||||
|
'securityResourceChanged' => 'loadScripts',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ class Show extends Component
|
||||||
|
|
||||||
public CloudProviderToken $cloudProviderToken;
|
public CloudProviderToken $cloudProviderToken;
|
||||||
|
|
||||||
|
public bool $modalMode = false;
|
||||||
|
|
||||||
public string $name = '';
|
public string $name = '';
|
||||||
|
|
||||||
public ?string $description = null;
|
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 {
|
try {
|
||||||
$this->cloudProviderToken = CloudProviderToken::ownedByCurrentTeam()
|
$this->cloudProviderToken = CloudProviderToken::ownedByCurrentTeam()
|
||||||
->whereUuid($cloud_token_uuid)
|
->whereUuid($cloud_token_uuid)
|
||||||
|
|
@ -71,6 +74,7 @@ public function save(): void
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->dispatch('success', 'Cloud provider token updated.');
|
$this->dispatch('success', 'Cloud provider token updated.');
|
||||||
|
$this->dispatch('securityResourceChanged');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function validateToken(): void
|
public function validateToken(): void
|
||||||
|
|
@ -122,6 +126,13 @@ public function delete(): mixed
|
||||||
|
|
||||||
$this->cloudProviderToken->delete();
|
$this->cloudProviderToken->delete();
|
||||||
|
|
||||||
|
if ($this->modalMode) {
|
||||||
|
$this->dispatch('securityResourceChanged');
|
||||||
|
$this->dispatch('close-modal');
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return redirectRoute($this, 'security.cloud-tokens');
|
return redirectRoute($this, 'security.cloud-tokens');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,18 +18,23 @@ class CloudProviderTokenForm extends Component
|
||||||
|
|
||||||
public string $provider = 'hetzner';
|
public string $provider = 'hetzner';
|
||||||
|
|
||||||
|
public bool $provider_locked = false;
|
||||||
|
|
||||||
public string $token = '';
|
public string $token = '';
|
||||||
|
|
||||||
public string $name = '';
|
public string $name = '';
|
||||||
|
|
||||||
public ?string $description = null;
|
public ?string $description = null;
|
||||||
|
|
||||||
public function mount()
|
public function mount(?string $provider = null): void
|
||||||
{
|
{
|
||||||
|
$this->provider_locked = filled($provider);
|
||||||
|
$this->provider = $provider ?? 'hetzner';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->authorize('create', CloudProviderToken::class);
|
$this->authorize('create', CloudProviderToken::class);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
handleError($e, $this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ public function getListeners()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tokenAdded' => 'loadTokens',
|
'tokenAdded' => 'loadTokens',
|
||||||
|
'securityResourceChanged' => 'loadTokens',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,13 @@ class Index extends Component
|
||||||
{
|
{
|
||||||
use AuthorizesRequests;
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
public function getListeners(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'securityResourceChanged' => '$refresh',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function generatePrivateKey(string $type)
|
public function generatePrivateKey(string $type)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
@ -29,7 +36,7 @@ public function generatePrivateKey(string $type)
|
||||||
'team_id' => currentTeam()->id,
|
'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) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ class Show extends Component
|
||||||
|
|
||||||
public PrivateKey $private_key;
|
public PrivateKey $private_key;
|
||||||
|
|
||||||
|
public bool $modalMode = false;
|
||||||
|
|
||||||
// Explicit properties
|
// Explicit properties
|
||||||
public string $name;
|
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 {
|
try {
|
||||||
$this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid($private_key_uuid ?? request()->private_key_uuid)->firstOrFail();
|
$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();
|
$this->private_key->delete();
|
||||||
currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get();
|
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');
|
return redirectRoute($this, 'security.private-key.index');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->dispatch('error', $e->getMessage());
|
$this->dispatch('error', $e->getMessage());
|
||||||
|
|
@ -140,6 +150,7 @@ public function changePrivateKey()
|
||||||
]);
|
]);
|
||||||
refresh_server_connection($this->private_key);
|
refresh_server_connection($this->private_key);
|
||||||
$this->dispatch('success', 'Private key updated.');
|
$this->dispatch('success', 'Private key updated.');
|
||||||
|
$this->dispatch('securityResourceChanged');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ private function updateOauthSettings(?string $provider = null)
|
||||||
'base_url' => $oauthData['base_url'],
|
'base_url' => $oauthData['base_url'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! $oauth->couldBeEnabled()) {
|
if ($oauthData['enabled'] && ! $oauth->couldBeEnabled()) {
|
||||||
$oauth->update(['enabled' => false]);
|
$oauth->update(['enabled' => false]);
|
||||||
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>Please fill in all required fields.');
|
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()
|
public function submit()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
56
app/Livewire/Team/DangerZone.php
Normal file
56
app/Livewire/Team/DangerZone.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,6 @@
|
||||||
use App\Models\TeamInvitation;
|
use App\Models\TeamInvitation;
|
||||||
use App\Support\ValidationPatterns;
|
use App\Support\ValidationPatterns;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Index extends Component
|
class Index extends Component
|
||||||
|
|
@ -100,34 +97,4 @@ public function submit()
|
||||||
return handleError($e, $this);
|
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
1
public/svgs/discord.svg
Normal 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
1
public/svgs/pushover.svg
Normal 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
1
public/svgs/slack.svg
Normal 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
1
public/svgs/telegram.svg
Normal 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 |
|
|
@ -1030,8 +1030,9 @@ .application-settings-section-body {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* All settings sidebars share one alignment rule. Their natural position is
|
/* All settings sidebars share one alignment rule. Keep the sticky offset equal
|
||||||
level with the content column; once scrolled they stay below the top bar. */
|
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) {
|
.server-settings-workspace > :not(.application-settings-navigation) {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -1039,9 +1040,9 @@ .server-settings-workspace > :not(.application-settings-navigation) {
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 1280px) {
|
||||||
.application-settings-navigation {
|
.application-settings-navigation {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 3.5rem;
|
top: calc(3rem + 1.75rem);
|
||||||
align-self: start;
|
align-self: start;
|
||||||
max-height: calc(100dvh - 4.25rem);
|
max-height: calc(100dvh - 5.5rem);
|
||||||
padding-right: 0.375rem;
|
padding-right: 0.375rem;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,18 @@
|
||||||
])
|
])
|
||||||
|
|
||||||
<section {{ $attributes->merge(['class' => 'application-settings-section']) }}>
|
<section {{ $attributes->merge(['class' => 'application-settings-section']) }}>
|
||||||
<header>
|
<header @class(['items-start!' => filled($description)])>
|
||||||
<div class="flex items-center gap-2">
|
<div class="min-w-0 py-0.5">
|
||||||
<h3>{{ $title }}</h3>
|
<div class="flex items-center gap-2">
|
||||||
@if ($helper)
|
<h3>{{ $title }}</h3>
|
||||||
<x-helper :helper="$helper" />
|
@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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@isset($actions)
|
@isset($actions)
|
||||||
|
|
|
||||||
|
|
@ -20,53 +20,20 @@
|
||||||
['label' => 'Servers', 'route' => 'shared-variables.server.index', 'active' => request()->routeIs('shared-variables.server.*')],
|
['label' => 'Servers', 'route' => 'shared-variables.server.index', 'active' => request()->routeIs('shared-variables.server.*')],
|
||||||
],
|
],
|
||||||
'team' => [
|
'team' => [
|
||||||
['label' => 'General', 'route' => 'team.index', 'active' => request()->routeIs('team.index')],
|
['label' => 'General', 'route' => 'team.index', 'active' => request()->routeIs('team.index', 'team.member.index', 'team.admin-view', 'team.danger-zone')],
|
||||||
['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(),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
'profile' => [
|
'profile' => [
|
||||||
['label' => 'General', 'route' => 'profile', 'active' => request()->routeIs('profile')],
|
['label' => 'General', 'route' => 'profile', 'active' => request()->routeIs('profile')],
|
||||||
['label' => 'Appearance', 'route' => 'profile.appearance', 'active' => request()->routeIs('profile.appearance')],
|
['label' => 'Appearance', 'route' => 'profile.appearance', 'active' => request()->routeIs('profile.appearance')],
|
||||||
],
|
],
|
||||||
'notifications' => [
|
'notifications' => [
|
||||||
['label' => 'Email', 'route' => 'notifications.email', 'active' => request()->routeIs('notifications.email')],
|
['label' => 'Email', 'route' => 'notifications.email', 'active' => request()->routeIs('notifications.*')],
|
||||||
['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')],
|
|
||||||
],
|
],
|
||||||
'security' => [
|
'security' => [
|
||||||
['label' => 'Private Keys', 'route' => 'security.private-key.index', 'active' => request()->routeIs('security.private-key.*')],
|
['label' => 'Private Keys', 'route' => 'security.private-key.index', 'active' => request()->routeIs('security.*')],
|
||||||
[
|
|
||||||
'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')],
|
|
||||||
],
|
],
|
||||||
'settings' => [
|
'settings' => [
|
||||||
[
|
['label' => 'General', 'route' => 'settings.index', 'active' => request()->routeIs('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')],
|
|
||||||
],
|
],
|
||||||
'source' => [
|
'source' => [
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,30 @@
|
||||||
<div x-data="{
|
<div x-data="{
|
||||||
open: false,
|
open: false,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
hideTimer: null,
|
||||||
style: '',
|
style: '',
|
||||||
show(pinned = false) {
|
show(pinned = false) {
|
||||||
|
this.cancelHide();
|
||||||
this.pinned = pinned;
|
this.pinned = pinned;
|
||||||
this.open = true;
|
this.open = true;
|
||||||
this.$nextTick(() => this.position());
|
this.$nextTick(() => this.position());
|
||||||
},
|
},
|
||||||
hide() {
|
hide() {
|
||||||
if (!this.pinned) {
|
this.cancelHide();
|
||||||
this.open = false;
|
this.hideTimer = setTimeout(() => {
|
||||||
|
if (!this.pinned) {
|
||||||
|
this.open = false;
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
},
|
||||||
|
cancelHide() {
|
||||||
|
if (this.hideTimer) {
|
||||||
|
clearTimeout(this.hideTimer);
|
||||||
|
this.hideTimer = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
|
this.cancelHide();
|
||||||
this.pinned = false;
|
this.pinned = false;
|
||||||
this.open = 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"
|
x-transition:leave-end="opacity-0"
|
||||||
:style="style"
|
:style="style"
|
||||||
class="info-helper-popup fixed z-[9999] w-max max-w-[min(20rem,calc(100vw-2rem))]"
|
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">
|
<div class="px-3 py-2.5 text-[13px] leading-5">
|
||||||
{!! $helper !!}
|
{!! $helper !!}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
'wireIgnore' => true,
|
'wireIgnore' => true,
|
||||||
// Optional Livewire bool property to entangle open state (survives Livewire re-renders).
|
// Optional Livewire bool property to entangle open state (survives Livewire re-renders).
|
||||||
'wireOpen' => null,
|
'wireOpen' => null,
|
||||||
|
'contentClicks' => true,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
|
|
@ -23,7 +24,7 @@
|
||||||
{{ $attributes->class(['relative', $isFullWidth ? 'h-full w-full' : 'h-auto w-auto']) }}
|
{{ $attributes->class(['relative', $isFullWidth ? 'h-full w-full' : 'h-auto w-auto']) }}
|
||||||
@close-modal.window="modalOpen=false" @if ($wireIgnore) wire:ignore @endif>
|
@close-modal.window="modalOpen=false" @if ($wireIgnore) wire:ignore @endif>
|
||||||
@if ($content)
|
@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 }}
|
{{ $content }}
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -33,44 +33,103 @@
|
||||||
['key' => 'traefikOutdated', 'label' => 'Traefik proxy outdated'],
|
['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
|
@endphp
|
||||||
|
|
||||||
<x-application.settings-section title="Notification events">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<x-application.settings-section title="Notification events"
|
||||||
@foreach ($eventGroups as $group => $events)
|
description="Choose which events send a notification on this channel.">
|
||||||
@php
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
$multiselectEvents = collect($events)
|
@foreach ($eventGroups as $group => $events)
|
||||||
->map(fn ($event) => [
|
@php
|
||||||
'property' => $event['key'] . Str::studly($channel) . 'Notifications',
|
$multiselectEvents = collect($events)
|
||||||
'label' => $event['label'],
|
->map(fn ($event) => [
|
||||||
'enabled' => (bool) data_get(
|
'property' => $event['key'] . Str::studly($channel) . 'Notifications',
|
||||||
$settings,
|
'label' => $event['label'],
|
||||||
Str::snake($event['key'] . '_' . $channel . '_notifications'),
|
'enabled' => (bool) data_get(
|
||||||
),
|
$settings,
|
||||||
])
|
Str::snake($event['key'] . '_' . $channel . '_notifications'),
|
||||||
->all();
|
),
|
||||||
$groupId = Str::slug($channel . '-' . $group . '-events');
|
])
|
||||||
@endphp
|
->all();
|
||||||
<div class="min-w-0">
|
$groupId = Str::slug($channel . '-' . $group . '-events');
|
||||||
<x-notification.event-multiselect :settings="$settings" :id="$groupId"
|
@endphp
|
||||||
:label="$group" :events="$multiselectEvents" />
|
<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)
|
@if ($threaded)
|
||||||
<div
|
<x-application.settings-section title="Forum topics"
|
||||||
class="mt-3 grid gap-3 border-l border-neutral-200 pl-3 sm:grid-cols-2 dark:border-white/[0.08]">
|
description="Optional. Route enabled events to a Telegram forum topic using its message thread ID. Leave blank to post in the main chat.">
|
||||||
@foreach ($events as $event)
|
@if ($enabledThreadEvents === [])
|
||||||
@php
|
<p class="text-[13px] leading-relaxed text-neutral-500 dark:text-fg-dim">
|
||||||
$threadModel = Str::camel(
|
Enable one or more events above to assign forum topic IDs.
|
||||||
Str::studly($channel) . 'Notifications' . Str::studly($event['key']) . 'ThreadId',
|
</p>
|
||||||
);
|
@else
|
||||||
@endphp
|
<div class="flex flex-col gap-5">
|
||||||
<x-forms.input wire:key="{{ $channel }}-thread-{{ $event['key'] }}"
|
@foreach ($enabledThreadEventsByGroup as $group => $events)
|
||||||
canGate="update" :canResource="$settings" type="password" :id="$threadModel"
|
<div class="min-w-0">
|
||||||
label="{{ $event['label'] }} thread ID" placeholder="Optional" />
|
<div
|
||||||
@endforeach
|
class="mb-2 text-[11px] font-medium tracking-wide text-neutral-500 uppercase dark:text-fg-dim">
|
||||||
</div>
|
{{ $group }}
|
||||||
@endif
|
</div>
|
||||||
</div>
|
<div
|
||||||
@endforeach
|
class="divide-y divide-neutral-200 overflow-hidden rounded-xl border border-neutral-200 dark:divide-white/[0.06] dark:border-white/[0.08]">
|
||||||
</div>
|
@foreach ($events as $event)
|
||||||
</x-application.settings-section>
|
<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="$event['threadModel']" :label="null"
|
||||||
|
:placeholder="'Optional'"
|
||||||
|
:aria-label="$event['label'] . ' topic ID'" />
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</x-application.settings-section>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -6,6 +6,9 @@
|
||||||
// copy the inner markup from the reicon Outline weight and swap
|
// copy the inner markup from the reicon Outline weight and swap
|
||||||
// #000000 -> currentColor. See UI_REDESIGN.md.
|
// #000000 -> currentColor. See UI_REDESIGN.md.
|
||||||
$icons = [
|
$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"/>',
|
'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"/>',
|
'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"/>',
|
'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"/>',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
48
resources/views/components/settings/layout.blade.php
Normal file
48
resources/views/components/settings/layout.blade.php
Normal 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>
|
||||||
57
resources/views/components/team/settings-layout.blade.php
Normal file
57
resources/views/components/team/settings-layout.blade.php
Normal 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>
|
||||||
|
|
@ -4,12 +4,25 @@
|
||||||
notification: true,
|
notification: true,
|
||||||
realtime: false,
|
realtime: false,
|
||||||
},
|
},
|
||||||
|
reminders: {
|
||||||
|
sponsorship: { compact: false },
|
||||||
|
notification: { compact: false },
|
||||||
|
},
|
||||||
|
reminderCollapseAfter: 10000,
|
||||||
isDevelopment: {{ isDev() ? 'true' : 'false' }},
|
isDevelopment: {{ isDev() ? 'true' : 'false' }},
|
||||||
init() {
|
init() {
|
||||||
this.popups.sponsorship = this.shouldShowMonthlyPopup('popupSponsorship');
|
this.popups.sponsorship = this.shouldShowMonthlyPopup('popupSponsorship');
|
||||||
this.popups.notification = this.shouldShowMonthlyPopup('popupNotification');
|
this.popups.notification = this.shouldShowMonthlyPopup('popupNotification');
|
||||||
this.popups.realtime = localStorage.getItem('popupRealtime');
|
this.popups.realtime = localStorage.getItem('popupRealtime');
|
||||||
|
|
||||||
|
if (this.popups.sponsorship) {
|
||||||
|
this.scheduleReminderCollapse('sponsorship');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.popups.notification) {
|
||||||
|
this.scheduleReminderCollapse('notification');
|
||||||
|
}
|
||||||
|
|
||||||
let checkNumber = 1;
|
let checkNumber = 1;
|
||||||
let checkPusherInterval = null;
|
let checkPusherInterval = null;
|
||||||
let checkReconnectInterval = null;
|
let checkReconnectInterval = null;
|
||||||
|
|
@ -33,6 +46,17 @@
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
scheduleReminderCollapse(reminder) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (reminder === 'sponsorship') {
|
||||||
|
this.reminders.sponsorship.compact = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reminder === 'notification') {
|
||||||
|
this.reminders.notification.compact = true;
|
||||||
|
}
|
||||||
|
}, this.reminderCollapseAfter);
|
||||||
|
},
|
||||||
shouldShowMonthlyPopup(storageKey) {
|
shouldShowMonthlyPopup(storageKey) {
|
||||||
const disabledTimestamp = localStorage.getItem(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">
|
<span x-show="popups.sponsorship">
|
||||||
<x-popup>
|
<x-popup>
|
||||||
<x-slot:customActions>
|
<x-slot:customActions>
|
||||||
<div
|
<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="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">
|
: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"
|
<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"
|
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()">
|
@click="bannerVisible=false;disableSponsorship()">
|
||||||
|
|
@ -129,7 +153,7 @@ class="absolute top-3 right-3 flex size-7 items-center justify-center rounded-fu
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="flex items-start gap-4 pr-8">
|
<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]">
|
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">
|
<img src="{{ asset('heart.png') }}" alt="" class="size-9">
|
||||||
</div>
|
</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">
|
<h2 class="text-[15px]! leading-5! font-semibold! text-black dark:text-fg">
|
||||||
Love Coolify? Support our work.
|
Love Coolify? Support our work.
|
||||||
</h2>
|
</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
|
Coolify is profitable thanks to <span
|
||||||
class="font-semibold text-coollabs dark:text-warning">you</span>. Your support
|
class="font-semibold text-coollabs dark:text-warning">you</span>. Your support
|
||||||
helps us build more features and keep improving the project.
|
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!">
|
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
|
GitHub Sponsors
|
||||||
</a>
|
</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="
|
href="https://opencollective.com/coollabsio/donate?interval=month&amount=10&name=&legalName=&email="
|
||||||
class="button h-9 justify-center sm:flex-1">
|
class="button h-9 justify-center sm:flex-1">
|
||||||
Open Collective
|
Open Collective
|
||||||
</a>
|
</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">
|
class="button h-9 justify-center sm:flex-1">
|
||||||
Stripe
|
Stripe
|
||||||
</a>
|
</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"
|
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()">
|
@click="bannerVisible=false;disableSponsorship()">
|
||||||
Maybe next time
|
Maybe next time
|
||||||
|
|
@ -223,8 +249,8 @@ class="underline dark:text-white">/subscription</a> to update your subscription
|
||||||
<span x-show="popups.notification">
|
<span x-show="popups.notification">
|
||||||
<x-popup>
|
<x-popup>
|
||||||
<x-slot:customActions>
|
<x-slot:customActions>
|
||||||
<div
|
<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="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">
|
: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"
|
<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"
|
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()">
|
@click="bannerVisible=false;disableNotification()">
|
||||||
|
|
@ -232,7 +258,7 @@ class="absolute top-3 right-3 flex size-7 items-center justify-center rounded-fu
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="flex items-start gap-4 pr-8">
|
<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">
|
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" />
|
<x-reicon name="alert-triangle" class="size-6" />
|
||||||
</div>
|
</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">
|
<h2 class="text-[15px]! leading-5! font-semibold! text-black dark:text-fg">
|
||||||
No notifications enabled
|
No notifications enabled
|
||||||
</h2>
|
</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.
|
Enable at least one notification channel so you receive important alerts.
|
||||||
Visit
|
Visit
|
||||||
<a href="{{ route('notifications.email') }}" {{ wireNavigate() }}
|
<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">
|
class="button h-9 justify-center sm:min-w-28">
|
||||||
Open notifications
|
Open notifications
|
||||||
</a>
|
</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()">
|
@click="bannerVisible=false;disableNotification()">
|
||||||
Accept and close
|
Accept and close
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -3,28 +3,18 @@
|
||||||
Discord Notifications | Coolify
|
Discord Notifications | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-notification.navbar />
|
<x-notification.settings-layout>
|
||||||
|
|
||||||
<div class="application-settings-form flex flex-col gap-6">
|
<div class="application-settings-form flex flex-col gap-6">
|
||||||
<form wire:submit="submit">
|
<form wire:submit="submit">
|
||||||
<x-unsaved-bar action="submit" />
|
<x-unsaved-bar action="submit" />
|
||||||
<x-application.settings-section title="Discord"
|
<x-application.settings-section title="Discord"
|
||||||
description="Send team notifications to a Discord channel through an incoming webhook.">
|
description="Send team notifications to a Discord channel through an incoming webhook.">
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-forms.button type="button" wire:click="sendTestNotification"
|
<x-notification.channel-actions :enabled="$discordEnabled" enabledProperty="discordEnabled"
|
||||||
:disabled="!$discordEnabled">
|
toggleMethod="instantSaveDiscordEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
|
||||||
<x-reicon name="notifications" class="size-3.5" />
|
|
||||||
Send test
|
|
||||||
</x-forms.button>
|
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<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"
|
<x-forms.listbox id="discordPingEnabled" label="Critical event mention"
|
||||||
helper="Mention @here when a critical event occurs."
|
helper="Mention @here when a critical event occurs."
|
||||||
onChange="instantSaveDiscordPingEnabled"
|
onChange="instantSaveDiscordPingEnabled"
|
||||||
|
|
@ -46,4 +36,5 @@
|
||||||
|
|
||||||
<x-notification.event-grid :settings="$settings" channel="discord" />
|
<x-notification.event-grid :settings="$settings" channel="discord" />
|
||||||
</div>
|
</div>
|
||||||
|
</x-notification.settings-layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
Notifications | Coolify
|
Notifications | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-notification.navbar />
|
<x-notification.settings-layout>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<form wire:submit="submit" class="application-settings-form">
|
<form wire:submit="submit" class="application-settings-form">
|
||||||
<x-unsaved-bar action="submit" />
|
<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>
|
</x-application.settings-section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</x-notification.settings-layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,18 @@
|
||||||
Pushover Notifications | Coolify
|
Pushover Notifications | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-notification.navbar />
|
<x-notification.settings-layout>
|
||||||
|
|
||||||
<div class="application-settings-form flex flex-col gap-6">
|
<div class="application-settings-form flex flex-col gap-6">
|
||||||
<form wire:submit="submit">
|
<form wire:submit="submit">
|
||||||
<x-unsaved-bar action="submit" />
|
<x-unsaved-bar action="submit" />
|
||||||
<x-application.settings-section title="Pushover"
|
<x-application.settings-section title="Pushover"
|
||||||
description="Deliver team alerts through your Pushover application.">
|
description="Deliver team alerts through your Pushover application.">
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-forms.button type="button" wire:click="sendTestNotification"
|
<x-notification.channel-actions :enabled="$pushoverEnabled" enabledProperty="pushoverEnabled"
|
||||||
:disabled="!$pushoverEnabled">
|
toggleMethod="instantSavePushoverEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
|
||||||
<x-reicon name="notifications" class="size-3.5" />
|
|
||||||
Send test
|
|
||||||
</x-forms.button>
|
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<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)
|
@can('update', $settings)
|
||||||
<x-forms.input type="password" required id="pushoverUserKey" label="User key"
|
<x-forms.input type="password" required id="pushoverUserKey" label="User key"
|
||||||
helper="Find this in the Pushover dashboard." />
|
helper="Find this in the Pushover dashboard." />
|
||||||
|
|
@ -44,4 +30,5 @@
|
||||||
|
|
||||||
<x-notification.event-grid :settings="$settings" channel="pushover" />
|
<x-notification.event-grid :settings="$settings" channel="pushover" />
|
||||||
</div>
|
</div>
|
||||||
|
</x-notification.settings-layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,18 @@
|
||||||
Slack Notifications | Coolify
|
Slack Notifications | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-notification.navbar />
|
<x-notification.settings-layout>
|
||||||
|
|
||||||
<div class="application-settings-form flex flex-col gap-6">
|
<div class="application-settings-form flex flex-col gap-6">
|
||||||
<form wire:submit="submit">
|
<form wire:submit="submit">
|
||||||
<x-unsaved-bar action="submit" />
|
<x-unsaved-bar action="submit" />
|
||||||
<x-application.settings-section title="Slack"
|
<x-application.settings-section title="Slack"
|
||||||
description="Send team notifications to Slack through an incoming webhook.">
|
description="Send team notifications to Slack through an incoming webhook.">
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-forms.button type="button" wire:click="sendTestNotification"
|
<x-notification.channel-actions :enabled="$slackEnabled" enabledProperty="slackEnabled"
|
||||||
:disabled="!$slackEnabled">
|
toggleMethod="instantSaveSlackEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
|
||||||
<x-reicon name="notifications" class="size-3.5" />
|
|
||||||
Send test
|
|
||||||
</x-forms.button>
|
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<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">
|
<div class="lg:col-span-2">
|
||||||
@can('update', $settings)
|
@can('update', $settings)
|
||||||
<x-forms.input type="password" required id="slackWebhookUrl" label="Webhook URL"
|
<x-forms.input type="password" required id="slackWebhookUrl" label="Webhook URL"
|
||||||
|
|
@ -43,4 +29,5 @@
|
||||||
|
|
||||||
<x-notification.event-grid :settings="$settings" channel="slack" />
|
<x-notification.event-grid :settings="$settings" channel="slack" />
|
||||||
</div>
|
</div>
|
||||||
|
</x-notification.settings-layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,18 @@
|
||||||
Telegram Notifications | Coolify
|
Telegram Notifications | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-notification.navbar />
|
<x-notification.settings-layout>
|
||||||
|
|
||||||
<div class="application-settings-form flex flex-col gap-6">
|
<div class="application-settings-form flex flex-col gap-6">
|
||||||
<form wire:submit="submit">
|
<form wire:submit="submit">
|
||||||
<x-unsaved-bar action="submit" />
|
<x-unsaved-bar action="submit" />
|
||||||
<x-application.settings-section title="Telegram"
|
<x-application.settings-section title="Telegram"
|
||||||
description="Deliver team notifications through a Telegram bot and chat.">
|
description="Deliver team notifications through a Telegram bot and chat.">
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-forms.button type="button" wire:click="sendTestNotification"
|
<x-notification.channel-actions :enabled="$telegramEnabled" enabledProperty="telegramEnabled"
|
||||||
:disabled="!$telegramEnabled">
|
toggleMethod="instantSaveTelegramEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
|
||||||
<x-reicon name="notifications" class="size-3.5" />
|
|
||||||
Send test
|
|
||||||
</x-forms.button>
|
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<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)
|
@can('update', $settings)
|
||||||
<x-forms.input type="password" autocomplete="new-password" required id="telegramToken"
|
<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." />
|
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 />
|
<x-notification.event-grid :settings="$settings" channel="telegram" threaded />
|
||||||
</div>
|
</div>
|
||||||
|
</x-notification.settings-layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,18 @@
|
||||||
Webhook Notifications | Coolify
|
Webhook Notifications | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-notification.navbar />
|
<x-notification.settings-layout>
|
||||||
|
|
||||||
<div class="application-settings-form flex flex-col gap-6">
|
<div class="application-settings-form flex flex-col gap-6">
|
||||||
<form wire:submit="submit">
|
<form wire:submit="submit">
|
||||||
<x-unsaved-bar action="submit" />
|
<x-unsaved-bar action="submit" />
|
||||||
<x-application.settings-section title="Webhook"
|
<x-application.settings-section title="Webhook"
|
||||||
description="Send JSON event payloads to your own HTTP endpoint.">
|
description="Send JSON event payloads to your own HTTP endpoint.">
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-forms.button type="button" wire:click="sendTestNotification"
|
<x-notification.channel-actions :enabled="$webhookEnabled" enabledProperty="webhookEnabled"
|
||||||
:disabled="!$webhookEnabled">
|
toggleMethod="instantSaveWebhookEnabled" :canUpdate="auth()->user()->can('update', $settings)" />
|
||||||
<x-reicon name="notifications" class="size-3.5" />
|
|
||||||
Send test
|
|
||||||
</x-forms.button>
|
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<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">
|
<div class="lg:col-span-2">
|
||||||
@can('update', $settings)
|
@can('update', $settings)
|
||||||
<x-forms.input type="password" required id="webhookUrl" label="Webhook URL"
|
<x-forms.input type="password" required id="webhookUrl" label="Webhook URL"
|
||||||
|
|
@ -43,4 +29,5 @@
|
||||||
|
|
||||||
<x-notification.event-grid :settings="$settings" channel="webhook" />
|
<x-notification.event-grid :settings="$settings" channel="webhook" />
|
||||||
</div>
|
</div>
|
||||||
|
</x-notification.settings-layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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')
|
@if ($currentRoute === 'project.service.configuration')
|
||||||
<livewire:project.service.stack-form :service="$service" />
|
<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 class="mb-3 flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-base font-semibold text-black dark:text-fg">Compose resources</h2>
|
<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.
|
Applications and databases defined in this service.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="button" target="_blank" href="{{ $service->documentation() }}">
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
Documentation
|
<div
|
||||||
<x-reicon name="external-link" class="size-4" />
|
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]">
|
||||||
</a>
|
<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>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<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
|
||||||
|
|
||||||
@if ($applications->isEmpty() && $databases->isEmpty())
|
@if ($applications->isEmpty() && $databases->isEmpty())
|
||||||
<div
|
<div
|
||||||
class="application-settings-section overflow-hidden sm:col-span-2">
|
class="application-settings-section overflow-hidden sm:col-span-2">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
@if (!$serviceApplication->serviceType()?->contains(str($serviceApplication->image)->before(':')))
|
@if (!$serviceApplication->serviceType()?->contains(str($serviceApplication->image)->before(':')))
|
||||||
<x-forms.domain-chips model="fqdn" label="Domains"
|
<div class="rounded-lg border border-neutral-200 p-4 dark:border-white/[0.08]">
|
||||||
:required="(bool) $serviceApplication->required_fqdn"
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
:can-update="auth()->user()->can('update', $serviceApplication)"
|
<p class="text-sm text-neutral-500 dark:text-fg-dim">
|
||||||
:disabled="! auth()->user()->can('update', $serviceApplication)" />
|
@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
|
@endif
|
||||||
<x-forms.input canGate="update" :canResource="$serviceApplication"
|
<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>"
|
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>"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<div
|
<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]">
|
|
||||||
@php
|
@php
|
||||||
[$statusType, $statusLabel] = match (true) {
|
[$statusType, $statusLabel] = match (true) {
|
||||||
str($resource->status)->contains('running') => ['success', formatContainerStatus($resource->status)],
|
str($resource->status)->contains('running') => ['success', formatContainerStatus($resource->status)],
|
||||||
|
|
@ -11,7 +10,9 @@ class="group flex min-w-0 flex-col overflow-hidden rounded-[10px] border border-
|
||||||
: Str::headline($resource->name);
|
: Str::headline($resource->name);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-1 items-start gap-3 p-4">
|
<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
|
<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">
|
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">
|
||||||
<x-reicon :name="$isDatabase ? 'database' : 'grid'" class="size-4" />
|
<x-reicon :name="$isDatabase ? 'database' : 'grid'" class="size-4" />
|
||||||
|
|
@ -52,10 +53,10 @@ class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-10
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-end gap-1 border-t border-neutral-200 bg-neutral-50 px-3 py-2 dark:border-white/[0.06] dark:bg-white/[0.02]">
|
class="flex items-center justify-end gap-1 border-t border-neutral-200 bg-neutral-50 px-3 py-2 dark:border-white/[0.06] dark:bg-white/[0.02]">
|
||||||
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))
|
@if ($isDatabase && ($resource->isBackupSolutionAvailable() || $resource->is_migrated))
|
||||||
<a class="button" {{ wireNavigate() }}
|
<a class="button" {{ wireNavigate() }}
|
||||||
href="{{ route('project.service.database.backups', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
|
href="{{ route('project.service.database.backups', [...$parameters, 'stack_service_uuid' => $resource->uuid]) }}">
|
||||||
|
|
@ -77,5 +78,32 @@ class="flex items-center justify-end gap-1 border-t border-neutral-200 bg-neutra
|
||||||
:step2ButtonText="$isApplication ? 'Restart Service Container' : 'Restart Database'" />
|
:step2ButtonText="$isApplication ? 'Restart Service Container' : 'Restart Database'" />
|
||||||
@endcan
|
@endcan
|
||||||
@endif
|
@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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
API Tokens | Coolify
|
API Tokens | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-security.navbar />
|
<x-security.settings-layout>
|
||||||
|
|
||||||
@if (!$isApiEnabled)
|
@if (!$isApiEnabled)
|
||||||
<div class="application-settings-form">
|
<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>
|
<h4 class="text-[12px] font-semibold text-black dark:text-fg">Permissions</h4>
|
||||||
<x-helper helper="Only grant the abilities this token needs." />
|
<x-helper helper="Only grant the abilities this token needs." />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-3 lg:grid-cols-2">
|
<div class="relative" x-data="{ permissionsOpen: false }"
|
||||||
@if ($canUseRootPermissions)
|
@click.outside="permissionsOpen = false" @keydown.escape.window="permissionsOpen = false">
|
||||||
<x-forms.checkbox label="Root" wire:model.live="permissions" domValue="root"
|
<button type="button" class="listbox-trigger" @click="permissionsOpen = !permissionsOpen"
|
||||||
helper="Full access to every API operation." :checked="in_array('root', $permissions)" />
|
aria-haspopup="listbox" :aria-expanded="permissionsOpen">
|
||||||
@else
|
<span class="truncate">
|
||||||
<x-forms.checkbox label="Root" disabled domValue="root"
|
Selected permissions: {{ collect($permissions)->map(fn ($permission) => str($permission)->replace(':', ' ')->headline())->join(', ') }}
|
||||||
helper="Requires an admin or owner role." :checked="false" />
|
</span>
|
||||||
@endif
|
<x-reicon name="chevron-down" class="size-3.5 shrink-0 opacity-60" />
|
||||||
|
</button>
|
||||||
|
|
||||||
@if (!in_array('root', $permissions))
|
<div x-cloak x-show="permissionsOpen" x-transition.origin.top
|
||||||
@if ($canUseWritePermissions)
|
class="listbox-panel top-full! mt-1! w-full!" role="listbox">
|
||||||
<x-forms.checkbox label="Write" wire:model.live="permissions"
|
<div class="listbox-option p-0!">
|
||||||
domValue="write" helper="Create and update resources."
|
<x-forms.checkbox id="permission-root" label="Root" fullWidth
|
||||||
:checked="in_array('write', $permissions)" />
|
wire:model.live="permissions" domValue="root"
|
||||||
@else
|
helper="Full access to every API operation."
|
||||||
<x-forms.checkbox label="Write" disabled domValue="write"
|
:checked="in_array('root', $permissions)" :disabled="!$canUseRootPermissions" />
|
||||||
helper="Requires an admin or owner role." :checked="false" />
|
</div>
|
||||||
@endif
|
<div class="listbox-option p-0!">
|
||||||
|
<x-forms.checkbox id="permission-write" label="Write" fullWidth
|
||||||
@if ($canUseDeployPermissions)
|
wire:model.live="permissions" domValue="write"
|
||||||
<x-forms.checkbox label="Deploy" wire:model.live="permissions"
|
helper="Create and update resources."
|
||||||
domValue="deploy" helper="Trigger deployments through webhooks."
|
:checked="in_array('write', $permissions)"
|
||||||
:checked="in_array('deploy', $permissions)" />
|
:disabled="in_array('root', $permissions) || !$canUseWritePermissions" />
|
||||||
@else
|
</div>
|
||||||
<x-forms.checkbox label="Deploy" disabled domValue="deploy"
|
<div class="listbox-option p-0!">
|
||||||
helper="Requires an admin or owner role." :checked="false" />
|
<x-forms.checkbox id="permission-deploy" label="Deploy" fullWidth
|
||||||
@endif
|
wire:model.live="permissions" domValue="deploy"
|
||||||
|
helper="Trigger deployments through webhooks."
|
||||||
<x-forms.checkbox label="Read" wire:model.live="permissions" domValue="read"
|
:checked="in_array('deploy', $permissions)"
|
||||||
helper="Read non-sensitive resource data."
|
:disabled="in_array('root', $permissions) || !$canUseDeployPermissions" />
|
||||||
:checked="in_array('read', $permissions)" />
|
</div>
|
||||||
|
<div class="listbox-option p-0!">
|
||||||
@if ($canUseSensitivePermissions)
|
<x-forms.checkbox id="permission-read" label="Read" fullWidth
|
||||||
<x-forms.checkbox label="Read sensitive data" wire:model.live="permissions"
|
wire:model.live="permissions" domValue="read"
|
||||||
domValue="read:sensitive"
|
helper="Read non-sensitive resource data."
|
||||||
|
: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."
|
helper="Include secrets, logs, passwords, and Compose content."
|
||||||
:checked="in_array('read:sensitive', $permissions)" />
|
:checked="in_array('read:sensitive', $permissions)"
|
||||||
@else
|
:disabled="in_array('root', $permissions) || !$canUseSensitivePermissions" />
|
||||||
<x-forms.checkbox label="Read sensitive data" disabled
|
</div>
|
||||||
domValue="read:sensitive" helper="Requires an admin or owner role."
|
</div>
|
||||||
:checked="false" />
|
|
||||||
@endif
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-application.settings-section>
|
</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>
|
</x-application.settings-section>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</x-security.settings-layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,26 @@
|
||||||
<div>
|
<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>
|
<x-slot:title>
|
||||||
{{ $cloudInitScript->name }} | Cloud-Init Scripts | Coolify
|
{{ $cloudInitScript->name }} | Cloud-Init Scripts | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-security.navbar :title="$cloudInitScript->name"
|
<x-security.settings-layout>
|
||||||
subtitle="Reusable cloud-init script for server provisioning" :titleOnDesktop="true">
|
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
@can('delete', $cloudInitScript)
|
@can('delete', $cloudInitScript)
|
||||||
<x-modal-confirmation title="Confirm Script Deletion?" isErrorButton buttonTitle="Delete"
|
<x-modal-confirmation title="Confirm Script Deletion?" isErrorButton buttonTitle="Delete"
|
||||||
|
|
@ -17,7 +33,7 @@
|
||||||
step2ButtonText="Delete script" />
|
step2ButtonText="Delete script" />
|
||||||
@endcan
|
@endcan
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-security.navbar>
|
|
||||||
|
|
||||||
<form wire:submit="save" class="application-settings-form">
|
<form wire:submit="save" class="application-settings-form">
|
||||||
<x-unsaved-bar action="save" />
|
<x-unsaved-bar action="save" />
|
||||||
|
|
@ -32,4 +48,6 @@
|
||||||
</div>
|
</div>
|
||||||
</x-application.settings-section>
|
</x-application.settings-section>
|
||||||
</form>
|
</form>
|
||||||
|
</x-security.settings-layout>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
Cloud-Init Scripts | Coolify
|
Cloud-Init Scripts | Coolify
|
||||||
</x-slot>
|
</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>
|
<x-slot:actions>
|
||||||
@can('create', App\Models\CloudInitScript::class)
|
@can('create', App\Models\CloudInitScript::class)
|
||||||
<x-modal-input title="New Cloud-Init Script">
|
<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>
|
</x-modal-input>
|
||||||
@endcan
|
@endcan
|
||||||
</x-slot:actions>
|
</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())
|
@if ($scripts->isEmpty())
|
||||||
<x-empty title="No cloud-init scripts"
|
<x-empty title="No cloud-init scripts"
|
||||||
description="Create a script to reuse it during server provisioning."
|
description="Create a script to reuse it during server provisioning."
|
||||||
icon-name="file-content" size="sm" />
|
icon-name="file-content" size="sm" />
|
||||||
@else
|
@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)
|
@foreach ($scripts as $script)
|
||||||
@can('view', $script)
|
@can('view', $script)
|
||||||
<a wire:key="cloud-init-script-{{ $script->id }}"
|
<x-modal-input title="Edit Cloud-Init Script" isFullWidth :wireIgnore="false" :contentClicks="false"
|
||||||
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]"
|
wire:key="cloud-init-script-{{ $script->id }}"
|
||||||
href="{{ route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]) }}"
|
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||||
{{ wireNavigate() }}>
|
<x-slot:content>
|
||||||
<div class="flex min-w-0 items-start gap-3">
|
<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
|
<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">
|
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" />
|
<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">
|
class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
|
||||||
{{ $script->name }}
|
{{ $script->name }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint">
|
|
||||||
Cloud-init script
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<span class="text-[10px] text-neutral-400 dark:text-fg-faint">
|
||||||
Updated {{ $script->updated_at->diffForHumans() }}
|
Updated {{ $script->updated_at->diffForHumans() }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
@endcan
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</x-application.settings-section>
|
</x-application.settings-section>
|
||||||
</div>
|
|
||||||
|
</x-security.settings-layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,20 @@
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<form class="application-settings-form flex w-full flex-col gap-4" wire:submit="addToken">
|
<form class="application-settings-form flex w-full flex-col gap-4" wire:submit="addToken"
|
||||||
@if (!isset($provider) || blank($provider))
|
x-data="{
|
||||||
<x-forms.listbox required id="provider" label="Provider" wire:model.live="provider" :options="[
|
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' => 'hetzner', 'label' => 'Hetzner'],
|
||||||
['value' => 'digitalocean', 'label' => 'DigitalOcean'],
|
['value' => 'digitalocean', 'label' => 'DigitalOcean'],
|
||||||
['value' => 'vultr', 'label' => 'Vultr'],
|
['value' => 'vultr', 'label' => 'Vultr'],
|
||||||
|
|
@ -10,9 +23,18 @@
|
||||||
<input type="hidden" wire:model="provider" />
|
<input type="hidden" wire:model="provider" />
|
||||||
@endif
|
@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">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<x-forms.input required id="name" label="Token name"
|
<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"
|
<x-forms.input required type="password" id="token" label="API token"
|
||||||
placeholder="Paste the provider token" />
|
placeholder="Paste the provider token" />
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
|
|
@ -21,19 +43,6 @@
|
||||||
</div>
|
</div>
|
||||||
</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]">
|
<div class="flex justify-end border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||||
<x-forms.button type="submit"
|
<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!"
|
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!"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,31 @@
|
||||||
<div>
|
<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>
|
<x-slot:title>
|
||||||
{{ $cloudProviderToken->name }} | Cloud Tokens | Coolify
|
{{ $cloudProviderToken->name }} | Cloud Tokens | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-security.navbar :title="$cloudProviderToken->name"
|
<x-security.settings-layout>
|
||||||
:subtitle="filled($cloudProviderToken->description) ? $cloudProviderToken->description : 'Cloud provider API credential'"
|
|
||||||
:titleOnDesktop="true">
|
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-forms.button type="button" wire:click="validateToken">
|
<x-forms.button type="button" wire:click="validateToken">
|
||||||
<x-reicon name="check-circle" class="size-3.5" />
|
<x-reicon name="check-circle" class="size-3.5" />
|
||||||
|
|
@ -22,7 +42,7 @@
|
||||||
step2ButtonText="Delete token" />
|
step2ButtonText="Delete token" />
|
||||||
@endcan
|
@endcan
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-security.navbar>
|
|
||||||
|
|
||||||
<form wire:submit="save" class="application-settings-form">
|
<form wire:submit="save" class="application-settings-form">
|
||||||
<x-unsaved-bar action="save" />
|
<x-unsaved-bar action="save" />
|
||||||
|
|
@ -38,4 +58,6 @@
|
||||||
</div>
|
</div>
|
||||||
</x-application.settings-section>
|
</x-application.settings-section>
|
||||||
</form>
|
</form>
|
||||||
|
</x-security.settings-layout>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,40 @@
|
||||||
<div class="application-settings-form">
|
<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())
|
@if ($tokens->isEmpty())
|
||||||
<x-empty title="No cloud tokens"
|
<x-empty title="No cloud tokens"
|
||||||
description="Add a provider token to provision new cloud servers." icon-name="keys" size="sm" />
|
description="Add a provider token to provision new cloud servers." icon-name="keys" size="sm" />
|
||||||
@else
|
@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)
|
@foreach ($tokens as $savedToken)
|
||||||
<a wire:key="cloud-token-{{ $savedToken->id }}"
|
<x-modal-input title="Edit Cloud Token" isFullWidth :wireIgnore="false" :contentClicks="false"
|
||||||
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]"
|
wire:key="cloud-token-{{ $savedToken->id }}"
|
||||||
href="{{ route('security.cloud-tokens.show', ['cloud_token_uuid' => $savedToken->uuid]) }}"
|
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||||
{{ wireNavigate() }}>
|
<x-slot:content>
|
||||||
<div class="flex min-w-0 items-start gap-3">
|
<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
|
<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">
|
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" />
|
<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">
|
<h3 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
|
||||||
{{ $savedToken->name }}
|
{{ $savedToken->name }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
|
|
||||||
{{ $savedToken->description ?: 'No description' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-auto pt-4">
|
<div class="flex justify-center">
|
||||||
<span
|
<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">
|
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) }}
|
{{ $savedToken->provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($savedToken->provider) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</x-application.settings-section>
|
</x-application.settings-section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,23 +3,7 @@
|
||||||
Cloud Tokens | Coolify
|
Cloud Tokens | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-security.navbar>
|
<x-security.settings-layout>
|
||||||
<x-slot:actions>
|
<livewire:security.cloud-provider-tokens />
|
||||||
@can('create', App\Models\CloudProviderToken::class)
|
</x-security.settings-layout>
|
||||||
<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>
|
|
||||||
|
|
||||||
<livewire:security.cloud-provider-tokens />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
Keys & Tokens | Coolify
|
Keys & Tokens | Coolify
|
||||||
</x-slot>
|
</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>
|
<x-slot:actions>
|
||||||
@can('create', App\Models\PrivateKey::class)
|
@can('create', App\Models\PrivateKey::class)
|
||||||
<x-modal-confirmation title="Confirm unused SSH Key Deletion?"
|
<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
|
Add manually
|
||||||
</button>
|
</button>
|
||||||
</x-slot:content>
|
</x-slot:content>
|
||||||
<livewire:security.private-key.create />
|
<livewire:security.private-key.create :modal_mode="true" />
|
||||||
</x-modal-input>
|
</x-modal-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endcan
|
@endcan
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-security.navbar>
|
|
||||||
|
|
||||||
@if ($privateKeys->isEmpty())
|
@if ($privateKeys->isEmpty())
|
||||||
<x-empty title="No private keys yet"
|
<x-empty title="No private keys yet"
|
||||||
description="Generate or add an SSH key to connect Coolify to servers and private repositories."
|
description="Generate or add an SSH key to connect Coolify to servers and private repositories."
|
||||||
icon-name="keys" />
|
icon-name="keys" />
|
||||||
@else
|
@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)
|
@foreach ($privateKeys as $key)
|
||||||
@can('view', $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]"
|
<x-modal-input title="Edit Private Key" isFullWidth :wireIgnore="false" :contentClicks="false"
|
||||||
href="{{ route('security.private-key.show', ['private_key_uuid' => data_get($key, 'uuid')]) }}"
|
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||||
{{ wireNavigate() }}>
|
<x-slot:content>
|
||||||
<div class="flex items-start gap-3">
|
<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
|
<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">
|
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" />
|
<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">
|
<h3 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
|
||||||
{{ data_get($key, 'name') }}
|
{{ data_get($key, 'name') }}
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
<div class="mt-auto pt-4">
|
<p class="hidden truncate text-[12px] text-neutral-500 sm:block dark:text-fg-dim">
|
||||||
|
{{ $key->description ?: '-' }}
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-center">
|
||||||
@if ($key->isInUse())
|
@if ($key->isInUse())
|
||||||
<x-status-badge label="In use" type="success" />
|
<x-status-badge label="In use" type="success" />
|
||||||
@else
|
@else
|
||||||
<x-status-badge label="Unused" type="warning" />
|
<x-status-badge label="Unused" type="warning" />
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</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
|
@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">
|
title="You do not have permission to view this private key">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div
|
<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">
|
<h3 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
|
||||||
{{ data_get($key, 'name') }}
|
{{ data_get($key, 'name') }}
|
||||||
</h3>
|
</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>
|
</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" />
|
<x-status-badge label="View only" type="neutral" />
|
||||||
@if (!$key->isInUse())
|
@if (!$key->isInUse())
|
||||||
<x-status-badge label="Unused" type="warning" />
|
<x-status-badge label="Unused" type="warning" />
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
<span class="size-7"></span>
|
||||||
</div>
|
</div>
|
||||||
@endcan
|
@endcan
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</x-application.settings-section>
|
||||||
|
|
||||||
|
</x-security.settings-layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,45 @@
|
||||||
<div x-init="$wire.loadPublicKey()">
|
<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>
|
<x-slot:title>
|
||||||
{{ $private_key->name }} | Private Keys | Coolify
|
{{ $private_key->name }} | Private Keys | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-security.navbar :title="$private_key->name"
|
<x-security.settings-layout>
|
||||||
:subtitle="filled($private_key->description) ? $private_key->description : 'SSH private key'"
|
|
||||||
:titleOnDesktop="true">
|
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
@if ($isGitRelated)
|
@if ($isGitRelated)
|
||||||
<x-status-badge label="Used by GitHub App" type="neutral" />
|
<x-status-badge label="Used by GitHub App" type="neutral" />
|
||||||
|
|
@ -25,7 +59,7 @@
|
||||||
@endcan
|
@endcan
|
||||||
@endif
|
@endif
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-security.navbar>
|
|
||||||
|
|
||||||
<form wire:submit="changePrivateKey" class="application-settings-form" x-data="{ showPrivateKey: false }">
|
<form wire:submit="changePrivateKey" class="application-settings-form" x-data="{ showPrivateKey: false }">
|
||||||
<x-unsaved-bar action="changePrivateKey" />
|
<x-unsaved-bar action="changePrivateKey" />
|
||||||
|
|
@ -59,4 +93,6 @@ class="text-[11px] font-medium text-coollabs hover:underline dark:text-warning"
|
||||||
</div>
|
</div>
|
||||||
</x-application.settings-section>
|
</x-application.settings-section>
|
||||||
</form>
|
</form>
|
||||||
|
</x-security.settings-layout>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
Instance Backup | Coolify
|
Instance Backup | Coolify
|
||||||
</x-slot>
|
</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">
|
<div class="application-settings-form mx-auto flex w-full max-w-[1180px] min-w-0 flex-col gap-6">
|
||||||
@if ($server->isFunctional())
|
@if ($server->isFunctional())
|
||||||
@if (isset($database) && isset($backup))
|
@if (isset($database) && isset($backup))
|
||||||
|
|
@ -51,4 +50,5 @@
|
||||||
</x-application.settings-section>
|
</x-application.settings-section>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
</x-settings.layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
Transactional Email | Coolify
|
Transactional Email | Coolify
|
||||||
</x-slot>
|
</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">
|
<div class="application-settings-form mx-auto flex w-full max-w-[1180px] min-w-0 flex-col gap-6">
|
||||||
<form wire:submit="submit">
|
<form wire:submit="submit">
|
||||||
<x-unsaved-bar action="submit" />
|
<x-unsaved-bar action="submit" />
|
||||||
|
|
@ -65,4 +64,5 @@
|
||||||
</x-application.settings-section>
|
</x-application.settings-section>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</x-settings.layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,28 @@
|
||||||
Authentication | Coolify
|
Authentication | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-settings.navbar />
|
<x-settings.layout>
|
||||||
|
<x-slot:submenu>
|
||||||
<div
|
<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'] ?? '' }}' }"
|
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', '')">
|
@hashchange.window="activeProvider = location.hash.slice(1).replace('-oauth-section', '')">
|
||||||
<nav aria-label="OAuth providers"
|
<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]">
|
class="grid gap-0.5 py-1">
|
||||||
<div class="nav-section hidden xl:block">Providers</div>
|
|
||||||
@foreach ($oauth_settings_map as $oauth_setting)
|
@foreach ($oauth_settings_map as $oauth_setting)
|
||||||
@php
|
@php
|
||||||
$provider = $oauth_setting['provider'];
|
$provider = $oauth_setting['provider'];
|
||||||
$providerLabel = str($provider)->headline();
|
$providerLabel = str($provider)->headline();
|
||||||
@endphp
|
@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 }}' }"
|
: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" />
|
<x-reicon name="keys" class="menu-item-icon" />
|
||||||
<span class="menu-item-label">{{ $providerLabel }}</span>
|
<span class="menu-item-label">{{ $providerLabel }}</span>
|
||||||
</a>
|
</a>
|
||||||
@endforeach
|
@endforeach
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</div>
|
||||||
|
</x-slot:submenu>
|
||||||
<form wire:submit="submit" class="application-settings-form flex w-full min-w-0 flex-col gap-6">
|
<form wire:submit="submit" class="application-settings-form flex w-full min-w-0 flex-col gap-6">
|
||||||
<x-unsaved-bar action="submit" />
|
<x-unsaved-bar action="submit" />
|
||||||
@foreach ($oauth_settings_map as $oauth_setting)
|
@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"
|
<x-application.settings-section id="{{ $provider }}-oauth-section" class="scroll-mt-28"
|
||||||
title="{{ $providerLabel }}">
|
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">
|
<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"
|
<x-forms.input id="oauth_settings_map.{{ $provider }}.redirect_uri"
|
||||||
placeholder="{{ route('auth.callback', $provider) }}" label="Redirect URI" />
|
placeholder="{{ route('auth.callback', $provider) }}" label="Redirect URI" />
|
||||||
|
|
||||||
<x-forms.input id="oauth_settings_map.{{ $provider }}.client_id"
|
<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"
|
<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')
|
@if ($provider === 'azure')
|
||||||
<x-forms.input id="oauth_settings_map.{{ $provider }}.tenant"
|
<x-forms.input id="oauth_settings_map.{{ $provider }}.tenant"
|
||||||
label="Tenant" />
|
label="Tenant" required />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($provider === 'google')
|
@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))
|
@if (in_array($provider, ['authentik', 'clerk', 'zitadel', 'gitlab'], true))
|
||||||
<x-forms.input id="oauth_settings_map.{{ $provider }}.base_url"
|
<x-forms.input id="oauth_settings_map.{{ $provider }}.base_url"
|
||||||
label="Base URL" />
|
label="Base URL" :required="in_array($provider, ['authentik', 'clerk'], true)" />
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</x-application.settings-section>
|
</x-application.settings-section>
|
||||||
@endforeach
|
@endforeach
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</x-settings.layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,7 @@
|
||||||
Advanced Settings | Coolify
|
Advanced Settings | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-settings.navbar />
|
<x-settings.layout>
|
||||||
|
|
||||||
<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" />
|
|
||||||
|
|
||||||
<form wire:submit="submit" class="application-settings-form flex min-w-0 flex-col gap-6">
|
<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
|
{{-- Scope dirty tracking to fields that need an explicit Save. Instant-save
|
||||||
listboxes (API, MCP, telemetry, …) update the snapshot on the server
|
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>
|
</div>
|
||||||
</x-application.settings-section>
|
</x-application.settings-section>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</x-settings.layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,7 @@
|
||||||
Settings | Coolify
|
Settings | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-settings.navbar />
|
<x-settings.layout>
|
||||||
|
|
||||||
<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" />
|
|
||||||
|
|
||||||
<form wire:submit="submit" class="application-settings-form flex w-full min-w-0 flex-col gap-6">
|
<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
|
{{-- instance_timezone auto-saves via $wire.set + submit; exclude it so
|
||||||
the bar does not flash while the snapshot catches up. --}}
|
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>
|
</x-application.settings-section>
|
||||||
@endif
|
@endif
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
|
<x-domain-conflict-modal :conflicts="$domainConflicts" :showModal="$showDomainConflictModal"
|
||||||
confirmAction="confirmDomainUsage">
|
confirmAction="confirmDomainUsage">
|
||||||
|
|
@ -72,4 +66,5 @@ class="application-settings-workspace mx-auto grid w-full max-w-[1180px] min-w-0
|
||||||
</ul>
|
</ul>
|
||||||
</x-slot:consequences>
|
</x-slot:consequences>
|
||||||
</x-domain-conflict-modal>
|
</x-domain-conflict-modal>
|
||||||
|
</x-settings.layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
Scheduled Jobs | Coolify
|
Scheduled Jobs | Coolify
|
||||||
</x-slot>
|
</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="{
|
<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))
|
activeTab: ['executions', 'scheduler-runs', 'skipped-jobs'].includes(location.hash.slice(1))
|
||||||
? 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>
|
</div>
|
||||||
</x-application.settings-section>
|
</x-application.settings-section>
|
||||||
</div>
|
</div>
|
||||||
|
</x-settings.layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,7 @@
|
||||||
Update Settings | Coolify
|
Update Settings | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-settings.navbar />
|
<x-settings.layout>
|
||||||
|
|
||||||
<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" />
|
|
||||||
|
|
||||||
<form wire:submit="submit" class="application-settings-form flex min-w-0 flex-col gap-6">
|
<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. --}}
|
{{-- Exclude is_auto_update_enabled (instantSave) so the bar does not flash. --}}
|
||||||
<x-unsaved-bar action="submit" targets="update_check_frequency,auto_update_frequency" />
|
<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>
|
</div>
|
||||||
</x-application.settings-section>
|
</x-application.settings-section>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</x-settings.layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,17 @@ class="listbox-option {{ $team->id === $currentTeam->id ? 'bg-neutral-100 font-m
|
||||||
@endif
|
@endif
|
||||||
</button>
|
</button>
|
||||||
@endforeach
|
@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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -66,6 +77,17 @@ class="listbox-option {{ $team->id === $currentTeam->id ? 'bg-neutral-100 font-m
|
||||||
@endif
|
@endif
|
||||||
</button>
|
</button>
|
||||||
@endforeach
|
@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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
Team Admin | Coolify
|
Team Admin | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-team.navbar />
|
<x-team.settings-layout>
|
||||||
|
|
||||||
<div class="application-settings-form">
|
<div class="application-settings-form">
|
||||||
<x-application.settings-section title="Instance users" flush>
|
<x-application.settings-section title="Instance users" flush>
|
||||||
<form wire:submit.prevent="submitSearch"
|
<form wire:submit.prevent="submitSearch"
|
||||||
|
|
@ -97,4 +96,5 @@ class="text-[12px] font-medium text-red-600 transition-colors hover:text-red-700
|
||||||
@endif
|
@endif
|
||||||
</x-application.settings-section>
|
</x-application.settings-section>
|
||||||
</div>
|
</div>
|
||||||
|
</x-team.settings-layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
98
resources/views/livewire/team/danger-zone.blade.php
Normal file
98
resources/views/livewire/team/danger-zone.blade.php
Normal 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>
|
||||||
|
|
@ -3,13 +3,17 @@
|
||||||
Teams | Coolify
|
Teams | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-team.navbar />
|
<x-team.settings-layout>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<form wire:submit="submit" class="application-settings-form">
|
<form wire:submit="submit" class="application-settings-form">
|
||||||
<x-unsaved-bar action="submit" />
|
<x-unsaved-bar action="submit" />
|
||||||
<x-application.settings-section title="General"
|
<x-application.settings-section title="General"
|
||||||
description="Manage this team's identity and shared API access.">
|
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">
|
<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="name" label="Name" required canGate="update" :canResource="$team" />
|
||||||
<x-forms.input id="description" label="Description" canGate="update" :canResource="$team" />
|
<x-forms.input id="description" label="Description" canGate="update" :canResource="$team" />
|
||||||
|
|
@ -25,125 +29,6 @@
|
||||||
</x-application.settings-section>
|
</x-application.settings-section>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
|
</x-team.settings-layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,7 @@
|
||||||
Team Members | Coolify
|
Team Members | Coolify
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-team.navbar />
|
<x-team.settings-layout>
|
||||||
|
|
||||||
<div class="application-settings-form flex flex-col gap-6">
|
<div class="application-settings-form flex flex-col gap-6">
|
||||||
<x-application.settings-section title="Members" flush>
|
<x-application.settings-section title="Members" flush>
|
||||||
<div
|
<div
|
||||||
|
|
@ -172,4 +171,5 @@ class="flex w-10 items-center justify-center text-neutral-500 transition-colors
|
||||||
<livewire:team.invitations :invitations="$invitations" />
|
<livewire:team.invitations :invitations="$invitations" />
|
||||||
@endcan
|
@endcan
|
||||||
</div>
|
</div>
|
||||||
|
</x-team.settings-layout>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@
|
||||||
use App\Livewire\Subscription\Show as SubscriptionShow;
|
use App\Livewire\Subscription\Show as SubscriptionShow;
|
||||||
use App\Livewire\Tags\Show as TagsShow;
|
use App\Livewire\Tags\Show as TagsShow;
|
||||||
use App\Livewire\Team\AdminView as TeamAdminView;
|
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\Index as TeamIndex;
|
||||||
use App\Livewire\Team\Member\Index as TeamMemberIndex;
|
use App\Livewire\Team\Member\Index as TeamMemberIndex;
|
||||||
use App\Livewire\Terminal\Index as TerminalIndex;
|
use App\Livewire\Terminal\Index as TerminalIndex;
|
||||||
|
|
@ -194,6 +195,7 @@
|
||||||
Route::get('/', TeamIndex::class)->name('team.index');
|
Route::get('/', TeamIndex::class)->name('team.index');
|
||||||
Route::get('/members', TeamMemberIndex::class)->name('team.member.index');
|
Route::get('/members', TeamMemberIndex::class)->name('team.member.index');
|
||||||
Route::get('/admin', TeamAdminView::class)->name('team.admin-view');
|
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');
|
Route::get('/terminal', TerminalIndex::class)->name('terminal')->middleware('can.access.terminal');
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
test('advanced sidebar and configuration menus use the grid icon', function () {
|
test('advanced sidebar and configuration menus use the grid icon', function () {
|
||||||
$files = [
|
$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/server/sidebar.blade.php'),
|
||||||
resource_path('views/components/service-database/sidebar.blade.php'),
|
resource_path('views/components/service-database/sidebar.blade.php'),
|
||||||
resource_path('views/livewire/project/service/index.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' =>")) {
|
if (str_contains($contents, "'label' => 'Advanced'") || str_contains($contents, "'Advanced' =>")) {
|
||||||
expect($contents)
|
expect($contents)
|
||||||
->toMatch("/'label'\\s*=>\\s*'Advanced'[\\s\\S]{0,160}?'icon'\\s*=>\\s*'grid'|'Advanced'\\s*=>\\s*'grid'/")
|
->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)[^']+'/");
|
->not->toMatch("/'Advanced'\\s*=>\\s*'(?!grid)[^']+'/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,3 +95,16 @@
|
||||||
expect($token)->not->toBeNull()
|
expect($token)->not->toBeNull()
|
||||||
->and($token->abilities)->toBe(['root']);
|
->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']);
|
||||||
|
});
|
||||||
|
|
|
||||||
18
tests/Feature/ApiTokenPermissionsLayoutTest.php
Normal file
18
tests/Feature/ApiTokenPermissionsLayoutTest.php
Normal 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))");
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<?php
|
<?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');
|
$path = resource_path('views/components/dashboard/navbar.blade.php');
|
||||||
$contents = file_get_contents($path);
|
$contents = file_get_contents($path);
|
||||||
|
|
||||||
expect($contents)
|
expect($contents)
|
||||||
->toContain("['label' => 'Email', 'route' => 'settings.email'")
|
->toContain("['label' => 'General', 'route' => 'settings.index', 'active' => request()->routeIs('settings.*')]")
|
||||||
->not->toContain("['label' => 'Transactional Email', 'route' => 'settings.email'");
|
->not->toContain("['label' => 'Email', 'route' => 'settings.email'");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dashboard navbar keeps the original side-by-side tab and actions layout', function () {
|
test('dashboard navbar keeps the original side-by-side tab and actions layout', function () {
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
$contents = file_get_contents($path);
|
$contents = file_get_contents($path);
|
||||||
|
|
||||||
expect($contents)
|
expect($contents)
|
||||||
->toContain('flex w-full items-center justify-between gap-4 lg:h-full')
|
->toContain('flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between')
|
||||||
->toContain('flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto')
|
->toContain('flex min-w-0 w-full items-center gap-0.5 overflow-x-auto');
|
||||||
->not->toContain('flex w-full flex-col gap-3 lg:h-full lg:flex-row');
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,16 @@
|
||||||
->toContain('rounded-lg');
|
->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 () {
|
test('listbox keeps the helper outside the label association', function () {
|
||||||
$path = resource_path('views/components/forms/listbox.blade.php');
|
$path = resource_path('views/components/forms/listbox.blade.php');
|
||||||
$contents = file_get_contents($path);
|
$contents = file_get_contents($path);
|
||||||
|
|
|
||||||
|
|
@ -25,3 +25,14 @@
|
||||||
->toContain('Open notifications')
|
->toContain('Open notifications')
|
||||||
->not->toContain('Accept and Close');
|
->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');
|
||||||
|
});
|
||||||
|
|
|
||||||
94
tests/Feature/NotificationSettingsNavigationTest.php
Normal file
94
tests/Feature/NotificationSettingsNavigationTest.php
Normal 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\"");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<?php
|
<?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.
|
* Detail views keep the resource title on desktop.
|
||||||
*/
|
*/
|
||||||
test('security navbar defaults to hiding the title on desktop', function () {
|
test('security navbar defaults to hiding the title on desktop', function () {
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
->toContain(':titleOnDesktop="$titleOnDesktop"');
|
->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 = [
|
$pages = [
|
||||||
resource_path('views/livewire/security/private-key/index.blade.php') => [
|
resource_path('views/livewire/security/private-key/index.blade.php') => [
|
||||||
'New private key',
|
'New private key',
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
'Delete unused keys',
|
'Delete unused keys',
|
||||||
'Delete unused',
|
'Delete unused',
|
||||||
],
|
],
|
||||||
resource_path('views/livewire/security/cloud-tokens.blade.php') => [
|
resource_path('views/livewire/security/cloud-provider-tokens.blade.php') => [
|
||||||
'New token',
|
'New token',
|
||||||
],
|
],
|
||||||
resource_path('views/livewire/security/cloud-init-scripts.blade.php') => [
|
resource_path('views/livewire/security/cloud-init-scripts.blade.php') => [
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
$blade = file_get_contents($path);
|
$blade = file_get_contents($path);
|
||||||
|
|
||||||
expect($blade)
|
expect($blade)
|
||||||
|
->toContain('<x-application.settings-section')
|
||||||
->toContain('<x-slot:actions>')
|
->toContain('<x-slot:actions>')
|
||||||
->not->toContain('<x-slot:titleActions>');
|
->not->toContain('<x-slot:titleActions>');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
->assertDontSee('Cancel');
|
->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([
|
$script = CloudInitScript::query()->create([
|
||||||
'team_id' => $this->team->id,
|
'team_id' => $this->team->id,
|
||||||
'name' => 'Docker Host Setup',
|
'name' => 'Docker Host Setup',
|
||||||
|
|
@ -51,10 +51,10 @@
|
||||||
|
|
||||||
Livewire::test(CloudInitScripts::class)
|
Livewire::test(CloudInitScripts::class)
|
||||||
->assertSee('Docker Host Setup')
|
->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('Created')
|
||||||
->assertDontSee('Edit')
|
->assertSee('Delete');
|
||||||
->assertDontSee('Delete');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cloud-init script detail page shows editable script fields', function () {
|
test('cloud-init script detail page shows editable script fields', function () {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,29 @@
|
||||||
&& data_get($dispatch, 'to') === 'security.cloud-provider-tokens'))->toBeTrue();
|
&& 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 () {
|
test('adding a cloud provider token stores an optional description', function () {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
|
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,16 @@
|
||||||
$this->actingAs($this->user);
|
$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([
|
$token = CloudProviderToken::factory()->create([
|
||||||
'team_id' => $this->team->id,
|
'team_id' => $this->team->id,
|
||||||
'name' => 'Production Hetzner',
|
'name' => 'Production Hetzner',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Livewire::test(CloudProviderTokens::class)
|
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 () {
|
test('cloud token detail page shows editable name and description fields', function () {
|
||||||
|
|
|
||||||
|
|
@ -39,14 +39,14 @@
|
||||||
|
|
||||||
test('private key index shows highlighted add dropdown actions', function () {
|
test('private key index shows highlighted add dropdown actions', function () {
|
||||||
Livewire::test(Index::class)
|
Livewire::test(Index::class)
|
||||||
->assertSee('+ Add')
|
->assertSee('New private key')
|
||||||
->assertSee('Generate ED25519')
|
->assertSee('Generate ED25519')
|
||||||
->assertSee('Generate RSA')
|
->assertSee('Generate RSA')
|
||||||
->assertSee('Add manually')
|
->assertSee('Add manually')
|
||||||
->assertDontSee('Manage your SSH keys for your servers and integrations.');
|
->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)
|
$component = Livewire::test(Index::class)
|
||||||
->call('generatePrivateKey', 'ed25519');
|
->call('generatePrivateKey', 'ed25519');
|
||||||
|
|
||||||
|
|
@ -55,9 +55,9 @@
|
||||||
expect($privateKey->team_id)->toBe($this->team->id)
|
expect($privateKey->team_id)->toBe($this->team->id)
|
||||||
->and($privateKey->public_key)->toStartWith('ssh-ed25519');
|
->and($privateKey->public_key)->toStartWith('ssh-ed25519');
|
||||||
|
|
||||||
$component->assertRedirect(route('security.private-key.show', [
|
$component
|
||||||
'private_key_uuid' => $privateKey->uuid,
|
->assertDispatched('success')
|
||||||
]));
|
->assertNoRedirect();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('manual private key form does not expose key generation controls', function () {
|
test('manual private key form does not expose key generation controls', function () {
|
||||||
|
|
|
||||||
84
tests/Feature/SecurityResourceModalEditorsTest.php
Normal file
84
tests/Feature/SecurityResourceModalEditorsTest.php
Normal 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);
|
||||||
|
});
|
||||||
15
tests/Feature/SecuritySettingsIconsTest.php
Normal file
15
tests/Feature/SecuritySettingsIconsTest.php
Normal 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' =>");
|
||||||
|
});
|
||||||
31
tests/Feature/SecuritySettingsNavigationTest.php
Normal file
31
tests/Feature/SecuritySettingsNavigationTest.php
Normal 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'");
|
||||||
|
});
|
||||||
28
tests/Feature/ServiceComposeResourcesViewSwitcherTest.php
Normal file
28
tests/Feature/ServiceComposeResourcesViewSwitcherTest.php
Normal 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"');
|
||||||
|
});
|
||||||
|
|
@ -44,11 +44,10 @@ function setupInstanceAdminWithTransactionalEmail(): User
|
||||||
$view = file_get_contents(resource_path('views/livewire/settings-email.blade.php'));
|
$view = file_get_contents(resource_path('views/livewire/settings-email.blade.php'));
|
||||||
|
|
||||||
expect($view)
|
expect($view)
|
||||||
->toContain('<x-settings.navbar />')
|
->toContain('<x-settings.layout>')
|
||||||
->toContain('settings-section title="Sender"')
|
->toContain('settings-section title="Sender"')
|
||||||
->toContain('settings-email-send-test')
|
->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 () {
|
test('send test is not rendered when transactional email is disabled', function () {
|
||||||
|
|
|
||||||
34
tests/Feature/SettingsOauthProviderControlsTest.php
Normal file
34
tests/Feature/SettingsOauthProviderControlsTest.php
Normal 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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
@ -4,11 +4,10 @@
|
||||||
$view = file_get_contents(resource_path('views/livewire/settings/scheduled-jobs.blade.php'));
|
$view = file_get_contents(resource_path('views/livewire/settings/scheduled-jobs.blade.php'));
|
||||||
|
|
||||||
expect($view)
|
expect($view)
|
||||||
->toContain('<x-settings.navbar />')
|
->toContain('<x-settings.layout>')
|
||||||
->toContain('settings-section title="Scheduler activity"')
|
->toContain('settings-section title="Scheduler activity"')
|
||||||
->toContain('wire:click="refresh"')
|
->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.
|
// Refresh should appear after the activity section opens, not before.
|
||||||
$activityPos = strpos($view, 'settings-section title="Scheduler activity"');
|
$activityPos = strpos($view, 'settings-section title="Scheduler activity"');
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@
|
||||||
expect($navigationCss)
|
expect($navigationCss)
|
||||||
->toContain('align-self: start')
|
->toContain('align-self: start')
|
||||||
->toContain('position: sticky')
|
->toContain('position: sticky')
|
||||||
->toContain('top: 3.5rem')
|
->toContain('top: calc(3rem + 1.75rem)')
|
||||||
->toContain('max-height: calc(100dvh - 4.25rem)')
|
->toContain('max-height: calc(100dvh - 5.5rem)')
|
||||||
->toContain('overflow-y: auto');
|
->toContain('overflow-y: auto');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,40 +4,48 @@
|
||||||
* Settings title sits above the workspace; sidebar is below it. Title shell and
|
* Settings title sits above the workspace; sidebar is below it. Title shell and
|
||||||
* workspace share max-w-[1180px] so their left edges align.
|
* workspace share max-w-[1180px] so their left edges align.
|
||||||
*/
|
*/
|
||||||
test('settings navbar and workspace share the same max width shell', function () {
|
test('instance settings pages use one shared sidebar workspace', function () {
|
||||||
$navbar = file_get_contents(resource_path('views/components/settings/navbar.blade.php'));
|
$layout = file_get_contents(resource_path('views/components/settings/layout.blade.php'));
|
||||||
|
|
||||||
expect($navbar)
|
expect($layout)
|
||||||
->toContain('max-w-[1180px]')
|
->toContain('max-w-[1180px]')
|
||||||
->toContain("title' => 'Settings'")
|
->toContain('application-settings-navigation')
|
||||||
->toContain(':titleOnDesktop="false"');
|
->toContain("'Configuration' =>")
|
||||||
|
->toContain("'Instance' =>");
|
||||||
|
|
||||||
$pages = [
|
$pages = [
|
||||||
resource_path('views/livewire/settings/index.blade.php'),
|
resource_path('views/livewire/settings/index.blade.php'),
|
||||||
resource_path('views/livewire/settings/advanced.blade.php'),
|
resource_path('views/livewire/settings/advanced.blade.php'),
|
||||||
resource_path('views/livewire/settings/updates.blade.php'),
|
resource_path('views/livewire/settings/updates.blade.php'),
|
||||||
resource_path('views/livewire/settings-oauth.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) {
|
foreach ($pages as $path) {
|
||||||
$blade = file_get_contents($path);
|
$blade = file_get_contents($path);
|
||||||
|
|
||||||
expect($blade)
|
expect($blade)
|
||||||
->toContain('<x-settings.navbar')
|
->toContain('<x-settings.layout>')
|
||||||
->toContain('max-w-[1180px]')
|
->not->toContain('<x-settings.navbar')
|
||||||
->toContain('xl:grid-cols-[210px_minmax(0,1fr)]')
|
|
||||||
->not->toContain('x-settings.page-header');
|
->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 () {
|
test('dashboard navbar hides family titles at lg by default', function () {
|
||||||
$dashboardNavbar = file_get_contents(resource_path('views/components/dashboard/navbar.blade.php'));
|
$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)
|
expect($dashboardNavbar)
|
||||||
->toContain("'titleOnDesktop' => false")
|
->toContain("'titleOnDesktop' => false")
|
||||||
->toContain("'lg:hidden' => ! \$titleOnDesktop");
|
->toContain("'lg:hidden' => \$mobileTitleOnly || (! \$titleOnDesktop && \$showNav)");
|
||||||
|
|
||||||
expect($settingsNavbar)
|
|
||||||
->toContain(':titleOnDesktop="false"');
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Instance settings Updates nav must use the reicon "refresh3" glyph.
|
* Instance settings Updates nav must use the reicon "refresh3" glyph.
|
||||||
*/
|
*/
|
||||||
test('settings updates sidebar uses the refresh3 icon', function () {
|
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'));
|
$reicon = file_get_contents(resource_path('views/components/reicon.blade.php'));
|
||||||
|
|
||||||
expect($sidebar)
|
expect($sidebar)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Livewire\Team\Index;
|
use App\Livewire\Team\DangerZone;
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
InstanceSettings::forceCreate(['id' => 0]);
|
||||||
|
|
||||||
$this->owner = User::factory()->create();
|
$this->owner = User::factory()->create();
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
$this->actingAs($this->owner);
|
$this->actingAs($this->owner);
|
||||||
session(['currentTeam' => $this->teamToDelete]);
|
session(['currentTeam' => $this->teamToDelete]);
|
||||||
|
|
||||||
Livewire::test(Index::class)
|
Livewire::test(DangerZone::class)
|
||||||
->call('delete')
|
->call('delete')
|
||||||
->assertRedirect(route('team.index'));
|
->assertRedirect(route('team.index'));
|
||||||
|
|
||||||
|
|
|
||||||
39
tests/Feature/TeamSettingsNavigationTest.php
Normal file
39
tests/Feature/TeamSettingsNavigationTest.php
Normal 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'");
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue