update the user profile so it works for non admins

This commit is contained in:
zipben 2025-06-04 22:33:14 +00:00
parent 2fd6809624
commit 0d39fc429c
4 changed files with 142 additions and 99 deletions

View file

@ -11,18 +11,17 @@
"VARIANT": "20" "VARIANT": "20"
} }
}, },
"mounts": [ "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"],
"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 to add to the dev container. More info: https://containers.dev/features.
// "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. // Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [ "forwardPorts": [3000, 3333],
3000,
3333
],
// Use 'postCreateCommand' to run commands after the container is created. // Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "sh .devcontainer/post-create.sh", "postCreateCommand": "sh .devcontainer/post-create.sh",
// Configure tool-specific properties. // Configure tool-specific properties.
@ -30,12 +29,19 @@
// Configure properties specific to VS Code. // Configure properties specific to VS Code.
"vscode": { "vscode": {
// Add the IDs of extensions you want installed when the container is created. // Add the IDs of extensions you want installed when the container is created.
"extensions": [ "extensions": ["dbaeumer.vscode-eslint", "octref.vetur"]
"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. // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root" "remoteUser": "vscode",
"updateRemoteUserUID": true
} }

View file

@ -42,14 +42,31 @@
</ui-tooltip> </ui-tooltip>
</nuxt-link> </nuxt-link>
<nuxt-link :to="'/user/' + user.id" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded-sm shadow-xs ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg/40" aria-haspopup="listbox" aria-expanded="true"> <div class="relative">
<button @click="showProfileDropdown = !showProfileDropdown" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded-sm shadow-xs ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg/40" aria-haspopup="true" :aria-expanded="showProfileDropdown">
<span class="items-center hidden md:flex"> <span class="items-center hidden md:flex">
<span class="block truncate">{{ username }}</span> <span class="block truncate">{{ username }}</span>
</span> </span>
<span class="h-full md:ml-3 md:absolute inset-y-0 md:right-0 flex items-center justify-center md:pr-2 pointer-events-none"> <span class="h-full md:ml-3 md:absolute inset-y-0 md:right-0 flex items-center justify-center md:pr-2 pointer-events-none">
<span class="material-symbols text-xl text-gray-100">&#xe7fd;</span> <span class="material-symbols text-xl text-gray-100">&#xe7fd;</span>
</span> </span>
</button>
<div v-if="showProfileDropdown" class="absolute right-0 mt-1 w-48 bg-bg border border-gray-500 rounded-sm shadow-lg z-50 overflow-hidden">
<nuxt-link :to="'/user/' + user.id" class="block px-4 py-2.5 text-sm text-white hover:bg-primary/40 transition-colors duration-150">
<div class="flex items-center">
<span class="material-symbols text-lg mr-2">&#xe7fd;</span>
Profile
</div>
</nuxt-link> </nuxt-link>
<button @click="logout" class="w-full text-left px-4 py-2.5 text-sm text-white hover:bg-primary/40 transition-colors duration-150">
<div class="flex items-center">
<span class="material-symbols text-lg mr-2">&#xe9ba;</span>
Logout
</div>
</button>
</div>
</div>
</div> </div>
<div v-show="numMediaItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center"> <div v-show="numMediaItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1> <h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1>
@ -87,7 +104,8 @@
export default { export default {
data() { data() {
return { return {
totalEntities: 0 totalEntities: 0,
showProfileDropdown: false
} }
}, },
computed: { computed: {
@ -375,13 +393,54 @@ export default {
}, },
batchAutoMatchClick() { batchAutoMatchClick() {
this.$store.commit('globals/setShowBatchQuickMatchModal', true) 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() { mounted() {
this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities) 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() { beforeDestroy() {
this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities) this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)
// Remove event listener with proper reference
if (this.handleClickOutside) {
document.removeEventListener('click', this.handleClickOutside)
}
} }
} }
</script> </script>

View file

@ -10,7 +10,7 @@
<div v-else> <div v-else>
<!-- Tab Navigation --> <!-- Tab Navigation -->
<div v-if="user" class="flex border-b border-white/10 mb-6"> <div v-if="user" class="flex border-b border-white/10 mb-6">
<button class="px-4 py-2 mr-4 font-semibold rounded-t-lg" :class="activeTab === 'account' ? 'text-white bg-primary border-b-0' : 'text-gray-400 hover:text-white'" @click="activeTab = 'account'">Account Settings</button> <button v-if="isCurrentUser" class="px-4 py-2 mr-4 font-semibold rounded-t-lg" :class="activeTab === 'account' ? 'text-white bg-primary border-b-0' : 'text-gray-400 hover:text-white'" @click="activeTab = 'account'">Account Settings</button>
<button class="px-4 py-2 font-semibold rounded-t-lg" :class="activeTab === 'reviews' ? 'text-white bg-primary border-b-0' : 'text-gray-400 hover:text-white'" @click="activeTab = 'reviews'">Reviews</button> <button class="px-4 py-2 font-semibold rounded-t-lg" :class="activeTab === 'reviews' ? 'text-white bg-primary border-b-0' : 'text-gray-400 hover:text-white'" @click="activeTab = 'reviews'">Reviews</button>
</div> </div>
@ -143,7 +143,7 @@ export default {
return { return {
user: null, user: null,
reviews: [], reviews: [],
activeTab: 'account', activeTab: 'reviews',
loading: true, loading: true,
// Account tab data // Account tab data
password: null, password: null,
@ -196,9 +196,13 @@ export default {
try { try {
this.user = await this.$axios.$get('/api/users/' + this.$route.params.id) this.user = await this.$axios.$get('/api/users/' + this.$route.params.id)
if (this.isCurrentUser) { if (this.isCurrentUser) {
this.activeTab = 'account'
this.displayNameInput = this.user.displayName || '' this.displayNameInput = this.user.displayName || ''
this.selectedLanguage = this.$languageCodes.current this.selectedLanguage = this.$languageCodes.current
await this.loadEreaderDevices() await this.loadEreaderDevices()
} else {
// Set default tab to reviews for other users
this.activeTab = 'reviews'
} }
} catch (error) { } catch (error) {
console.error('Error loading user:', error) console.error('Error loading user:', error)

View file

@ -50,58 +50,30 @@ class UserController {
/** /**
* GET: /api/users/:id * GET: /api/users/:id
* Get a single user toJSONForBrowser * Get a single user
* Media progress items include: `displayTitle`, `displaySubtitle` (for podcasts), `coverPath` and `mediaUpdatedAt`
* *
* @param {UserControllerRequest} req * @param {UserControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async findOne(req, res) { async findOne(req, res) {
if (!req.user.isAdminOrUp) { const user = req.reqUser
Logger.error(`Non-admin user "${req.user.username}" attempted to get user`) const hideRootToken = !req.user.isRoot
return res.sendStatus(403)
// 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 res.json(user.toOldJSONForBrowser(hideRootToken))
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)
} }
/** /**
@ -497,9 +469,11 @@ class UserController {
username: req.user?.username username: req.user?.username
}) })
if (!req.user.isAdminOrUp && req.user.id !== req.params.id) { // For GET requests, allow viewing any user's public profile
return res.sendStatus(403) if (req.method === 'GET') {
} else if ((req.method == 'PATCH' || req.method == 'POST' || req.method == 'DELETE') && !req.user.isAdminOrUp) { // 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) return res.sendStatus(403)
} }