add authentication activity pages

This commit is contained in:
Gauthier Roebroeck 2025-07-24 17:52:02 +08:00
parent 22df05757d
commit d6bfbb7a13
14 changed files with 702 additions and 10 deletions

View file

@ -1,4 +1,11 @@
import { defineMutation, defineQuery, useMutation, useQuery, useQueryCache } from '@pinia/colada'
import {
defineMutation,
defineQuery,
defineQueryOptions,
useMutation,
useQuery,
useQueryCache,
} from '@pinia/colada'
import { komgaClient } from '@/api/komga-client'
import { UserRoles } from '@/types/UserRoles'
import type { components } from '@/generated/openapi/komga'
@ -150,3 +157,61 @@ export const useDeleteApiKey = defineMutation(() => {
},
})
})
///////////
// Authentication Activity
///////////
export const authenticationActivityQuery = defineQueryOptions(
({
page,
size,
sort,
unpaged,
}: {
page?: number
size?: number
sort?: string[]
unpaged?: boolean
}) => ({
key: ['authentication-activity', { page: page, size: size, sort: sort, unpaged: unpaged }],
query: () =>
komgaClient
.GET('/api/v2/users/authentication-activity', {
params: {
query: { page: page, size: size, sort: sort, unpaged: unpaged },
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
}),
)
export const myAuthenticationActivityQuery = defineQueryOptions(
({
page,
size,
sort,
unpaged,
}: {
page?: number
size?: number
sort?: string[]
unpaged?: boolean
}) => ({
key: [
...QUERY_KEYS_USERS.currentUser,
'authentication-activity',
{ page: page, size: size, sort: sort, unpaged: unpaged },
],
query: () =>
komgaClient
.GET('/api/v2/users/me/authentication-activity', {
params: {
query: { page: page, size: size, sort: sort, unpaged: unpaged },
},
})
// unwrap the openapi-fetch structure on success
.then((res) => res.data),
}),
)

View file

@ -25,6 +25,7 @@ declare module 'vue' {
FragmentLocaleSelector: typeof import('./fragments/fragment/LocaleSelector.vue')['default']
FragmentSnackQueue: typeof import('./fragments/fragment/SnackQueue.vue')['default']
FragmentThemeSelector: typeof import('./fragments/fragment/ThemeSelector.vue')['default']
FragmentUserAuthenticationActivityTable: typeof import('./fragments/fragment/user/AuthenticationActivityTable.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']
@ -42,6 +43,7 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ServerSettings: typeof import('./components/server/Settings.vue')['default']
UserAuthenticationActivityTable: typeof import('./components/user/AuthenticationActivityTable.vue')['default']
UserDeletionWarning: typeof import('./components/user/DeletionWarning.vue')['default']
UserDetails: typeof import('./components/user/Details.vue')['default']
UserFormChangePassword: typeof import('./components/user/form/ChangePassword.vue')['default']

View file

@ -0,0 +1,64 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AuthenticationActivityTable from './AuthenticationActivityTable.vue'
import { delay, http } from 'msw'
import { response401Unauthorized } from '@/mocks/api/handlers'
import { httpTyped } from '@/mocks/api/httpTyped'
import { mockPage } from '@/mocks/api/pageable'
import { PageRequest } from '@/types/PageRequest'
const meta = {
component: AuthenticationActivityTable,
render: (args: object) => ({
components: { AuthenticationActivityTable },
setup() {
return { args }
},
template: '<AuthenticationActivityTable v-bind="args" />',
}),
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/configure/story-layout
},
args: {},
} satisfies Meta<typeof AuthenticationActivityTable>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
}
export const ForMe: Story = {
args: {
forMe: true,
},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [http.all('*', async () => await delay(5_000))],
},
},
}
export const NoData: Story = {
parameters: {
msw: {
handlers: [
httpTyped.get('/api/v2/users/authentication-activity', ({ response }) =>
response(200).json(mockPage([], new PageRequest())),
),
],
},
},
}
export const Error: Story = {
parameters: {
msw: {
handlers: [http.all('*', response401Unauthorized)],
},
},
}

View file

@ -0,0 +1,173 @@
<template>
<v-data-table-server
:loading="isLoading"
:items="data?.content"
:items-length="lastTotalElements"
:headers="headers"
fixed-header
fixed-footer
multi-sort
mobile-breakpoint="md"
@update:options="updateOptions"
style="height: 100%"
>
<template #top>
<v-toolbar flat>
<v-toolbar-title>
<v-icon
color="medium-emphasis"
icon="i-mdi:account-key"
size="x-small"
start
/>
{{
$formatMessage({
description: 'Authentication Activity table global header',
defaultMessage: 'Authentication Activity',
id: 'LaMAsc',
})
}}
</v-toolbar-title>
</v-toolbar>
</template>
<template #no-data>
<EmptyStateNetworkError v-if="error" />
<template v-else>
{{
$formatMessage({
description: 'Authentication Activity table: shown when table has no data',
defaultMessage: 'No recent activity',
id: 'kGC6Gu',
})
}}
</template>
</template>
<template #[`item.success`]="{ value }">
<v-icon
v-if="value"
icon="i-mdi:check-circle-outline"
color="success"
/>
<v-icon
v-else
icon="i-mdi:error-outline"
color="error"
/>
</template>
<template #[`item.dateTime`]="{ value }">
{{ $formatDate(value, { dateStyle: 'medium', timeStyle: 'short' }) }}
</template>
</v-data-table-server>
</template>
<script setup lang="ts">
import { useIntl } from 'vue-intl'
import { PageRequest, type SortItem } from '@/types/PageRequest'
import { useQuery } from '@pinia/colada'
import { authenticationActivityQuery, myAuthenticationActivityQuery } from '@/colada/users'
const intl = useIntl()
const { forMe = false } = defineProps<{ forMe?: boolean }>()
const headers = computed(() => {
const h = []
if (!forMe)
h.push({
title: intl.formatMessage({
description: 'Authentication Activity table header: email',
defaultMessage: 'Email',
id: 'HHvDPs',
}),
key: 'email',
})
h.push(
{
title: intl.formatMessage({
description: 'Authentication Activity table header: IP address',
defaultMessage: 'IP',
id: 'YbkrN9',
}),
key: 'ip',
},
{
title: intl.formatMessage({
description: 'Authentication Activity table header: User Agent',
defaultMessage: 'User Agent',
id: 'cWVIRm',
}),
key: 'userAgent',
},
{
title: intl.formatMessage({
description: 'Authentication Activity table header: Successful authentication or not',
defaultMessage: 'Success',
id: 'XZ5lw4',
}),
key: 'success',
},
{
title: intl.formatMessage({
description: 'Authentication Activity table header: Source',
defaultMessage: 'Source',
id: 'Zozcfh',
}),
key: 'source',
},
{
title: intl.formatMessage({
description: 'Authentication Activity table header: API Key',
defaultMessage: 'API Key',
id: 'o1XnPU',
}),
key: 'apiKeyComment',
sortable: false,
},
{
title: intl.formatMessage({
description: 'Authentication Activity table header: Error',
defaultMessage: 'Error',
id: 'CVk1J6',
}),
key: 'error',
},
{
title: intl.formatMessage({
description: 'Authentication Activity table header: Date Time',
defaultMessage: 'Date Time',
id: 'CpNtjm',
}),
key: 'dateTime',
},
)
return h
})
const pageRequest = ref<PageRequest>(new PageRequest())
const { data, isLoading, error } = useQuery(
forMe ? myAuthenticationActivityQuery : authenticationActivityQuery,
() => ({ ...pageRequest.value }),
)
const lastTotalElements = ref<number>(0)
watch(data, (data) => {
// to avoid NaN showing in the table footer
if (data && data.totalElements) lastTotalElements.value = data.totalElements
})
function updateOptions({
page,
itemsPerPage,
sortBy,
}: {
page: number
itemsPerPage: number
sortBy: SortItem[]
}) {
pageRequest.value = PageRequest.FromVuetify(page - 1, itemsPerPage, sortBy)
}
</script>

View file

@ -49,8 +49,8 @@
:title="
$formatMessage({
description: 'Drawer menu for My Account > Activity',
defaultMessage: 'Activity',
id: 'cGFtPg',
defaultMessage: 'My activity',
id: 'Xx+ITC',
})
"
/>

View file

@ -34,6 +34,18 @@
})
"
/>
<v-list-item
to="/server/activity"
:title="
$formatMessage({
description: 'Drawer menu for Server > Authentication Activity',
defaultMessage: 'Activity',
id: 'm9tuzy',
})
"
/>
<v-list-item
to="/server/settings"
:title="

View file

@ -1,4 +1,6 @@
import { httpTyped } from '@/mocks/api/httpTyped'
import { mockPage } from '@/mocks/api/pageable'
import { PageRequest } from '@/types/PageRequest'
export const userAdmin = {
id: '0JEDA00AV4Z7G',
@ -26,7 +28,7 @@ const latestActivity = {
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,
dateTime: new Date('2025-06-30T06:56:33Z'),
dateTime: new Date(new Date('2025-06-30T06:56:33Z')),
source: 'Password',
}
@ -66,6 +68,261 @@ export const apiKeys = [
},
]
export const authenticationActivity = [
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: false,
error: 'Bad credentials',
dateTime: new Date('2025-07-24T03:29:46Z'),
source: 'Password',
},
{
userId: '0JEDA00AV4Z7G',
email: 'jacky@example.org',
apiKeyId: '0MDR59N06BFJN',
apiKeyComment: 'Kobo',
ip: '0:0:0:0:0:0:0:1',
userAgent: 'HTTPie/3.2.4',
success: true,
dateTime: new Date('2025-07-24T03:32:00Z'),
source: 'ApiKey',
},
{
userId: '0JEDA00AV4Z7G',
email: 'michel@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile Safari/537.36',
success: true,
dateTime: new Date('2025-07-22T05:14:33Z'),
source: 'Password',
},
{
userId: '0JEDA00AV4Z7G',
email: 'jean@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: true,
dateTime: new Date('2025-07-22T02:31:49Z'),
source: 'Password',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: true,
dateTime: new Date('2025-07-21T06:14:54Z'),
source: 'RememberMe',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: true,
dateTime: new Date('2025-07-21T06:13:05Z'),
source: 'RememberMe',
},
{
userId: '0JEDA00AV4Z7G',
email: 'jacky@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: true,
dateTime: new Date('2025-07-21T06:09:30Z'),
source: 'RememberMe',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: true,
dateTime: new Date('2025-07-16T04:05:25Z'),
source: 'RememberMe',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: true,
dateTime: new Date('2025-07-15T07:44:42Z'),
source: 'RememberMe',
},
{
userId: '0JEDA00AV4Z7G',
email: 'michel@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: true,
dateTime: new Date('2025-07-15T07:44:07Z'),
source: 'RememberMe',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: true,
dateTime: new Date('2025-07-15T07:40:28Z'),
source: 'RememberMe',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: true,
dateTime: new Date('2025-07-15T06:07:31Z'),
source: 'Password',
},
{
userId: '0JEDA00AV4Z7G',
email: 'jean@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: true,
dateTime: new Date('2025-07-14T06:32:27Z'),
source: 'RememberMe',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: true,
dateTime: new Date('2025-07-14T06:32:25Z'),
source: 'RememberMe',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: true,
dateTime: new Date('2025-07-14T06:03:59Z'),
source: 'Password',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: true,
dateTime: new Date('2025-07-14T01:53:05Z'),
source: 'RememberMe',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
ip: '127.0.0.1',
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0',
success: true,
dateTime: new Date('2025-07-07T07:20:46Z'),
source: 'Password',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
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,
dateTime: new Date('2025-06-30T06:56:33Z'),
source: 'Password',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
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,
dateTime: new Date('2025-06-30T01:16:27Z'),
source: 'RememberMe',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
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,
dateTime: new Date('2025-06-27T01:37:31Z'),
source: 'RememberMe',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
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,
dateTime: new Date('2025-06-26T03:30:23Z'),
source: 'Password',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
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,
dateTime: new Date('2025-06-25T08:58:19Z'),
source: 'RememberMe',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
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,
dateTime: new Date('2025-06-25T08:38:13Z'),
source: 'Password',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
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,
dateTime: new Date('2025-06-25T07:19:39Z'),
source: 'RememberMe',
},
{
userId: '0JEDA00AV4Z7G',
email: 'admin@example.org',
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,
dateTime: new Date('2025-06-25T03:16:33Z'),
source: 'Password',
},
]
export const usersHandlers = [
httpTyped.get('/api/v2/users/me', ({ response }) => response(200).json(userAdmin)),
httpTyped.get('/api/v2/users', ({ response }) => response(200).json(users)),
@ -74,4 +331,20 @@ export const usersHandlers = [
),
httpTyped.get('/api/v2/users/me/api-keys', ({ response }) => response(200).json(apiKeys)),
httpTyped.post('/api/v2/users/me/api-keys', ({ response }) => response(200).json(newApiKey)),
httpTyped.get('/api/v2/users/authentication-activity', ({ query, response }) =>
response(200).json(
mockPage(
authenticationActivity,
new PageRequest(Number(query.get('page')), Number(query.get('size')), query.getAll('sort')),
),
),
),
httpTyped.get('/api/v2/users/me/authentication-activity', ({ query, response }) =>
response(200).json(
mockPage(
authenticationActivity.filter((x) => x.email === 'jacky@example.org'),
new PageRequest(Number(query.get('page')), Number(query.get('size')), query.getAll('sort')),
),
),
),
]

View file

@ -0,0 +1,39 @@
import { PageRequest } from '@/types/PageRequest'
export function mockPage<T>(data: T[], pageRequest: PageRequest) {
const page = Number(pageRequest.page) || 0
const size = Number(pageRequest.size) || 20
const unpaged = pageRequest.unpaged || false
const start = page * size
const slice = unpaged ? data : data.slice(start, start + size)
return {
content: slice,
pageable: {
pageNumber: page,
pageSize: size,
sort: {
empty: false,
unsorted: false,
sorted: true,
},
offset: size * page,
unpaged: unpaged,
paged: !unpaged,
},
last: false,
totalPages: data.length / size,
totalElements: data.length,
first: false,
size: size,
number: 1,
sort: {
empty: false,
unsorted: false,
sorted: true,
},
numberOfElements: slice.length,
empty: slice.length > 0,
}
}

View file

@ -1,7 +1,10 @@
<template>
<h1>Authentication Activity</h1>
<v-container
fluid
class="pa-0 pa-sm-4 h-100 h-sm-auto"
>
<FragmentUserAuthenticationActivityTable for-me />
</v-container>
</template>
<script lang="ts" setup>
//
</script>
<script lang="ts" setup></script>

View file

@ -0,0 +1,10 @@
<template>
<v-container
fluid
class="pa-0 pa-sm-4 h-100 h-sm-auto"
>
<FragmentUserAuthenticationActivityTable />
</v-container>
</template>
<script lang="ts" setup></script>

View file

@ -55,7 +55,6 @@
<script lang="ts" setup>
import { useAnnouncements, useMarkAnnouncementsRead } from '@/colada/announcements'
import { commonMessages } from '@/utils/i18n/common-messages'
import EmptyStateNetworkError from '@/components/EmptyStateNetworkError.vue'
const { data: announcements, error, unreadCount, isPending } = useAnnouncements()

View file

@ -58,7 +58,6 @@
<script lang="ts" setup>
import { useAppReleases } from '@/colada/app-releases'
import { commonMessages } from '@/utils/i18n/common-messages'
import EmptyStateNetworkError from '@/components/EmptyStateNetworkError.vue'
const {

View file

@ -32,6 +32,7 @@ declare module 'vue-router/auto-routes' {
'/media/duplicate-pages/known': RouteRecordInfo<'/media/duplicate-pages/known', '/media/duplicate-pages/known', Record<never, never>, Record<never, never>>,
'/media/duplicate-pages/unknown': RouteRecordInfo<'/media/duplicate-pages/unknown', '/media/duplicate-pages/unknown', Record<never, never>, Record<never, never>>,
'/media/missing-posters': RouteRecordInfo<'/media/missing-posters', '/media/missing-posters', Record<never, never>, Record<never, never>>,
'/server/activity': RouteRecordInfo<'/server/activity', '/server/activity', Record<never, never>, Record<never, never>>,
'/server/announcements': RouteRecordInfo<'/server/announcements', '/server/announcements', Record<never, never>, Record<never, never>>,
'/server/metrics': RouteRecordInfo<'/server/metrics', '/server/metrics', Record<never, never>, Record<never, never>>,
'/server/settings': RouteRecordInfo<'/server/settings', '/server/settings', Record<never, never>, Record<never, never>>,

View file

@ -0,0 +1,52 @@
// from Vuetify
export type SortItem = { key: string; order?: boolean | 'asc' | 'desc' }
function vSortItemToSort(sortItem: SortItem): string {
let sort = sortItem.key
if (sortItem.order && typeof sortItem.order === 'string') sort += `,${sortItem.order}`
return sort
}
export class PageRequest {
readonly unpaged?: boolean
readonly page?: number
readonly size?: number
readonly sort?: string[]
static Unpaged(): PageRequest {
return new PageRequest(undefined, undefined, undefined, true)
}
/**
* Can be used from v-data-table-server @update:options
* @param page
* @param size
* @param sortItems
* @constructor
*/
static FromVuetify(page?: number, size?: number, sortItems?: SortItem[]): PageRequest {
if (size && size < 0)
return new PageRequest(
undefined,
undefined,
sortItems?.map((x) => vSortItemToSort(x)),
true,
)
return new PageRequest(
page,
size,
sortItems?.map((x) => vSortItemToSort(x)),
false,
)
}
constructor(page?: number, size?: number, sort?: string[], unpaged?: boolean) {
if (page && page < 0) throw new Error('page cannot be negative')
if (size && size < 0) throw new Error('size cannot be negative')
this.page = page
this.size = size
this.sort = sort
this.unpaged = unpaged
}
}