Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
28 changes: 28 additions & 0 deletions app/Http/Controllers/Api/Client/Servers/BackupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,34 @@ public function toggleLock(Request $request, Server $server, Backup $backup): ar
->toArray();
}

/**
* Rename the backup alias shown in the panel.
*
* This does not change the archived filename on disk.
*
* @throws AuthorizationException
*/
public function rename(Request $request, Server $server, Backup $backup): array
{
if (! $request->user()->can(Permission::ACTION_BACKUP_CREATE, $server)) {
throw new AuthorizationException();
}

$data = $request->validate([
'name' => ['required', 'string', 'max:191'],
]);

$backup->update([
'name' => $data['name'],
]);

Activity::event('server:backup.rename')->subject($backup)->property('name', $backup->name)->log();

return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
->toArray();
}

/**
* Returns information about a single backup.
*
Expand Down
9 changes: 8 additions & 1 deletion app/Http/Controllers/Auth/AbstractLoginController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Illuminate\Auth\Events\Failed;
use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
Expand Down Expand Up @@ -78,7 +79,13 @@ protected function sendLoginResponse(User $user, Request $request): JsonResponse

$this->clearLoginAttempts($request);

$this->auth->guard()->login($user, true);
$guard = $this->auth->guard();

if (! $guard instanceof StatefulGuard) {
throw new \RuntimeException('Configured auth guard does not support stateful login.');
}

$guard->login($user, true);

Event::dispatch(new DirectLogin($user, true));

Expand Down
3 changes: 3 additions & 0 deletions resources/scripts/api/server/backups/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import http from '@/api/http';
import renameBackup from '@/api/server/backups/renameBackup';

export const restoreServerBackup = async (uuid: string, backup: string, truncate?: boolean): Promise<void> => {
await http.post(`/api/client/servers/${uuid}/backups/${backup}/restore`, {
truncate,
});
};

export { renameBackup };
7 changes: 7 additions & 0 deletions resources/scripts/api/server/backups/renameBackup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import http from '@/api/http';

export default async (uuid: string, backup: string, name: string): Promise<void> => {
await http.post(`/api/client/servers/${uuid}/backups/${backup}/rename`, {
name,
});
};
3 changes: 1 addition & 2 deletions resources/scripts/components/elements/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ const ModalContainer = styled.div<{ alignTop?: boolean; size?: 'sm' | 'md' | 'lg
margin-bottom: auto;

& > .close-icon {
${tw`absolute right-0 p-2 text-white cursor-pointer opacity-50 transition-all duration-150 ease-linear hover:opacity-100`};
top: -2.5rem;
${tw`absolute right-0 top-0 p-2 m-2 text-gray-200 cursor-pointer opacity-70 transition-all duration-150 ease-linear hover:opacity-100`};

&:hover {
${tw`transform rotate-90`}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useRef, forwardRef, useImperativeHandle } from 'react';
import { FaBoxOpen, FaCloudArrowDown, FaEllipsis, FaLock, FaTrash, FaUnlock } from 'react-icons/fa6';
import { FaBoxOpen, FaCloudArrowDown, FaEllipsis, FaLock, FaPen, FaTrash, FaUnlock } from 'react-icons/fa6';
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
import useFlash from '@/plugins/useFlash';
Expand All @@ -16,6 +16,8 @@ import http, { httpErrorToHuman } from '@/api/http';
import { Dialog } from '@/components/elements/dialog';
import { useTranslation } from 'react-i18next';
import { ExtensionSlot } from '@/extensions/ExtensionSlot';
import { renameBackup } from '@/api/server/backups';
import RenameBackupModal from '@/components/server/backups/RenameBackupModal';

interface Props {
backup: ServerBackup;
Expand Down Expand Up @@ -122,8 +124,43 @@ const BackupContextMenu = forwardRef<BackupContextMenuHandle, Props>(({ backup }
.then(() => setModal(''));
};

const onRename = (name: string) => {
clearFlashes('backups');

return renameBackup(uuid, backup.uuid, name)
.then(() =>
mutate(
(data) => ({
...data,
items: data.items.map((b) =>
b.uuid === backup.uuid
? {
...b,
name,
}
: b
),
}),
false
)
)
.then(() => undefined)
.catch((error) => {
clearAndAddHttpError({ key: 'backups', error });

throw error;
});
};

return (
<>
<RenameBackupModal
visible={modal === 'rename'}
appear
backup={backup}
onDismissed={() => setModal('')}
onRenamed={onRename}
/>
<Dialog.Confirm
open={modal === 'unlock'}
onClose={() => setModal('')}
Expand Down Expand Up @@ -178,6 +215,12 @@ const BackupContextMenu = forwardRef<BackupContextMenuHandle, Props>(({ backup }
>
<div css={tw`text-sm`}>
<ExtensionSlot name={`server:backups:menu:start`} />
<Can action={'backup.create'}>
<DropdownButtonRow onClick={() => setModal('rename')}>
<FaPen className={'text-xs inline-block w-[1.25em]'} />
<span css={tw`ml-2`}>Rename</span>
</DropdownButtonRow>
</Can>
<Can action={'backup.download'}>
<DropdownButtonRow onClick={doDownload}>
<FaCloudArrowDown className={'text-xs inline-block w-[1.25em]'} />
Expand Down
2 changes: 1 addition & 1 deletion resources/scripts/components/server/backups/BackupRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export default ({ backup, className }: Props) => {
</p>
<p css={tw`text-2xs text-gray-500 uppercase mt-1`}>{t('created')}</p>
</div>
<Can action={['backup.download', 'backup.restore', 'backup.delete']} matchAny>
<Can action={['backup.create', 'backup.download', 'backup.restore', 'backup.delete']} matchAny>
<div css={tw`mt-4 md:mt-0 ml-6`} style={{ marginRight: '-0.5rem' }}>
{!backup.completedAt ? (
<div css={tw`p-2 invisible`}>
Expand Down
56 changes: 56 additions & 0 deletions resources/scripts/components/server/backups/RenameBackupModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import { Form, Formik, FormikHelpers } from 'formik';
import Field from '@/components/elements/Field';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import { ServerBackup } from '@/api/server/types';
import { useTranslation } from 'react-i18next';

interface FormikValues {
name: string;
}

interface Props extends RequiredModalProps {
backup: ServerBackup;
onRenamed: (name: string) => Promise<void>;
}

const RenameBackupModal = ({ backup, onRenamed, ...props }: Props) => {
const { t } = useTranslation('server/backups');

const submit = ({ name }: FormikValues, { setSubmitting }: FormikHelpers<FormikValues>) => {
onRenamed(name)
.then(() => props.onDismissed())
.catch(() => setSubmitting(false));
};

return (
<Formik onSubmit={submit} enableReinitialize initialValues={{ name: backup.name }}>
{({ isSubmitting, values }) => (
<Modal {...props} dismissable={!isSubmitting} showSpinnerOverlay={isSubmitting}>
<Form css={tw`m-0`}>
<div css={tw`flex flex-wrap items-end`}>
<div css={tw`w-full sm:flex-1 sm:mr-4`}>
<Field
type={'string'}
id={'backup_name'}
name={'name'}
label={t('backup-name')}
description={t('name-description')}
autoFocus
/>
</div>
<div css={tw`w-full sm:w-auto mt-4 sm:mt-0`}>
<Button css={tw`w-full`} disabled={values.name.trim().length < 1}>
Rename
</Button>
</div>
</div>
</Form>
</Modal>
)}
</Formik>
);
};

export default RenameBackupModal;
1 change: 1 addition & 0 deletions routes/api-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
Route::post('/', [Client\Servers\BackupController::class, 'store']);
Route::get('/{backup}', [Client\Servers\BackupController::class, 'view']);
Route::get('/{backup}/download', [Client\Servers\BackupController::class, 'download']);
Route::post('/{backup}/rename', [Client\Servers\BackupController::class, 'rename']);
Route::post('/{backup}/lock', [Client\Servers\BackupController::class, 'toggleLock']);
Route::middleware([ResourceLimit::Backup->middleware()])
->post('/{backup}/restore', [Client\Servers\BackupController::class, 'restore']);
Expand Down
47 changes: 47 additions & 0 deletions tests/Integration/Api/Client/Server/Backup/RenameBackupTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace Tests\Integration\Api\Client\Server\Backup;

use App\Events\ActivityLogged;
use App\Models\Backup;
use App\Models\Permission;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Event;
use Tests\Integration\Api\Client\ClientApiIntegrationTestCase;

class RenameBackupTest extends ClientApiIntegrationTestCase
{
public function test_user_without_permission_cannot_rename_backup()
{
[$user, $server] = $this->generateTestAccount([Permission::ACTION_BACKUP_READ]);

$backup = Backup::factory()->create(['server_id' => $server->id]);

$this->actingAs($user)
->postJson($this->link($backup, '/rename'), ['name' => 'New Backup Name'])
->assertStatus(Response::HTTP_FORBIDDEN);
}

public function test_backup_alias_can_be_renamed()
{
Event::fake([ActivityLogged::class]);

[$user, $server] = $this->generateTestAccount([Permission::ACTION_BACKUP_CREATE]);

/** @var Backup $backup */
$backup = Backup::factory()->create([
'server_id' => $server->id,
'name' => 'Old Backup Name',
]);

$this->actingAs($user)
->postJson($this->link($backup, '/rename'), ['name' => 'New Backup Name'])
->assertStatus(Response::HTTP_OK)
->assertJsonPath('attributes.name', 'New Backup Name');

$backup->refresh();

$this->assertSame('New Backup Name', $backup->name);
$this->assertActivityFor('server:backup.rename', $user, [$backup, $backup->server]);
}
}
Loading