mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-24 07:25:13 -05:00
chore(storage): remove dead storages show component (#11615)
This commit is contained in:
@@ -1,201 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared\Storages;
|
||||
|
||||
use App\Livewire\Project\Service\Storage as StorageComponent;
|
||||
use App\Models\Application;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public LocalPersistentVolume $storage;
|
||||
|
||||
public $resource;
|
||||
|
||||
public bool $isReadOnly = false;
|
||||
|
||||
public bool $isFirst = true;
|
||||
|
||||
public bool $isService = false;
|
||||
|
||||
public ?string $startedAt = null;
|
||||
|
||||
public bool $supportsPreviewSuffix = false;
|
||||
|
||||
// Explicit properties
|
||||
public string $name;
|
||||
|
||||
public string $mountPath;
|
||||
|
||||
public ?string $hostPath = null;
|
||||
|
||||
public bool $isPreviewSuffixEnabled = true;
|
||||
|
||||
public bool $hasEnabledBackup = false;
|
||||
|
||||
public ?string $backupUrl = null;
|
||||
|
||||
/**
|
||||
* When true, parent already batched badge/url data — skip per-row queries on mount.
|
||||
*/
|
||||
public bool $backupMetaHydrated = false;
|
||||
|
||||
/** When true, the Backup Configure Livewire modal is mounted (lazy). */
|
||||
public bool $showBackupModal = false;
|
||||
|
||||
protected $validationAttributes = [
|
||||
'name' => 'name',
|
||||
'mountPath' => 'mount',
|
||||
'hostPath' => 'host',
|
||||
];
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ValidationPatterns::volumeNameRules(),
|
||||
'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'hostPath' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'isPreviewSuffixEnabled' => 'required|boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::volumeNameMessages(),
|
||||
[
|
||||
'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.',
|
||||
'hostPath.regex' => 'Host path must start with / and only contain safe path characters.',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync data between component properties and model
|
||||
*
|
||||
* @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
|
||||
*/
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
// Sync TO model (before save)
|
||||
$this->storage->name = $this->name;
|
||||
$this->storage->mount_path = $this->mountPath;
|
||||
$this->storage->host_path = $this->hostPath;
|
||||
$this->storage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
|
||||
} else {
|
||||
// Sync FROM model (on load/refresh)
|
||||
$this->name = $this->storage->name;
|
||||
$this->mountPath = $this->storage->mount_path;
|
||||
$this->hostPath = $this->storage->host_path;
|
||||
$this->isPreviewSuffixEnabled = $this->storage->is_preview_suffix_enabled ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->syncData(false);
|
||||
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
|
||||
// PR deployment volume suffixes only apply to git-based applications.
|
||||
$this->supportsPreviewSuffix = $this->resource instanceof Application
|
||||
&& $this->resource->git_based()
|
||||
&& filled($this->resource->git_repository)
|
||||
&& ! $this->isService;
|
||||
// Parent All batches badge/url; isolated embeds still hydrate themselves.
|
||||
if (! $this->backupMetaHydrated) {
|
||||
$this->refreshBackupStatus();
|
||||
}
|
||||
}
|
||||
|
||||
#[On('refreshVolumeBackups')]
|
||||
public function refreshBackupStatus(): void
|
||||
{
|
||||
$backup = $this->storage->scheduledBackups()->first();
|
||||
|
||||
$this->hasEnabledBackup = $backup?->enabled ?? false;
|
||||
$this->backupUrl = null;
|
||||
|
||||
if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resource->loadMissing('environment.project');
|
||||
|
||||
$parameters = [
|
||||
'project_uuid' => $this->resource->project()->uuid,
|
||||
'environment_uuid' => $this->resource->environment->uuid,
|
||||
'application_uuid' => $this->resource->uuid,
|
||||
];
|
||||
$hasOtherBackups = ScheduledVolumeBackup::query()
|
||||
->forApplication($this->resource)
|
||||
->where('id', '!=', $backup->id)
|
||||
->exists();
|
||||
|
||||
$this->backupUrl = $hasOtherBackups
|
||||
? route('project.application.backup.index', [...$parameters, 'search' => $this->storage->name])
|
||||
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
|
||||
}
|
||||
|
||||
public function openBackupModal(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->showBackupModal = true;
|
||||
}
|
||||
|
||||
#[On('modalClosed')]
|
||||
public function onModalClosed(): void
|
||||
{
|
||||
// Drop the nested Create component from the DOM after close to free snapshot weight.
|
||||
if ($this->showBackupModal) {
|
||||
$this->showBackupModal = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->validate();
|
||||
|
||||
$this->syncData(true);
|
||||
$this->storage->save();
|
||||
$this->dispatch('success', 'Storage updated successfully');
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate();
|
||||
$this->syncData(true);
|
||||
$this->storage->save();
|
||||
$this->dispatch('success', 'Storage updated successfully');
|
||||
}
|
||||
|
||||
public function delete($password, $selectedActions = [])
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
if ($this->storage->scheduledBackups()->exists()) {
|
||||
$this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->storage->delete();
|
||||
$this->dispatch('storageCountsChanged')->to(StorageComponent::class);
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,7 @@
|
||||
@if ($supportsPreviewSuffix)
|
||||
<div class="volumes-col-pr min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
|
||||
<x-forms.listbox id="forms.{{ $id }}.isPreviewSuffixEnabled" :options="[
|
||||
<x-forms.listbox id="forms.{{ $id }}.isPreviewSuffixEnabled" portal :options="[
|
||||
['value' => true, 'label' => 'Add suffix'],
|
||||
['value' => false, 'label' => 'Share volume'],
|
||||
]" />
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
@php
|
||||
$showActionsColumn = $resource instanceof \App\Models\Application;
|
||||
$gridClass = match (true) {
|
||||
$supportsPreviewSuffix => 'volumes-table-grid-with-pr',
|
||||
$showActionsColumn => 'volumes-table-grid',
|
||||
default => 'volumes-table-grid-readonly',
|
||||
};
|
||||
$canUpdate = auth()->user()?->can('update', $resource) ?? false;
|
||||
$inputsReadonly = $isReadOnly || ! $canUpdate;
|
||||
$displayHostPath = filled($hostPath) ? $hostPath : '—';
|
||||
@endphp
|
||||
|
||||
@if ($inputsReadonly)
|
||||
{{-- Read-only: plain data-table row (service / compose / no permission) --}}
|
||||
<div class="env-table-item" wire:key="storage-row-{{ $storage->id }}">
|
||||
<div class="data-table-row {{ $gridClass }} text-[13px] text-neutral-700 dark:text-fg-dim">
|
||||
<div class="volumes-cell-name min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="min-w-0 truncate text-[13px] font-medium text-neutral-950 dark:text-fg"
|
||||
title="{{ $name }}">{{ $name }}</span>
|
||||
@if ($hasEnabledBackup)
|
||||
@if ($backupUrl)
|
||||
<a href="{{ $backupUrl }}"
|
||||
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
|
||||
title="Volume backup is enabled">
|
||||
Backup
|
||||
</a>
|
||||
@else
|
||||
<span class="table-badge table-badge-success shrink-0" title="Volume backup is enabled">
|
||||
Backup
|
||||
</span>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="volumes-col-source min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
|
||||
<span class="block min-w-0 truncate text-[13px]" title="{{ $hostPath }}">
|
||||
{{ $displayHostPath }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="volumes-cell-dest min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
|
||||
<span class="block min-w-0 truncate text-[13px] text-neutral-950 dark:text-fg"
|
||||
title="{{ $mountPath }}">{{ $mountPath }}</span>
|
||||
</div>
|
||||
|
||||
@if ($supportsPreviewSuffix)
|
||||
<div class="volumes-col-pr min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
|
||||
<span>{{ $isPreviewSuffixEnabled ? 'Add suffix' : 'Share volume' }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($showActionsColumn)
|
||||
<div class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
|
||||
@if ($canUpdate)
|
||||
@if ($showBackupModal)
|
||||
<x-modal-input buttonTitle="Backup" title="Configure Volume Backup" :wireIgnore="false"
|
||||
wireOpen="showBackupModal">
|
||||
<livewire:project.application.backup.create :application="$resource"
|
||||
:selected-target-key="'volume:' . $storage->id"
|
||||
wire:key="configure-readonly-volume-backup-{{ $storage->id }}" />
|
||||
</x-modal-input>
|
||||
@else
|
||||
<x-forms.button type="button" wire:click="openBackupModal" class="!px-2.5 !text-xs">
|
||||
Backup
|
||||
</x-forms.button>
|
||||
@endif
|
||||
@else
|
||||
<span class="text-neutral-400 dark:text-fg-faint">—</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
{{-- Editable volume row --}}
|
||||
<form wire:submit="submit" class="env-table-item" wire:key="storage-row-{{ $storage->id }}">
|
||||
<div class="data-table-row {{ $gridClass }}">
|
||||
<div class="volumes-cell-name min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Volume Name</span>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<x-forms.input id="name" required />
|
||||
</div>
|
||||
@if ($hasEnabledBackup)
|
||||
@if ($backupUrl)
|
||||
<a href="{{ $backupUrl }}"
|
||||
class="table-badge table-badge-success shrink-0 underline-offset-2 hover:underline"
|
||||
title="Volume backup is enabled">
|
||||
Backup
|
||||
</a>
|
||||
@else
|
||||
<span class="table-badge table-badge-success shrink-0" title="Volume backup is enabled">
|
||||
Backup
|
||||
</span>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="volumes-col-source min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
|
||||
<x-forms.input id="hostPath" placeholder="Host path (optional)" />
|
||||
</div>
|
||||
|
||||
<div class="volumes-cell-dest min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Destination Path</span>
|
||||
<x-forms.input id="mountPath" required placeholder="/path/in/container" />
|
||||
</div>
|
||||
|
||||
@if ($supportsPreviewSuffix)
|
||||
<div class="volumes-col-pr min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">PR suffix</span>
|
||||
<x-forms.listbox id="isPreviewSuffixEnabled" onChange="instantSave" :options="[
|
||||
['value' => true, 'label' => 'Add suffix'],
|
||||
['value' => false, 'label' => 'Share volume'],
|
||||
]" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="volumes-col-actions volumes-cell-actions flex flex-wrap items-center justify-end gap-1.5">
|
||||
<x-forms.button type="submit" class="!px-2.5 !text-xs">
|
||||
Update
|
||||
</x-forms.button>
|
||||
|
||||
@if ($resource instanceof \App\Models\Application)
|
||||
@if ($showBackupModal)
|
||||
<x-modal-input buttonTitle="Backup" title="Configure Volume Backup" :wireIgnore="false"
|
||||
wireOpen="showBackupModal">
|
||||
<livewire:project.application.backup.create :application="$resource"
|
||||
:selected-target-key="'volume:' . $storage->id"
|
||||
wire:key="configure-volume-backup-{{ $storage->id }}" />
|
||||
</x-modal-input>
|
||||
@else
|
||||
<x-forms.button type="button" wire:click="openBackupModal" class="!px-2.5 !text-xs">
|
||||
Backup
|
||||
</x-forms.button>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<x-modal-confirmation title="Confirm persistent storage deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete" :actions="[
|
||||
'The selected persistent storage/volume will be permanently deleted.',
|
||||
'If the persistent storage/volume is actvily used by a resource data will be lost.',
|
||||
]" confirmationText="{{ $storage->name }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Storage Name below"
|
||||
shortConfirmationLabel="Storage Name" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
@@ -4,7 +4,6 @@ use App\Jobs\ServerStorageSaveJob;
|
||||
use App\Livewire\Project\Service\FileStorage;
|
||||
use App\Livewire\Project\Service\Storage;
|
||||
use App\Livewire\Project\Shared\Storages\All;
|
||||
use App\Livewire\Project\Shared\Storages\Show;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
@@ -247,8 +246,8 @@ test('deleting a volume mount refreshes the configuration warning', function ()
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['storage' => $volume, 'resource' => $database])
|
||||
->call('delete', 'password')
|
||||
Livewire::test(All::class, ['resource' => $database])
|
||||
->call('delete', $volume->id, 'password')
|
||||
->assertDispatched('configurationChanged');
|
||||
|
||||
expect($volume->fresh())->toBeNull();
|
||||
|
||||
@@ -91,7 +91,7 @@ function createPerfApplicationWithVolumes(int $volumeCount = 5): array
|
||||
return [$application, $firstVolume, $team];
|
||||
}
|
||||
|
||||
it('renders volume rows without nesting Livewire Show components', function () {
|
||||
it('renders volume rows inline without nested Livewire row components', function () {
|
||||
[$application] = createPerfApplicationWithVolumes(5);
|
||||
|
||||
$html = Livewire::test(All::class, ['resource' => $application])->html();
|
||||
@@ -100,7 +100,6 @@ it('renders volume rows without nesting Livewire Show components', function () {
|
||||
->toContain('data-table')
|
||||
->toContain('openBackupModal')
|
||||
->toContain('wire:submit="submit(')
|
||||
->not->toContain('livewire:project.shared.storages.show')
|
||||
->not->toContain('shared-configure-volume-backup-');
|
||||
});
|
||||
|
||||
|
||||
@@ -141,7 +141,6 @@ function createApplicationWithVolume(array $applicationAttributes = [], array $v
|
||||
|
||||
it('renders volumes as a data table with shared column headers', function () {
|
||||
$allView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
|
||||
$showView = file_get_contents(resource_path('views/livewire/project/shared/storages/show.blade.php'));
|
||||
$storageView = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php'));
|
||||
|
||||
expect($allView)
|
||||
@@ -159,17 +158,10 @@ it('renders volumes as a data table with shared column headers', function () {
|
||||
->toContain('data-table-row')
|
||||
->toContain('volumes-mobile-label')
|
||||
->not->toContain('table-badge table-badge-success')
|
||||
->not->toContain('livewire:project.shared.storages.show')
|
||||
->not->toContain('x-status-badge')
|
||||
->not->toContain('font-mono')
|
||||
->not->toContain('Service volume mounts are read-only here.');
|
||||
|
||||
// Show remains available for isolated embeds/tests but is no longer nested from All.
|
||||
expect($showView)
|
||||
->toContain('data-table-row')
|
||||
->toContain('volumes-table-grid')
|
||||
->not->toContain('font-mono');
|
||||
|
||||
// Service stack page: one settings-section card per compose service/resource.
|
||||
expect($storageView)
|
||||
->toContain('Str::headline($resource->name)')
|
||||
@@ -434,16 +426,13 @@ it('hides PR deployment suffix for databases', function () {
|
||||
});
|
||||
|
||||
it('uses a compact table badge for enabled backups instead of status-badge', function () {
|
||||
$showView = file_get_contents(resource_path('views/livewire/project/shared/storages/show.blade.php'));
|
||||
$allView = file_get_contents(resource_path('views/livewire/project/shared/storages/all.blade.php'));
|
||||
|
||||
expect($showView)
|
||||
->toContain('table-badge-success')
|
||||
expect($allView)
|
||||
->toContain("'table-badge-success' => \$hasS3Backup")
|
||||
->toContain('Volume backup is enabled')
|
||||
->not->toContain('x-status-badge')
|
||||
->not->toContain('status="Backup enabled"');
|
||||
|
||||
// Badge label is the short "Backup" text, not the old pill-with-label that broke the input row.
|
||||
expect(preg_match('/table-badge-success[^>]*>\s*Backup\s*</', $showView))->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('gates file storage PR suffix markup behind git_based applications', function () {
|
||||
|
||||
@@ -8,7 +8,7 @@ use App\Livewire\Project\Application\Backup\Create as CreateScheduledVolumeBacku
|
||||
use App\Livewire\Project\Service\FileStorage;
|
||||
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
|
||||
use App\Livewire\Project\Service\VolumeBackup\Index as ServiceVolumeBackupIndex;
|
||||
use App\Livewire\Project\Shared\Storages\Show;
|
||||
use App\Livewire\Project\Shared\Storages\All;
|
||||
use App\Livewire\Project\Shared\Storages\VolumeBackups;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
@@ -483,11 +483,8 @@ it('shows the configure backup modal trigger inside the volume card instead of i
|
||||
signInForVolumeBackups($this, $team);
|
||||
[$application, $volume] = createVolumeBackupApplication($team);
|
||||
|
||||
$component = Livewire::test(Show::class, [
|
||||
'storage' => $volume,
|
||||
'resource' => $application,
|
||||
])
|
||||
->set('isReadOnly', true)
|
||||
$component = Livewire::test(All::class, ['resource' => $application])
|
||||
->set("forms.{$volume->id}.isReadOnly", true)
|
||||
->assertSee('Backup')
|
||||
->assertDontSee('Backups made while the application is writing');
|
||||
|
||||
@@ -497,6 +494,7 @@ it('shows the configure backup modal trigger inside the volume card instead of i
|
||||
expect($html)
|
||||
->toContain('Configure Volume Backup')
|
||||
->toContain('data-table-row')
|
||||
->not->toContain('wire:submit="submit('.$volume->id.')"')
|
||||
->toContain('Backup');
|
||||
});
|
||||
|
||||
@@ -512,10 +510,10 @@ it('only shows the backup enabled badge for an enabled volume backup', function
|
||||
'enabled' => false,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Show::class, [
|
||||
'storage' => $volume,
|
||||
'resource' => $application,
|
||||
])->assertDontSee('table-badge-success', false);
|
||||
$component = Livewire::test(All::class, ['resource' => $application])
|
||||
->assertDontSee('Volume backup is enabled');
|
||||
|
||||
expect($component->get("volumeBackupMeta.{$volume->id}.enabled"))->toBeFalse();
|
||||
|
||||
$backup->update(['enabled' => true]);
|
||||
|
||||
@@ -528,17 +526,10 @@ it('only shows the backup enabled badge for an enabled volume backup', function
|
||||
|
||||
$component
|
||||
->dispatch('refreshVolumeBackups')
|
||||
->assertSee('table-badge-success', false)
|
||||
->assertSee('Volume backup is enabled')
|
||||
->assertSee('href="'.$backupUrl.'"', false);
|
||||
|
||||
Livewire::test(Show::class, [
|
||||
'storage' => $volume,
|
||||
'resource' => $application,
|
||||
'isFirst' => false,
|
||||
])
|
||||
->assertSee('table-badge-success', false)
|
||||
->assertSee('Volume backup is enabled');
|
||||
expect($component->get("volumeBackupMeta.{$volume->id}.url"))->toBe($backupUrl);
|
||||
});
|
||||
|
||||
it('links the backup enabled badge to a filtered backup list when the application has multiple schedules', function () {
|
||||
@@ -564,11 +555,7 @@ it('links the backup enabled badge to a filtered backup list when the applicatio
|
||||
'search' => $volume->name,
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, [
|
||||
'storage' => $volume,
|
||||
'resource' => $application,
|
||||
])
|
||||
->assertSee('table-badge-success', false)
|
||||
Livewire::test(All::class, ['resource' => $application])
|
||||
->assertSee('Volume backup is enabled')
|
||||
->assertSee('href="'.$backupUrl.'"', false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user