diff --git a/app/Http/Controllers/Api/Client/Servers/BackupController.php b/app/Http/Controllers/Api/Client/Servers/BackupController.php index a30f0f4f9..4930ddd09 100644 --- a/app/Http/Controllers/Api/Client/Servers/BackupController.php +++ b/app/Http/Controllers/Api/Client/Servers/BackupController.php @@ -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. * diff --git a/app/Http/Controllers/Auth/AbstractLoginController.php b/app/Http/Controllers/Auth/AbstractLoginController.php index 38e740f7f..1478e01db 100644 --- a/app/Http/Controllers/Auth/AbstractLoginController.php +++ b/app/Http/Controllers/Auth/AbstractLoginController.php @@ -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; @@ -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)); diff --git a/resources/scripts/api/server/backups/index.ts b/resources/scripts/api/server/backups/index.ts index 4f1311fdf..c993362ef 100644 --- a/resources/scripts/api/server/backups/index.ts +++ b/resources/scripts/api/server/backups/index.ts @@ -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 => { await http.post(`/api/client/servers/${uuid}/backups/${backup}/restore`, { truncate, }); }; + +export { renameBackup }; diff --git a/resources/scripts/api/server/backups/renameBackup.ts b/resources/scripts/api/server/backups/renameBackup.ts new file mode 100644 index 000000000..462de89d1 --- /dev/null +++ b/resources/scripts/api/server/backups/renameBackup.ts @@ -0,0 +1,7 @@ +import http from '@/api/http'; + +export default async (uuid: string, backup: string, name: string): Promise => { + await http.post(`/api/client/servers/${uuid}/backups/${backup}/rename`, { + name, + }); +}; diff --git a/resources/scripts/components/elements/Modal.tsx b/resources/scripts/components/elements/Modal.tsx index e9892624f..545e7d0a4 100644 --- a/resources/scripts/components/elements/Modal.tsx +++ b/resources/scripts/components/elements/Modal.tsx @@ -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`} diff --git a/resources/scripts/components/server/backups/BackupContextMenu.tsx b/resources/scripts/components/server/backups/BackupContextMenu.tsx index 4bb08cdc4..266ba43ab 100644 --- a/resources/scripts/components/server/backups/BackupContextMenu.tsx +++ b/resources/scripts/components/server/backups/BackupContextMenu.tsx @@ -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'; @@ -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; @@ -122,8 +124,43 @@ const BackupContextMenu = forwardRef(({ 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 ( <> + setModal('')} + onRenamed={onRename} + /> setModal('')} @@ -178,6 +215,12 @@ const BackupContextMenu = forwardRef(({ backup } >
+ + setModal('rename')}> + + Rename + + diff --git a/resources/scripts/components/server/backups/BackupRow.tsx b/resources/scripts/components/server/backups/BackupRow.tsx index 21ca27bde..a0418b2c1 100644 --- a/resources/scripts/components/server/backups/BackupRow.tsx +++ b/resources/scripts/components/server/backups/BackupRow.tsx @@ -99,7 +99,7 @@ export default ({ backup, className }: Props) => {

{t('created')}

- +
{!backup.completedAt ? (
diff --git a/resources/scripts/components/server/backups/RenameBackupModal.tsx b/resources/scripts/components/server/backups/RenameBackupModal.tsx new file mode 100644 index 000000000..5bea8f40f --- /dev/null +++ b/resources/scripts/components/server/backups/RenameBackupModal.tsx @@ -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; +} + +const RenameBackupModal = ({ backup, onRenamed, ...props }: Props) => { + const { t } = useTranslation('server/backups'); + + const submit = ({ name }: FormikValues, { setSubmitting }: FormikHelpers) => { + onRenamed(name) + .then(() => props.onDismissed()) + .catch(() => setSubmitting(false)); + }; + + return ( + + {({ isSubmitting, values }) => ( + +
+
+
+ +
+
+ +
+
+
+
+ )} +
+ ); +}; + +export default RenameBackupModal; diff --git a/routes/api-client.php b/routes/api-client.php index 48a111d0b..9858290ba 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -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']); diff --git a/tests/Integration/Api/Client/Server/Backup/RenameBackupTest.php b/tests/Integration/Api/Client/Server/Backup/RenameBackupTest.php new file mode 100644 index 000000000..8dcb3bb84 --- /dev/null +++ b/tests/Integration/Api/Client/Server/Backup/RenameBackupTest.php @@ -0,0 +1,47 @@ +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]); + } +}