diff --git a/next-ui/src/mocks/api/handlers.ts b/next-ui/src/mocks/api/handlers.ts
index 416e838f8..6a7c0b8ba 100644
--- a/next-ui/src/mocks/api/handlers.ts
+++ b/next-ui/src/mocks/api/handlers.ts
@@ -4,6 +4,7 @@ import { releasesHandlers } from '@/mocks/api/handlers/releases'
import { HttpResponse } from 'msw'
import { librariesHandlers } from '@/mocks/api/handlers/libraries'
import { referentialHandlers } from '@/mocks/api/handlers/referential'
+import { usersHandlers } from '@/mocks/api/handlers/users'
export const handlers = [
...librariesHandlers,
@@ -11,6 +12,7 @@ export const handlers = [
...actuatorHandlers,
...announcementHandlers,
...releasesHandlers,
+ ...usersHandlers,
]
export const response401Unauthorized = () =>
diff --git a/next-ui/src/mocks/api/handlers/users.ts b/next-ui/src/mocks/api/handlers/users.ts
new file mode 100644
index 000000000..1d7898d47
--- /dev/null
+++ b/next-ui/src/mocks/api/handlers/users.ts
@@ -0,0 +1,45 @@
+import { httpTyped } from '@/mocks/api/httpTyped'
+
+export const users = [
+ {
+ id: '0JEDA00AV4Z7G',
+ email: 'admin@example.org',
+ roles: ['ADMIN', 'FILE_DOWNLOAD', 'KOBO_SYNC', 'KOREADER_SYNC', 'PAGE_STREAMING', 'USER'],
+ sharedAllLibraries: true,
+ sharedLibrariesIds: [],
+ labelsAllow: [],
+ labelsExclude: [],
+ ageRestriction: null,
+ },
+ {
+ id: '0JEDA00AZ4QXH',
+ email: 'user@example.org',
+ roles: ['KOBO_SYNC', 'PAGE_STREAMING', 'USER'],
+ sharedAllLibraries: true,
+ sharedLibrariesIds: [],
+ labelsAllow: [],
+ labelsExclude: ['book'],
+ ageRestriction: null,
+ },
+]
+
+const latestActivity = {
+ userId: '0JEDA00AV4Z7G',
+ email: 'admin@example.org',
+ apiKeyId: null,
+ apiKeyComment: null,
+ ip: '127.0.0.1',
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0',
+ success: true,
+ error: null,
+ dateTime: new Date('2025-06-30T06:56:33Z'),
+ source: 'Password',
+}
+
+export const usersHandlers = [
+ httpTyped.get('/api/v2/users/me', ({ response }) => response(200).json(users[0])),
+ httpTyped.get('/api/v2/users', ({ response }) => response(200).json(users)),
+ httpTyped.get('/api/v2/users/{userId}/authentication-activity/latest', ({ response }) =>
+ response(200).json(latestActivity),
+ ),
+]
diff --git a/next-ui/src/pages/server/users.stories.ts b/next-ui/src/pages/server/users.stories.ts
new file mode 100644
index 000000000..901f4c9c3
--- /dev/null
+++ b/next-ui/src/pages/server/users.stories.ts
@@ -0,0 +1,44 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import users from './users.vue'
+import { http, delay } from 'msw'
+
+import { response401Unauthorized } from '@/mocks/api/handlers'
+
+const meta = {
+ component: users,
+ render: (args: object) => ({
+ components: { users },
+ setup() {
+ return { args }
+ },
+ template: '',
+ }),
+ parameters: {
+ // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
+ },
+ args: {},
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {},
+}
+
+export const Loading: Story = {
+ parameters: {
+ msw: {
+ handlers: [http.all('*', async () => await delay(5_000))],
+ },
+ },
+}
+
+export const Error: Story = {
+ parameters: {
+ msw: {
+ handlers: [http.all('*', response401Unauthorized)],
+ },
+ },
+}
diff --git a/next-ui/src/pages/server/users.vue b/next-ui/src/pages/server/users.vue
index 15d5c05cc..5ad9dc1e9 100644
--- a/next-ui/src/pages/server/users.vue
+++ b/next-ui/src/pages/server/users.vue
@@ -51,27 +51,34 @@
+
+ {{ $formatDate(value, { dateStyle: 'medium', timeStyle: 'short' }) }}
+
+
@@ -96,7 +103,7 @@ import { commonMessages } from '@/utils/i18n/common-messages'
import { storeToRefs } from 'pinia'
import { useDialogsStore } from '@/stores/dialogs'
import { useMessagesStore } from '@/stores/messages'
-import { useIntl } from 'vue-intl'
+import { defineMessage, useIntl } from 'vue-intl'
import { useDisplay } from 'vuetify'
import UserDeletionWarning from '@/components/user/DeletionWarning.vue'
import UserFormCreateEdit from '@/fragments/fragment/user/form/CreateEdit.vue'
@@ -348,6 +355,24 @@ function handleDialogConfirmation(
setLoading(false)
})
}
+
+const messages = {
+ deleteUser: defineMessage({
+ description: 'Tooltip for the delete user button in the users table',
+ defaultMessage: 'Delete user',
+ id: 'r6CqyT',
+ }),
+ editUser: defineMessage({
+ description: 'Tooltip for the edit user button in the users table',
+ defaultMessage: 'Edit user',
+ id: 'K40g4r',
+ }),
+ changePassword: defineMessage({
+ description: 'Tooltip for the change password button in the users table',
+ defaultMessage: 'Change password',
+ id: 'r7xCeA',
+ }),
+}