mirror of
https://github.com/gotson/komga.git
synced 2026-01-19 22:51:55 +01:00
extract users table from users page
This commit is contained in:
parent
4af7895c26
commit
e55aac8400
5 changed files with 294 additions and 165 deletions
2
next-ui/src/components.d.ts
vendored
2
next-ui/src/components.d.ts
vendored
|
|
@ -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']
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
next-ui/src/fragments/fragment/user/Table.stories.ts
Normal file
50
next-ui/src/fragments/fragment/user/Table.stories.ts
Normal 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: [],
|
||||
},
|
||||
}
|
||||
209
next-ui/src/fragments/fragment/user/Table.vue
Normal file
209
next-ui/src/fragments/fragment/user/Table.vue
Normal 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>
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue