From ecc30b85bc3fd390b0dbad535bc8e16ec5d7bd5d Mon Sep 17 00:00:00 2001 From: Austin Spencer Date: Sat, 26 Oct 2024 16:34:34 -0400 Subject: [PATCH] Allow users to create ereaders (#3531) * add create eReader permission toggle * add english label for create EReader permission * add ereader table to account with user specific modal * add createEreader permission * create api endpoint and logic for updating user eReader devices * add translated label for createEreader permission * handle name duplicates and remove helper func * toast for duplicate name error caught on server * restrict user ereader updates to devices with sole ownership * remove label * fix other devices logic and client socket emitter * fix for deleting ereaders * User create ereader endpoint validate accessibility --------- Co-authored-by: advplyr --- client/components/modals/AccountModal.vue | 15 +- .../modals/emails/UserEReaderDeviceModal.vue | 188 ++++++++++++++++++ client/pages/account.vue | 98 ++++++++- client/strings/en-us.json | 1 + server/controllers/MeController.js | 52 +++++ server/models/User.js | 2 + server/routers/ApiRouter.js | 1 + 7 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 client/components/modals/emails/UserEReaderDeviceModal.vue diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index 1ea24fd0..9c70e728 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -69,6 +69,15 @@ +
+
+

{{ $strings.LabelPermissionsCreateEreader }}

+
+
+ +
+
+

{{ $strings.LabelPermissionsAccessExplicitContent }}

@@ -354,7 +363,8 @@ export default { accessExplicitContent: type === 'admin', accessAllLibraries: true, accessAllTags: true, - selectedTagsNotAccessible: false + selectedTagsNotAccessible: false, + createEreader: type === 'admin' } }, init() { @@ -387,7 +397,8 @@ export default { accessAllLibraries: true, accessAllTags: true, accessExplicitContent: false, - selectedTagsNotAccessible: false + selectedTagsNotAccessible: false, + createEreader: false }, librariesAccessible: [], itemTagsSelected: [] diff --git a/client/components/modals/emails/UserEReaderDeviceModal.vue b/client/components/modals/emails/UserEReaderDeviceModal.vue new file mode 100644 index 00000000..b1706305 --- /dev/null +++ b/client/components/modals/emails/UserEReaderDeviceModal.vue @@ -0,0 +1,188 @@ + + + diff --git a/client/pages/account.vue b/client/pages/account.vue index b6c932a0..4515ab1f 100644 --- a/client/pages/account.vue +++ b/client/pages/account.vue @@ -32,9 +32,48 @@
+
+
+ + + + + + + + + + + + + + + +
{{ $strings.LabelName }}{{ $strings.LabelEmail }}
+

{{ device.name }}

+
+

{{ device.email }}

+
+
+ + +
+
+
+

{{ $strings.MessageNoDevices }}

+
+
+
+
logout{{ $strings.ButtonLogout }}
+ +
@@ -43,11 +82,20 @@ export default { data() { return { + loading: false, password: null, newPassword: null, confirmPassword: null, changingPassword: false, - selectedLanguage: '' + selectedLanguage: '', + newEReaderDevice: { + name: '', + email: '' + }, + ereaderDevices: [], + deletingDeviceName: null, + selectedEReaderDevice: null, + showEReaderDeviceModal: false } }, computed: { @@ -75,6 +123,12 @@ export default { }, showChangePasswordForm() { return !this.isGuest && this.isPasswordAuthEnabled + }, + showEreaderTable() { + return this.usertype !== 'root' && this.usertype !== 'admin' && this.user.permissions?.createEreader + }, + revisedEreaderDevices() { + return this.ereaderDevices.filter((device) => device.users?.length === 1) } }, methods: { @@ -142,10 +196,52 @@ export default { this.$toast.error(this.$strings.ToastUnknownError) this.changingPassword = false }) + }, + addNewDeviceClick() { + this.selectedEReaderDevice = null + this.showEReaderDeviceModal = true + }, + editDeviceClick(device) { + this.selectedEReaderDevice = device + this.showEReaderDeviceModal = true + }, + deleteDeviceClick(device) { + const payload = { + message: this.$getString('MessageConfirmDeleteDevice', [device.name]), + callback: (confirmed) => { + if (confirmed) { + this.deleteDevice(device) + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + }, + deleteDevice(device) { + const payload = { + ereaderDevices: this.revisedEreaderDevices.filter((d) => d.name !== device.name) + } + this.deletingDeviceName = device.name + this.$axios + .$post(`/api/me/ereader-devices`, payload) + .then((data) => { + this.ereaderDevicesUpdated(data.ereaderDevices) + }) + .catch((error) => { + console.error('Failed to delete device', error) + this.$toast.error(this.$strings.ToastRemoveFailed) + }) + .finally(() => { + this.deletingDeviceName = null + }) + }, + ereaderDevicesUpdated(ereaderDevices) { + this.ereaderDevices = ereaderDevices } }, mounted() { this.selectedLanguage = this.$languageCodes.current + this.ereaderDevices = this.$store.state.libraries.ereaderDevices || [] } } diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 918bf685..8eb37550 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -472,6 +472,7 @@ "LabelPermissionsAccessAllLibraries": "Can Access All Libraries", "LabelPermissionsAccessAllTags": "Can Access All Tags", "LabelPermissionsAccessExplicitContent": "Can Access Explicit Content", + "LabelPermissionsCreateEreader": "Can Create Ereader", "LabelPermissionsDelete": "Can Delete", "LabelPermissionsDownload": "Can Download", "LabelPermissionsUpdate": "Can Update", diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index c7abbc23..cc67b320 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -394,6 +394,58 @@ class MeController { res.json(req.user.toOldJSONForBrowser()) } + /** + * POST: /api/me/ereader-devices + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async updateUserEReaderDevices(req, res) { + if (!req.body.ereaderDevices || !Array.isArray(req.body.ereaderDevices)) { + return res.status(400).send('Invalid payload. ereaderDevices array required') + } + + const userEReaderDevices = req.body.ereaderDevices + for (const device of userEReaderDevices) { + if (!device.name || !device.email) { + return res.status(400).send('Invalid payload. ereaderDevices array items must have name and email') + } else if (device.availabilityOption !== 'specificUsers' || device.users?.length !== 1 || device.users[0] !== req.user.id) { + return res.status(400).send('Invalid payload. ereaderDevices array items must have availabilityOption "specificUsers" and only the current user') + } + } + + const otherDevices = Database.emailSettings.ereaderDevices.filter((device) => { + return !Database.emailSettings.checkUserCanAccessDevice(device, req.user) || device.users?.length !== 1 + }) + + const ereaderDevices = otherDevices.concat(userEReaderDevices) + + // Check for duplicate names + const nameSet = new Set() + const hasDupes = ereaderDevices.some((device) => { + if (nameSet.has(device.name)) { + return true // Duplicate found + } + nameSet.add(device.name) + return false + }) + + if (hasDupes) { + return res.status(400).send('Invalid payload. Duplicate "name" field found.') + } + + const updated = Database.emailSettings.update({ ereaderDevices }) + if (updated) { + await Database.updateSetting(Database.emailSettings) + SocketAuthority.clientEmitter(req.user.id, 'ereader-devices-updated', { + ereaderDevices: Database.emailSettings.ereaderDevices + }) + } + res.json({ + ereaderDevices: Database.emailSettings.getEReaderDevices(req.user) + }) + } + /** * GET: /api/me/stats/year/:year * diff --git a/server/models/User.js b/server/models/User.js index 8bd3f742..906a7d68 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -82,6 +82,7 @@ class User extends Model { canAccessExplicitContent: 'accessExplicitContent', canAccessAllLibraries: 'accessAllLibraries', canAccessAllTags: 'accessAllTags', + canCreateEReader: 'createEreader', tagsAreDenylist: 'selectedTagsNotAccessible', // Direct mapping for array-based permissions allowedLibraries: 'librariesAccessible', @@ -122,6 +123,7 @@ class User extends Model { update: type === 'root' || type === 'admin', delete: type === 'root', upload: type === 'root' || type === 'admin', + createEreader: type === 'root' || type === 'admin', accessAllLibraries: true, accessAllTags: true, accessExplicitContent: type === 'root' || type === 'admin', diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 57067ad8..f81bc26d 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -190,6 +190,7 @@ class ApiRouter { this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this)) + this.router.post('/me/ereader-devices', MeController.updateUserEReaderDevices.bind(this)) // // Backup Routes