Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion app/Livewire/BackupJob/Index.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Livewire\BackupJob;

use App\Enums\DatabaseType;
use App\Models\BackupJob;
use App\Models\DatabaseServer;
use App\Models\Snapshot;
Expand Down Expand Up @@ -182,7 +183,13 @@ public function typeOptions(): array
*/
public function serverOptions(): array
{
/** @var \App\Models\User $user */
$user = auth()->user();

return DatabaseServer::query()
->when($user->isScopedUser(), function ($query) use ($user) {
$query->whereIn('id', $user->getAccessibleServerIds());
})
->orderBy('name')
->get()
->map(fn (DatabaseServer $server) => [
Expand All @@ -192,6 +199,27 @@ public function serverOptions(): array
->toArray();
}

public function confirmRestoreFromJob(string $serverId, string $snapshotId): void
{
$server = DatabaseServer::findOrFail($serverId);

$this->authorize('restore', $server);

if ($server->agent_id) {
$this->error(__('Restore is not yet supported for agent-backed servers.'));

return;
}

if ($server->database_type === DatabaseType::REDIS) {
$this->error(__('Automated restore is not supported for Redis/Valkey.'));

return;
}

$this->dispatch('open-restore-from-snapshot', targetServerId: $serverId, snapshotId: $snapshotId);
}

public function confirmDeleteSnapshot(string $snapshotId): void
{
$snapshot = Snapshot::findOrFail($snapshotId);
Expand Down Expand Up @@ -259,14 +287,18 @@ public function deletePendingJob(): void

public function render(): View
{
/** @var \App\Models\User $user */
$user = auth()->user();

$jobs = BackupJobQuery::buildFromParams(
search: $this->search,
statusFilter: $this->statusFilter !== '' ? [$this->statusFilter] : [],
typeFilter: $this->typeFilter,
serverFilter: $this->serverFilter,
fileMissing: $this->fileMissing !== '',
sortColumn: $this->sortBy['column'],
sortDirection: $this->sortBy['direction']
sortDirection: $this->sortBy['direction'],
scopedUser: $user->isScopedUser() ? $user : null,
)->paginate(15);

return view('livewire.backup-job.index', [
Expand Down
6 changes: 5 additions & 1 deletion app/Livewire/DatabaseServer/Index.php
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,14 @@ public function runBackupAll(string $serverId, TriggerBackupAction $action): voi

public function render(): View
{
/** @var \App\Models\User $user */
$user = auth()->user();

$servers = DatabaseServerQuery::buildFromParams(
search: $this->search,
sortColumn: $this->sortBy['column'],
sortDirection: $this->sortBy['direction']
sortDirection: $this->sortBy['direction'],
scopedUser: $user->isScopedUser() ? $user : null,
)->paginate(10);

// Share total count globally so it's available inside Mary UI scoped slots
Expand Down
36 changes: 35 additions & 1 deletion app/Livewire/DatabaseServer/RestoreModal.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,30 @@ public function openModal(string $targetServerId): void
$this->showModal = true;
}

#[On('open-restore-from-snapshot')]
public function openFromSnapshot(string $targetServerId, string $snapshotId): void
{
$this->reset(['selectedSnapshotId', 'schemaName', 'forceDatabase', 'ownerUser', 'currentStep', 'existingDatabases', 'snapshotSearch', 'serverFilter']);
$this->resetPage('snapshots');
$this->targetServer = DatabaseServer::findOrFail($targetServerId);

$this->authorize('restore', $this->targetServer);

/** @var \App\Models\User $user */
$user = auth()->user();

$snapshot = SnapshotQuery::buildFromParams(
statusFilter: 'completed',
scopedUser: $user->isScopedUser() ? $user : null,
)
->whereKey($snapshotId)
->whereHas('databaseServer', fn (Builder $q) => $q->whereRaw('database_type = ?', [$this->targetServer->database_type->value]))
->firstOrFail();

$this->showModal = true;
$this->selectSnapshot($snapshot->id);
}

public function selectSnapshot(string $snapshotId): void
{
$this->selectedSnapshotId = $snapshotId;
Expand Down Expand Up @@ -210,11 +234,17 @@ public function getCompatibleServersProperty(): Collection
return collect();
}

/** @var \App\Models\User $user */
$user = auth()->user();

return DatabaseServer::query()
->where('database_type', $this->targetServer->database_type)
->whereHas('snapshots', function ($query) {
$query->whereHas('job', fn ($q) => $q->whereRaw("status = 'completed'"));
})
->when($user->isScopedUser(), function ($query) use ($user) {
$query->whereIn('id', $user->getAccessibleServerIds());
})
->orderBy('name')
->get(['id', 'name']);
}
Expand All @@ -239,11 +269,15 @@ public function getPaginatedSnapshotsProperty(): ?LengthAwarePaginator
return null;
}

/** @var \App\Models\User $user */
$user = auth()->user();

return SnapshotQuery::buildFromParams(
search: $this->snapshotSearch ?: null,
statusFilter: 'completed',
sortColumn: 'created_at',
sortDirection: 'desc'
sortDirection: 'desc',
scopedUser: $user->isScopedUser() ? $user : null,
)
->whereHas('databaseServer', fn (Builder $q) => $q->whereRaw('database_type = ?', [$this->targetServer->database_type]))
->when($this->serverFilter, fn ($q) => $q->where('database_server_id', $this->serverFilter))
Expand Down
194 changes: 194 additions & 0 deletions app/Livewire/User/ServerAccess.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
<?php

namespace App\Livewire\User;

use App\Models\DatabaseServer;
use App\Models\Snapshot;
use App\Models\User;
use App\Models\UserServerAccess;
use App\Traits\Toast;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;

class ServerAccess extends Component
{
use AuthorizesRequests, Toast;

#[Locked]
public User $user;

public bool $showGrantModal = false;

public string $selectedServerId = '';

/** @var array<int, string> */
public array $allowedDatabases = [];

public string $databaseSearch = '';

public bool $canDownload = true;

public bool $canBackup = false;

public bool $canRestore = false;

public function mount(User $user): void
{
$this->user = $user;
}

public function openGrantModal(?string $accessId = null): void
{
$this->authorize('update', $this->user);

$this->reset(['selectedServerId', 'allowedDatabases', 'databaseSearch', 'canDownload', 'canBackup', 'canRestore']);
$this->canDownload = true;

if ($accessId !== null) {
$access = UserServerAccess::where('id', $accessId)
->where('user_id', $this->user->id)
->firstOrFail();

$this->selectedServerId = $access->database_server_id;
$this->allowedDatabases = $access->allowed_databases ?? [];
$this->canDownload = $access->can_download;
$this->canBackup = $access->can_backup;
$this->canRestore = $access->can_restore;
}

$this->showGrantModal = true;
}

public function updatedSelectedServerId(): void
{
$this->databaseSearch = '';
}

/**
* Known database names from past snapshots for the selected server.
*
* @return array<int, string>
*/
#[Computed]
public function knownDatabases(): array
{
if ($this->selectedServerId === '') {
return [];
}

return Snapshot::where('database_server_id', $this->selectedServerId)
->whereNotNull('database_name')
->when($this->databaseSearch !== '', function ($q) {
$q->where('database_name', 'like', '%'.$this->databaseSearch.'%');
})
->distinct()
->orderBy('database_name')
->pluck('database_name')
->filter(fn ($db) => ! in_array($db, $this->allowedDatabases, strict: true))
->values()
->all();
}

public function addDatabase(string $database): void
{
$db = trim($database);

if ($db !== '' && ! in_array($db, $this->allowedDatabases, strict: true)) {
$this->allowedDatabases[] = $db;
}

$this->databaseSearch = '';
}

public function addSearchedDatabase(): void
{
$this->addDatabase($this->databaseSearch);
}

public function removeDatabase(string $database): void
{
$this->allowedDatabases = array_values(
array_filter($this->allowedDatabases, fn ($d) => $d !== $database)
);
}

public function grantAccess(): void
{
$this->authorize('update', $this->user);

$this->validate([
'selectedServerId' => 'required|exists:database_servers,id',
'canDownload' => 'boolean',
'canBackup' => 'boolean',
'canRestore' => 'boolean',
]);

UserServerAccess::updateOrCreate(
[
'user_id' => $this->user->id,
'database_server_id' => $this->selectedServerId,
],
[
'allowed_databases' => ! empty($this->allowedDatabases) ? array_values($this->allowedDatabases) : null,
'can_download' => $this->canDownload,
'can_backup' => $this->canBackup,
'can_restore' => $this->canRestore,
]
);

$this->showGrantModal = false;
$this->success(__('Access granted successfully.'));
}

public function revokeAccess(int $accessId): void
{
$this->authorize('update', $this->user);

UserServerAccess::where('id', $accessId)
->where('user_id', $this->user->id)
->delete();

$this->success(__('Access revoked successfully.'));
}

/**
* Servers available for selection: excludes already-granted servers
* except the one currently selected (so editing a grant keeps it visible).
*
* @return array<int, array<string, string>>
*/
public function availableServerOptions(): array
{
$grantedIds = $this->user->serverAccesses()->pluck('database_server_id')->all();

// When editing an existing grant, keep its server selectable
if ($this->selectedServerId !== '') {
$grantedIds = array_filter($grantedIds, fn ($id) => $id !== $this->selectedServerId);
}

return DatabaseServer::query()
->whereNotIn('id', array_values($grantedIds))
->orderBy('name')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
->get()
->map(fn (DatabaseServer $server) => [
'id' => $server->id,
'name' => $server->name.' ('.$server->database_type->value.')',
])
->toArray();
}

public function render(): View
{
$accesses = $this->user->serverAccesses()
->with('databaseServer')
->get();

return view('livewire.user.server-access', [
'accesses' => $accesses,
'availableServers' => $this->availableServerOptions(),
]);
}
}
10 changes: 10 additions & 0 deletions app/Models/DatabaseServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
* @property-read int|null $snapshots_count
* @property-read Collection<int, NotificationChannel> $notificationChannels
* @property-read int|null $notification_channels_count
* @property-read Collection<int, UserServerAccess> $userAccesses
* @property-read int|null $user_accesses_count
*
* @method static DatabaseServerFactory factory($count = null, $state = [])
* @method static Builder<static>|DatabaseServer newModelQuery()
Expand Down Expand Up @@ -180,6 +182,14 @@ public function sshConfig(): BelongsTo
return $this->belongsTo(DatabaseServerSshConfig::class, 'ssh_config_id');
}

/**
* @return HasMany<UserServerAccess, DatabaseServer>
*/
public function userAccesses(): HasMany
{
return $this->hasMany(UserServerAccess::class);
}

/**
* Get the decrypted password with proper exception handling.
*
Expand Down
Loading