diff --git a/dashboard/public/js/client.js b/dashboard/public/js/client.js index d8cac4a8a..2a9690b6a 100644 --- a/dashboard/public/js/client.js +++ b/dashboard/public/js/client.js @@ -1004,6 +1004,17 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }); }; + Client.prototype.archiveApp = function (appId, backupId, callback) { + var data = { backupId: backupId }; + + post('/api/v1/apps/' + appId + '/archive', data, null, function (error, data, status) { + if (error) return callback(error); + if (status !== 202) return callback(new ClientError(status, data)); + + callback(null); + }); + }; + Client.prototype.uninstallApp = function (appId, callback) { var data = {}; diff --git a/dashboard/public/translation/en.json b/dashboard/public/translation/en.json index dc1205965..a975f232a 100644 --- a/dashboard/public/translation/en.json +++ b/dashboard/public/translation/en.json @@ -1664,7 +1664,7 @@ }, "uninstall": { "title": "Uninstall", - "description": "This will uninstall the app immediately and remove the app's data. The site will be inaccessible.", + "description": "This will uninstall the app and remove the app's data. The site will be inaccessible.", "backupWarning": "App backups are not removed and will be cleaned up based on the backup policy. You can resurrect this app from an existing app backup using the following instructions.", "uninstallAction": "Uninstall" } @@ -1788,6 +1788,12 @@ "notes": { "title": "Admin Notes" } + }, + "archive": { + "title": "Archive", + "description": "The latest app backup will be added to the App Archive. The app will be uninstalled, but it can easily be restored at any time from the Backups View.", + "action": "Archive", + "latestBackupInfo": "The last backup was created at : {{ timestamp }}" } }, "login": { diff --git a/dashboard/public/translation/nl.json b/dashboard/public/translation/nl.json index 11c4ff575..b9e76b292 100644 --- a/dashboard/public/translation/nl.json +++ b/dashboard/public/translation/nl.json @@ -1942,16 +1942,16 @@ }, "oidc": { "newClientDialog": { - "title": "Client toevoegen", - "description": "Nieuwe OpenID Connect client instellingen toevoegen.", - "createAction": "Aanmaken" + "title": "OIDC Client toevoegen", + "description": "Nieuwe OIDC client instellingen invoeren", + "createAction": "Toevoegen" }, "client": { "name": "Naam", "id": "Client ID", "secret": "Client geheim", "signingAlgorithm": "Ondertekeningsalgoritme", - "loginRedirectUri": "Login callback URL (met komma gescheiden indien meer dan één)", + "loginRedirectUri": "Login callback URLs (met komma gescheiden)", "logoutRedirectUri": "Logout callback URL (optioneel)" }, "title": "OpenID Connect aanbieder", @@ -1961,7 +1961,7 @@ }, "deleteClientDialog": { "title": "Weet je zeker dat je Client {{ client }} wilt verwijderen?", - "description": "Hiermee worden alle externe OpenID apps met dit Client ID losgekoppeld." + "description": "Door het verwijderen van deze OIDC Client worden toegang tokens ongeldig. Apps die deze OIDC Client gebruiken kunnen zich niet meer authenticeren." }, "env": { "discoveryUrl": "Discovery URL", diff --git a/dashboard/public/views/app.html b/dashboard/public/views/app.html index 8bc182580..7789c61a4 100644 --- a/dashboard/public/views/app.html +++ b/dashboard/public/views/app.html @@ -1722,6 +1722,15 @@
+
+
+ +

{{ 'app.archive.description' | tr }}

+

+ +
+
+
diff --git a/dashboard/public/views/app.js b/dashboard/public/views/app.js index 2a4d39467..91810271a 100644 --- a/dashboard/public/views/app.js +++ b/dashboard/public/views/app.js @@ -1735,6 +1735,8 @@ angular.module('Application').controller('AppController', ['$scope', '$location' error: {}, busyRunState: false, startButton: false, + busyArchive: false, + latestBackup: null, toggleRunState: function (confirmStop) { if (confirmStop && $scope.app.runState !== RSTATES.STOPPED) { @@ -1760,12 +1762,34 @@ angular.module('Application').controller('AppController', ['$scope', '$location' show: function () { $scope.uninstall.error = {}; + + $scope.uninstall.latestBackup = null; + + Client.getAppBackups($scope.app.id, function (error, backups) { + if (!error && backups.length) $scope.uninstall.latestBackup = backups[0]; + }); }, ask: function () { $('#uninstallModal').modal('show'); }, + archive: function () { + $scope.uninstall.busyArchive = true; + + Client.archiveApp($scope.app.id, $scope.uninstall.latestBackup.id, function (error) { + if (error && error.statusCode === 402) { // unpurchase failed + Client.error('Relogin to Cloudron App Store'); + } else if (error) { + Client.error(error); + } else { + $location.path('/apps'); + } + + $scope.uninstall.busyArchive = false; + }); + }, + submit: function () { $scope.uninstall.busy = true; diff --git a/src/apps.js b/src/apps.js index a6c4ec20b..cc4f9015f 100644 --- a/src/apps.js +++ b/src/apps.js @@ -22,6 +22,7 @@ exports = module.exports = { // user actions install, uninstall, + archive, setAccessRestriction, setOperators, @@ -2477,6 +2478,20 @@ async function uninstall(app, auditSource) { return { taskId }; } +async function archive(app, backupId, auditSource) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof backupId, 'string'); + assert.strictEqual(typeof auditSource, 'object'); + + const result = await backups.getByIdentifierAndStatePaged(app.id, backups.BACKUP_STATE_NORMAL, 1, 1); + if (result.length === 0) throw new BoxError(BoxError.BAD_STATE, 'No recent backup to archive'); + if (result[0].id !== backupId) throw new BoxError(BoxError.BAD_STATE, 'Latest backup id has changed'); + + const { taskId } = await uninstall(app, auditSource); + await backups.update(result[0].id, { archive: true }); + return { taskId }; +} + async function start(app, auditSource) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof auditSource, 'object'); diff --git a/src/backups.js b/src/backups.js index b1666cfd0..6d85f0495 100644 --- a/src/backups.js +++ b/src/backups.js @@ -209,7 +209,7 @@ async function update(id, data) { const fields = [], values = []; for (const p in data) { - if (p === 'label' || p === 'preserveSecs') { + if (p === 'label' || p === 'preserveSecs' || p === 'archive') { fields.push(p + ' = ?'); values.push(data[p]); } diff --git a/src/routes/apps.js b/src/routes/apps.js index 4e430d02c..6365489cc 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -6,6 +6,7 @@ exports = module.exports = { getAppIcon, install, uninstall, + archive, restore, importApp, exportApp, @@ -642,6 +643,17 @@ async function uninstall(req, res, next) { next(new HttpSuccess(202, { taskId: result.taskId })); } +async function archive(req, res, next) { + assert.strictEqual(typeof req.app, 'object'); + + if (typeof req.body.backupId !== 'string') return next(new HttpError(400, 'backupId must be a string')); + + const [error, result] = await safe(apps.archive(req.app, req.body.backupId, AuditSource.fromRequest(req))); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(202, { taskId: result.taskId })); +} + async function start(req, res, next) { assert.strictEqual(typeof req.app, 'object'); diff --git a/src/server.js b/src/server.js index b7e22c838..20b8ed1fa 100644 --- a/src/server.js +++ b/src/server.js @@ -240,6 +240,7 @@ async function initializeExpressSync() { router.get ('/api/v1/apps', token, authorizeUser, routes.apps.listByUser); router.get ('/api/v1/apps/:id', token, routes.apps.load, authorizeOperator, routes.apps.getApp); router.get ('/api/v1/apps/:id/icon', routes.apps.load, routes.apps.getAppIcon); + router.post('/api/v1/apps/:id/archive', json, token, routes.apps.load, authorizeAdmin, routes.apps.archive); router.post('/api/v1/apps/:id/uninstall', json, token, routes.apps.load, authorizeAdmin, routes.apps.uninstall); router.post('/api/v1/apps/:id/configure/access_restriction', json, token, routes.apps.load, authorizeAdmin, routes.apps.setAccessRestriction); router.post('/api/v1/apps/:id/configure/operators', json, token, routes.apps.load, authorizeAdmin, routes.apps.setOperators);