diff --git a/packages/backend/src/metadata.ts b/packages/backend/src/metadata.ts index ccd1664d..0f90b374 100644 --- a/packages/backend/src/metadata.ts +++ b/packages/backend/src/metadata.ts @@ -8,5 +8,5 @@ export default async () => { ["./modules/links/dto/links.dto"]: await import("./modules/links/dto/links.dto"), ["./modules/system/dto/system.dto"]: await import("./modules/system/dto/system.dto") }; - return { "@nestjs/swagger": { "models": [[import("./modules/apps/dto/app-info.dto"), { "AppInfoSimpleDto": {}, "AppInfoDto": {} }], [import("./modules/user/dto/user.dto"), { "UserDto": {} }], [import("./app.dto"), { "UserSettingsDto": {}, "PartialUserSettingsDto": {}, "AppContextDto": {}, "UserContextDto": {}, "AcknowledgeWelcomeBody": {} }], [import("./modules/queue/queue.entity"), { "Queue": { queueNameResponse: { required: true, type: () => String } } }], [import("./modules/auth/dto/auth.dto"), { "LoginBody": {}, "VerifyTotpBody": {}, "LoginDto": {}, "RegisterBody": {}, "RegisterDto": {}, "ChangeUsernameBody": {}, "ChangePasswordBody": {}, "GetTotpUriBody": {}, "GetTotpUriDto": {}, "SetupTotpBody": {}, "DisableTotpBody": {}, "ResetPasswordBody": {}, "ResetPasswordDto": {}, "CheckResetPasswordRequestDto": {} }], [import("./modules/app-lifecycle/dto/app-lifecycle.dto"), { "AppFormBody": {} }], [import("./modules/apps/dto/app.dto"), { "SearchAppsQueryDto": {}, "SearchAppsDto": {}, "UpdateInfoDto": {}, "AppDto": {}, "MyAppsDto": {}, "GuestAppsDto": {}, "AppDetailsDto": {} }], [import("./modules/backups/dto/backups.dto"), { "BackupDto": {}, "RestoreAppBackupDto": {}, "GetAppBackupsDto": {}, "GetAppBackupsQueryDto": {}, "DeleteAppBackupBodyDto": {} }], [import("./modules/links/dto/links.dto"), { "LinkBodyDto": {}, "EditLinkBodyDto": {}, "LinksDto": {} }], [import("./modules/system/dto/system.dto"), { "LoadDto": {} }]], "controllers": [[import("./app.controller"), { "AppController": { "userContext": { type: t["./app.dto"].UserContextDto }, "appContext": { type: t["./app.dto"].AppContextDto }, "updateUserSettings": {}, "acknowledgeWelcome": {}, "getError": {} } }], [import("./modules/auth/auth.controller"), { "AuthController": { "login": { type: t["./modules/auth/dto/auth.dto"].LoginDto }, "verifyTotp": { type: t["./modules/auth/dto/auth.dto"].LoginDto }, "register": { type: t["./modules/auth/dto/auth.dto"].RegisterDto }, "logout": {}, "changeUsername": {}, "changePassword": {}, "getTotpUri": { type: t["./modules/auth/dto/auth.dto"].GetTotpUriDto }, "setupTotp": {}, "disableTotp": {}, "resetPassword": { type: t["./modules/auth/dto/auth.dto"].ResetPasswordDto }, "cancelResetPassword": {}, "checkResetPasswordRequest": { type: t["./modules/auth/dto/auth.dto"].CheckResetPasswordRequestDto } } }], [import("./modules/i18n/i18n.controller"), { "I18nController": { "getTranslation": { type: Object } } }], [import("./core/health/health.controller"), { "HealthController": { "check": { type: Object } } }], [import("./modules/apps/apps.controller"), { "AppsController": { "getInstalledApps": { type: t["./modules/apps/dto/app.dto"].MyAppsDto }, "getGuestApps": { type: t["./modules/apps/dto/app.dto"].GuestAppsDto }, "searchApps": { type: t["./modules/apps/dto/app.dto"].SearchAppsDto }, "getAppDetails": { type: t["./modules/apps/dto/app.dto"].AppDetailsDto }, "getImage": {} } }], [import("./modules/backups/backups.controller"), { "BackupsController": { "backupApp": {}, "restoreAppBackup": {}, "getAppBackups": { type: t["./modules/backups/dto/backups.dto"].GetAppBackupsDto }, "deleteAppBackup": {} } }], [import("./modules/app-lifecycle/app-lifecycle.controller"), { "AppLifecycleController": { "installApp": {}, "startApp": {}, "stopApp": {}, "restartApp": {}, "uninstallApp": {}, "resetApp": {} } }], [import("./modules/links/links.controller"), { "LinksController": { "getLinks": { type: t["./modules/links/dto/links.dto"].LinksDto }, "createLink": {}, "editLink": {}, "deleteLink": {} } }], [import("./modules/system/system.controller"), { "SystemController": { "systemLoad": { type: t["./modules/system/dto/system.dto"].LoadDto }, "downloadLocalCertificate": {} } }]] } }; + return { "@nestjs/swagger": { "models": [[import("./modules/apps/dto/app-info.dto"), { "AppInfoSimpleDto": {}, "AppInfoDto": {} }], [import("./modules/user/dto/user.dto"), { "UserDto": {} }], [import("./app.dto"), { "UserSettingsDto": {}, "PartialUserSettingsDto": {}, "AppContextDto": {}, "UserContextDto": {}, "AcknowledgeWelcomeBody": {} }], [import("./modules/queue/queue.entity"), { "Queue": { queueNameResponse: { required: true, type: () => String } } }], [import("./modules/auth/dto/auth.dto"), { "LoginBody": {}, "VerifyTotpBody": {}, "LoginDto": {}, "RegisterBody": {}, "RegisterDto": {}, "ChangeUsernameBody": {}, "ChangePasswordBody": {}, "GetTotpUriBody": {}, "GetTotpUriDto": {}, "SetupTotpBody": {}, "DisableTotpBody": {}, "ResetPasswordBody": {}, "ResetPasswordDto": {}, "CheckResetPasswordRequestDto": {} }], [import("./modules/app-lifecycle/dto/app-lifecycle.dto"), { "AppFormBody": {}, "UninstallAppBody": {} }], [import("./modules/apps/dto/app.dto"), { "SearchAppsQueryDto": {}, "SearchAppsDto": {}, "UpdateInfoDto": {}, "AppDto": {}, "MyAppsDto": {}, "GuestAppsDto": {}, "AppDetailsDto": {} }], [import("./modules/backups/dto/backups.dto"), { "BackupDto": {}, "RestoreAppBackupDto": {}, "GetAppBackupsDto": {}, "GetAppBackupsQueryDto": {}, "DeleteAppBackupBodyDto": {} }], [import("./modules/links/dto/links.dto"), { "LinkBodyDto": {}, "EditLinkBodyDto": {}, "LinksDto": {} }], [import("./modules/system/dto/system.dto"), { "LoadDto": {} }]], "controllers": [[import("./app.controller"), { "AppController": { "userContext": { type: t["./app.dto"].UserContextDto }, "appContext": { type: t["./app.dto"].AppContextDto }, "updateUserSettings": {}, "acknowledgeWelcome": {}, "getError": {} } }], [import("./modules/auth/auth.controller"), { "AuthController": { "login": { type: t["./modules/auth/dto/auth.dto"].LoginDto }, "verifyTotp": { type: t["./modules/auth/dto/auth.dto"].LoginDto }, "register": { type: t["./modules/auth/dto/auth.dto"].RegisterDto }, "logout": {}, "changeUsername": {}, "changePassword": {}, "getTotpUri": { type: t["./modules/auth/dto/auth.dto"].GetTotpUriDto }, "setupTotp": {}, "disableTotp": {}, "resetPassword": { type: t["./modules/auth/dto/auth.dto"].ResetPasswordDto }, "cancelResetPassword": {}, "checkResetPasswordRequest": { type: t["./modules/auth/dto/auth.dto"].CheckResetPasswordRequestDto } } }], [import("./modules/i18n/i18n.controller"), { "I18nController": { "getTranslation": { type: Object } } }], [import("./core/health/health.controller"), { "HealthController": { "check": { type: Object } } }], [import("./modules/apps/apps.controller"), { "AppsController": { "getInstalledApps": { type: t["./modules/apps/dto/app.dto"].MyAppsDto }, "getGuestApps": { type: t["./modules/apps/dto/app.dto"].GuestAppsDto }, "searchApps": { type: t["./modules/apps/dto/app.dto"].SearchAppsDto }, "getAppDetails": { type: t["./modules/apps/dto/app.dto"].AppDetailsDto }, "getImage": {} } }], [import("./modules/backups/backups.controller"), { "BackupsController": { "backupApp": {}, "restoreAppBackup": {}, "getAppBackups": { type: t["./modules/backups/dto/backups.dto"].GetAppBackupsDto }, "deleteAppBackup": {} } }], [import("./modules/app-lifecycle/app-lifecycle.controller"), { "AppLifecycleController": { "installApp": {}, "startApp": {}, "stopApp": {}, "restartApp": {}, "uninstallApp": {}, "resetApp": {} } }], [import("./modules/links/links.controller"), { "LinksController": { "getLinks": { type: t["./modules/links/dto/links.dto"].LinksDto }, "createLink": {}, "editLink": {}, "deleteLink": {} } }], [import("./modules/system/system.controller"), { "SystemController": { "systemLoad": { type: t["./modules/system/dto/system.dto"].LoadDto }, "downloadLocalCertificate": {} } }]] } }; }; \ No newline at end of file diff --git a/packages/backend/src/modules/app-lifecycle/app-lifecycle.controller.ts b/packages/backend/src/modules/app-lifecycle/app-lifecycle.controller.ts index a66b4020..60762fe4 100644 --- a/packages/backend/src/modules/app-lifecycle/app-lifecycle.controller.ts +++ b/packages/backend/src/modules/app-lifecycle/app-lifecycle.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Delete, Param, Post, UseGuards } from '@nestjs/common'; import { AuthGuard } from '../auth/auth.guard'; import { AppLifecycleService } from './app-lifecycle.service'; -import { AppFormBody, appFormSchema } from './dto/app-lifecycle.dto'; +import { AppFormBody, UninstallAppBody, appFormSchema } from './dto/app-lifecycle.dto'; @UseGuards(AuthGuard) @Controller('app-lifecycle') @@ -31,8 +31,8 @@ export class AppLifecycleController { } @Delete(':id/uninstall') - async uninstallApp(@Param('id') id: string) { - return this.appLifecycleService.uninstallApp({ appId: id }); + async uninstallApp(@Param('id') id: string, @Body() body: UninstallAppBody) { + return this.appLifecycleService.uninstallApp({ appId: id, removeBackups: body.removeBackups }); } @Post(':id/reset') diff --git a/packages/backend/src/modules/app-lifecycle/app-lifecycle.service.ts b/packages/backend/src/modules/app-lifecycle/app-lifecycle.service.ts index c7c5fc41..ce04ec92 100644 --- a/packages/backend/src/modules/app-lifecycle/app-lifecycle.service.ts +++ b/packages/backend/src/modules/app-lifecycle/app-lifecycle.service.ts @@ -8,6 +8,7 @@ import { isFQDN } from 'validator'; import type { z } from 'zod'; import { AppFilesManager } from '../apps/app-files-manager'; import { AppsRepository } from '../apps/apps.repository'; +import { BackupManager } from '../backups/backup.manager'; import { type AppEventFormInput, AppEventsQueue, appEventSchema } from '../queue/entities/app-events'; import { AppLifecycleCommandFactory } from './app-lifecycle-command.factory'; @@ -21,6 +22,7 @@ export class AppLifecycleService { private readonly config: ConfigurationService, private readonly appFilesManager: AppFilesManager, private readonly socketManager: SocketManager, + private readonly backupManager: BackupManager, ) { this.logger.debug('Subscribing to app events...'); this.appEventsQueue.onEvent(({ eventId, ...data }) => this.invokeCommand(eventId, data)); @@ -203,8 +205,8 @@ export class AppLifecycleService { /** * Uninstall an app by its ID */ - public async uninstallApp(params: { appId: string }) { - const { appId } = params; + public async uninstallApp(params: { appId: string, removeBackups: boolean }) { + const { appId, removeBackups } = params; const app = await this.appRepository.getApp(appId); @@ -212,6 +214,10 @@ export class AppLifecycleService { throw new TranslatableError('APP_ERROR_APP_NOT_FOUND', { id: appId }); } + if (removeBackups) { + await this.backupManager.deleteAppBackupsById(appId); + } + await this.appRepository.updateApp(appId, { status: 'uninstalling' }); this.socketManager.emit({ type: 'app', event: 'status_change', data: { appId, appStatus: 'uninstalling' } }); diff --git a/packages/backend/src/modules/app-lifecycle/dto/app-lifecycle.dto.ts b/packages/backend/src/modules/app-lifecycle/dto/app-lifecycle.dto.ts index ed5f28e0..819236e3 100644 --- a/packages/backend/src/modules/app-lifecycle/dto/app-lifecycle.dto.ts +++ b/packages/backend/src/modules/app-lifecycle/dto/app-lifecycle.dto.ts @@ -13,3 +13,5 @@ export const appFormSchema = z .catchall(z.unknown()); export class AppFormBody extends createZodDto(appFormSchema) {} + +export class UninstallAppBody extends createZodDto(z.object({ removeBackups: z.boolean() })) {} diff --git a/packages/backend/src/modules/backups/backup.manager.ts b/packages/backend/src/modules/backups/backup.manager.ts index 2238adbe..56cce787 100644 --- a/packages/backend/src/modules/backups/backup.manager.ts +++ b/packages/backend/src/modules/backups/backup.manager.ts @@ -117,6 +117,16 @@ export class BackupManager { } } + /** + * Delete all backups for an app + * @param appId - The app id + */ + public async deleteAppBackupsById(appId: string): Promise { + const backups = await this.listBackupsByAppId(appId); + + await Promise.all(backups.map((backup) => this.deleteBackup(appId, backup.id))) + } + /** * List the backups for an app * @param appId - The app id diff --git a/packages/backend/src/modules/i18n/translations/en.json b/packages/backend/src/modules/i18n/translations/en.json index d83beb32..c4267190 100644 --- a/packages/backend/src/modules/i18n/translations/en.json +++ b/packages/backend/src/modules/i18n/translations/en.json @@ -126,6 +126,7 @@ "APP_STORE_NO_RESULTS_SUBTITLE": "Try to refine your search", "APP_STORE_SEARCH_PLACEHOLDER": "Search apps", "APP_STORE_TITLE": "App Store", + "APP_UNINSTALL_FORM_REMOVE_BACKUPS": "Remove backups", "APP_UNINSTALL_FORM_SUBMIT": "Uninstall", "APP_UNINSTALL_FORM_SUBTITLE": "All data for this app will be lost.", "APP_UNINSTALL_FORM_TITLE": "Uninstall {{name}} ?", diff --git a/packages/backend/src/swagger.json b/packages/backend/src/swagger.json index 66333789..f96369cf 100644 --- a/packages/backend/src/swagger.json +++ b/packages/backend/src/swagger.json @@ -668,6 +668,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UninstallAppBody" + } + } + } + }, "responses": { "200": { "description": "" @@ -2542,6 +2552,17 @@ } } }, + "UninstallAppBody": { + "type": "object", + "properties": { + "removeBackups": { + "type": "boolean" + } + }, + "required": [ + "removeBackups" + ] + }, "RestoreAppBackupDto": { "type": "object", "properties": { diff --git a/packages/frontend/src/api-client/types.gen.ts b/packages/frontend/src/api-client/types.gen.ts index 0d5b067b..5fa3cc12 100644 --- a/packages/frontend/src/api-client/types.gen.ts +++ b/packages/frontend/src/api-client/types.gen.ts @@ -758,6 +758,9 @@ export type UninstallAppData = { path: { id: string; }; + body: { + removeBackups: boolean; + } }; export type UninstallAppResponse = unknown; diff --git a/packages/frontend/src/modules/app/components/dialogs/uninstall-dialog/uninstall-dialog.tsx b/packages/frontend/src/modules/app/components/dialogs/uninstall-dialog/uninstall-dialog.tsx index e42fe759..e5187a47 100644 --- a/packages/frontend/src/modules/app/components/dialogs/uninstall-dialog/uninstall-dialog.tsx +++ b/packages/frontend/src/modules/app/components/dialogs/uninstall-dialog/uninstall-dialog.tsx @@ -1,11 +1,13 @@ import { uninstallAppMutation } from '@/api-client/@tanstack/react-query.gen'; import { Button } from '@/components/ui/Button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog'; +import { Switch } from '@/components/ui/Switch'; import { useAppStatus } from '@/modules/app/helpers/use-app-status'; import type { AppInfo } from '@/types/app.types'; import type { TranslatableError } from '@/types/error.types'; import { IconAlertTriangle } from '@tabler/icons-react'; import { useMutation } from '@tanstack/react-query'; +import { useState } from 'react'; import toast from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; @@ -19,6 +21,8 @@ export const UninstallDialog = ({ info, isOpen, onClose }: IProps) => { const { t } = useTranslation(); const { setOptimisticStatus } = useAppStatus(); + const [shouldRemoveBackups, setShouldRemoveBackups] = useState(false); + const uninstallMutation = useMutation({ ...uninstallAppMutation(), onError: (error: TranslatableError) => { @@ -40,9 +44,10 @@ export const UninstallDialog = ({ info, isOpen, onClose }: IProps) => {

{t('APP_UNINSTALL_FORM_WARNING')}

{t('APP_UNINSTALL_FORM_SUBTITLE')}
+ -