extract users table from users page

This commit is contained in:
Gauthier Roebroeck 2025-06-30 16:56:38 +08:00
parent 4af7895c26
commit e55aac8400
5 changed files with 294 additions and 165 deletions

View file

@ -20,6 +20,7 @@ declare module 'vue' {
FragmentSnackQueue: typeof import('./fragments/fragment/SnackQueue.vue')['default']
FragmentThemeSelector: typeof import('./fragments/fragment/ThemeSelector.vue')['default']
FragmentUserFormCreateEdit: typeof import('./fragments/fragment/user/form/CreateEdit.vue')['default']
FragmentUserTable: typeof import('./fragments/fragment/user/Table.vue')['default']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
LayoutAppBar: typeof import('./fragments/layout/app/Bar.vue')['default']
LayoutAppDrawer: typeof import('./fragments/layout/app/drawer/Drawer.vue')['default']
@ -36,6 +37,5 @@ declare module 'vue' {
RouterView: typeof import('vue-router')['RouterView']
UserDeletionWarning: typeof import('./components/user/DeletionWarning.vue')['default']
UserFormChangePassword: typeof import('./components/user/form/ChangePassword.vue')['default']
UserFormCreateEdit: typeof import('./components/user/form/CreateEdit.vue')['default']
}
}

View file

@ -0,0 +1,50 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Table from './Table.vue'
import { users } from '@/mocks/api/handlers/users'
import { fn } from 'storybook/test'
const meta = {
component: Table,
render: (args: object) => ({
components: { Table },
setup() {
return { args }
},
template: '<Table v-bind="args" />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {
onAddUser: fn(),
onEnterAddUser: fn(),
onEnterEditUser: fn(),
onEnterDeleteUser: fn(),
onEnterChangePassword: fn(),
onChangePassword: fn(),
onEditUser: fn(),
onDeleteUser: fn(),
},
} satisfies Meta<typeof Table>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
users: users,
},
}
export const Loading: Story = {
args: {
loading: true,
},
}
export const NoData: Story = {
args: {
users: [],
},
}

View file

@ -0,0 +1,209 @@
<template>
<v-data-table
:loading="loading"
:items="users"
:headers="headers"
:hide-default-footer="hideFooter"
mobile-breakpoint="md"
>
<template #top>
<v-toolbar flat>
<v-toolbar-title>
<v-icon
color="medium-emphasis"
icon="i-mdi:account-multiple"
size="x-small"
start
/>
{{
$formatMessage({
description: 'Users table global header',
defaultMessage: 'Users',
id: 'c+hx0g',
})
}}
</v-toolbar-title>
<v-btn
class="me-2"
prepend-icon="i-mdi:plus"
rounded="lg"
:text="
$formatMessage({
description: 'Users table global header: add user button',
defaultMessage: 'Add a user',
id: 'GZCEBS',
})
"
border
:disabled="loading"
@click="emit('addUser')"
@mouseenter="emit('enterAddUser', $event.currentTarget)"
/>
</v-toolbar>
</template>
<template #[`item.roles`]="{ value }">
<div class="d-flex ga-1 flex-wrap">
<v-chip
v-for="role in value"
:key="role"
:color="getRoleColor(role)"
:text="role"
size="x-small"
rounded
/>
</div>
</template>
<template #[`item.activity`]="{ value }">
{{ $formatDate(value, { dateStyle: 'medium', timeStyle: 'short' }) }}
</template>
<template #[`item.actions`]="{ item: user }">
<div class="d-flex ga-1 justify-end">
<v-icon-btn
v-tooltip:bottom="$formatMessage(messages.changePassword)"
icon="i-mdi:lock-reset"
@click="emit('changePassword', user)"
@mouseenter="emit('enterChangePassword', $event.currentTarget)"
:aria-label="$formatMessage(messages.changePassword)"
/>
<v-icon-btn
v-tooltip:bottom="$formatMessage(messages.editUser)"
icon="i-mdi:pencil"
:disabled="me?.id == user.id"
@click="emit('editUser', user)"
@mouseenter="emit('enterEditUser', $event.currentTarget)"
:aria-label="$formatMessage(messages.editUser)"
/>
<v-icon-btn
v-tooltip:bottom="$formatMessage(messages.deleteUser)"
icon="i-mdi:delete"
:disabled="me?.id == user.id"
@click="emit('deleteUser', user)"
@mouseenter="emit('enterDeleteUser', $event.currentTarget)"
:aria-label="$formatMessage(messages.deleteUser)"
/>
</div>
</template>
</v-data-table>
</template>
<script lang="ts" setup>
import { komgaClient } from '@/api/komga-client'
import type { components } from '@/generated/openapi/komga'
import { useCurrentUser } from '@/colada/queries/current-user'
import { UserRoles } from '@/types/UserRoles'
import { defineMessage, useIntl } from 'vue-intl'
const intl = useIntl()
const { users = [], loading = false } = defineProps<{
users?: components['schemas']['UserDto'][]
loading?: boolean
}>()
const emit = defineEmits<{
addUser: []
enterAddUser: [target: Element]
enterEditUser: [target: Element]
enterDeleteUser: [target: Element]
enterChangePassword: [target: Element]
changePassword: [user: components['schemas']['UserDto']]
editUser: [user: components['schemas']['UserDto']]
deleteUser: [user: components['schemas']['UserDto']]
}>()
// API data
const { data: me } = useCurrentUser()
// Table
const hideFooter = computed(() => users.length < 11)
const headers = [
{
title: intl.formatMessage({
description: 'User table header: user email',
defaultMessage: 'Email',
id: 'zfQq+w',
}),
key: 'email',
},
{
title: intl.formatMessage({
description: 'User table header: user latest activity',
defaultMessage: 'Latest activity',
id: 'y1P/K4',
}),
key: 'activity',
value: (item: components['schemas']['UserDto']) => latestActivity[item.id],
},
{
title: intl.formatMessage({
description: 'User table header: user roles',
defaultMessage: 'Roles',
id: 'ut2gmo',
}),
value: 'roles',
sortable: false,
},
{
title: intl.formatMessage({
description: 'User table header: user actions',
defaultMessage: 'Actions',
id: 'lvCpSX',
}),
key: 'actions',
align: 'end',
sortable: false,
},
] as const // workaround for https://github.com/vuetifyjs/vuetify/issues/18901
function getRoleColor(role: UserRoles) {
if (role === UserRoles.ADMIN) return 'error'
}
// store each user's latest activity in a map
// when the 'users' change, we call the API for each user
const latestActivity: Record<string, Date | undefined> = reactive({})
function getLatestActivity(userId: string) {
komgaClient
.GET('/api/v2/users/{id}/authentication-activity/latest', {
params: {
path: { id: userId },
},
})
// unwrap the openapi-fetch structure on success
.then((res) => (latestActivity[userId] = res.data?.dateTime))
.catch(() => {})
}
watch(
() => users,
(users) => {
if (users)
for (const user of users) {
getLatestActivity(user.id)
}
},
)
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',
}),
}
</script>

View file

@ -1,45 +1,39 @@
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,
},
]
export const userAdmin = {
id: '0JEDA00AV4Z7G',
email: 'admin@example.org',
roles: ['ADMIN', 'FILE_DOWNLOAD', 'KOBO_SYNC', 'KOREADER_SYNC', 'PAGE_STREAMING', 'USER'],
sharedAllLibraries: true,
sharedLibrariesIds: [],
labelsAllow: [],
labelsExclude: [],
}
export const userRegular = {
id: '0JEDA00AZ4QXH',
email: 'user@example.org',
roles: ['KOBO_SYNC', 'PAGE_STREAMING', 'USER'],
sharedAllLibraries: true,
sharedLibrariesIds: [],
labelsAllow: [],
labelsExclude: ['book'],
}
export const users = [userAdmin, userRegular]
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/me', ({ response }) => response(200).json(userAdmin)),
httpTyped.get('/api/v2/users', ({ response }) => response(200).json(users)),
httpTyped.get('/api/v2/users/{userId}/authentication-activity/latest', ({ response }) =>
httpTyped.get('/api/v2/users/{id}/authentication-activity/latest', ({ response }) =>
response(200).json(latestActivity),
),
]

View file

@ -7,90 +7,25 @@
/>
<template v-else>
<v-data-table
<FragmentUserTable
:users="users"
:loading="isLoading"
:items="users"
:headers="headers"
:hide-default-footer="hideFooter"
mobile-breakpoint="md"
>
<template #top>
<v-toolbar flat>
<v-toolbar-title>
<v-icon
color="medium-emphasis"
icon="i-mdi:account-multiple"
size="x-small"
start
/>
Users
</v-toolbar-title>
<v-btn
class="me-2"
prepend-icon="i-mdi:plus"
rounded="lg"
text="Add a User"
border
@click="showDialog(ACTION.ADD)"
@mouseenter="dialogConfirmEdit.activator = $event.currentTarget"
/>
</v-toolbar>
</template>
<template #[`item.roles`]="{ value }">
<div class="d-flex ga-1 flex-wrap">
<v-chip
v-for="role in value"
:key="role"
:color="getRoleColor(role)"
:text="role"
size="x-small"
rounded
/>
</div>
</template>
<template #[`item.activity`]="{ value }">
{{ $formatDate(value, { dateStyle: 'medium', timeStyle: 'short' }) }}
</template>
<template #[`item.actions`]="{ item: user }">
<div class="d-flex ga-1 justify-end">
<v-icon-btn
v-tooltip:bottom="$formatMessage(messages.changePassword)"
icon="i-mdi:lock-reset"
@click="showDialog(ACTION.PASSWORD, user)"
@mouseenter="dialogConfirmEdit.activator = $event.currentTarget"
:aria-label="$formatMessage(messages.changePassword)"
/>
<v-icon-btn
v-tooltip:bottom="$formatMessage(messages.editUser)"
icon="i-mdi:pencil"
:disabled="me?.id == user.id"
@click="showDialog(ACTION.EDIT, user)"
@mouseenter="dialogConfirmEdit.activator = $event.currentTarget"
:aria-label="$formatMessage(messages.editUser)"
/>
<v-icon-btn
v-tooltip:bottom="$formatMessage(messages.deleteUser)"
icon="i-mdi:delete"
:disabled="me?.id == user.id"
@click="showDialog(ACTION.DELETE, user)"
@mouseenter="dialogConfirm.activator = $event.currentTarget"
:aria-label="$formatMessage(messages.deleteUser)"
/>
</div>
</template>
</v-data-table>
@add-user="showDialog(ACTION.ADD)"
@change-password="(user) => showDialog(ACTION.PASSWORD, user)"
@edit-user="(user) => showDialog(ACTION.EDIT, user)"
@delete-user="(user) => showDialog(ACTION.DELETE, user)"
@enter-add-user="(target) => (dialogConfirmEdit.activator = target)"
@enter-edit-user="(target) => (dialogConfirmEdit.activator = target)"
@enter-change-password="(target) => (dialogConfirmEdit.activator = target)"
@enter-delete-user="(target) => (dialogConfirm.activator = target)"
/>
</template>
</template>
<script lang="ts" setup>
import { useUsers } from '@/colada/queries/users'
import { type ErrorCause, komgaClient } from '@/api/komga-client'
import { type ErrorCause } from '@/api/komga-client'
import type { components } from '@/generated/openapi/komga'
import { useCurrentUser } from '@/colada/queries/current-user'
import { UserRoles } from '@/types/UserRoles'
import {
useCreateUser,
@ -103,7 +38,7 @@ import { commonMessages } from '@/utils/i18n/common-messages'
import { storeToRefs } from 'pinia'
import { useDialogsStore } from '@/stores/dialogs'
import { useMessagesStore } from '@/stores/messages'
import { defineMessage, useIntl } from 'vue-intl'
import { useIntl } from 'vue-intl'
import { useDisplay } from 'vuetify'
import UserDeletionWarning from '@/components/user/DeletionWarning.vue'
import UserFormCreateEdit from '@/fragments/fragment/user/form/CreateEdit.vue'
@ -113,47 +48,6 @@ const intl = useIntl()
// API data
const { data: users, error, isLoading, refetch: refetchUsers } = useUsers()
const { data: me } = useCurrentUser()
// Table
const hideFooter = computed(() => users.value && users.value.length < 11)
const headers = [
{ title: 'Email', key: 'email' },
{
title: 'Latest Activity',
key: 'activity',
value: (item: components['schemas']['UserDto']) => latestActivity[item.id],
},
{ title: 'Roles', value: 'roles', sortable: false },
{ title: 'Actions', key: 'actions', align: 'end', sortable: false },
] as const // workaround for https://github.com/vuetifyjs/vuetify/issues/18901
function getRoleColor(role: UserRoles) {
if (role === UserRoles.ADMIN) return 'error'
}
// store each user's latest activity in a map
// when the 'users' change, we call the API for each user
const latestActivity: Record<string, Date | undefined> = reactive({})
function getLatestActivity(userId: string) {
komgaClient
.GET('/api/v2/users/{id}/authentication-activity/latest', {
params: {
path: { id: userId },
},
})
// unwrap the openapi-fetch structure on success
.then((res) => (latestActivity[userId] = res.data?.dateTime))
.catch(() => {})
}
watch(users, (users) => {
if (users)
for (const user of users) {
getLatestActivity(user.id)
}
})
onMounted(() => refetchUsers())
@ -355,24 +249,6 @@ 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',
}),
}
</script>
<route lang="yaml">