diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0213d5178..53c0864a9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,40 +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" - ], - // 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/.gitignore b/.gitignore index 12ebec1c2..b6d6f25dd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /media/ /metadata/ /plugins/ +*.epub /client/.nuxt/ /client/dist/ /dist/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 000000000..30568a12f --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,114 @@ +# Audiobookshelf Deployment Guide + +## Prerequisites + +- Docker Desktop (for local deployment) +- Portainer (optional, for container management) +- At least 1GB of free RAM +- Sufficient disk space for your audiobooks and metadata + +## Directory Structure + +``` +audiobookshelf/ +├── audiobooks/ # Your audiobooks directory +├── podcasts/ # Your podcasts directory +├── metadata/ # Application metadata +├── config/ # Configuration files +├── client/ # Frontend source code +├── server/ # Backend source code +├── Dockerfile # Docker build instructions +└── docker-compose.prod.yml + +External Directories: +/libraries/ # Additional media libraries directory (customizable path) +``` + +## Deployment Steps + +### Using Docker Desktop + +1. Create the required directories: + + ```bash + mkdir -p audiobooks podcasts metadata config + ``` + +2. Configure your libraries path: Edit `docker-compose.prod.yml` and modify the libraries volume mount to point to your desired path: + + ```yaml + volumes: + - /path/to/your/libraries:/libraries + ``` + +3. Build and start the application: + + ```bash + # Build the image + docker compose -f docker-compose.prod.yml build + + # Start the containers + docker compose -f docker-compose.prod.yml up -d + ``` + +4. Access the application at `http://localhost:13378` + +### Using Portainer + +1. In Portainer, go to "Stacks" and click "Add Stack" +2. Give your stack a name (e.g., "audiobookshelf") +3. Copy the contents of `docker-compose.prod.yml` into the web editor +4. Modify the libraries volume mount path to match your system +5. Enable "Build Image" option in Portainer +6. Click "Deploy the stack" + +## Configuration + +- The application runs on port 13378 by default +- Data is persisted in the mounted volumes: + - `./audiobooks`: Default audiobooks directory + - `./podcasts`: Default podcasts directory + - `./metadata`: Application metadata + - `./config`: Configuration files + - `/libraries`: Additional media libraries (customize path in docker-compose.prod.yml) +- Timezone can be configured in the docker-compose file +- User/Group IDs can be set via PUID/PGID environment variables + +## Post-Installation + +1. On first run, create an admin account +2. Configure your libraries in the web interface: + - Set up the default audiobooks and podcasts directories + - Add additional libraries from the `/libraries` mount point +3. Add your media files to the appropriate directories + +## Updating + +When you make changes to your code: + +```bash +# Rebuild the image with your changes +docker compose -f docker-compose.prod.yml build + +# Restart the containers with the new image +docker compose -f docker-compose.prod.yml up -d +``` + +## Backup + +Important directories to backup: + +- `config/` - Contains application settings and database +- `metadata/` - Contains metadata for your media +- `audiobooks/` and `podcasts/` - Your media files +- Any additional libraries you've configured + +## Troubleshooting + +- Check container logs: `docker logs audiobookshelf` +- Ensure proper file permissions on mounted volumes +- Verify port 13378 is not in use by another application +- Check resource usage in Docker Desktop dashboard +- Build logs: `docker compose -f docker-compose.prod.yml build --progress=plain` +- Check that all volume mount paths exist and are accessible +- Verify file permissions on the libraries directory diff --git a/client/assets/app.css b/client/assets/app.css index 36b3b4bad..7ad730842 100644 --- a/client/assets/app.css +++ b/client/assets/app.css @@ -7,6 +7,92 @@ :root { --bookshelf-texture-img: url(~static/textures/wood_default.jpg); --bookshelf-divider-bg: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); + + /* Theme Colors */ + --color-primary: #1e293b; + --color-primary-dark: #0f172a; + --color-success: #22c55e; + --color-warning: #f59e0b; + --color-error: #ef4444; + --color-info: #3b82f6; + --color-bg: #111827; +} + +/* Color Classes */ +.bg-primary { + background-color: var(--color-primary) !important; +} + +.bg-primary-dark { + background-color: var(--color-primary-dark) !important; +} + +.bg-success { + background-color: var(--color-success) !important; +} + +.bg-warning { + background-color: var(--color-warning) !important; +} + +.bg-error { + background-color: var(--color-error) !important; +} + +.bg-info { + background-color: var(--color-info) !important; +} + +.bg-bg { + background-color: var(--color-bg) !important; +} + +.text-primary { + color: var(--color-primary) !important; +} + +.text-primary-dark { + color: var(--color-primary-dark) !important; +} + +.text-success { + color: var(--color-success) !important; +} + +.text-warning { + color: var(--color-warning) !important; +} + +.text-error { + color: var(--color-error) !important; +} + +.text-info { + color: var(--color-info) !important; +} + +.border-primary { + border-color: var(--color-primary) !important; +} + +.border-primary-dark { + border-color: var(--color-primary-dark) !important; +} + +.border-success { + border-color: var(--color-success) !important; +} + +.border-warning { + border-color: var(--color-warning) !important; +} + +.border-error { + border-color: var(--color-error) !important; +} + +.border-info { + border-color: var(--color-info) !important; } .page { @@ -249,4 +335,4 @@ Bookshelf Label .abs-btn:disabled::before { background-color: rgba(0, 0, 0, 0.2); -} +} \ No newline at end of file diff --git a/client/assets/tailwind.css b/client/assets/tailwind.css index 7883f32ff..5608bf7f4 100644 --- a/client/assets/tailwind.css +++ b/client/assets/tailwind.css @@ -9,6 +9,7 @@ color utility to any element that depends on these defaults. */ @layer base { + *, ::after, ::before, @@ -58,22 +59,23 @@ --spacing-80e: 20em; --spacing-96e: 24em; - --color-bg: #373838; - --color-primary: #232323; + --color-bg: #2F4F4F; + --color-primary: #2E8B57; + --color-mid-purple: #3CB371; --color-accent: #1ad691; --color-error: #ff5252; --color-info: #2196f3; --color-success: #4caf50; --color-warning: #fb8c00; --color-darkgreen: rgb(34, 127, 35); - --color-black-50: #bbbbbb; - --color-black-100: #666666; - --color-black-200: #555555; - --color-black-300: #444444; - --color-black-400: #333333; - --color-black-500: #222222; - --color-black-600: #111111; - --color-black-700: #101010; + --color-black-50: #F0FFF0; + --color-black-100: #98FB98; + --color-black-200: #90EE90; + --color-black-300: #3CB371; + --color-black-400: #2E8B57; + --color-black-500: #228B22; + --color-black-600: #006400; + --color-black-700: #004225; --font-sans: 'Source Sans Pro'; --font-mono: 'Ubuntu Mono'; @@ -82,4 +84,4 @@ --text-1\.5xl: 1.375rem; --text-2\.5xl: 1.6875rem; --text-4\.5xl: 2.625rem; -} +} \ No newline at end of file diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index f74134041..6d022fd75 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -3,11 +3,11 @@ + + + @@ -87,7 +107,9 @@ export default { data() { return { - totalEntities: 0 + totalEntities: 0, + showProfileDropdown: false, + showApplyTagsModal: false } }, computed: { @@ -116,7 +138,7 @@ export default { return this.$store.getters['user/getIsAdminOrUp'] }, username() { - return this.user ? this.user.username : 'err' + return this.user ? this.user.displayName || this.user.username : 'err' }, numMediaItemsSelected() { return this.selectedMediaItems.length @@ -180,6 +202,14 @@ export default { action: 'rescan' }) + // Add Edit Tags option + if (this.userCanUpdate) { + options.push({ + text: 'Edit Tags', + action: 'apply-tags' + }) + } + // The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440 // + 40 , separators = 1480 chars + base path 280 chars = 1760 chars. This keeps the URL under 2000 chars even with longer domains if (this.selectedMediaItems.length <= 40) { @@ -190,6 +220,9 @@ export default { } return options + }, + serverTitle() { + return this.$store.state.serverSettings?.title || 'Bookfire' } }, methods: { @@ -226,6 +259,8 @@ export default { this.batchRescan() } else if (action === 'download') { this.batchDownload() + } else if (action === 'apply-tags') { + this.batchApplyTagsClick() } }, async batchRescan() { @@ -375,13 +410,64 @@ export default { }, batchAutoMatchClick() { this.$store.commit('globals/setShowBatchQuickMatchModal', true) + }, + batchApplyTagsClick() { + this.showApplyTagsModal = true + }, + tagsApplied() { + // Clear selection and refresh the bookshelf + this.$store.commit('globals/resetSelectedMediaItems', []) + this.$eventBus.$emit('bookshelf_clear_selection') + // Optionally refresh the current page to show updated tags + this.$eventBus.$emit('bookshelf_refresh') + }, + 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/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index 4bf8cfbbf..b00841a64 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -310,7 +310,7 @@ export default { } }, libraryItemAdded(libraryItem) { - console.log('libraryItem added', libraryItem) + console.log('BookShelfCategorized: libraryItem added', libraryItem) // TODO: Check if libraryItem would be on this shelf if (!this.search) { this.fetchCategories() @@ -499,6 +499,70 @@ export default { } else { console.error('Error socket not initialized') } + }, + selectAllItems() { + console.log('BookShelfCategorized: selectAllItems called!') + console.log('BookShelfCategorized: shelves:', this.shelves) + + // Get all visible items from shelves + const allVisibleEntities = [] + this.shelves.forEach((shelf) => { + console.log('BookShelfCategorized: processing shelf:', shelf.label, 'type:', shelf.type, 'entities:', shelf.entities.length) + if (shelf.type === 'book' || shelf.type === 'podcast') { + allVisibleEntities.push(...shelf.entities) + } + }) + + // Check if all visible items are already selected + const selectedMediaItems = this.$store.state.globals.selectedMediaItems + const visibleEntityIds = allVisibleEntities.map((e) => e.id) + const selectedVisibleCount = selectedMediaItems.filter((item) => visibleEntityIds.includes(item.id)).length + const shouldDeselect = selectedVisibleCount === visibleEntityIds.length && selectedVisibleCount > 0 + + console.log('BookShelfCategorized: visible entities:', visibleEntityIds.length, 'selected visible:', selectedVisibleCount, 'shouldDeselect:', shouldDeselect) + + if (shouldDeselect) { + // Deselect all visible items + this.shelves.forEach((shelf) => { + console.log('BookShelfCategorized: processing shelf for deselection:', shelf.label, 'type:', shelf.type, 'entities:', shelf.entities.length) + if (shelf.type === 'book' || shelf.type === 'podcast') { + shelf.entities.forEach((entity) => { + console.log('BookShelfCategorized: deselecting entity:', entity.id, entity.mediaType) + // Create the media item object the same way the individual deselect does + const mediaItem = { + id: entity.id, + mediaType: entity.mediaType, + hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length) + } + + // Remove the item from selection + this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: false }) + }) + } + }) + } else { + // Select all visible items + this.shelves.forEach((shelf) => { + if (shelf.type === 'book' || shelf.type === 'podcast') { + shelf.entities.forEach((entity) => { + // Create the media item object the same way the individual select does + const mediaItem = { + id: entity.id, + mediaType: entity.mediaType, + hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length) + } + + // Use the existing store mutation to mark the item as selected + this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: true }) + }) + } + }) + } + + // Trigger a single UI update after all items are selected/deselected + this.$nextTick(() => { + this.$eventBus.$emit('item-selected') + }) } }, mounted() { diff --git a/client/components/app/BookShelfToolbar.vue b/client/components/app/BookShelfToolbar.vue index 95e7c378c..8dd7a8819 100644 --- a/client/components/app/BookShelfToolbar.vue +++ b/client/components/app/BookShelfToolbar.vue @@ -328,6 +328,13 @@ export default { }) } + if (!this.isBatchSelecting && (!this.page || this.page === 'search')) { + items.push({ + text: 'Select All', + action: 'select-all' + }) + } + this.addSubtitlesMenuItem(items) this.addCollapseSeriesMenuItem(items) @@ -426,12 +433,19 @@ export default { if (action === 'export-opml') { this.exportOPML() return + } else if (action === 'select-all') { + this.selectAllItems() + return } else if (this.handleSubtitlesAction(action)) { return } else if (this.handleCollapseSeriesAction(action)) { return } }, + selectAllItems() { + // Emit an event to the parent component to handle selecting all items + this.$emit('select-all-items') + }, exportOPML() { this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true) }, @@ -644,9 +658,8 @@ export default { } - + \ No newline at end of file diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index 50fa7a06f..533e691d1 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -7,7 +7,7 @@

{{ route.title }}

-
+
@@ -109,6 +109,11 @@ export default { id: 'config-authentication', title: this.$strings.HeaderAuthentication, path: '/config/authentication' + }, + { + id: 'styling', + title: this.$strings.HeaderStyling, + path: '/config/styling' } ] diff --git a/client/components/app/GlobalStyling.vue b/client/components/app/GlobalStyling.vue new file mode 100644 index 000000000..b9cb7262e --- /dev/null +++ b/client/components/app/GlobalStyling.vue @@ -0,0 +1,53 @@ + + + \ No newline at end of file diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 61331fb9e..c4612e7a5 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -543,7 +543,7 @@ export default { this.handleScroll(scrollTop) }, libraryItemAdded(libraryItem) { - console.log('libraryItem added', libraryItem) + console.log('LazyBookshelf: libraryItem added', libraryItem) // TODO: Check if audiobook would be on this shelf this.resetEntities() }, @@ -880,6 +880,64 @@ export default { const shelfOffsetY = this.shelfPaddingHeight * this.sizeMultiplier const shelfOffsetX = (entityIndex - 1) * this.totalEntityCardWidth + this.bookshelfMarginLeft return `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)` + }, + selectAllItems() { + // Check if all visible items are already selected + const selectedMediaItems = this.$store.state.globals.selectedMediaItems + const visibleEntityIds = this.entities.filter((e) => e).map((e) => e.id) + const selectedVisibleCount = selectedMediaItems.filter((item) => visibleEntityIds.includes(item.id)).length + const shouldDeselect = selectedVisibleCount === visibleEntityIds.length && selectedVisibleCount > 0 + + if (shouldDeselect) { + // Deselect all visible items + this.entities.forEach((entity) => { + if (entity) { + // Create the media item object the same way the individual deselect does + const mediaItem = { + id: entity.id, + mediaType: entity.mediaType, + hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length) + } + + // Remove the item from selection + this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: false }) + } + }) + } else { + // Select all visible items + this.entities.forEach((entity) => { + if (entity) { + // Create the media item object the same way the individual select does + const mediaItem = { + id: entity.id, + mediaType: entity.mediaType, + hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length) + } + + // Use the existing store mutation to mark the item as selected + this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: true }) + } + }) + } + + // Force update all card components to reflect the selection state + this.$nextTick(() => { + this.updateBookSelectionMode(true) + + // Also update the selected property of each card component + const updatedSelectedItems = this.$store.state.globals.selectedMediaItems + + for (const key in this.entityComponentRefs) { + if (this.entityIndexesMounted.includes(Number(key))) { + const component = this.entityComponentRefs[key] + const entity = this.entities[key] + if (component && entity) { + const isSelected = updatedSelectedItems.some((item) => item.id === entity.id) + component.selected = isSelected + } + } + } + }) } }, async mounted() { diff --git a/client/components/app/ServerSettingsTab.vue b/client/components/app/ServerSettingsTab.vue new file mode 100644 index 000000000..6621fb710 --- /dev/null +++ b/client/components/app/ServerSettingsTab.vue @@ -0,0 +1,68 @@ + + + \ No newline at end of file diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index 71ac81550..c291a7a99 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -18,9 +18,14 @@
+
+ +
+
+
@@ -387,6 +392,7 @@ export default { username: null, email: null, password: null, + displayName: null, type: 'user', isActive: true, permissions: { diff --git a/client/components/modals/BatchApplyTagsModal.vue b/client/components/modals/BatchApplyTagsModal.vue new file mode 100644 index 000000000..2df18f3b1 --- /dev/null +++ b/client/components/modals/BatchApplyTagsModal.vue @@ -0,0 +1,162 @@ + + + \ No newline at end of file diff --git a/client/components/stats/YearInReview.vue b/client/components/stats/YearInReview.vue index ed8b800ce..c923f4077 100644 --- a/client/components/stats/YearInReview.vue +++ b/client/components/stats/YearInReview.vue @@ -151,7 +151,7 @@ export default { ctx.fillText('\ue900', 15, 36) // Top text - addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28) + addText('Bookfire', '28px', 'normal', tanColor, '0px', 65, 28) addText(`${this.year} ${this.$strings.StatsYearInReview}`, '18px', 'bold', 'white', '1px', 65, 51) // Top left box diff --git a/client/components/stats/YearInReviewShort.vue b/client/components/stats/YearInReviewShort.vue index dbba0172d..c9c56f0a2 100644 --- a/client/components/stats/YearInReviewShort.vue +++ b/client/components/stats/YearInReviewShort.vue @@ -132,7 +132,7 @@ export default { ctx.fillText('\ue900', 15, 36) // Top text - addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28) + addText('Bookfire', '28px', 'normal', tanColor, '0px', 65, 28) addText(`${this.year} ${this.$strings.StatsYearInReview}`, '18px', 'bold', 'white', '1px', 65, 51) // Top left box diff --git a/client/components/tables/UsersTable.vue b/client/components/tables/UsersTable.vue index 20f412288..00a211de0 100644 --- a/client/components/tables/UsersTable.vue +++ b/client/components/tables/UsersTable.vue @@ -4,6 +4,7 @@ + @@ -17,6 +18,9 @@

{{ user.username }}

+
{{ $strings.LabelUsername }}{{ $strings.LabelDisplayName }} {{ $strings.LabelAccountType }} +

{{ user.displayName || '-' }}

+
{{ user.type }}