diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 038bcaa70..53c0864a9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,41 +1,47 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node { - "name": "Audiobookshelf", - "build": { - "dockerfile": "Dockerfile", - // Update 'VARIANT' to pick a Node version: 18, 16, 14. - // Append -bullseye or -buster to pin to an OS version. - // Use -bullseye variants on local arm64/Apple Silicon. - "args": { - "VARIANT": "20" - } - }, - "mounts": [ - "source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume", - "source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume", - "type=bind,source=/Users/benjamindonaldson/Downloads,target=${containerWorkspaceFolder}/downloads" - ], - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - "forwardPorts": [ - 3000, - 3333 - ], - // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "sh .devcontainer/post-create.sh", - // Configure tool-specific properties. - "customizations": { - // Configure properties specific to VS Code. - "vscode": { - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "dbaeumer.vscode-eslint", - "octref.vetur" - ] - } - } - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" -} \ No newline at end of file + "name": "Audiobookshelf", + "build": { + "dockerfile": "Dockerfile", + // Update 'VARIANT' to pick a Node version: 18, 16, 14. + // Append -bullseye or -buster to pin to an OS version. + // Use -bullseye variants on local arm64/Apple Silicon. + "args": { + "VARIANT": "20" + } + }, + "mounts": ["source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume", "source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume", "type=bind,source=/Users/benjamindonaldson/Downloads,target=${containerWorkspaceFolder}/downloads"], + // Features to add to the dev container. More info: https://containers.dev/features. + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": true, + "username": "vscode", + "upgradePackages": true + } + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [3000, 3333], + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "sh .devcontainer/post-create.sh", + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Add the IDs of extensions you want installed when the container is created. + "extensions": ["dbaeumer.vscode-eslint", "octref.vetur"] + } + }, + // Add settings for Cursor + "settings": { + "terminal.integrated.defaultProfile.linux": "zsh", + "terminal.integrated.profiles.linux": { + "zsh": { + "path": "/bin/zsh" + } + } + }, + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + "remoteUser": "vscode", + "updateRemoteUserUID": true +} diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index 5f1c3140c..2ca27791a 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -42,14 +42,31 @@ - - - - - - +
+ + +
+ +
+ + Profile +
+
+ +
+

{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}

@@ -87,7 +104,8 @@ export default { data() { return { - totalEntities: 0 + totalEntities: 0, + showProfileDropdown: false } }, computed: { @@ -375,13 +393,54 @@ export default { }, batchAutoMatchClick() { this.$store.commit('globals/setShowBatchQuickMatchModal', true) + }, + logout() { + this.showProfileDropdown = false + + // Disconnect from socket if it exists and is connected + if (this.$root.socket && this.$root.socket.connected) { + try { + console.log('Disconnecting from socket', this.$root.socket.id) + this.$root.socket.removeAllListeners() + this.$root.socket.disconnect() + } catch (error) { + console.error('Error disconnecting socket:', error) + } + } + + // Clear user data + localStorage.removeItem('token') + this.$store.commit('libraries/setUserPlaylists', []) + this.$store.commit('libraries/setCollections', []) + this.$store.commit('user/setUser', null) + + // Logout request + this.$axios + .$post('/logout') + .catch((error) => { + console.error('Logout error:', error) + }) + .finally(() => { + this.$router.push('/login') + }) } }, mounted() { this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities) + // Close dropdown when clicking outside + this.handleClickOutside = (e) => { + if (!this.$el.contains(e.target)) { + this.showProfileDropdown = false + } + } + document.addEventListener('click', this.handleClickOutside) }, beforeDestroy() { this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities) + // Remove event listener with proper reference + if (this.handleClickOutside) { + document.removeEventListener('click', this.handleClickOutside) + } } } diff --git a/client/pages/user/_id/index.vue b/client/pages/user/_id/index.vue index f44ddaab3..c41fb8edd 100644 --- a/client/pages/user/_id/index.vue +++ b/client/pages/user/_id/index.vue @@ -10,7 +10,7 @@
- +
@@ -143,7 +143,7 @@ export default { return { user: null, reviews: [], - activeTab: 'account', + activeTab: 'reviews', loading: true, // Account tab data password: null, @@ -196,9 +196,13 @@ export default { try { this.user = await this.$axios.$get('/api/users/' + this.$route.params.id) if (this.isCurrentUser) { + this.activeTab = 'account' this.displayNameInput = this.user.displayName || '' this.selectedLanguage = this.$languageCodes.current await this.loadEreaderDevices() + } else { + // Set default tab to reviews for other users + this.activeTab = 'reviews' } } catch (error) { console.error('Error loading user:', error) @@ -367,4 +371,4 @@ export default { font-size: 0.875rem; color: rgba(255, 255, 255, 0.7); } - \ No newline at end of file + diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 1f5b35a8f..e907a068a 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -50,58 +50,30 @@ class UserController { /** * GET: /api/users/:id - * Get a single user toJSONForBrowser - * Media progress items include: `displayTitle`, `displaySubtitle` (for podcasts), `coverPath` and `mediaUpdatedAt` - * + * Get a single user + * * @param {UserControllerRequest} req * @param {Response} res */ async findOne(req, res) { - if (!req.user.isAdminOrUp) { - Logger.error(`Non-admin user "${req.user.username}" attempted to get user`) - return res.sendStatus(403) + const user = req.reqUser + const hideRootToken = !req.user.isRoot + + // If requesting user is not admin and not the user themselves, return limited public data + if (!req.user.isAdminOrUp && req.user.id !== user.id) { + const publicUserData = { + id: user.id, + username: user.username, + displayName: user.displayName, + type: user.type, + isActive: user.isActive, + createdAt: user.createdAt, + updatedAt: user.updatedAt + } + return res.json(publicUserData) } - // Get user media progress with associated mediaItem - const mediaProgresses = await Database.mediaProgressModel.findAll({ - where: { - userId: req.reqUser.id - }, - include: [ - { - model: Database.bookModel, - attributes: ['id', 'title', 'coverPath', 'updatedAt'] - }, - { - model: Database.podcastEpisodeModel, - attributes: ['id', 'title'], - include: { - model: Database.podcastModel, - attributes: ['id', 'title', 'coverPath', 'updatedAt'] - } - } - ] - }) - - const oldMediaProgresses = mediaProgresses.map((mp) => { - const oldMediaProgress = mp.getOldMediaProgress() - oldMediaProgress.displayTitle = mp.mediaItem?.title - if (mp.mediaItem?.podcast) { - oldMediaProgress.displaySubtitle = mp.mediaItem.podcast?.title - oldMediaProgress.coverPath = mp.mediaItem.podcast?.coverPath - oldMediaProgress.mediaUpdatedAt = mp.mediaItem.podcast?.updatedAt - } else if (mp.mediaItem) { - oldMediaProgress.coverPath = mp.mediaItem.coverPath - oldMediaProgress.mediaUpdatedAt = mp.mediaItem.updatedAt - } - return oldMediaProgress - }) - - const userJson = req.reqUser.toOldJSONForBrowser(!req.user.isRoot) - - userJson.mediaProgress = oldMediaProgresses - - res.json(userJson) + res.json(user.toOldJSONForBrowser(hideRootToken)) } /** @@ -497,9 +469,11 @@ class UserController { username: req.user?.username }) - if (!req.user.isAdminOrUp && req.user.id !== req.params.id) { - return res.sendStatus(403) - } else if ((req.method == 'PATCH' || req.method == 'POST' || req.method == 'DELETE') && !req.user.isAdminOrUp) { + // For GET requests, allow viewing any user's public profile + if (req.method === 'GET') { + // Continue to next middleware + } else if (!req.user.isAdminOrUp && req.user.id !== req.params.id) { + // For non-GET requests, require admin or self return res.sendStatus(403) }